From 81965a697d6f7a24e9fceb28c4f8e5217b112493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 22 Mar 2026 02:57:43 -0400 Subject: [PATCH] refactor(01-05): decompose hiring.tsx (321 lines) into 2 focused sub-components - Extract hiring-helpers.ts (36 lines): HiringStage type, HiringCandidate interface, getHiringStage, stageColor, STAGE_ORDER - HiringPipeline.tsx (293 lines): main hiring pipeline component - hiring.tsx becomes re-export barrel (2 lines) - All sub-components under 300 lines --- src/renderer/components/hiring.tsx | 323 +----------------- .../components/hiring/HiringPipeline.tsx | 293 ++++++++++++++++ .../components/hiring/hiring-helpers.ts | 36 ++ 3 files changed, 331 insertions(+), 321 deletions(-) create mode 100644 src/renderer/components/hiring/HiringPipeline.tsx create mode 100644 src/renderer/components/hiring/hiring-helpers.ts diff --git a/src/renderer/components/hiring.tsx b/src/renderer/components/hiring.tsx index d175331d..b04242e4 100644 --- a/src/renderer/components/hiring.tsx +++ b/src/renderer/components/hiring.tsx @@ -1,321 +1,2 @@ -import { useState } from "react"; -import { - ArrowRight, - Check, - UserPlus, - X, -} from "@phosphor-icons/react"; -import type { AgentRecord, ApprovalRecord } from "@shared/types"; -import { useT } from "../i18n"; -import { getAgentInitials } from "../lib/formatters"; -import { unwrap } from "../lib/desktop"; -import { ActionButton, EmptyState, Panel, StatusPill } from "./ui"; - -interface HiringPipelineProps { - agents: AgentRecord[]; - approvals: ApprovalRecord[]; - onRefresh: () => Promise; - onNavigateToAgent: (agentId: string) => void; -} - -type HiringStage = "requisition" | "pending_approval" | "approved" | "onboarding" | "active" | "rejected"; - -interface HiringCandidate { - agent: AgentRecord | null; - approval: ApprovalRecord | null; - stage: HiringStage; -} - -function getHiringStage(agent: AgentRecord, approval: ApprovalRecord | null): HiringStage { - if (agent.status === "pending_approval") { - if (!approval) return "requisition"; - if (approval.state === "pending") return "pending_approval"; - if (approval.state === "rejected") return "rejected"; - if (approval.state === "revision_requested") return "requisition"; - return "approved"; - } - if (agent.status === "idle" && approval?.state === "approved") { - return "onboarding"; - } - return "active"; -} - -function stageColor(stage: HiringStage): string { - switch (stage) { - case "requisition": return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"; - case "pending_approval": return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"; - case "approved": return "bg-[color:var(--success-soft)] text-[color:var(--success)]"; - case "onboarding": return "bg-[color:var(--accent-soft)] text-[color:var(--accent)]"; - case "active": return "bg-[color:var(--success-soft)] text-[color:var(--success)]"; - case "rejected": return "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"; - } -} - -const STAGE_ORDER: HiringStage[] = ["requisition", "pending_approval", "approved", "onboarding", "active", "rejected"]; - -export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent }: HiringPipelineProps) { - const t = useT(); - const [filterStage, setFilterStage] = useState("all"); - - const stageLabelMap: Record = { - requisition: t("hiring.requisition"), - pending_approval: t("hiring.pendingApproval"), - approved: t("hiring.approved"), - onboarding: t("hiring.onboarding"), - active: t("hiring.active"), - rejected: t("status.rejected"), - }; - - const hireApprovals = approvals.filter(a => a.type === "hire_agent"); - - const candidates: HiringCandidate[] = agents.map(agent => { - const approval = hireApprovals.find(a => { - if (a.relatedAgentId === agent.id) return true; - if (!a.payloadJson) return false; - try { return JSON.parse(a.payloadJson).agentId === agent.id; } catch { return false; } - }) ?? null; - return { - agent, - approval, - stage: getHiringStage(agent, approval), - }; - }); - - const pendingHireApprovals = hireApprovals.filter(a => { - return a.state === "pending" && !candidates.some(c => c.approval?.id === a.id); - }); - - const allCandidates = [ - ...candidates, - ...pendingHireApprovals.map(a => ({ - agent: null, - approval: a, - stage: "pending_approval" as HiringStage, - })), - ].sort((a, b) => STAGE_ORDER.indexOf(a.stage) - STAGE_ORDER.indexOf(b.stage)); - - const filteredCandidates = filterStage === "all" - ? allCandidates - : allCandidates.filter(c => c.stage === filterStage); - - const stageCounts = STAGE_ORDER.reduce((acc, stage) => { - acc[stage] = allCandidates.filter(c => c.stage === stage).length; - return acc; - }, {} as Record); - - const pendingCount = stageCounts.requisition + stageCounts.pending_approval; - const activeHires = allCandidates.filter(c => c.stage !== "active" && c.stage !== "rejected").length; - - return ( -
- -
- {STAGE_ORDER.filter(s => s !== "rejected").map((stage, idx) => ( -
- - {idx < STAGE_ORDER.length - 2 && ( - - )} -
- ))} - {stageCounts.rejected > 0 && ( - - )} -
-
- - {pendingCount > 0 && ( - -
- {allCandidates - .filter(c => c.stage === "requisition" || c.stage === "pending_approval") - .map((candidate) => { - const a = candidate.approval; - const ag = candidate.agent; - return ( -
-
- -
-
-
- {ag?.name ?? t("hiring.pendingHires")} - - {stageLabelMap[candidate.stage]} - -
-
- {ag ? `${ag.role} — ${ag.title}` : a?.payloadSummary} -
- {ag?.capabilities && ( -
{ag.capabilities}
- )} -
-
- {a && a.state === "pending" && ( - <> - { - unwrap(await window.agentCompany.decideApproval({ - companyId: a.companyId, - approvalId: a.id, - state: "approved", - decisionNote: "Hire approved via hiring pipeline", - })); - await onRefresh(); - }} - /> - { - unwrap(await window.agentCompany.decideApproval({ - companyId: a.companyId, - approvalId: a.id, - state: "rejected", - decisionNote: "Hire rejected via hiring pipeline", - })); - await onRefresh(); - }} - /> - - )} -
-
- ); - })} -
-
- )} - - setFilterStage("all")} /> - ) : undefined - } - > - {filteredCandidates.length === 0 ? ( - - ) : ( -
- {filteredCandidates.map((candidate) => { - const ag = candidate.agent; - if (!ag) return null; - const manager = agents.find(a => a.id === ag.reportsTo); - const requestedBy = candidate.approval?.requestedByAgentId - ? agents.find(a => a.id === candidate.approval!.requestedByAgentId) - : null; - return ( -
onNavigateToAgent(ag.id)} - > -
- {getAgentInitials(ag.name)} -
-
-
- {ag.name} - - {stageLabelMap[candidate.stage]} - - -
-
- {ag.role} — {ag.title}{ag.department ? ` | ${ag.department}` : ""} -
-
-
-
- {manager ? `${t("hiring.reportsTo")} ${manager.name}` : t("hiring.topLevel")} -
- {requestedBy && ( -
{t("hiring.proposedBy")} {requestedBy.name}
- )} -
${ag.budgetMonthlyUsd}/mo
-
-
- ); - })} -
- )} -
- -
-
-
{t("hiring.totalAgents")}
-
{agents.filter(a => a.status !== "terminated").length}
-
-
-
{t("hiring.pendingHires")}
-
{agents.filter(a => a.status === "pending_approval").length}
-
-
-
{t("hiring.departments")}
-
- {new Set(agents.filter(a => a.department && a.status !== "terminated").map(a => a.department)).size} -
-
-
-
{t("hiring.monthlyPayroll")}
-
- ${agents.filter(a => a.status !== "terminated" && a.status !== "pending_approval").reduce((s, a) => s + a.budgetMonthlyUsd, 0).toLocaleString()} -
-
-
- - -
- {(["executive", "engineering", "product", "design", "marketing", "sales", "hr", "finance", "legal", "customer_support", "operations", "research"] as const).map(dept => { - const deptAgents = agents.filter(a => a.department === dept && a.status !== "terminated"); - const pending = deptAgents.filter(a => a.status === "pending_approval").length; - const active = deptAgents.filter(a => a.status !== "pending_approval").length; - if (active === 0 && pending === 0) return null; - return ( -
-
{dept.replace("_", " ")}
-
- {active} - {t("hiring.active")} - {pending > 0 && ( - <> - +{pending} - {t("hiring.pendingApproval")} - - )} -
-
- ); - })} -
-
-
- ); -} +// Backward-compatible re-exports from decomposed hiring components +export { HiringPipeline } from "./hiring/HiringPipeline"; diff --git a/src/renderer/components/hiring/HiringPipeline.tsx b/src/renderer/components/hiring/HiringPipeline.tsx new file mode 100644 index 00000000..7a19b2a8 --- /dev/null +++ b/src/renderer/components/hiring/HiringPipeline.tsx @@ -0,0 +1,293 @@ +import { useState } from "react"; +import { + ArrowRight, + Check, + UserPlus, + X, +} from "@phosphor-icons/react"; +import type { AgentRecord, ApprovalRecord } from "@shared/types"; +import { useT } from "../../i18n"; +import { getAgentInitials } from "../../lib/formatters"; +import { unwrap } from "../../lib/desktop"; +import { ActionButton, EmptyState, Panel, StatusPill } from "../ui"; +import { + type HiringStage, + type HiringCandidate, + getHiringStage, + stageColor, + STAGE_ORDER, +} from "./hiring-helpers"; + +interface HiringPipelineProps { + agents: AgentRecord[]; + approvals: ApprovalRecord[]; + onRefresh: () => Promise; + onNavigateToAgent: (agentId: string) => void; +} + +export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent }: HiringPipelineProps) { + const t = useT(); + const [filterStage, setFilterStage] = useState("all"); + + const stageLabelMap: Record = { + requisition: t("hiring.requisition"), + pending_approval: t("hiring.pendingApproval"), + approved: t("hiring.approved"), + onboarding: t("hiring.onboarding"), + active: t("hiring.active"), + rejected: t("status.rejected"), + }; + + const hireApprovals = approvals.filter(a => a.type === "hire_agent"); + + const candidates: HiringCandidate[] = agents.map(agent => { + const approval = hireApprovals.find(a => { + if (a.relatedAgentId === agent.id) return true; + if (!a.payloadJson) return false; + try { return JSON.parse(a.payloadJson).agentId === agent.id; } catch { return false; } + }) ?? null; + return { + agent, + approval, + stage: getHiringStage(agent, approval), + }; + }); + + const pendingHireApprovals = hireApprovals.filter(a => { + return a.state === "pending" && !candidates.some(c => c.approval?.id === a.id); + }); + + const allCandidates = [ + ...candidates, + ...pendingHireApprovals.map(a => ({ + agent: null, + approval: a, + stage: "pending_approval" as HiringStage, + })), + ].sort((a, b) => STAGE_ORDER.indexOf(a.stage) - STAGE_ORDER.indexOf(b.stage)); + + const filteredCandidates = filterStage === "all" + ? allCandidates + : allCandidates.filter(c => c.stage === filterStage); + + const stageCounts = STAGE_ORDER.reduce((acc, stage) => { + acc[stage] = allCandidates.filter(c => c.stage === stage).length; + return acc; + }, {} as Record); + + const pendingCount = stageCounts.requisition + stageCounts.pending_approval; + const activeHires = allCandidates.filter(c => c.stage !== "active" && c.stage !== "rejected").length; + + return ( +
+ +
+ {STAGE_ORDER.filter(s => s !== "rejected").map((stage, idx) => ( +
+ + {idx < STAGE_ORDER.length - 2 && ( + + )} +
+ ))} + {stageCounts.rejected > 0 && ( + + )} +
+
+ + {pendingCount > 0 && ( + +
+ {allCandidates + .filter(c => c.stage === "requisition" || c.stage === "pending_approval") + .map((candidate) => { + const a = candidate.approval; + const ag = candidate.agent; + return ( +
+
+ +
+
+
+ {ag?.name ?? t("hiring.pendingHires")} + + {stageLabelMap[candidate.stage]} + +
+
+ {ag ? `${ag.role} — ${ag.title}` : a?.payloadSummary} +
+ {ag?.capabilities && ( +
{ag.capabilities}
+ )} +
+
+ {a && a.state === "pending" && ( + <> + { + unwrap(await window.agentCompany.decideApproval({ + companyId: a.companyId, + approvalId: a.id, + state: "approved", + decisionNote: "Hire approved via hiring pipeline", + })); + await onRefresh(); + }} + /> + { + unwrap(await window.agentCompany.decideApproval({ + companyId: a.companyId, + approvalId: a.id, + state: "rejected", + decisionNote: "Hire rejected via hiring pipeline", + })); + await onRefresh(); + }} + /> + + )} +
+
+ ); + })} +
+
+ )} + + setFilterStage("all")} /> + ) : undefined + } + > + {filteredCandidates.length === 0 ? ( + + ) : ( +
+ {filteredCandidates.map((candidate) => { + const ag = candidate.agent; + if (!ag) return null; + const manager = agents.find(a => a.id === ag.reportsTo); + const requestedBy = candidate.approval?.requestedByAgentId + ? agents.find(a => a.id === candidate.approval!.requestedByAgentId) + : null; + return ( +
onNavigateToAgent(ag.id)} + > +
+ {getAgentInitials(ag.name)} +
+
+
+ {ag.name} + + {stageLabelMap[candidate.stage]} + + +
+
+ {ag.role} — {ag.title}{ag.department ? ` | ${ag.department}` : ""} +
+
+
+
+ {manager ? `${t("hiring.reportsTo")} ${manager.name}` : t("hiring.topLevel")} +
+ {requestedBy && ( +
{t("hiring.proposedBy")} {requestedBy.name}
+ )} +
${ag.budgetMonthlyUsd}/mo
+
+
+ ); + })} +
+ )} +
+ +
+
+
{t("hiring.totalAgents")}
+
{agents.filter(a => a.status !== "terminated").length}
+
+
+
{t("hiring.pendingHires")}
+
{agents.filter(a => a.status === "pending_approval").length}
+
+
+
{t("hiring.departments")}
+
+ {new Set(agents.filter(a => a.department && a.status !== "terminated").map(a => a.department)).size} +
+
+
+
{t("hiring.monthlyPayroll")}
+
+ ${agents.filter(a => a.status !== "terminated" && a.status !== "pending_approval").reduce((s, a) => s + a.budgetMonthlyUsd, 0).toLocaleString()} +
+
+
+ + +
+ {(["executive", "engineering", "product", "design", "marketing", "sales", "hr", "finance", "legal", "customer_support", "operations", "research"] as const).map(dept => { + const deptAgents = agents.filter(a => a.department === dept && a.status !== "terminated"); + const pending = deptAgents.filter(a => a.status === "pending_approval").length; + const active = deptAgents.filter(a => a.status !== "pending_approval").length; + if (active === 0 && pending === 0) return null; + return ( +
+
{dept.replace("_", " ")}
+
+ {active} + {t("hiring.active")} + {pending > 0 && ( + <> + +{pending} + {t("hiring.pendingApproval")} + + )} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/renderer/components/hiring/hiring-helpers.ts b/src/renderer/components/hiring/hiring-helpers.ts new file mode 100644 index 00000000..a25d7f03 --- /dev/null +++ b/src/renderer/components/hiring/hiring-helpers.ts @@ -0,0 +1,36 @@ +import type { AgentRecord, ApprovalRecord } from "@shared/types"; + +export type HiringStage = "requisition" | "pending_approval" | "approved" | "onboarding" | "active" | "rejected"; + +export interface HiringCandidate { + agent: AgentRecord | null; + approval: ApprovalRecord | null; + stage: HiringStage; +} + +export function getHiringStage(agent: AgentRecord, approval: ApprovalRecord | null): HiringStage { + if (agent.status === "pending_approval") { + if (!approval) return "requisition"; + if (approval.state === "pending") return "pending_approval"; + if (approval.state === "rejected") return "rejected"; + if (approval.state === "revision_requested") return "requisition"; + return "approved"; + } + if (agent.status === "idle" && approval?.state === "approved") { + return "onboarding"; + } + return "active"; +} + +export function stageColor(stage: HiringStage): string { + switch (stage) { + case "requisition": return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"; + case "pending_approval": return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"; + case "approved": return "bg-[color:var(--success-soft)] text-[color:var(--success)]"; + case "onboarding": return "bg-[color:var(--accent-soft)] text-[color:var(--accent)]"; + case "active": return "bg-[color:var(--success-soft)] text-[color:var(--success)]"; + case "rejected": return "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"; + } +} + +export const STAGE_ORDER: HiringStage[] = ["requisition", "pending_approval", "approved", "onboarding", "active", "rejected"];