686 lines
25 KiB
TypeScript
686 lines
25 KiB
TypeScript
"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<string, unknown> | null;
|
|
confidence: number;
|
|
rubric_scores?: Record<string, number> | 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<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 =
|
|
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<Approval[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const lastDecisionRef = useRef<string | null>(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 (
|
|
<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 = 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
|
|
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;
|