"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@clerk/nextjs"; 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 { getApiBaseUrl } from "@/lib/api-base"; import { cn } from "@/lib/utils"; const apiBase = getApiBaseUrl(); type Approval = { id: string; action_type: string; payload?: Record | null; confidence: number; rubric_scores?: Record | null; status: string; created_at: string; resolved_at?: string | null; }; type BoardApprovalsPanelProps = { boardId: string; approvals?: Approval[]; isLoading?: boolean; error?: string | null; onDecision?: (approvalId: string, status: "approved" | "rejected") => void; scrollable?: boolean; }; const formatTimestamp = (value?: string | null) => { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); }; const statusBadgeClass = (status: string) => { if (status === "approved") { return "bg-emerald-50 text-emerald-700"; } if (status === "rejected") { return "bg-rose-50 text-rose-700"; } return "bg-amber-100 text-amber-700"; }; const confidenceBadgeClass = (confidence: number) => { if (confidence >= 90) { return "bg-emerald-50 text-emerald-700"; } if (confidence >= 80) { return "bg-amber-100 text-amber-700"; } return "bg-orange-100 text-orange-700"; }; const humanizeAction = (value: string) => value .split(".") .map((part) => part .replace(/_/g, " ") .replace(/\b\w/g, (char) => char.toUpperCase()) ) .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; 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 (
{label} {displayValue}
); }; const payloadValue = (payload: Approval["payload"], key: string) => { if (!payload) return null; const value = payload[key as keyof typeof payload]; if (typeof value === "string" || typeof value === "number") { return String(value); } return null; }; const approvalSummary = (approval: Approval) => { const payload = approval.payload ?? {}; const taskId = payloadValue(payload, "task_id") ?? payloadValue(payload, "taskId") ?? payloadValue(payload, "taskID"); const assignedAgentId = payloadValue(payload, "assigned_agent_id") ?? payloadValue(payload, "assignedAgentId"); const reason = payloadValue(payload, "reason"); const title = payloadValue(payload, "title"); const description = payloadValue(payload, "description"); const role = payloadValue(payload, "role"); const isAssign = approval.action_type.includes("assign"); const rows: Array<{ label: string; value: string }> = []; if (taskId) rows.push({ label: "Task", value: taskId }); if (isAssign) { rows.push({ label: "Assignee", value: assignedAgentId ?? "Unassigned", }); } if (title) rows.push({ label: "Title", value: title }); if (role) rows.push({ label: "Role", value: role }); return { taskId, reason, rows, description }; }; export function BoardApprovalsPanel({ boardId, approvals: externalApprovals, isLoading: externalLoading, error: externalError, onDecision, scrollable = false, }: BoardApprovalsPanelProps) { const { getToken, isSignedIn } = useAuth(); const [internalApprovals, setInternalApprovals] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [updatingId, setUpdatingId] = useState(null); const [selectedId, setSelectedId] = useState(null); const lastDecisionRef = useRef(null); const usingExternal = Array.isArray(externalApprovals); const approvals = useMemo( () => (usingExternal ? externalApprovals ?? [] : internalApprovals), [externalApprovals, internalApprovals, usingExternal], ); const loadingState = usingExternal ? externalLoading ?? false : isLoading; const errorState = usingExternal ? externalError ?? null : error; const loadApprovals = useCallback(async () => { if (usingExternal) return; if (!isSignedIn || !boardId) return; setIsLoading(true); setError(null); try { const token = await getToken(); const res = await fetch(`${apiBase}/api/v1/boards/${boardId}/approvals`, { headers: { Authorization: token ? `Bearer ${token}` : "", }, }); if (!res.ok) throw new Error("Unable to load approvals."); const data = (await res.json()) as Approval[]; setInternalApprovals(data); } catch (err) { setError(err instanceof Error ? err.message : "Unable to load approvals."); } finally { setIsLoading(false); } }, [boardId, getToken, isSignedIn, usingExternal]); useEffect(() => { if (usingExternal) return; loadApprovals(); if (!isSignedIn || !boardId) return; const interval = setInterval(loadApprovals, 15000); return () => clearInterval(interval); }, [boardId, isSignedIn, loadApprovals, usingExternal]); const handleDecision = useCallback( async (approvalId: string, status: "approved" | "rejected") => { lastDecisionRef.current = approvalId; if (onDecision) { onDecision(approvalId, status); return; } if (usingExternal) return; if (!isSignedIn || !boardId) return; setUpdatingId(approvalId); setError(null); try { const token = await getToken(); const res = await fetch( `${apiBase}/api/v1/boards/${boardId}/approvals/${approvalId}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: token ? `Bearer ${token}` : "", }, body: JSON.stringify({ status }), } ); if (!res.ok) throw new Error("Unable to update approval."); const updated = (await res.json()) as Approval; setInternalApprovals((prev) => prev.map((item) => (item.id === approvalId ? updated : item)) ); } catch (err) { setError( err instanceof Error ? err.message : "Unable to update approval." ); } finally { setUpdatingId(null); } }, [boardId, getToken, isSignedIn, onDecision, usingExternal] ); const sortedApprovals = useMemo(() => { const sortByTime = (items: Approval[]) => [...items].sort((a, b) => { const aTime = new Date(a.created_at).getTime(); const bTime = new Date(b.created_at).getTime(); return bTime - aTime; }); const pending = sortByTime( approvals.filter((item) => item.status === "pending") ); const resolved = sortByTime( approvals.filter((item) => item.status !== "pending") ); return { pending, resolved }; }, [approvals]); const orderedApprovals = useMemo( () => [...sortedApprovals.pending, ...sortedApprovals.resolved], [sortedApprovals.pending, sortedApprovals.resolved] ); useEffect(() => { if (orderedApprovals.length === 0) { setSelectedId(null); 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 (
{errorState ? (
{errorState}
) : null} {loadingState ? (

Loading approvals…

) : pendingCount === 0 && resolvedCount === 0 ? (

No approvals yet.

) : (

Unapproved tasks

{pendingCount} pending · {resolvedCount} resolved

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

{selectedApproval?.status === "pending" ? "Latest unapproved task" : "Approval detail"}

{!selectedApproval ? (
Select an approval to review details.
) : ( (() => { const summary = approvalSummary(selectedApproval); const titleRow = summary.rows.find( (row) => row.label.toLowerCase() === "title" ); const titleText = titleRow?.value?.trim() ?? ""; const descriptionText = summary.description?.trim() ?? ""; const reasoningText = summary.reason?.trim() ?? ""; const extraRows = summary.rows.filter((row) => { const normalized = row.label.toLowerCase(); if (normalized === "title") return false; if (normalized === "task") return false; if (normalized === "assignee") return false; return true; }); const rubricEntries = Object.entries( selectedApproval.rubric_scores ?? {} ).map(([key, value]) => ({ label: key .replace(/_/g, " ") .replace(/\b\w/g, (char) => char.toUpperCase()), value, })); const rubricTotal = rubricEntries.reduce( (total, entry) => total + entry.value, 0, ); const hasRubric = rubricEntries.length > 0 && rubricTotal > 0; const rubricChartData = rubricEntries.map((entry, index) => { const percent = rubricTotal > 0 ? (entry.value / rubricTotal) * 100 : 0; return { key: entry.label.toLowerCase().replace(/[^a-z0-9]+/g, "_"), name: entry.label, value: entry.value, percent, percentLabel: `${percent.toFixed(1)}%`, fill: rubricColors[index % rubricColors.length], }; }); const rubricChartConfig = rubricChartData.reduce( (accumulator, entry) => { accumulator[entry.key] = { label: entry.name, color: entry.fill, }; return accumulator; }, {}, ); return (

{humanizeAction(selectedApproval.action_type)}

Requested {formatTimestamp(selectedApproval.created_at)}

{selectedApproval.confidence}% confidence {selectedApproval.status === "pending" ? (
) : null}

Status

{formatStatusLabel(selectedApproval.status)} ·{" "} {selectedApproval.status === "pending" ? "Awaiting your decision" : "Decision complete"}

{titleText ? (

Title

{titleText}
) : null} {descriptionText ? (

Description

{descriptionText}
) : null} {reasoningText ? (

Lead reasoning

{reasoningText}

) : null} {extraRows.length > 0 ? (

Details

{extraRows.map((row) => (

{row.label}

{row.value}

))}
) : null} {hasRubric ? (

Rubric scores

{rubricChartData.map((entry) => (
{entry.name}
{entry.percentLabel}
))}
{rubricTotal > 0 ? ( } /> ) : null} {rubricChartData.map((entry) => ( ))}
) : null}
); })() )}
)}
); } export default BoardApprovalsPanel;