feat: enhance BoardApprovalsPanel with detailed approval views and chart integration

This commit is contained in:
Abhimanyu Saharan
2026-02-06 02:17:32 +05:30
parent fe3bfade92
commit 574800e5a9
6 changed files with 1038 additions and 301 deletions

View File

@@ -0,0 +1,45 @@
"use client";
import { useParams } from "next/navigation";
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
export default function BoardApprovalsPage() {
const params = useParams();
const boardIdParam = params?.boardId;
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-2xl surface-panel p-10 text-center">
<p className="text-sm text-muted">Sign in to view approvals.</p>
<SignInButton
mode="modal"
forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards"
>
<Button>Sign in</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<main className="flex-1 overflow-y-auto bg-gradient-to-br from-slate-50 to-slate-100">
<div className="p-6">
{boardId ? (
<div className="h-[calc(100vh-160px)] min-h-[520px]">
<BoardApprovalsPanel boardId={boardId} scrollable />
</div>
) : null}
</div>
</main>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -7,7 +7,6 @@ import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { MessageSquare, Pencil, Settings, X } from "lucide-react"; import { MessageSquare, Pencil, Settings, X } from "lucide-react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { TaskBoard } from "@/components/organisms/TaskBoard"; import { TaskBoard } from "@/components/organisms/TaskBoard";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
@@ -157,7 +156,6 @@ export default function BoardDetailPage() {
const approvalsRef = useRef<Approval[]>([]); const approvalsRef = useRef<Approval[]>([]);
const agentsRef = useRef<Agent[]>([]); const agentsRef = useRef<Agent[]>([]);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isApprovalsOpen, setIsApprovalsOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [approvals, setApprovals] = useState<Approval[]>([]); const [approvals, setApprovals] = useState<Approval[]>([]);
@@ -172,6 +170,7 @@ export default function BoardDetailPage() {
const [isChatSending, setIsChatSending] = useState(false); const [isChatSending, setIsChatSending] = useState(false);
const [chatError, setChatError] = useState<string | null>(null); const [chatError, setChatError] = useState<string | null>(null);
const chatMessagesRef = useRef<BoardChatMessage[]>([]); const chatMessagesRef = useRef<BoardChatMessage[]>([]);
const chatEndRef = useRef<HTMLDivElement | null>(null);
const [isDeletingTask, setIsDeletingTask] = useState(false); const [isDeletingTask, setIsDeletingTask] = useState(false);
const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null); const [deleteTaskError, setDeleteTaskError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"board" | "list">("board"); const [viewMode, setViewMode] = useState<"board" | "list">("board");
@@ -303,6 +302,14 @@ export default function BoardDetailPage() {
chatMessagesRef.current = chatMessages; chatMessagesRef.current = chatMessages;
}, [chatMessages]); }, [chatMessages]);
useEffect(() => {
if (!isChatOpen) return;
const timeout = window.setTimeout(() => {
chatEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, 50);
return () => window.clearTimeout(timeout);
}, [chatMessages, isChatOpen]);
const loadApprovals = useCallback(async () => { const loadApprovals = useCallback(async () => {
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
setIsApprovalsLoading(true); setIsApprovalsLoading(true);
@@ -1433,7 +1440,7 @@ export default function BoardDetailPage() {
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => setIsApprovalsOpen(true)} onClick={() => router.push(`/boards/${boardId}/approvals`)}
className="relative" className="relative"
> >
Approvals Approvals
@@ -1545,8 +1552,6 @@ export default function BoardDetailPage() {
{viewMode === "board" ? ( {viewMode === "board" ? (
<TaskBoard <TaskBoard
tasks={displayTasks} tasks={displayTasks}
onCreateTask={() => setIsDialogOpen(true)}
isCreateDisabled={isCreating}
onTaskSelect={openComments} onTaskSelect={openComments}
onTaskMove={handleTaskMove} onTaskMove={handleTaskMove}
/> />
@@ -1746,7 +1751,7 @@ export default function BoardDetailPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsApprovalsOpen(true)} onClick={() => router.push(`/boards/${boardId}/approvals`)}
> >
View all View all
</Button> </Button>
@@ -1926,33 +1931,6 @@ export default function BoardDetailPage() {
</div> </div>
</aside> </aside>
<Dialog open={isApprovalsOpen} onOpenChange={setIsApprovalsOpen}>
<DialogContent
aria-label="Approvals"
className="flex h-[85vh] max-w-3xl flex-col overflow-hidden"
>
<DialogHeader>
<DialogTitle>Approvals</DialogTitle>
<DialogDescription>
Review pending decisions from your lead agent.
</DialogDescription>
</DialogHeader>
{boardId ? (
<div className="flex-1 overflow-hidden">
<BoardApprovalsPanel
boardId={boardId}
approvals={approvals}
isLoading={isApprovalsLoading}
error={approvalsError}
onDecision={handleApprovalDecision}
onRefresh={loadApprovals}
scrollable
/>
</div>
) : null}
</DialogContent>
</Dialog>
<aside <aside
className={cn( className={cn(
"fixed right-0 top-0 z-50 h-full w-[560px] max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform", "fixed right-0 top-0 z-50 h-full w-[560px] max-w-[96vw] transform border-l border-slate-200 bg-white shadow-2xl transition-transform",
@@ -2026,6 +2004,7 @@ export default function BoardDetailPage() {
</div> </div>
)) ))
)} )}
<div ref={chatEndRef} />
</div> </div>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
<Textarea <Textarea

View File

@@ -1,12 +1,19 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@clerk/nextjs"; import { useAuth } from "@clerk/nextjs";
import { Badge } from "@/components/ui/badge"; import { Clock } from "lucide-react";
import { Cell, Pie, PieChart } from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipCard,
type ChartConfig,
} from "@/components/charts/chart";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { getApiBaseUrl } from "@/lib/api-base"; import { getApiBaseUrl } from "@/lib/api-base";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -28,7 +35,6 @@ type BoardApprovalsPanelProps = {
approvals?: Approval[]; approvals?: Approval[];
isLoading?: boolean; isLoading?: boolean;
error?: string | null; error?: string | null;
onRefresh?: () => void;
onDecision?: (approvalId: string, status: "approved" | "rejected") => void; onDecision?: (approvalId: string, status: "approved" | "rejected") => void;
scrollable?: boolean; scrollable?: boolean;
}; };
@@ -45,16 +51,24 @@ const formatTimestamp = (value?: string | null) => {
}); });
}; };
const statusBadgeVariant = (status: string) => { const statusBadgeClass = (status: string) => {
if (status === "approved") return "success"; if (status === "approved") {
if (status === "rejected") return "danger"; return "bg-emerald-50 text-emerald-700";
return "outline"; }
if (status === "rejected") {
return "bg-rose-50 text-rose-700";
}
return "bg-amber-100 text-amber-700";
}; };
const confidenceVariant = (confidence: number) => { const confidenceBadgeClass = (confidence: number) => {
if (confidence >= 90) return "success"; if (confidence >= 90) {
if (confidence >= 80) return "accent"; return "bg-emerald-50 text-emerald-700";
return "warning"; }
if (confidence >= 80) {
return "bg-amber-100 text-amber-700";
}
return "bg-orange-100 text-orange-700";
}; };
const humanizeAction = (value: string) => const humanizeAction = (value: string) =>
@@ -67,6 +81,73 @@ const humanizeAction = (value: string) =>
) )
.join(" · "); .join(" · ");
const formatStatusLabel = (status: string) =>
status
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
const statusDotClass = (status: string) => {
if (status === "approved") return "bg-emerald-500";
if (status === "rejected") return "bg-rose-500";
return "bg-amber-500";
};
const rubricColors = [
"#0f172a",
"#1d4ed8",
"#10b981",
"#f59e0b",
"#ef4444",
"#8b5cf6",
];
type TooltipValue = number | string | Array<number | string>;
const formatRubricTooltipValue = (
value?: TooltipValue,
name?: TooltipValue,
item?:
| {
color?: string | null;
payload?: {
name?: string;
fill?: string;
percent?: number;
percentLabel?: string;
};
}
| null,
) => {
const payload = item?.payload;
const label =
payload?.name ??
(typeof name === "string" || typeof name === "number" ? String(name) : "");
const percentLabel =
payload?.percentLabel ??
(typeof payload?.percent === "number" && Number.isFinite(payload.percent)
? `${payload.percent.toFixed(1)}%`
: null);
const fallback =
value === null || value === undefined ? "" : String(value ?? "");
const displayValue = percentLabel ?? fallback;
const indicatorColor = payload?.fill ?? item?.color ?? "#94a3b8";
return (
<div className="flex w-full items-center justify-between gap-3">
<span className="flex items-center gap-2 text-slate-600">
<span
className="h-2.5 w-2.5 rounded-[2px]"
style={{ backgroundColor: indicatorColor }}
/>
<span>{label}</span>
</span>
<span className="font-mono font-medium tabular-nums text-slate-900">
{displayValue}
</span>
</div>
);
};
const payloadValue = (payload: Approval["payload"], key: string) => { const payloadValue = (payload: Approval["payload"], key: string) => {
if (!payload) return null; if (!payload) return null;
const value = payload[key as keyof typeof payload]; const value = payload[key as keyof typeof payload];
@@ -87,6 +168,7 @@ const approvalSummary = (approval: Approval) => {
payloadValue(payload, "assignedAgentId"); payloadValue(payload, "assignedAgentId");
const reason = payloadValue(payload, "reason"); const reason = payloadValue(payload, "reason");
const title = payloadValue(payload, "title"); const title = payloadValue(payload, "title");
const description = payloadValue(payload, "description");
const role = payloadValue(payload, "role"); const role = payloadValue(payload, "role");
const isAssign = approval.action_type.includes("assign"); const isAssign = approval.action_type.includes("assign");
const rows: Array<{ label: string; value: string }> = []; const rows: Array<{ label: string; value: string }> = [];
@@ -99,7 +181,7 @@ const approvalSummary = (approval: Approval) => {
} }
if (title) rows.push({ label: "Title", value: title }); if (title) rows.push({ label: "Title", value: title });
if (role) rows.push({ label: "Role", value: role }); if (role) rows.push({ label: "Role", value: role });
return { taskId, reason, rows }; return { taskId, reason, rows, description };
}; };
export function BoardApprovalsPanel({ export function BoardApprovalsPanel({
@@ -107,7 +189,6 @@ export function BoardApprovalsPanel({
approvals: externalApprovals, approvals: externalApprovals,
isLoading: externalLoading, isLoading: externalLoading,
error: externalError, error: externalError,
onRefresh,
onDecision, onDecision,
scrollable = false, scrollable = false,
}: BoardApprovalsPanelProps) { }: BoardApprovalsPanelProps) {
@@ -116,9 +197,13 @@ export function BoardApprovalsPanel({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [updatingId, setUpdatingId] = useState<string | null>(null); const [updatingId, setUpdatingId] = useState<string | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); const [selectedId, setSelectedId] = useState<string | null>(null);
const lastDecisionRef = useRef<string | null>(null);
const usingExternal = Array.isArray(externalApprovals); const usingExternal = Array.isArray(externalApprovals);
const approvals = usingExternal ? externalApprovals ?? [] : internalApprovals; const approvals = useMemo(
() => (usingExternal ? externalApprovals ?? [] : internalApprovals),
[externalApprovals, internalApprovals, usingExternal],
);
const loadingState = usingExternal ? externalLoading ?? false : isLoading; const loadingState = usingExternal ? externalLoading ?? false : isLoading;
const errorState = usingExternal ? externalError ?? null : error; const errorState = usingExternal ? externalError ?? null : error;
@@ -154,6 +239,7 @@ export function BoardApprovalsPanel({
const handleDecision = useCallback( const handleDecision = useCallback(
async (approvalId: string, status: "approved" | "rejected") => { async (approvalId: string, status: "approved" | "rejected") => {
lastDecisionRef.current = approvalId;
if (onDecision) { if (onDecision) {
onDecision(approvalId, status); onDecision(approvalId, status);
return; return;
@@ -207,257 +293,392 @@ export function BoardApprovalsPanel({
return { pending, resolved }; return { pending, resolved };
}, [approvals]); }, [approvals]);
const toggleExpanded = useCallback((approvalId: string) => { const orderedApprovals = useMemo(
setExpandedIds((prev) => { () => [...sortedApprovals.pending, ...sortedApprovals.resolved],
const next = new Set(prev); [sortedApprovals.pending, sortedApprovals.resolved]
if (next.has(approvalId)) { );
next.delete(approvalId);
} else { useEffect(() => {
next.add(approvalId); if (orderedApprovals.length === 0) {
} setSelectedId(null);
return next; return;
}); }
}, []); if (!selectedId || !orderedApprovals.some((item) => item.id === selectedId)) {
setSelectedId(orderedApprovals[0].id);
}
}, [orderedApprovals, selectedId]);
const selectedApproval = useMemo(() => {
if (!selectedId) return null;
return orderedApprovals.find((item) => item.id === selectedId) ?? null;
}, [orderedApprovals, selectedId]);
useEffect(() => {
if (!lastDecisionRef.current) return;
const resolvedId = lastDecisionRef.current;
const pendingNext = sortedApprovals.pending.find(
(item) => item.id !== resolvedId,
);
if (pendingNext) {
setSelectedId(pendingNext.id);
}
lastDecisionRef.current = null;
}, [sortedApprovals.pending]);
const pendingCount = sortedApprovals.pending.length;
const resolvedCount = sortedApprovals.resolved.length;
return ( return (
<Card className={scrollable ? "flex h-full flex-col" : undefined}> <div className={cn("space-y-6", scrollable && "h-full")}>
<CardHeader className="flex flex-col gap-4 border-b border-[color:var(--border)] pb-4">
<div className="flex flex-wrap items-center justify-between gap-3"> {errorState ? (
<div> <div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
<p className="text-xs font-semibold uppercase tracking-wider text-muted"> {errorState}
Approvals
</p>
<p className="mt-1 text-lg font-semibold text-strong">
{sortedApprovals.pending.length} pending
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={onRefresh ?? loadApprovals}
>
Refresh
</Button>
</div> </div>
<p className="text-sm text-muted"> ) : null}
Review lead-agent decisions that require human approval. {loadingState ? (
</p> <p className="text-sm text-slate-500">Loading approvals</p>
</CardHeader> ) : pendingCount === 0 && resolvedCount === 0 ? (
<CardContent <p className="text-sm text-slate-500">No approvals yet.</p>
className={cn( ) : (
"space-y-4 pt-5", <div
scrollable && "flex-1 overflow-y-auto" className={cn(
)} "grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]",
> scrollable && "h-full"
{errorState ? ( )}
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"> >
{errorState} <div
className={cn(
"overflow-hidden rounded-xl border border-slate-200 bg-white",
scrollable && "flex min-h-0 flex-col"
)}
>
<div className="border-b border-slate-200 bg-slate-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Unapproved tasks
</p>
<p className="mt-1 text-xs text-slate-500">
{pendingCount} pending · {resolvedCount} resolved
</p>
</div>
<div
className={cn(
"divide-y divide-slate-100",
scrollable && "min-h-0 overflow-y-auto"
)}
>
{orderedApprovals.map((approval) => {
const summary = approvalSummary(approval);
const isSelected = selectedId === approval.id;
const isPending = approval.status === "pending";
const titleRow = summary.rows.find(
(row) => row.label.toLowerCase() === "title"
);
const fallbackRow = summary.rows.find(
(row) => row.label.toLowerCase() !== "title"
);
const primaryLabel =
titleRow?.value ?? fallbackRow?.value ?? "Untitled";
return (
<button
key={approval.id}
type="button"
onClick={() => setSelectedId(approval.id)}
className={cn(
"w-full px-4 py-4 text-left transition hover:bg-slate-50",
isSelected &&
"bg-amber-50 border-l-2 border-amber-500",
!isPending && "opacity-60"
)}
>
<div className="flex items-start justify-between gap-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
{humanizeAction(approval.action_type)}
</span>
<span
className={cn(
"rounded-[3px] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em]",
statusBadgeClass(approval.status)
)}
>
{formatStatusLabel(approval.status)}
</span>
</div>
<p className="mt-2 text-sm font-semibold text-slate-900">
{primaryLabel}
</p>
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500">
<Clock className="h-3.5 w-3.5 opacity-60" />
<span>{formatTimestamp(approval.created_at)}</span>
</div>
</button>
);
})}
</div>
</div> </div>
) : null}
{loadingState ? ( <div
<p className="text-sm text-muted">Loading approvals</p> className={cn(
) : sortedApprovals.pending.length === 0 && "overflow-hidden rounded-xl border border-slate-200 bg-white",
sortedApprovals.resolved.length === 0 ? ( scrollable && "flex min-h-0 flex-col"
<p className="text-sm text-muted">No approvals yet.</p> )}
) : ( >
<div className="space-y-6"> <div className="border-b border-slate-200 bg-slate-50 px-4 py-3">
{sortedApprovals.pending.length > 0 ? ( <p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
<div className="space-y-3"> {selectedApproval?.status === "pending"
<p className="text-xs font-semibold uppercase tracking-wider text-muted"> ? "Latest unapproved task"
Pending : "Approval detail"}
</p> </p>
<div className="divide-y divide-[color:var(--border)] rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)]"> </div>
{sortedApprovals.pending.map((approval) => { {!selectedApproval ? (
const summary = approvalSummary(approval); <div className="flex h-full items-center justify-center px-6 py-10 text-sm text-slate-500">
const summaryLine = summary.rows Select an approval to review details.
.map((row) => `${row.label}: ${row.value}`)
.join(" • ");
const detailsPayload = JSON.stringify(
{
payload: approval.payload ?? null,
rubric_scores: approval.rubric_scores ?? null,
},
null,
2
);
const isExpanded = expandedIds.has(approval.id);
return (
<div key={approval.id} className="space-y-3 px-5 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-semibold text-strong">
{humanizeAction(approval.action_type)}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
<span>
Requested {formatTimestamp(approval.created_at)}
</span>
{summaryLine ? (
<>
<span className="text-slate-300"></span>
<span className="truncate">{summaryLine}</span>
</>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={confidenceVariant(approval.confidence)}>
{approval.confidence}% confidence
</Badge>
<Badge variant={statusBadgeVariant(approval.status)}>
{approval.status}
</Badge>
</div>
</div>
{summary.reason ? (
<p className="text-sm text-muted">{summary.reason}</p>
) : null}
{summary.rows.length > 0 ? (
<dl className="grid gap-2 text-xs text-muted sm:grid-cols-2">
{summary.rows.map((row) => (
<div key={`${approval.id}-${row.label}`}>
<dt className="font-semibold uppercase tracking-wide text-slate-500">
{row.label}
</dt>
<dd className="mt-1 text-sm text-strong">
{row.value}
</dd>
</div>
))}
</dl>
) : null}
<div className="flex flex-wrap items-center gap-3 text-xs text-muted">
<button
type="button"
className="font-semibold text-slate-700 hover:text-slate-900"
onClick={() => toggleExpanded(approval.id)}
>
{isExpanded ? "Hide raw" : "View raw"}
</button>
<span>JSON payload + rubric</span>
</div>
{isExpanded ? (
<pre className="max-h-40 overflow-auto rounded-xl bg-slate-950 px-3 py-3 text-[11px] text-slate-100">
{detailsPayload}
</pre>
) : null}
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
size="sm"
onClick={() => handleDecision(approval.id, "approved")}
disabled={updatingId === approval.id}
>
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDecision(approval.id, "rejected")}
disabled={updatingId === approval.id}
className={cn(
"border-[color:var(--danger)] text-[color:var(--danger)] hover:text-[color:var(--danger)]"
)}
>
Reject
</Button>
</div>
</div>
);
})}
</div>
</div> </div>
) : null} ) : (
{sortedApprovals.resolved.length > 0 ? ( (() => {
<div className="space-y-3"> const summary = approvalSummary(selectedApproval);
<p className="text-xs font-semibold uppercase tracking-wider text-muted"> const titleRow = summary.rows.find(
Resolved (row) => row.label.toLowerCase() === "title"
</p> );
<div className="divide-y divide-[color:var(--border)] rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)]"> const titleText = titleRow?.value?.trim() ?? "";
{sortedApprovals.resolved.map((approval) => { const descriptionText = summary.description?.trim() ?? "";
const summary = approvalSummary(approval); const reasoningText = summary.reason?.trim() ?? "";
const summaryLine = summary.rows const extraRows = summary.rows.filter((row) => {
.map((row) => `${row.label}: ${row.value}`) const normalized = row.label.toLowerCase();
.join(" • "); if (normalized === "title") return false;
const detailsPayload = JSON.stringify( if (normalized === "task") return false;
{ if (normalized === "assignee") return false;
payload: approval.payload ?? null, return true;
rubric_scores: approval.rubric_scores ?? null, });
}, const rubricEntries = Object.entries(
null, selectedApproval.rubric_scores ?? {}
2 ).map(([key, value]) => ({
); label: key
const isExpanded = expandedIds.has(approval.id); .replace(/_/g, " ")
return ( .replace(/\b\w/g, (char) => char.toUpperCase()),
<div key={approval.id} className="space-y-3 px-5 py-4"> value,
<div className="flex flex-wrap items-start justify-between gap-3"> }));
<div className="space-y-1"> const rubricTotal = rubricEntries.reduce(
<p className="text-sm font-semibold text-strong"> (total, entry) => total + entry.value,
{humanizeAction(approval.action_type)} 0,
</p> );
<div className="flex flex-wrap items-center gap-2 text-xs text-muted"> const hasRubric = rubricEntries.length > 0 && rubricTotal > 0;
<span> const rubricChartData = rubricEntries.map((entry, index) => {
Requested {formatTimestamp(approval.created_at)} const percent = rubricTotal > 0 ? (entry.value / rubricTotal) * 100 : 0;
</span> return {
{summaryLine ? ( key: entry.label.toLowerCase().replace(/[^a-z0-9]+/g, "_"),
<> name: entry.label,
<span className="text-slate-300"></span> value: entry.value,
<span className="truncate">{summaryLine}</span> percent,
</> percentLabel: `${percent.toFixed(1)}%`,
) : null} fill: rubricColors[index % rubricColors.length],
</div> };
});
const rubricChartConfig = rubricChartData.reduce<ChartConfig>(
(accumulator, entry) => {
accumulator[entry.key] = {
label: entry.name,
color: entry.fill,
};
return accumulator;
},
{},
);
return (
<div className="flex h-full flex-col gap-6 px-6 py-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-lg font-semibold text-slate-900">
{humanizeAction(selectedApproval.action_type)}
</p>
<p className="mt-1 text-xs text-slate-500">
Requested {formatTimestamp(selectedApproval.created_at)}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<span
className={cn(
"rounded-md px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em]",
confidenceBadgeClass(selectedApproval.confidence)
)}
>
{selectedApproval.confidence}% confidence
</span>
{selectedApproval.status === "pending" ? (
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
size="sm"
onClick={() =>
handleDecision(selectedApproval.id, "approved")
}
disabled={updatingId === selectedApproval.id}
className="bg-slate-900 text-white hover:bg-slate-800"
>
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
handleDecision(selectedApproval.id, "rejected")
}
disabled={updatingId === selectedApproval.id}
className="border-slate-300 text-slate-700 hover:bg-slate-100"
>
Reject
</Button>
</div> </div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={confidenceVariant(approval.confidence)}>
{approval.confidence}% confidence
</Badge>
<Badge variant={statusBadgeVariant(approval.status)}>
{approval.status}
</Badge>
</div>
</div>
{summary.reason ? (
<p className="text-sm text-muted">{summary.reason}</p>
) : null}
{summary.rows.length > 0 ? (
<dl className="grid gap-2 text-xs text-muted sm:grid-cols-2">
{summary.rows.map((row) => (
<div key={`${approval.id}-${row.label}`}>
<dt className="font-semibold uppercase tracking-wide text-slate-500">
{row.label}
</dt>
<dd className="mt-1 text-sm text-strong">
{row.value}
</dd>
</div>
))}
</dl>
) : null}
<div className="flex flex-wrap items-center gap-3 text-xs text-muted">
<button
type="button"
className="font-semibold text-slate-700 hover:text-slate-900"
onClick={() => toggleExpanded(approval.id)}
>
{isExpanded ? "Hide raw" : "View raw"}
</button>
<span>JSON payload + rubric</span>
</div>
{isExpanded ? (
<pre className="max-h-40 overflow-auto rounded-xl bg-slate-950 px-3 py-3 text-[11px] text-slate-100">
{detailsPayload}
</pre>
) : null} ) : null}
</div> </div>
); </div>
})}
</div> <div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
</div> <span
) : null} className={cn(
"h-2 w-2 rounded-full",
statusDotClass(selectedApproval.status)
)}
/>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Status
</p>
<p className="text-sm font-medium text-slate-700">
{formatStatusLabel(selectedApproval.status)} ·{" "}
{selectedApproval.status === "pending"
? "Awaiting your decision"
: "Decision complete"}
</p>
</div>
</div>
{titleText ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Title
</p>
<div className="text-sm font-medium text-slate-900">
{titleText}
</div>
</div>
) : null}
{descriptionText ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Description
</p>
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700">
{descriptionText}
</div>
</div>
) : null}
{reasoningText ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Lead reasoning
</p>
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600">
<p>{reasoningText}</p>
</div>
</div>
) : null}
{extraRows.length > 0 ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Details
</p>
<div className="grid gap-3 sm:grid-cols-2">
{extraRows.map((row) => (
<div
key={`${selectedApproval.id}-${row.label}`}
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
{row.label}
</p>
<p className="mt-1 text-sm font-medium text-slate-900">
{row.value}
</p>
</div>
))}
</div>
</div>
) : null}
{hasRubric ? (
<div className="space-y-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
Rubric scores
</p>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="w-full space-y-2 sm:max-w-[220px]">
{rubricChartData.map((entry) => (
<div
key={entry.key}
className="flex items-center justify-between gap-4 text-xs"
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-slate-700">
{entry.name}
</span>
</div>
<span className="font-medium tabular-nums text-slate-900">
{entry.percentLabel}
</span>
</div>
))}
</div>
<ChartContainer
config={rubricChartConfig}
className="h-56 w-full max-w-[260px] aspect-square"
>
<PieChart>
{rubricTotal > 0 ? (
<ChartTooltip
cursor={false}
content={
<ChartTooltipCard
formatter={formatRubricTooltipValue}
hideLabel
/>
}
/>
) : null}
<Pie
data={rubricChartData}
dataKey="value"
nameKey="name"
innerRadius={50}
outerRadius={80}
strokeWidth={2}
>
{rubricChartData.map((entry) => (
<Cell key={entry.key} fill={entry.fill} />
))}
</Pie>
</PieChart>
</ChartContainer>
</div>
</div>
) : null}
</div>
);
})()
)}
</div> </div>
)} </div>
</CardContent> )}
</Card> </div>
); );
} }

View File

@@ -0,0 +1,501 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import type { DefaultLegendContentProps, TooltipContentProps } from "recharts";
import { cn } from "@/lib/utils";
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
export type ChartLegendState = {
hiddenKeys: Set<string>;
isSeriesHidden: (key: string) => boolean;
toggleSeries: (key: string) => void;
};
type ChartContextProps = {
config: ChartConfig;
} & ChartLegendState;
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
legend,
...props
}: Omit<React.ComponentProps<"div">, "children"> & {
config: ChartConfig;
children:
| React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
| ((state: ChartLegendState) => React.ReactNode);
legend?: React.ReactNode | ((state: ChartLegendState) => React.ReactNode);
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
const [hiddenKeys, setHiddenKeys] = React.useState<Set<string>>(
() => new Set(),
);
const toggleSeries = React.useCallback((key: string) => {
setHiddenKeys((previous) => {
const next = new Set(previous);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const isSeriesHidden = React.useCallback(
(key: string) => hiddenKeys.has(key),
[hiddenKeys],
);
const legendState = React.useMemo(
() => ({ hiddenKeys, isSeriesHidden, toggleSeries }),
[hiddenKeys, isSeriesHidden, toggleSeries],
);
const resolvedChildren =
typeof children === "function" ? children(legendState) : children;
const resolvedLegend =
typeof legend === "function" ? legend(legendState) : legend;
return (
<ChartContext.Provider value={{ config, ...legendState }}>
<>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{resolvedChildren}
</RechartsPrimitive.ResponsiveContainer>
</div>
{resolvedLegend}
</>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
type ChartTooltipValue = number | string | Array<number | string>;
type ChartTooltipName = number | string;
type ChartTooltipContentProps = Partial<
TooltipContentProps<ChartTooltipValue, ChartTooltipName>
> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
};
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: ChartTooltipContentProps) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
function ChartTooltipCard({
className,
labelClassName,
...props
}: ChartTooltipContentProps) {
return (
<ChartTooltipContent
{...props}
className={cn(
"border border-gray-200 bg-white px-3 py-2 text-sm shadow-lg",
className,
)}
labelClassName={cn("text-sm font-semibold text-gray-900", labelClassName)}
/>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config, isSeriesHidden, toggleSeries } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const seriesKey =
typeof item.dataKey === "string"
? item.dataKey
: typeof item.value === "string"
? item.value
: key;
const isHidden = isSeriesHidden(seriesKey);
const indicatorColor =
item.color ?? itemConfig?.color ?? `var(--color-${seriesKey})`;
return (
<button
key={seriesKey}
type="button"
onClick={() => toggleSeries(seriesKey)}
aria-pressed={!isHidden}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 transition-opacity [&>svg]:h-3 [&>svg]:w-3 cursor-pointer",
isHidden && "opacity-50",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: indicatorColor,
}}
/>
)}
<span
className={cn(
"text-muted-foreground",
isHidden && "line-through text-muted-foreground/70",
)}
>
{itemConfig?.label ?? item.value}
</span>
</button>
);
})}
</div>
);
}
type ChartLegendItemProps = React.ComponentProps<"button"> & {
seriesKey: string;
label?: React.ReactNode;
color?: string;
icon?: React.ComponentType;
hideIcon?: boolean;
};
function ChartLegendItem({
seriesKey,
label,
color,
icon,
hideIcon = false,
className,
onClick,
...props
}: ChartLegendItemProps) {
const { config, isSeriesHidden, toggleSeries } = useChart();
const itemConfig = config[seriesKey];
const resolvedLabel = label ?? itemConfig?.label ?? seriesKey;
const resolvedColor =
color ?? itemConfig?.color ?? `var(--color-${seriesKey})`;
const Icon = icon ?? itemConfig?.icon;
const isHidden = isSeriesHidden(seriesKey);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (!event.defaultPrevented) {
toggleSeries(seriesKey);
}
};
return (
<button
type="button"
aria-pressed={!isHidden}
onClick={handleClick}
className={cn(
"flex items-center gap-2 text-gray-600 transition-opacity [&>svg]:h-3 [&>svg]:w-3 cursor-pointer disabled:cursor-not-allowed disabled:opacity-60",
isHidden && "opacity-50",
className,
)}
{...props}
>
{Icon && !hideIcon ? (
<Icon />
) : (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: resolvedColor }}
/>
)}
<span className={cn(isHidden && "line-through text-gray-400")}>
{resolvedLabel}
</span>
</button>
);
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartLegendItem,
useChart,
ChartTooltip,
ChartTooltipContent,
ChartTooltipCard,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -7,7 +7,6 @@ interface TaskCardProps {
priority?: string; priority?: string;
assignee?: string; assignee?: string;
due?: string; due?: string;
approvalsCount?: number;
approvalsPendingCount?: number; approvalsPendingCount?: number;
onClick?: () => void; onClick?: () => void;
draggable?: boolean; draggable?: boolean;
@@ -21,7 +20,6 @@ export function TaskCard({
priority, priority,
assignee, assignee,
due, due,
approvalsCount = 0,
approvalsPendingCount = 0, approvalsPendingCount = 0,
onClick, onClick,
draggable = false, draggable = false,

View File

@@ -14,14 +14,11 @@ type Task = {
due_at?: string | null; due_at?: string | null;
assigned_agent_id?: string | null; assigned_agent_id?: string | null;
assignee?: string; assignee?: string;
approvalsCount?: number;
approvalsPendingCount?: number; approvalsPendingCount?: number;
}; };
type TaskBoardProps = { type TaskBoardProps = {
tasks: Task[]; tasks: Task[];
onCreateTask: () => void;
isCreateDisabled?: boolean;
onTaskSelect?: (task: Task) => void; onTaskSelect?: (task: Task) => void;
onTaskMove?: (taskId: string, status: string) => void; onTaskMove?: (taskId: string, status: string) => void;
}; };
@@ -73,8 +70,6 @@ const formatDueDate = (value?: string | null) => {
export function TaskBoard({ export function TaskBoard({
tasks, tasks,
onCreateTask,
isCreateDisabled = false,
onTaskSelect, onTaskSelect,
onTaskMove, onTaskMove,
}: TaskBoardProps) { }: TaskBoardProps) {
@@ -132,8 +127,7 @@ export function TaskBoard({
} }
}; };
const handleDragLeave = const handleDragLeave = (status: string) => () => {
(status: string) => (_event: React.DragEvent<HTMLDivElement>) => {
if (activeColumn === status) { if (activeColumn === status) {
setActiveColumn(null); setActiveColumn(null);
} }
@@ -181,7 +175,6 @@ export function TaskBoard({
priority={task.priority} priority={task.priority}
assignee={task.assignee} assignee={task.assignee}
due={formatDueDate(task.due_at)} due={formatDueDate(task.due_at)}
approvalsCount={task.approvalsCount}
approvalsPendingCount={task.approvalsPendingCount} approvalsPendingCount={task.approvalsPendingCount}
onClick={() => onTaskSelect?.(task)} onClick={() => onTaskSelect?.(task)}
draggable draggable