Add ApprovalDetail page with comment thread, revision request/resubmit flow, and ApprovalPayload component for structured payload display. Extend AgentDetail with permissions management, config revision history, and duplicate action. Add agent hire dialog with permission-gated access. Rework Costs page with per-agent breakdown table and period filtering. Add sidebar badge counts for pending approvals and inbox items. Enhance Dashboard with live metrics and sparkline trends. Extend Agents list with pending_approval status and bulk actions. Update IssueDetail with approval linking. Various component improvements to MetricCard, InlineEditor, CommentThread, and StatusBadge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { approvalsApi } from "../api/approvals";
|
|
import { agentsApi } from "../api/agents";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { StatusBadge } from "../components/StatusBadge";
|
|
import { Identity } from "../components/Identity";
|
|
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { CheckCircle2, ChevronRight, Sparkles } from "lucide-react";
|
|
import type { ApprovalComment } from "@paperclip/shared";
|
|
|
|
export function ApprovalDetail() {
|
|
const { approvalId } = useParams<{ approvalId: string }>();
|
|
const { selectedCompanyId } = useCompany();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const queryClient = useQueryClient();
|
|
const [commentBody, setCommentBody] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showRawPayload, setShowRawPayload] = useState(false);
|
|
|
|
const { data: approval, isLoading } = useQuery({
|
|
queryKey: queryKeys.approvals.detail(approvalId!),
|
|
queryFn: () => approvalsApi.get(approvalId!),
|
|
enabled: !!approvalId,
|
|
});
|
|
|
|
const { data: comments } = useQuery({
|
|
queryKey: queryKeys.approvals.comments(approvalId!),
|
|
queryFn: () => approvalsApi.listComments(approvalId!),
|
|
enabled: !!approvalId,
|
|
});
|
|
|
|
const { data: linkedIssues } = useQuery({
|
|
queryKey: queryKeys.approvals.issues(approvalId!),
|
|
queryFn: () => approvalsApi.listIssues(approvalId!),
|
|
enabled: !!approvalId,
|
|
});
|
|
|
|
const { data: agents } = useQuery({
|
|
queryKey: queryKeys.agents.list(approval?.companyId ?? selectedCompanyId ?? ""),
|
|
queryFn: () => agentsApi.list(approval?.companyId ?? selectedCompanyId ?? ""),
|
|
enabled: !!(approval?.companyId ?? selectedCompanyId),
|
|
});
|
|
|
|
const agentNameById = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const agent of agents ?? []) map.set(agent.id, agent.name);
|
|
return map;
|
|
}, [agents]);
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([
|
|
{ label: "Approvals", href: "/approvals" },
|
|
{ label: approval?.id?.slice(0, 8) ?? approvalId ?? "Approval" },
|
|
]);
|
|
}, [setBreadcrumbs, approval, approvalId]);
|
|
|
|
const refresh = () => {
|
|
if (!approvalId) return;
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(approvalId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.comments(approvalId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.issues(approvalId) });
|
|
if (approval?.companyId) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(approval.companyId) });
|
|
queryClient.invalidateQueries({
|
|
queryKey: queryKeys.approvals.list(approval.companyId, "pending"),
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(approval.companyId) });
|
|
}
|
|
};
|
|
|
|
const approveMutation = useMutation({
|
|
mutationFn: () => approvalsApi.approve(approvalId!),
|
|
onSuccess: () => {
|
|
setError(null);
|
|
refresh();
|
|
navigate(`/approvals/${approvalId}?resolved=approved`, { replace: true });
|
|
},
|
|
onError: (err) => setError(err instanceof Error ? err.message : "Approve failed"),
|
|
});
|
|
|
|
const rejectMutation = useMutation({
|
|
mutationFn: () => approvalsApi.reject(approvalId!),
|
|
onSuccess: () => {
|
|
setError(null);
|
|
refresh();
|
|
},
|
|
onError: (err) => setError(err instanceof Error ? err.message : "Reject failed"),
|
|
});
|
|
|
|
const revisionMutation = useMutation({
|
|
mutationFn: () => approvalsApi.requestRevision(approvalId!),
|
|
onSuccess: () => {
|
|
setError(null);
|
|
refresh();
|
|
},
|
|
onError: (err) => setError(err instanceof Error ? err.message : "Revision request failed"),
|
|
});
|
|
|
|
const resubmitMutation = useMutation({
|
|
mutationFn: () => approvalsApi.resubmit(approvalId!),
|
|
onSuccess: () => {
|
|
setError(null);
|
|
refresh();
|
|
},
|
|
onError: (err) => setError(err instanceof Error ? err.message : "Resubmit failed"),
|
|
});
|
|
|
|
const addCommentMutation = useMutation({
|
|
mutationFn: () => approvalsApi.addComment(approvalId!, commentBody.trim()),
|
|
onSuccess: () => {
|
|
setCommentBody("");
|
|
setError(null);
|
|
refresh();
|
|
},
|
|
onError: (err) => setError(err instanceof Error ? err.message : "Comment failed"),
|
|
});
|
|
|
|
const deleteAgentMutation = useMutation({
|
|
mutationFn: (agentId: string) => agentsApi.remove(agentId),
|
|
onSuccess: () => {
|
|
setError(null);
|
|
refresh();
|
|
navigate("/approvals");
|
|
},
|
|
onError: (err) => setError(err instanceof Error ? err.message : "Delete failed"),
|
|
});
|
|
|
|
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
|
|
if (!approval) return <p className="text-sm text-muted-foreground">Approval not found.</p>;
|
|
|
|
const payload = approval.payload as Record<string, unknown>;
|
|
const linkedAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
|
const isActionable = approval.status === "pending" || approval.status === "revision_requested";
|
|
const TypeIcon = typeIcon[approval.type] ?? defaultTypeIcon;
|
|
const showApprovedBanner = searchParams.get("resolved") === "approved" && approval.status === "approved";
|
|
const primaryLinkedIssue = linkedIssues?.[0] ?? null;
|
|
const resolvedCta =
|
|
primaryLinkedIssue
|
|
? {
|
|
label:
|
|
(linkedIssues?.length ?? 0) > 1
|
|
? "Review linked issues"
|
|
: "Review linked issue",
|
|
to: `/issues/${primaryLinkedIssue.id}`,
|
|
}
|
|
: linkedAgentId
|
|
? {
|
|
label: "Open hired agent",
|
|
to: `/agents/${linkedAgentId}`,
|
|
}
|
|
: {
|
|
label: "Back to approvals",
|
|
to: "/approvals",
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6 max-w-3xl">
|
|
{showApprovedBanner && (
|
|
<div className="border border-green-700/40 bg-green-900/20 rounded-lg px-4 py-3 animate-in fade-in zoom-in-95 duration-300">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-start gap-2">
|
|
<div className="relative mt-0.5">
|
|
<CheckCircle2 className="h-4 w-4 text-green-300" />
|
|
<Sparkles className="h-3 w-3 text-green-200 absolute -right-2 -top-1 animate-pulse" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-green-100 font-medium">Approval confirmed</p>
|
|
<p className="text-xs text-green-200/90">
|
|
Requesting agent was notified to review this approval and linked issues.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="border-green-600/50 text-green-100 hover:bg-green-900/30"
|
|
onClick={() => navigate(resolvedCta.to)}
|
|
>
|
|
{resolvedCta.label}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="border border-border rounded-lg p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<TypeIcon className="h-5 w-5 text-muted-foreground shrink-0" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold">{typeLabel[approval.type] ?? approval.type.replace(/_/g, " ")}</h2>
|
|
<p className="text-xs text-muted-foreground font-mono">{approval.id}</p>
|
|
</div>
|
|
</div>
|
|
<StatusBadge status={approval.status} />
|
|
</div>
|
|
<div className="text-sm space-y-1">
|
|
{approval.requestedByAgentId && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-xs">Requested by</span>
|
|
<Identity
|
|
name={agentNameById.get(approval.requestedByAgentId) ?? approval.requestedByAgentId.slice(0, 8)}
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
)}
|
|
<ApprovalPayloadRenderer type={approval.type} payload={payload} />
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors mt-2"
|
|
onClick={() => setShowRawPayload((v) => !v)}
|
|
>
|
|
<ChevronRight className={`h-3 w-3 transition-transform ${showRawPayload ? "rotate-90" : ""}`} />
|
|
See full request
|
|
</button>
|
|
{showRawPayload && (
|
|
<pre className="text-xs bg-muted/40 rounded-md p-3 overflow-x-auto">
|
|
{JSON.stringify(payload, null, 2)}
|
|
</pre>
|
|
)}
|
|
{approval.decisionNote && (
|
|
<p className="text-xs text-muted-foreground">Decision note: {approval.decisionNote}</p>
|
|
)}
|
|
</div>
|
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
{linkedIssues && linkedIssues.length > 0 && (
|
|
<div className="pt-2 border-t border-border/60">
|
|
<p className="text-xs text-muted-foreground mb-1.5">Linked Issues</p>
|
|
<div className="space-y-1.5">
|
|
{linkedIssues.map((issue) => (
|
|
<Link
|
|
key={issue.id}
|
|
to={`/issues/${issue.id}`}
|
|
className="block text-xs rounded border border-border/70 px-2 py-1.5 hover:bg-accent/20"
|
|
>
|
|
<span className="font-mono text-muted-foreground mr-2">
|
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
</span>
|
|
<span>{issue.title}</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground mt-2">
|
|
Linked issues remain open until the requesting agent follows up and closes them.
|
|
</p>
|
|
</div>
|
|
)}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{isActionable && (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
className="bg-green-700 hover:bg-green-600 text-white"
|
|
onClick={() => approveMutation.mutate()}
|
|
disabled={approveMutation.isPending}
|
|
>
|
|
Approve
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => rejectMutation.mutate()}
|
|
disabled={rejectMutation.isPending}
|
|
>
|
|
Reject
|
|
</Button>
|
|
</>
|
|
)}
|
|
{approval.status === "pending" && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => revisionMutation.mutate()}
|
|
disabled={revisionMutation.isPending}
|
|
>
|
|
Request revision
|
|
</Button>
|
|
)}
|
|
{approval.status === "revision_requested" && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => resubmitMutation.mutate()}
|
|
disabled={resubmitMutation.isPending}
|
|
>
|
|
Mark resubmitted
|
|
</Button>
|
|
)}
|
|
{approval.status === "rejected" && approval.type === "hire_agent" && linkedAgentId && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="text-destructive border-destructive/40"
|
|
onClick={() => {
|
|
if (!window.confirm("Delete this disapproved agent? This cannot be undone.")) return;
|
|
deleteAgentMutation.mutate(linkedAgentId);
|
|
}}
|
|
disabled={deleteAgentMutation.isPending}
|
|
>
|
|
Delete disapproved agent
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border border-border rounded-lg p-4 space-y-3">
|
|
<h3 className="text-sm font-medium">Comments ({comments?.length ?? 0})</h3>
|
|
<div className="space-y-2">
|
|
{(comments ?? []).map((comment: ApprovalComment) => (
|
|
<div key={comment.id} className="border border-border/60 rounded-md p-3">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<Identity
|
|
name={
|
|
comment.authorAgentId
|
|
? agentNameById.get(comment.authorAgentId) ?? comment.authorAgentId.slice(0, 8)
|
|
: "Board"
|
|
}
|
|
size="sm"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">
|
|
{new Date(comment.createdAt).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm whitespace-pre-wrap">{comment.body}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<Textarea
|
|
value={commentBody}
|
|
onChange={(e) => setCommentBody(e.target.value)}
|
|
placeholder="Add a comment..."
|
|
rows={3}
|
|
/>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => addCommentMutation.mutate()}
|
|
disabled={!commentBody.trim() || addCommentMutation.isPending}
|
|
>
|
|
{addCommentMutation.isPending ? "Posting..." : "Post comment"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|