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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -9,99 +10,37 @@ import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ShieldCheck, UserPlus, Lightbulb, CheckCircle2, XCircle, Clock } from "lucide-react";
|
||||
import { CheckCircle2, XCircle, Clock, ShieldCheck } from "lucide-react";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload";
|
||||
import type { Approval, Agent } from "@paperclip/shared";
|
||||
|
||||
type StatusFilter = "pending" | "all";
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
hire_agent: "Hire Agent",
|
||||
approve_ceo_strategy: "CEO Strategy",
|
||||
};
|
||||
|
||||
const typeIcon: Record<string, typeof UserPlus> = {
|
||||
hire_agent: UserPlus,
|
||||
approve_ceo_strategy: Lightbulb,
|
||||
};
|
||||
|
||||
function statusIcon(status: string) {
|
||||
if (status === "approved") return <CheckCircle2 className="h-3.5 w-3.5 text-green-400" />;
|
||||
if (status === "rejected") return <XCircle className="h-3.5 w-3.5 text-red-400" />;
|
||||
if (status === "revision_requested") return <Clock className="h-3.5 w-3.5 text-amber-400" />;
|
||||
if (status === "pending") return <Clock className="h-3.5 w-3.5 text-yellow-400" />;
|
||||
return null;
|
||||
}
|
||||
|
||||
function PayloadField({ label, value }: { label: string; value: unknown }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">{label}</span>
|
||||
<span>{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HireAgentPayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Name</span>
|
||||
<span className="font-medium">{String(payload.name ?? "—")}</span>
|
||||
</div>
|
||||
<PayloadField label="Role" value={payload.role} />
|
||||
<PayloadField label="Title" value={payload.title} />
|
||||
{!!payload.capabilities && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs pt-0.5">Capabilities</span>
|
||||
<span className="text-muted-foreground">{String(payload.capabilities)}</span>
|
||||
</div>
|
||||
)}
|
||||
{!!payload.adapterType && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Adapter</span>
|
||||
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{String(payload.adapterType)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CeoStrategyPayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
const plan = payload.plan ?? payload.description ?? payload.strategy ?? payload.text;
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
<PayloadField label="Title" value={payload.title} />
|
||||
{!!plan && (
|
||||
<div className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-sm text-muted-foreground whitespace-pre-wrap font-mono text-xs max-h-48 overflow-y-auto">
|
||||
{String(plan)}
|
||||
</div>
|
||||
)}
|
||||
{!plan && (
|
||||
<pre className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground overflow-x-auto max-h-48">
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalCard({
|
||||
approval,
|
||||
requesterAgent,
|
||||
onApprove,
|
||||
onReject,
|
||||
onOpen,
|
||||
isPending,
|
||||
}: {
|
||||
approval: Approval;
|
||||
requesterAgent: Agent | null;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onOpen: () => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const Icon = typeIcon[approval.type] ?? ShieldCheck;
|
||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
const label = typeLabel[approval.type] ?? approval.type;
|
||||
|
||||
return (
|
||||
@@ -127,11 +66,7 @@ function ApprovalCard({
|
||||
</div>
|
||||
|
||||
{/* Payload */}
|
||||
{approval.type === "hire_agent" ? (
|
||||
<HireAgentPayload payload={approval.payload} />
|
||||
) : (
|
||||
<CeoStrategyPayload payload={approval.payload} />
|
||||
)}
|
||||
<ApprovalPayloadRenderer type={approval.type} payload={approval.payload} />
|
||||
|
||||
{/* Decision note */}
|
||||
{approval.decisionNote && (
|
||||
@@ -141,7 +76,7 @@ function ApprovalCard({
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{approval.status === "pending" && (
|
||||
{(approval.status === "pending" || approval.status === "revision_requested") && (
|
||||
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -161,6 +96,11 @@ function ApprovalCard({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Button variant="ghost" size="sm" className="text-xs px-0" onClick={onOpen}>
|
||||
View details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -169,6 +109,7 @@ export function Approvals() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("pending");
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
@@ -190,9 +131,10 @@ export function Approvals() {
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: string) => approvalsApi.approve(id),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_approval, id) => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
navigate(`/approvals/${id}?resolved=approved`);
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to approve");
|
||||
@@ -211,10 +153,12 @@ export function Approvals() {
|
||||
});
|
||||
|
||||
const filtered = (data ?? []).filter(
|
||||
(a) => statusFilter === "all" || a.status === "pending",
|
||||
(a) => statusFilter === "all" || a.status === "pending" || a.status === "revision_requested",
|
||||
);
|
||||
|
||||
const pendingCount = (data ?? []).filter((a) => a.status === "pending").length;
|
||||
const pendingCount = (data ?? []).filter(
|
||||
(a) => a.status === "pending" || a.status === "revision_requested",
|
||||
).length;
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <p className="text-sm text-muted-foreground">Select a company first.</p>;
|
||||
@@ -263,6 +207,7 @@ export function Approvals() {
|
||||
requesterAgent={approval.requestedByAgentId ? (agents ?? []).find((a) => a.id === approval.requestedByAgentId) ?? null : null}
|
||||
onApprove={() => approveMutation.mutate(approval.id)}
|
||||
onReject={() => rejectMutation.mutate(approval.id)}
|
||||
onOpen={() => navigate(`/approvals/${approval.id}`)}
|
||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user