refactor(execution): split ApprovalInspector 346-line god-component
Decompose monolithic src/renderer/components/execution/ApprovalInspector.tsx into src/renderer/components/execution/approval-inspector/: - ApprovalInspector.tsx (168 lines, orchestrator only) - ApprovalSummary.tsx, ProposedHireCard.tsx, ApprovalDocumentsPanel.tsx - DeliverableReviewPanel.tsx, SchedulePicker.tsx, DecisionForm.tsx - TypeBadge.tsx, useApprovalPayload.ts, index.ts (barrel) Hoist JSON.parse out of JSX IIFE into useApprovalPayload hook with typed return. Replace roll-your-own role=radio spans with native input[type=radio]. Extract getTypeBadgeClass switch into TypeBadge component. Update execution.tsx barrel to re-export from new path. Business logic preserved: approval decision flow, schedule validation, deliverable review, document editing, delete confirmation.
This commit is contained in:
1 parent
cc02b45864
commit
0f1b313272
12 files changed
+763
-347
No files matched your search
@@ -1,346 +0,0 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Briefcase, CheckCircle, Clock, ClockCounterClockwise, Robot, Trash, XCircle } from "@phosphor-icons/react"
|
||||
import { CaretDown, CaretUp } from "@phosphor-icons/react"
|
||||
import type { AgentRecord, ApprovalRecord, DocumentRecord, TaskRecord } from "@shared/types"
|
||||
import { extractApprovalDocumentReferences, type ApprovalDocumentReference } from "@shared/approval-documents"
|
||||
import { useT } from "../../i18n"
|
||||
import { formatMoney, formatTime } from "../../lib/formatters"
|
||||
import { unwrap } from "../../lib/desktop"
|
||||
import { ActionButton, ConfirmDialog, InlineNotice, Input, StatusPill, TextArea } from "../ui"
|
||||
import { describeActionError, parseApprovalPayloadJson } from "./execution-utils"
|
||||
import { SocialDraftPreview } from "./SocialDraftPreview"
|
||||
import { useAppStore } from "../../state/store"
|
||||
|
||||
function getTypeBadgeClass(type: string): string {
|
||||
if (type === "hire_agent") return "bg-accent text-primary"
|
||||
if (type === "approve_ceo_strategy") return "bg-warning-muted text-warning"
|
||||
if (type === "secret_access") return "bg-destructive-muted text-destructive"
|
||||
if (type === "social_post") return "bg-accent text-primary"
|
||||
if (type === "deliverable_review") return "bg-accent text-primary"
|
||||
return "bg-muted text-muted-foreground"
|
||||
}
|
||||
|
||||
function ApprovalDocumentEditor({
|
||||
companyId,
|
||||
document,
|
||||
fallback,
|
||||
}: {
|
||||
companyId: string
|
||||
document: DocumentRecord
|
||||
fallback?: ApprovalDocumentReference
|
||||
}) {
|
||||
const addToast = useAppStore((s) => s.addToast)
|
||||
const [title, setTitle] = useState(document.title)
|
||||
const [content, setContent] = useState(document.content ?? "")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.trim()) {
|
||||
setSaveError("Document title is required.")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveDocument({
|
||||
id: document.id,
|
||||
companyId,
|
||||
type: document.type,
|
||||
title: title.trim(),
|
||||
content,
|
||||
authorAgentId: document.authorAgentId ?? null,
|
||||
reviewerAgentId: document.reviewerAgentId ?? null,
|
||||
projectId: document.projectId ?? null,
|
||||
goalId: document.goalId ?? null,
|
||||
parentDocId: document.parentDocId ?? null,
|
||||
version: document.version,
|
||||
status: document.status,
|
||||
tagsJson: document.tagsJson,
|
||||
}))
|
||||
addToast(`Saved document: ${title.trim()}`, "success")
|
||||
} catch (error) {
|
||||
setSaveError(describeActionError(error))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-border px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">{fallback?.title ?? document.title}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{(fallback?.type ?? document.type).replaceAll("_", " ")} · v{document.version} · {document.status}</div>
|
||||
</div>
|
||||
<ActionButton label={saving ? "Saving..." : "Save changes"} icon={CheckCircle} tone="accent" onClick={() => void handleSave()} loading={saving} />
|
||||
</div>
|
||||
{saveError ? <InlineNotice message={saveError} /> : null}
|
||||
<Input label="Title" value={title} onChange={setTitle} placeholder="Document title" />
|
||||
<TextArea label="Content" value={content} onChange={setContent} rows={12} placeholder="Document content" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ApprovalDocumentsPanel({
|
||||
approval,
|
||||
documents,
|
||||
companyId,
|
||||
}: {
|
||||
approval: ApprovalRecord
|
||||
documents?: DocumentRecord[]
|
||||
companyId: string
|
||||
}) {
|
||||
const references = useMemo(
|
||||
() => extractApprovalDocumentReferences(approval.payloadJson),
|
||||
[approval.payloadJson],
|
||||
)
|
||||
|
||||
if (references.length === 0) return null
|
||||
|
||||
const fullDocs = documents
|
||||
? references
|
||||
.map((reference) => ({
|
||||
reference,
|
||||
document: documents.find((entry) => entry.id === reference.id) ?? null,
|
||||
}))
|
||||
.filter((entry): entry is typeof entry & { document: NonNullable<typeof entry.document> } => entry.document != null)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">Linked Documents</div>
|
||||
{fullDocs.length > 0 ? fullDocs.map(({ reference, document }) => (
|
||||
<ApprovalDocumentEditor
|
||||
key={document.id}
|
||||
companyId={companyId}
|
||||
document={document}
|
||||
fallback={reference}
|
||||
/>
|
||||
)) : references.map((reference) => (
|
||||
<div key={reference.id} className="rounded-lg border border-border bg-muted px-4 py-3">
|
||||
<div className="text-[13px] font-medium text-foreground">{reference.title ?? reference.id}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{reference.type?.replaceAll("_", " ") ?? "document"} · {reference.id}</div>
|
||||
<div className="mt-2 text-[12px] text-muted-foreground">The linked document is not loaded in the current snapshot yet. Reopen the approval after the next refresh.</div>
|
||||
</div>
|
||||
))}
|
||||
{approval.impactSummary ? <div className="text-[11px] text-muted-foreground">{approval.impactSummary}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApprovalInspector({ approval, agents, tasks, documents, companyId, onDecide, onDeleted }: {
|
||||
approval: ApprovalRecord; agents: AgentRecord[]; tasks: TaskRecord[]; documents?: DocumentRecord[]; companyId: string
|
||||
onDecide: (state: "approved" | "rejected" | "revision_requested", note?: string) => Promise<void>; onDeleted: () => Promise<void>
|
||||
}) {
|
||||
const t = useT()
|
||||
const addToast = useAppStore((s) => s.addToast)
|
||||
const [decisionNote, setDecisionNote] = useState(""); const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [actionError, setActionError] = useState<string | null>(null); const [acting, setActing] = useState<"approved"|"rejected"|"revision_requested"|"delete"|null>(null)
|
||||
const [scheduleMode, setScheduleMode] = useState<"immediate" | "scheduled">("immediate")
|
||||
const [scheduledAt, setScheduledAt] = useState("")
|
||||
const [scheduleError, setScheduleError] = useState<string | null>(null)
|
||||
const requestingAgent = agents.find((a) => a.id === approval.requestedByAgentId) ?? null
|
||||
const relatedTask = tasks.find((t) => t.id === approval.relatedTaskId) ?? null
|
||||
const isDecided = approval.state !== "pending"
|
||||
const isSocialPost = approval.type === "social_post"
|
||||
|
||||
async function handleDelete() { setActionError(null); setActing("delete"); try { unwrap(await window.agentCompany.deleteApproval({ id: approval.id, companyId })); setConfirmDelete(false); await onDeleted() } catch (e) { setActionError(describeActionError(e)) } finally { setActing(null) } }
|
||||
|
||||
async function handleDecision(state: "approved"|"rejected"|"revision_requested") {
|
||||
setActionError(null)
|
||||
setScheduleError(null)
|
||||
|
||||
// Validate schedule if social_post and user picked "scheduled"
|
||||
if (isSocialPost && state === "approved" && scheduleMode === "scheduled") {
|
||||
if (!scheduledAt) {
|
||||
setScheduleError("Please select a date and time for scheduling.")
|
||||
return
|
||||
}
|
||||
const chosenTime = new Date(scheduledAt).getTime()
|
||||
if (chosenTime <= Date.now()) {
|
||||
setScheduleError("Scheduled time must be in the future.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setActing(state)
|
||||
try {
|
||||
// For social_post approvals with a schedule, include the schedule info in the decision note
|
||||
let noteForDecision = decisionNote || undefined
|
||||
if (isSocialPost && state === "approved" && scheduleMode === "scheduled" && scheduledAt) {
|
||||
const scheduleInfo = `[scheduled:${new Date(scheduledAt).toISOString()}]`
|
||||
noteForDecision = noteForDecision ? `${noteForDecision} ${scheduleInfo}` : scheduleInfo
|
||||
}
|
||||
await onDecide(state, noteForDecision)
|
||||
if (state === "approved") addToast("Approval granted — agent is moving forward", "success")
|
||||
else if (state === "rejected") addToast("Approval rejected", "warn")
|
||||
else if (state === "revision_requested") addToast("Revision requested — agent will revise and resubmit", "warn")
|
||||
} catch (e) {
|
||||
setActionError(describeActionError(e))
|
||||
} finally {
|
||||
setActing(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute minimum datetime for schedule picker: now + 5 minutes
|
||||
const minDatetime = (() => {
|
||||
const d = new Date(Date.now() + 5 * 60 * 1000)
|
||||
// Format as YYYY-MM-DDTHH:mm for datetime-local input
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
})()
|
||||
|
||||
const socialPlatform = (() => {
|
||||
if (!isSocialPost || !approval.payloadJson) return "other"
|
||||
try { return (JSON.parse(approval.payloadJson) as { platform?: string }).platform ?? "other" } catch { return "other" }
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfirmDialog open={confirmDelete} title={t("exec.deleteApproval")} message={t("exec.deleteApprovalConfirm")} errorMessage={actionError} onConfirm={() => void handleDelete()} onCancel={() => { setActionError(null); setConfirmDelete(false) }} />
|
||||
{actionError && !confirmDelete ? <InlineNotice message={actionError} /> : null}
|
||||
<div className="border-y border-border py-4 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.08em] ${getTypeBadgeClass(approval.type)}`}>{approval.type.replaceAll("_", " ")}</span>
|
||||
<StatusPill status={approval.state} />
|
||||
</div>
|
||||
<div className="text-[13px] font-medium text-foreground">{approval.payloadSummary}</div>
|
||||
{approval.impactSummary ? <div className="text-[13px] text-muted-foreground">{approval.impactSummary}</div> : null}
|
||||
<div className="grid gap-2 text-[13px] md:grid-cols-2">
|
||||
<div className="flex items-center gap-2 text-muted-foreground"><Robot size={14} />Requested by: <span className="font-medium text-foreground">{requestingAgent?.name ?? "Unknown"}</span>{requestingAgent?.role ? <span className="text-[10px] text-muted-foreground">({requestingAgent.role})</span> : null}</div>
|
||||
{relatedTask ? <div className="flex items-center gap-2 text-muted-foreground"><Briefcase size={14} />Task: <span className="font-medium text-foreground">{relatedTask.title}</span></div> : null}
|
||||
<div className="text-muted-foreground">Created: <span className="font-mono text-foreground">{formatTime(approval.createdAt)}</span></div>
|
||||
{approval.updatedAt !== approval.createdAt ? <div className="text-muted-foreground">Updated: <span className="font-mono text-foreground">{formatTime(approval.updatedAt)}</span></div> : null}
|
||||
</div>
|
||||
{approval.type === "hire_agent" ? (() => { const payload = parseApprovalPayloadJson(approval); const payloadAgentId = typeof payload?.agentId === "string" ? payload.agentId : null; const reqSnap = payload?.requestedConfigurationSnapshot && typeof payload.requestedConfigurationSnapshot === "object" && !Array.isArray(payload.requestedConfigurationSnapshot) ? payload.requestedConfigurationSnapshot as Record<string, unknown> : null; const proposedAgent = (approval.relatedAgentId ? (agents.find((a) => a.id === approval.relatedAgentId) ?? null) : null) ?? (payloadAgentId ? (agents.find((a) => a.id === payloadAgentId) ?? null) : null); const mgrId = proposedAgent?.reportsTo ?? (typeof reqSnap?.reportsTo === "string" ? reqSnap.reportsTo : null); const mgr = mgrId ? (agents.find((a) => a.id === mgrId) ?? null) : null; if (!proposedAgent && !reqSnap) return null; return (
|
||||
<div className="rounded-lg border border-[color:var(--primary)] border-opacity-30 bg-accent px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">Proposed Hire</div><div className="grid gap-1.5 text-[12px]">
|
||||
<div><span className="text-muted-foreground">Name:</span> <span className="font-medium text-foreground">{proposedAgent?.name ?? (typeof reqSnap?.name === "string" ? reqSnap.name : "Unspecified")}</span></div>
|
||||
<div><span className="text-muted-foreground">Role:</span> <span className="text-foreground">{proposedAgent?.role ?? (typeof reqSnap?.role === "string" ? reqSnap.role : "Unspecified")}</span></div>
|
||||
{(proposedAgent?.title || typeof reqSnap?.title === "string") ? <div><span className="text-muted-foreground">Title:</span> <span className="text-foreground">{proposedAgent?.title || reqSnap?.title as string}</span></div> : null}
|
||||
{(proposedAgent?.capabilities || typeof reqSnap?.capabilities === "string") ? <div><span className="text-muted-foreground">Capabilities:</span> <span className="text-foreground">{proposedAgent?.capabilities || reqSnap?.capabilities as string}</span></div> : null}
|
||||
<div><span className="text-muted-foreground">Connector:</span> <span className="font-mono text-foreground">{proposedAgent?.connectorId ?? (typeof reqSnap?.connectorId === "string" ? reqSnap.connectorId : "Unknown")}</span></div>
|
||||
<div><span className="text-muted-foreground">Budget:</span> <span className="font-mono text-foreground">{formatMoney(proposedAgent?.budgetMonthlyUsd ?? (typeof reqSnap?.budgetMonthlyUsd === "number" ? reqSnap.budgetMonthlyUsd : 0))}/mo</span></div>
|
||||
{mgr ? <div><span className="text-muted-foreground">Reports to:</span> <span className="text-foreground">{mgr.name}</span></div> : null}
|
||||
</div></div>) })() : null}
|
||||
{approval.type === "approve_ceo_strategy" ? <div className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-warning-muted px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-warning">Organization Strategy</div><div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">{approval.payloadSummary}</div></div> : null}
|
||||
<ApprovalDocumentsPanel approval={approval} documents={documents} companyId={companyId} />
|
||||
{approval.type === "deliverable_review" && !extractApprovalDocumentReferences(approval.payloadJson).length ? <DeliverableReviewPanel approval={approval} documents={documents} agents={agents} /> : null}
|
||||
{isSocialPost && approval.payloadJson ? (
|
||||
<SocialDraftPreview
|
||||
payloadJson={approval.payloadJson}
|
||||
platform={socialPlatform}
|
||||
isPending={approval.state === "pending"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Schedule picker for social_post approvals */}
|
||||
{isSocialPost && !isDecided ? (
|
||||
<div className="space-y-3">
|
||||
<div role="radiogroup" aria-label="Post timing" className="space-y-2">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<span
|
||||
role="radio"
|
||||
aria-checked={scheduleMode === "immediate"}
|
||||
tabIndex={0}
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 ${scheduleMode === "immediate" ? "border-[color:var(--primary)]" : "border-border"}`}
|
||||
onClick={() => { setScheduleMode("immediate"); setScheduleError(null) }}
|
||||
onKeyDown={(e) => { if (e.key === " " || e.key === "Enter") { setScheduleMode("immediate"); setScheduleError(null) } }}
|
||||
>
|
||||
{scheduleMode === "immediate" ? <span className="h-2 w-2 rounded-full bg-primary" aria-hidden="true" /> : null}
|
||||
</span>
|
||||
<span className="text-[13px] font-medium text-foreground">Post immediately</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<span
|
||||
role="radio"
|
||||
aria-checked={scheduleMode === "scheduled"}
|
||||
tabIndex={0}
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 ${scheduleMode === "scheduled" ? "border-[color:var(--primary)]" : "border-border"}`}
|
||||
onClick={() => setScheduleMode("scheduled")}
|
||||
onKeyDown={(e) => { if (e.key === " " || e.key === "Enter") setScheduleMode("scheduled") }}
|
||||
>
|
||||
{scheduleMode === "scheduled" ? <span className="h-2 w-2 rounded-full bg-primary" aria-hidden="true" /> : null}
|
||||
</span>
|
||||
<span className="text-[13px] font-medium text-foreground">Schedule for later</span>
|
||||
</label>
|
||||
</div>
|
||||
{scheduleMode === "scheduled" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={16} className="text-muted-foreground" />
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
min={minDatetime}
|
||||
onChange={(e) => { setScheduledAt(e.target.value); setScheduleError(null) }}
|
||||
className="rounded-lg border border-border bg-card px-3.5 py-2.5 text-[13px] text-foreground focus:border-[color:var(--primary)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{scheduleError ? <InlineNotice message={scheduleError} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isDecided ? <div><label className="mb-1 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Decision note</label><TextArea value={decisionNote} onChange={setDecisionNote} placeholder="Add a note (optional)" rows={3} /></div> : null}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{!isDecided ? <>
|
||||
<ActionButton
|
||||
label={isSocialPost ? "Approve & Post" : t("exec.approve")}
|
||||
tone="accent"
|
||||
onClick={() => void handleDecision("approved")}
|
||||
icon={CheckCircle}
|
||||
loading={acting === "approved"}
|
||||
/>
|
||||
<ActionButton label={t("exec.requestRevision")} onClick={() => void handleDecision("revision_requested")} icon={ClockCounterClockwise} loading={acting === "revision_requested"} />
|
||||
<ActionButton label={t("exec.reject")} tone="danger" onClick={() => void handleDecision("rejected")} icon={XCircle} loading={acting === "rejected"} />
|
||||
</> : null}
|
||||
{isDecided ? <ActionButton label="Delete" onClick={() => setConfirmDelete(true)} icon={Trash} tone="danger" loading={acting === "delete"} /> : null}
|
||||
</div>
|
||||
{approval.decisionNote ? <div className="border-t border-border pt-4 text-[13px] text-muted-foreground">Last note: {approval.decisionNote}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeliverableReviewPanel({ approval, documents, agents }: { approval: ApprovalRecord; documents?: DocumentRecord[]; agents: AgentRecord[] }) {
|
||||
const payload = parseApprovalPayloadJson(approval)
|
||||
const docRefs = Array.isArray(payload?.documents) ? payload.documents as Array<{ id: string; type: string; title: string }> : []
|
||||
const docIds = Array.isArray(payload?.documentIds) ? payload.documentIds as string[] : docRefs.map((d) => d.id)
|
||||
const fullDocs = documents ? docIds.map((id) => documents.find((d) => d.id === id)).filter(Boolean) as DocumentRecord[] : []
|
||||
const [expandedDocId, setExpandedDocId] = useState<string | null>(fullDocs.length === 1 ? fullDocs[0]!.id : null)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">Deliverable Review</div>
|
||||
{fullDocs.length > 0 ? fullDocs.map((doc) => {
|
||||
const isExpanded = expandedDocId === doc.id
|
||||
const author = doc.authorAgentId ? agents.find((a) => a.id === doc.authorAgentId) : null
|
||||
return (
|
||||
<div key={doc.id} className="rounded-lg border border-border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedDocId(isExpanded ? null : doc.id)}
|
||||
className="flex w-full cursor-pointer items-center gap-3 bg-muted px-4 py-3 text-left transition hover:bg-card"
|
||||
>
|
||||
<span className="shrink-0 rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase text-primary">{doc.type.replaceAll("_", " ")}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{doc.title}</span>
|
||||
{isExpanded ? <CaretUp size={14} className="shrink-0 text-muted-foreground" /> : <CaretDown size={14} className="shrink-0 text-muted-foreground" />}
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
{author ? <div className="mb-2 text-[11px] text-muted-foreground">Author: <span className="font-medium text-foreground">{author.name}</span> ({author.role})</div> : null}
|
||||
<div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">{doc.content || "No content."}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}) : docRefs.length > 0 ? docRefs.map((doc) => (
|
||||
<div key={doc.id} className="flex items-center gap-2 rounded-lg border border-border bg-muted px-4 py-3 text-[12px]">
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase text-primary">{doc.type.replaceAll("_", " ")}</span>
|
||||
<span className="font-medium text-foreground">{doc.title}</span>
|
||||
</div>
|
||||
)) : <div className="text-[12px] text-muted-foreground">{approval.payloadSummary}</div>}
|
||||
{approval.impactSummary ? <div className="text-[11px] text-muted-foreground">{approval.impactSummary}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { CheckCircle } from "@phosphor-icons/react"
|
||||
import type { ApprovalRecord, DocumentRecord } from "@shared/types"
|
||||
import { extractApprovalDocumentReferences, type ApprovalDocumentReference } from "@shared/approval-documents"
|
||||
import { unwrap } from "../../../lib/desktop"
|
||||
import { useAppStore } from "../../../state/store"
|
||||
import { ActionButton, InlineNotice, Input, TextArea } from "../../ui"
|
||||
import { describeActionError } from "../execution-utils"
|
||||
|
||||
function ApprovalDocumentEditor({
|
||||
companyId,
|
||||
document,
|
||||
fallback,
|
||||
}: {
|
||||
companyId: string
|
||||
document: DocumentRecord
|
||||
fallback?: ApprovalDocumentReference
|
||||
}) {
|
||||
const addToast = useAppStore((s) => s.addToast)
|
||||
const [title, setTitle] = useState(document.title)
|
||||
const [content, setContent] = useState(document.content ?? "")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.trim()) {
|
||||
setSaveError("Document title is required.")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.saveDocument({
|
||||
id: document.id,
|
||||
companyId,
|
||||
type: document.type,
|
||||
title: title.trim(),
|
||||
content,
|
||||
authorAgentId: document.authorAgentId ?? null,
|
||||
reviewerAgentId: document.reviewerAgentId ?? null,
|
||||
projectId: document.projectId ?? null,
|
||||
goalId: document.goalId ?? null,
|
||||
parentDocId: document.parentDocId ?? null,
|
||||
version: document.version,
|
||||
status: document.status,
|
||||
tagsJson: document.tagsJson,
|
||||
}),
|
||||
)
|
||||
addToast(`Saved document: ${title.trim()}`, "success")
|
||||
} catch (error) {
|
||||
setSaveError(describeActionError(error))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-border px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">{fallback?.title ?? document.title}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{(fallback?.type ?? document.type).replaceAll("_", " ")} · v{document.version} · {document.status}
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
label={saving ? "Saving..." : "Save changes"}
|
||||
icon={CheckCircle}
|
||||
tone="accent"
|
||||
onClick={() => void handleSave()}
|
||||
loading={saving}
|
||||
/>
|
||||
</div>
|
||||
{saveError ? <InlineNotice message={saveError} /> : null}
|
||||
<Input label="Title" value={title} onChange={setTitle} placeholder="Document title" />
|
||||
<TextArea label="Content" value={content} onChange={setContent} rows={12} placeholder="Document content" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApprovalDocumentsPanel({
|
||||
approval,
|
||||
documents,
|
||||
companyId,
|
||||
}: {
|
||||
approval: ApprovalRecord
|
||||
documents?: DocumentRecord[]
|
||||
companyId: string
|
||||
}) {
|
||||
const references = useMemo(
|
||||
() => extractApprovalDocumentReferences(approval.payloadJson),
|
||||
[approval.payloadJson],
|
||||
)
|
||||
|
||||
if (references.length === 0) return null
|
||||
|
||||
const fullDocs = documents
|
||||
? references
|
||||
.map((reference) => ({
|
||||
reference,
|
||||
document: documents.find((entry) => entry.id === reference.id) ?? null,
|
||||
}))
|
||||
.filter(
|
||||
(entry): entry is typeof entry & { document: NonNullable<typeof entry.document> } =>
|
||||
entry.document != null,
|
||||
)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">Linked Documents</div>
|
||||
{fullDocs.length > 0
|
||||
? fullDocs.map(({ reference, document }) => (
|
||||
<ApprovalDocumentEditor
|
||||
key={document.id}
|
||||
companyId={companyId}
|
||||
document={document}
|
||||
fallback={reference}
|
||||
/>
|
||||
))
|
||||
: references.map((reference) => (
|
||||
<div key={reference.id} className="rounded-lg border border-border bg-muted px-4 py-3">
|
||||
<div className="text-[13px] font-medium text-foreground">{reference.title ?? reference.id}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{reference.type?.replaceAll("_", " ") ?? "document"} · {reference.id}
|
||||
</div>
|
||||
<div className="mt-2 text-[12px] text-muted-foreground">
|
||||
The linked document is not loaded in the current snapshot yet. Reopen the approval after the next refresh.
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{approval.impactSummary ? (
|
||||
<div className="text-[11px] text-muted-foreground">{approval.impactSummary}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState } from "react"
|
||||
import type { AgentRecord, ApprovalRecord, DocumentRecord, TaskRecord } from "@shared/types"
|
||||
import { extractApprovalDocumentReferences } from "@shared/approval-documents"
|
||||
import { useT } from "../../../i18n"
|
||||
import { unwrap } from "../../../lib/desktop"
|
||||
import { ConfirmDialog, InlineNotice } from "../../ui"
|
||||
import { useAppStore } from "../../../state/store"
|
||||
import { describeActionError } from "../execution-utils"
|
||||
import { SocialDraftPreview } from "../SocialDraftPreview"
|
||||
import { ApprovalSummary } from "./ApprovalSummary"
|
||||
import { ProposedHireCard } from "./ProposedHireCard"
|
||||
import { ApprovalDocumentsPanel } from "./ApprovalDocumentsPanel"
|
||||
import { DeliverableReviewPanel } from "./DeliverableReviewPanel"
|
||||
import { SchedulePicker, type ScheduleMode } from "./SchedulePicker"
|
||||
import { DecisionForm, type ActingState, type DecisionState } from "./DecisionForm"
|
||||
import { useSocialPlatform } from "./useApprovalPayload"
|
||||
|
||||
export function ApprovalInspector({
|
||||
approval,
|
||||
agents,
|
||||
tasks,
|
||||
documents,
|
||||
companyId,
|
||||
onDecide,
|
||||
onDeleted,
|
||||
}: {
|
||||
approval: ApprovalRecord
|
||||
agents: AgentRecord[]
|
||||
tasks: TaskRecord[]
|
||||
documents?: DocumentRecord[]
|
||||
companyId: string
|
||||
onDecide: (state: DecisionState, note?: string) => Promise<void>
|
||||
onDeleted: () => Promise<void>
|
||||
}) {
|
||||
const t = useT()
|
||||
const addToast = useAppStore((s) => s.addToast)
|
||||
const [decisionNote, setDecisionNote] = useState("")
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [acting, setActing] = useState<ActingState>(null)
|
||||
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>("immediate")
|
||||
const [scheduledAt, setScheduledAt] = useState("")
|
||||
const [scheduleError, setScheduleError] = useState<string | null>(null)
|
||||
|
||||
const requestingAgent = agents.find((a) => a.id === approval.requestedByAgentId) ?? null
|
||||
const relatedTask = tasks.find((task) => task.id === approval.relatedTaskId) ?? null
|
||||
const isDecided = approval.state !== "pending"
|
||||
const isSocialPost = approval.type === "social_post"
|
||||
const socialPlatform = useSocialPlatform(approval)
|
||||
const hasDocumentReferences = extractApprovalDocumentReferences(approval.payloadJson).length > 0
|
||||
|
||||
async function handleDelete() {
|
||||
setActionError(null)
|
||||
setActing("delete")
|
||||
try {
|
||||
unwrap(await window.agentCompany.deleteApproval({ id: approval.id, companyId }))
|
||||
setConfirmDelete(false)
|
||||
await onDeleted()
|
||||
} catch (error) {
|
||||
setActionError(describeActionError(error))
|
||||
} finally {
|
||||
setActing(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecision(state: DecisionState) {
|
||||
setActionError(null)
|
||||
setScheduleError(null)
|
||||
|
||||
if (isSocialPost && state === "approved" && scheduleMode === "scheduled") {
|
||||
if (!scheduledAt) {
|
||||
setScheduleError("Please select a date and time for scheduling.")
|
||||
return
|
||||
}
|
||||
const chosenTime = new Date(scheduledAt).getTime()
|
||||
if (chosenTime <= Date.now()) {
|
||||
setScheduleError("Scheduled time must be in the future.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setActing(state)
|
||||
try {
|
||||
let noteForDecision = decisionNote || undefined
|
||||
if (isSocialPost && state === "approved" && scheduleMode === "scheduled" && scheduledAt) {
|
||||
const scheduleInfo = `[scheduled:${new Date(scheduledAt).toISOString()}]`
|
||||
noteForDecision = noteForDecision ? `${noteForDecision} ${scheduleInfo}` : scheduleInfo
|
||||
}
|
||||
await onDecide(state, noteForDecision)
|
||||
if (state === "approved") addToast("Approval granted — agent is moving forward", "success")
|
||||
else if (state === "rejected") addToast("Approval rejected", "warn")
|
||||
else if (state === "revision_requested") addToast("Revision requested — agent will revise and resubmit", "warn")
|
||||
} catch (error) {
|
||||
setActionError(describeActionError(error))
|
||||
} finally {
|
||||
setActing(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title={t("exec.deleteApproval")}
|
||||
message={t("exec.deleteApprovalConfirm")}
|
||||
errorMessage={actionError}
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => {
|
||||
setActionError(null)
|
||||
setConfirmDelete(false)
|
||||
}}
|
||||
/>
|
||||
{actionError && !confirmDelete ? <InlineNotice message={actionError} /> : null}
|
||||
<div className="border-y border-border py-4 space-y-4">
|
||||
<ApprovalSummary approval={approval} requestingAgent={requestingAgent} relatedTask={relatedTask} />
|
||||
{approval.type === "hire_agent" ? <ProposedHireCard approval={approval} agents={agents} /> : null}
|
||||
{approval.type === "approve_ceo_strategy" ? (
|
||||
<div className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-warning-muted px-4 py-3">
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-warning">
|
||||
Organization Strategy
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">
|
||||
{approval.payloadSummary}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ApprovalDocumentsPanel approval={approval} documents={documents} companyId={companyId} />
|
||||
{approval.type === "deliverable_review" && !hasDocumentReferences ? (
|
||||
<DeliverableReviewPanel approval={approval} documents={documents} agents={agents} />
|
||||
) : null}
|
||||
{isSocialPost && approval.payloadJson ? (
|
||||
<SocialDraftPreview
|
||||
payloadJson={approval.payloadJson}
|
||||
platform={socialPlatform}
|
||||
isPending={approval.state === "pending"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isSocialPost && !isDecided ? (
|
||||
<SchedulePicker
|
||||
mode={scheduleMode}
|
||||
scheduledAt={scheduledAt}
|
||||
error={scheduleError}
|
||||
onModeChange={setScheduleMode}
|
||||
onScheduledAtChange={setScheduledAt}
|
||||
onClearError={() => setScheduleError(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DecisionForm
|
||||
isDecided={isDecided}
|
||||
isSocialPost={isSocialPost}
|
||||
decisionNote={decisionNote}
|
||||
acting={acting}
|
||||
onDecisionNoteChange={setDecisionNote}
|
||||
onDecide={(state) => void handleDecision(state)}
|
||||
onRequestDelete={() => setConfirmDelete(true)}
|
||||
/>
|
||||
|
||||
{approval.decisionNote ? (
|
||||
<div className="border-t border-border pt-4 text-[13px] text-muted-foreground">
|
||||
Last note: {approval.decisionNote}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Briefcase, Robot } from "@phosphor-icons/react"
|
||||
import type { AgentRecord, ApprovalRecord, TaskRecord } from "@shared/types"
|
||||
import { formatTime } from "../../../lib/formatters"
|
||||
import { StatusPill } from "../../ui"
|
||||
import { TypeBadge } from "./TypeBadge"
|
||||
|
||||
export function ApprovalSummary({
|
||||
approval,
|
||||
requestingAgent,
|
||||
relatedTask,
|
||||
}: {
|
||||
approval: ApprovalRecord
|
||||
requestingAgent: AgentRecord | null
|
||||
relatedTask: TaskRecord | null
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<TypeBadge type={approval.type} />
|
||||
<StatusPill status={approval.state} />
|
||||
</div>
|
||||
<div className="text-[13px] font-medium text-foreground">{approval.payloadSummary}</div>
|
||||
{approval.impactSummary ? (
|
||||
<div className="text-[13px] text-muted-foreground">{approval.impactSummary}</div>
|
||||
) : null}
|
||||
<div className="grid gap-2 text-[13px] md:grid-cols-2">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Robot size={14} />
|
||||
Requested by:{" "}
|
||||
<span className="font-medium text-foreground">{requestingAgent?.name ?? "Unknown"}</span>
|
||||
{requestingAgent?.role ? (
|
||||
<span className="text-[10px] text-muted-foreground">({requestingAgent.role})</span>
|
||||
) : null}
|
||||
</div>
|
||||
{relatedTask ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Briefcase size={14} />
|
||||
Task: <span className="font-medium text-foreground">{relatedTask.title}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-muted-foreground">
|
||||
Created: <span className="font-mono text-foreground">{formatTime(approval.createdAt)}</span>
|
||||
</div>
|
||||
{approval.updatedAt !== approval.createdAt ? (
|
||||
<div className="text-muted-foreground">
|
||||
Updated: <span className="font-mono text-foreground">{formatTime(approval.updatedAt)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { CheckCircle, ClockCounterClockwise, Trash, XCircle } from "@phosphor-icons/react"
|
||||
import { ActionButton, TextArea } from "../../ui"
|
||||
import { useT } from "../../../i18n"
|
||||
|
||||
export type DecisionState = "approved" | "rejected" | "revision_requested"
|
||||
export type ActingState = DecisionState | "delete" | null
|
||||
|
||||
export function DecisionForm({
|
||||
isDecided,
|
||||
isSocialPost,
|
||||
decisionNote,
|
||||
acting,
|
||||
onDecisionNoteChange,
|
||||
onDecide,
|
||||
onRequestDelete,
|
||||
}: {
|
||||
isDecided: boolean
|
||||
isSocialPost: boolean
|
||||
decisionNote: string
|
||||
acting: ActingState
|
||||
onDecisionNoteChange: (value: string) => void
|
||||
onDecide: (state: DecisionState) => void
|
||||
onRequestDelete: () => void
|
||||
}) {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isDecided ? (
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
Decision note
|
||||
</label>
|
||||
<TextArea
|
||||
value={decisionNote}
|
||||
onChange={onDecisionNoteChange}
|
||||
placeholder="Add a note (optional)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{!isDecided ? (
|
||||
<>
|
||||
<ActionButton
|
||||
label={isSocialPost ? "Approve & Post" : t("exec.approve")}
|
||||
tone="accent"
|
||||
onClick={() => onDecide("approved")}
|
||||
icon={CheckCircle}
|
||||
loading={acting === "approved"}
|
||||
/>
|
||||
<ActionButton
|
||||
label={t("exec.requestRevision")}
|
||||
onClick={() => onDecide("revision_requested")}
|
||||
icon={ClockCounterClockwise}
|
||||
loading={acting === "revision_requested"}
|
||||
/>
|
||||
<ActionButton
|
||||
label={t("exec.reject")}
|
||||
tone="danger"
|
||||
onClick={() => onDecide("rejected")}
|
||||
icon={XCircle}
|
||||
loading={acting === "rejected"}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{isDecided ? (
|
||||
<ActionButton
|
||||
label="Delete"
|
||||
onClick={onRequestDelete}
|
||||
icon={Trash}
|
||||
tone="danger"
|
||||
loading={acting === "delete"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { CaretDown, CaretUp } from "@phosphor-icons/react"
|
||||
import type { AgentRecord, ApprovalRecord, DocumentRecord } from "@shared/types"
|
||||
import { useApprovalPayload } from "./useApprovalPayload"
|
||||
|
||||
interface DocRef {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
}
|
||||
|
||||
function toDocRefs(value: unknown): DocRef[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(
|
||||
(entry): entry is DocRef =>
|
||||
!!entry && typeof entry === "object" &&
|
||||
typeof (entry as DocRef).id === "string" &&
|
||||
typeof (entry as DocRef).type === "string" &&
|
||||
typeof (entry as DocRef).title === "string",
|
||||
)
|
||||
}
|
||||
|
||||
function toDocIds(value: unknown, fallback: DocRef[]): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === "string")
|
||||
}
|
||||
return fallback.map((d) => d.id)
|
||||
}
|
||||
|
||||
export function DeliverableReviewPanel({
|
||||
approval,
|
||||
documents,
|
||||
agents,
|
||||
}: {
|
||||
approval: ApprovalRecord
|
||||
documents?: DocumentRecord[]
|
||||
agents: AgentRecord[]
|
||||
}) {
|
||||
const payload = useApprovalPayload(approval)
|
||||
const docRefs = useMemo(() => toDocRefs(payload?.documents), [payload])
|
||||
const docIds = useMemo(() => toDocIds(payload?.documentIds, docRefs), [payload, docRefs])
|
||||
const fullDocs = useMemo(() => {
|
||||
if (!documents) return [] as DocumentRecord[]
|
||||
return docIds
|
||||
.map((id) => documents.find((d) => d.id === id))
|
||||
.filter((entry): entry is DocumentRecord => entry != null)
|
||||
}, [documents, docIds])
|
||||
|
||||
const [expandedDocId, setExpandedDocId] = useState<string | null>(
|
||||
fullDocs.length === 1 ? fullDocs[0]!.id : null,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">Deliverable Review</div>
|
||||
{fullDocs.length > 0 ? (
|
||||
fullDocs.map((doc) => {
|
||||
const isExpanded = expandedDocId === doc.id
|
||||
const author = doc.authorAgentId ? agents.find((a) => a.id === doc.authorAgentId) : null
|
||||
return (
|
||||
<div key={doc.id} className="rounded-lg border border-border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedDocId(isExpanded ? null : doc.id)}
|
||||
className="flex w-full cursor-pointer items-center gap-3 bg-muted px-4 py-3 text-left transition hover:bg-card"
|
||||
>
|
||||
<span className="shrink-0 rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase text-primary">
|
||||
{doc.type.replaceAll("_", " ")}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{doc.title}</span>
|
||||
{isExpanded ? (
|
||||
<CaretUp size={14} className="shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<CaretDown size={14} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
{author ? (
|
||||
<div className="mb-2 text-[11px] text-muted-foreground">
|
||||
Author: <span className="font-medium text-foreground">{author.name}</span> ({author.role})
|
||||
</div>
|
||||
) : null}
|
||||
<div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">
|
||||
{doc.content || "No content."}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : docRefs.length > 0 ? (
|
||||
docRefs.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-muted px-4 py-3 text-[12px]"
|
||||
>
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-[10px] font-semibold uppercase text-primary">
|
||||
{doc.type.replaceAll("_", " ")}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">{doc.title}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[12px] text-muted-foreground">{approval.payloadSummary}</div>
|
||||
)}
|
||||
{approval.impactSummary ? (
|
||||
<div className="text-[11px] text-muted-foreground">{approval.impactSummary}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo } from "react"
|
||||
import type { AgentRecord, ApprovalRecord } from "@shared/types"
|
||||
import { formatMoney } from "../../../lib/formatters"
|
||||
import { useApprovalPayload } from "./useApprovalPayload"
|
||||
|
||||
interface HireSnapshot {
|
||||
name?: string
|
||||
role?: string
|
||||
title?: string
|
||||
capabilities?: string
|
||||
connectorId?: string
|
||||
budgetMonthlyUsd?: number
|
||||
reportsTo?: string
|
||||
}
|
||||
|
||||
function toHireSnapshot(value: unknown): HireSnapshot | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null
|
||||
return value as HireSnapshot
|
||||
}
|
||||
|
||||
export function ProposedHireCard({
|
||||
approval,
|
||||
agents,
|
||||
}: {
|
||||
approval: ApprovalRecord
|
||||
agents: AgentRecord[]
|
||||
}) {
|
||||
const payload = useApprovalPayload(approval)
|
||||
|
||||
const { proposedAgent, reqSnap, mgr } = useMemo(() => {
|
||||
const payloadAgentId = typeof payload?.agentId === "string" ? payload.agentId : null
|
||||
const snapshot = toHireSnapshot(payload?.requestedConfigurationSnapshot)
|
||||
const relatedAgent = approval.relatedAgentId
|
||||
? agents.find((a) => a.id === approval.relatedAgentId) ?? null
|
||||
: null
|
||||
const fallbackAgent = payloadAgentId ? agents.find((a) => a.id === payloadAgentId) ?? null : null
|
||||
const proposed = relatedAgent ?? fallbackAgent
|
||||
const mgrId = proposed?.reportsTo ?? snapshot?.reportsTo ?? null
|
||||
const manager = mgrId ? agents.find((a) => a.id === mgrId) ?? null : null
|
||||
return { proposedAgent: proposed, reqSnap: snapshot, mgr: manager }
|
||||
}, [payload, approval.relatedAgentId, agents])
|
||||
|
||||
if (!proposedAgent && !reqSnap) return null
|
||||
|
||||
const name = proposedAgent?.name ?? reqSnap?.name ?? "Unspecified"
|
||||
const role = proposedAgent?.role ?? reqSnap?.role ?? "Unspecified"
|
||||
const title = proposedAgent?.title || reqSnap?.title
|
||||
const capabilities = proposedAgent?.capabilities || reqSnap?.capabilities
|
||||
const connectorId = proposedAgent?.connectorId ?? reqSnap?.connectorId ?? "Unknown"
|
||||
const budget = proposedAgent?.budgetMonthlyUsd ?? reqSnap?.budgetMonthlyUsd ?? 0
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[color:var(--primary)] border-opacity-30 bg-accent px-4 py-3">
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">Proposed Hire</div>
|
||||
<div className="grid gap-1.5 text-[12px]">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Name:</span>{" "}
|
||||
<span className="font-medium text-foreground">{name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Role:</span>{" "}
|
||||
<span className="text-foreground">{role}</span>
|
||||
</div>
|
||||
{title ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Title:</span>{" "}
|
||||
<span className="text-foreground">{title}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{capabilities ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Capabilities:</span>{" "}
|
||||
<span className="text-foreground">{capabilities}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<span className="text-muted-foreground">Connector:</span>{" "}
|
||||
<span className="font-mono text-foreground">{connectorId}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Budget:</span>{" "}
|
||||
<span className="font-mono text-foreground">{formatMoney(budget)}/mo</span>
|
||||
</div>
|
||||
{mgr ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Reports to:</span>{" "}
|
||||
<span className="text-foreground">{mgr.name}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMemo } from "react"
|
||||
import { Clock } from "@phosphor-icons/react"
|
||||
import { InlineNotice } from "../../ui"
|
||||
|
||||
export type ScheduleMode = "immediate" | "scheduled"
|
||||
|
||||
function computeMinDatetime(): string {
|
||||
const d = new Date(Date.now() + 5 * 60 * 1000)
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
export function SchedulePicker({
|
||||
mode,
|
||||
scheduledAt,
|
||||
error,
|
||||
onModeChange,
|
||||
onScheduledAtChange,
|
||||
onClearError,
|
||||
}: {
|
||||
mode: ScheduleMode
|
||||
scheduledAt: string
|
||||
error: string | null
|
||||
onModeChange: (mode: ScheduleMode) => void
|
||||
onScheduledAtChange: (value: string) => void
|
||||
onClearError: () => void
|
||||
}) {
|
||||
const minDatetime = useMemo(computeMinDatetime, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div role="radiogroup" aria-label="Post timing" className="space-y-2">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="schedule-mode"
|
||||
value="immediate"
|
||||
checked={mode === "immediate"}
|
||||
onChange={() => {
|
||||
onModeChange("immediate")
|
||||
onClearError()
|
||||
}}
|
||||
className="h-4 w-4 shrink-0 cursor-pointer accent-[color:var(--primary)]"
|
||||
/>
|
||||
<span className="text-[13px] font-medium text-foreground">Post immediately</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="schedule-mode"
|
||||
value="scheduled"
|
||||
checked={mode === "scheduled"}
|
||||
onChange={() => onModeChange("scheduled")}
|
||||
className="h-4 w-4 shrink-0 cursor-pointer accent-[color:var(--primary)]"
|
||||
/>
|
||||
<span className="text-[13px] font-medium text-foreground">Schedule for later</span>
|
||||
</label>
|
||||
</div>
|
||||
{mode === "scheduled" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={16} className="text-muted-foreground" />
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
min={minDatetime}
|
||||
onChange={(e) => {
|
||||
onScheduledAtChange(e.target.value)
|
||||
onClearError()
|
||||
}}
|
||||
className="rounded-lg border border-border bg-card px-3.5 py-2.5 text-[13px] text-foreground focus:border-[color:var(--primary)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <InlineNotice message={error} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
const TYPE_BADGE_CLASSES: Record<string, string> = {
|
||||
hire_agent: "bg-accent text-primary",
|
||||
approve_ceo_strategy: "bg-warning-muted text-warning",
|
||||
secret_access: "bg-destructive-muted text-destructive",
|
||||
social_post: "bg-accent text-primary",
|
||||
deliverable_review: "bg-accent text-primary",
|
||||
}
|
||||
|
||||
export function TypeBadge({ type }: { type: string }) {
|
||||
const className = TYPE_BADGE_CLASSES[type] ?? "bg-muted text-muted-foreground"
|
||||
return (
|
||||
<span className={`rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.08em] ${className}`}>
|
||||
{type.replaceAll("_", " ")}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ApprovalInspector } from "./ApprovalInspector"
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMemo } from "react"
|
||||
import type { ApprovalRecord } from "@shared/types"
|
||||
|
||||
export function useApprovalPayload(approval: ApprovalRecord): Record<string, unknown> | null {
|
||||
return useMemo(() => {
|
||||
if (!approval.payloadJson) return null
|
||||
try {
|
||||
const parsed = JSON.parse(approval.payloadJson)
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [approval.payloadJson])
|
||||
}
|
||||
|
||||
export function useSocialPlatform(approval: ApprovalRecord): string {
|
||||
const payload = useApprovalPayload(approval)
|
||||
return useMemo(() => {
|
||||
if (approval.type !== "social_post") return "other"
|
||||
const platform = payload?.platform
|
||||
return typeof platform === "string" ? platform : "other"
|
||||
}, [approval.type, payload])
|
||||
}
|
||||
Reference in new issue
Block a user