Files
mission-control/frontend/src/components/BoardApprovalsPanel.tsx
2026-02-07 20:29:55 +05:30

693 lines
26 KiB
TypeScript

"use client";
import { useCallback, useMemo, useState } from "react";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import { Clock } from "lucide-react";
import { Cell, Pie, PieChart } from "recharts";
import { ApiError } from "@/api/mutator";
import {
type listApprovalsApiV1BoardsBoardIdApprovalsGetResponse,
getListApprovalsApiV1BoardsBoardIdApprovalsGetQueryKey,
useListApprovalsApiV1BoardsBoardIdApprovalsGet,
useUpdateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch,
} from "@/api/generated/approvals/approvals";
import type { ApprovalRead } from "@/api/generated/model";
import {
ChartContainer,
ChartTooltip,
ChartTooltipCard,
type ChartConfig,
} from "@/components/charts/chart";
import { Button } from "@/components/ui/button";
import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime";
import { cn } from "@/lib/utils";
type Approval = ApprovalRead & { status: string };
const normalizeApproval = (approval: ApprovalRead): Approval => ({
...approval,
status: approval.status ?? "pending",
});
type BoardApprovalsPanelProps = {
boardId: string;
approvals?: ApprovalRead[];
isLoading?: boolean;
error?: string | null;
onDecision?: (approvalId: string, status: "approved" | "rejected") => void;
scrollable?: boolean;
};
const formatTimestamp = (value?: string | null) => {
if (!value) return "—";
const date = parseApiDatetime(value);
if (!date) 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<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) => {
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 =
approval.task_id ??
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 { isSignedIn } = useAuth();
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const usingExternal = Array.isArray(externalApprovals);
const approvalsKey = useMemo(
() => getListApprovalsApiV1BoardsBoardIdApprovalsGetQueryKey(boardId),
[boardId],
);
const approvalsQuery = useListApprovalsApiV1BoardsBoardIdApprovalsGet<
listApprovalsApiV1BoardsBoardIdApprovalsGetResponse,
ApiError
>(boardId, undefined, {
query: {
enabled: Boolean(!usingExternal && isSignedIn && boardId),
refetchInterval: 15_000,
refetchOnMount: "always",
retry: false,
},
});
const updateApprovalMutation =
useUpdateApprovalApiV1BoardsBoardIdApprovalsApprovalIdPatch<ApiError>();
const approvals = useMemo(() => {
const raw = usingExternal
? (externalApprovals ?? [])
: approvalsQuery.data?.status === 200
? (approvalsQuery.data.data.items ?? [])
: [];
return raw.map(normalizeApproval);
}, [approvalsQuery.data, externalApprovals, usingExternal]);
const loadingState = usingExternal
? (externalLoading ?? false)
: approvalsQuery.isLoading;
const errorState = usingExternal
? (externalError ?? null)
: (error ?? approvalsQuery.error?.message ?? null);
const handleDecision = useCallback(
(approvalId: string, status: "approved" | "rejected") => {
const pendingNext = [...approvals]
.filter((item) => item.id !== approvalId)
.filter((item) => item.status === "pending")
.sort(
(a, b) =>
(apiDatetimeToMs(b.created_at) ?? 0) -
(apiDatetimeToMs(a.created_at) ?? 0),
)[0]?.id;
if (pendingNext) {
setSelectedId(pendingNext);
}
if (onDecision) {
onDecision(approvalId, status);
return;
}
if (usingExternal) return;
if (!isSignedIn || !boardId) return;
setUpdatingId(approvalId);
setError(null);
updateApprovalMutation.mutate(
{ boardId, approvalId, data: { status } },
{
onSuccess: (result) => {
if (result.status !== 200) return;
queryClient.setQueryData<listApprovalsApiV1BoardsBoardIdApprovalsGetResponse>(
approvalsKey,
(previous) => {
if (!previous || previous.status !== 200) return previous;
return {
...previous,
data: {
...previous.data,
items: previous.data.items.map((item) =>
item.id === approvalId ? result.data : item,
),
},
};
},
);
},
onError: (err) => {
setError(err.message || "Unable to update approval.");
},
onSettled: () => {
setUpdatingId(null);
queryClient.invalidateQueries({ queryKey: approvalsKey });
},
},
);
},
[
approvals,
approvalsKey,
boardId,
isSignedIn,
onDecision,
queryClient,
updateApprovalMutation,
usingExternal,
],
);
const sortedApprovals = useMemo(() => {
const sortByTime = (items: Approval[]) =>
[...items].sort((a, b) => {
const aTime = apiDatetimeToMs(a.created_at) ?? 0;
const bTime = apiDatetimeToMs(b.created_at) ?? 0;
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],
);
const effectiveSelectedId = useMemo(() => {
if (orderedApprovals.length === 0) return null;
if (selectedId && orderedApprovals.some((item) => item.id === selectedId)) {
return selectedId;
}
return orderedApprovals[0].id;
}, [orderedApprovals, selectedId]);
const selectedApproval = useMemo(() => {
if (!effectiveSelectedId) return null;
return (
orderedApprovals.find((item) => item.id === effectiveSelectedId) ?? null
);
}, [effectiveSelectedId, orderedApprovals]);
const pendingCount = sortedApprovals.pending.length;
const resolvedCount = sortedApprovals.resolved.length;
return (
<div className={cn("space-y-6", 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>
) : null}
{loadingState ? (
<p className="text-sm text-slate-500">Loading approvals</p>
) : pendingCount === 0 && resolvedCount === 0 ? (
<p className="text-sm text-slate-500">No approvals yet.</p>
) : (
<div
className={cn(
"grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]",
scrollable && "h-full",
)}
>
<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 = effectiveSelectedId === 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
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">
{selectedApproval?.status === "pending"
? "Latest unapproved task"
: "Approval detail"}
</p>
</div>
{!selectedApproval ? (
<div className="flex h-full items-center justify-center px-6 py-10 text-sm text-slate-500">
Select an approval to review details.
</div>
) : (
(() => {
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<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>
) : null}
</div>
</div>
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
<span
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>
);
}
export default BoardApprovalsPanel;