feat: enhance BoardApprovalsPanel with detailed approval views and chart integration
This commit is contained in:
45
frontend/src/app/boards/[boardId]/approvals/page.tsx
Normal file
45
frontend/src/app/boards/[boardId]/approvals/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
501
frontend/src/components/charts/chart.tsx
Normal file
501
frontend/src/components/charts/chart.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user