730 lines
27 KiB
TypeScript
730 lines
27 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo, useState } from "react";
|
|
|
|
import { useAuth } from "@/auth/clerk";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
|
|
import { CheckCircle2, 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 { StatusDot } from "@/components/atoms/StatusDot";
|
|
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;
|
|
boardLabelById?: Record<string, string>;
|
|
};
|
|
|
|
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 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, boardLabel?: string | null) => {
|
|
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 (boardLabel) rows.push({ label: "Board", value: boardLabel });
|
|
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,
|
|
boardLabelById,
|
|
}: 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 ? (
|
|
<div
|
|
className={cn(
|
|
"rounded-xl border border-dashed border-slate-200 bg-white px-6 py-10 text-center",
|
|
scrollable && "flex h-full items-center justify-center",
|
|
)}
|
|
>
|
|
<div className="max-w-sm">
|
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-emerald-50 text-emerald-600">
|
|
<CheckCircle2 className="h-6 w-6" />
|
|
</div>
|
|
<p className="mt-4 text-sm font-semibold text-slate-900">
|
|
All clear
|
|
</p>
|
|
<p className="mt-2 text-sm text-slate-500">
|
|
No approvals to review right now. New approvals will show up here
|
|
as soon as they arrive.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<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,
|
|
boardLabelById?.[approval.board_id] ?? null,
|
|
);
|
|
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" &&
|
|
row.label.toLowerCase() !== "board",
|
|
);
|
|
const primaryLabel =
|
|
titleRow?.value ?? fallbackRow?.value ?? "Untitled";
|
|
const boardRow = summary.rows.find(
|
|
(row) => row.label.toLowerCase() === "board",
|
|
);
|
|
const boardText =
|
|
boardRow && boardRow.value !== primaryLabel
|
|
? boardRow.value
|
|
: null;
|
|
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>
|
|
{boardText ? (
|
|
<p className="mt-1 text-xs text-slate-500">
|
|
Board · {boardText}
|
|
</p>
|
|
) : null}
|
|
<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,
|
|
boardLabelById?.[selectedApproval.board_id] ?? null,
|
|
);
|
|
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">
|
|
<StatusDot
|
|
status={selectedApproval.status}
|
|
variant="approval"
|
|
className={cn(
|
|
"h-2 w-2 rounded-full",
|
|
)}
|
|
/>
|
|
<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;
|