Files
paperclip/ui/src/pages/ApprovalDetail.tsx
Forgotten 176d279403 UI: approval detail page, agent hiring UX, costs breakdown, sidebar badges, and dashboard improvements
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>
2026-02-19 13:03:08 -06:00

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>
);
}