diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index baa8f56..9aa811a 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -7,6 +7,7 @@ import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; import { X } from "lucide-react"; import ReactMarkdown from "react-markdown"; +import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel"; import { BoardGoalPanel } from "@/components/BoardGoalPanel"; import { BoardOnboardingChat } from "@/components/BoardOnboardingChat"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; @@ -575,7 +576,7 @@ export default function BoardDetailPage() {
-
+
setIsOnboardingOpen(true)} @@ -583,6 +584,7 @@ export default function BoardDetailPage() { boardId ? () => router.push(`/boards/${boardId}/edit`) : undefined } /> + {boardId ? : null}
{error && ( diff --git a/frontend/src/components/BoardApprovalsPanel.tsx b/frontend/src/components/BoardApprovalsPanel.tsx new file mode 100644 index 0000000..1a7b7df --- /dev/null +++ b/frontend/src/components/BoardApprovalsPanel.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { useAuth } from "@clerk/nextjs"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { getApiBaseUrl } from "@/lib/api-base"; +import { cn } from "@/lib/utils"; + +const apiBase = getApiBaseUrl(); + +type Approval = { + id: string; + action_type: string; + payload?: Record | null; + confidence: number; + rubric_scores?: Record | null; + status: string; + created_at: string; + resolved_at?: string | null; +}; + +type BoardApprovalsPanelProps = { + boardId: string; +}; + +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 statusBadgeVariant = (status: string) => { + if (status === "approved") return "success"; + if (status === "rejected") return "danger"; + return "outline"; +}; + +const confidenceVariant = (confidence: number) => { + if (confidence >= 90) return "success"; + if (confidence >= 80) return "accent"; + return "warning"; +}; + +export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) { + const { getToken, isSignedIn } = useAuth(); + const [approvals, setApprovals] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [updatingId, setUpdatingId] = useState(null); + + const loadApprovals = useCallback(async () => { + 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[]; + setApprovals(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to load approvals."); + } finally { + setIsLoading(false); + } + }, [boardId, getToken, isSignedIn]); + + useEffect(() => { + loadApprovals(); + if (!isSignedIn || !boardId) return; + const interval = setInterval(loadApprovals, 15000); + return () => clearInterval(interval); + }, [boardId, isSignedIn, loadApprovals]); + + const handleDecision = useCallback( + async (approvalId: string, status: "approved" | "rejected") => { + 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; + setApprovals((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] + ); + + const sortedApprovals = useMemo(() => { + const pending = approvals.filter((item) => item.status === "pending"); + const resolved = approvals.filter((item) => item.status !== "pending"); + 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; + }); + return [...sortByTime(pending), ...sortByTime(resolved)]; + }, [approvals]); + + return ( + + +
+
+

+ Approvals +

+

+ Pending decisions +

+
+ +
+

+ Review lead-agent decisions that require human approval. +

+
+ + {error ? ( +
+ {error} +
+ ) : null} + {isLoading ? ( +

Loading approvals…

+ ) : sortedApprovals.length === 0 ? ( +

No approvals yet.

+ ) : ( +
+ {sortedApprovals.map((approval) => ( +
+
+
+

+ {approval.action_type.replace(/_/g, " ")} +

+

+ Requested {formatTimestamp(approval.created_at)} +

+
+
+ + {approval.confidence}% confidence + + + {approval.status} + +
+
+ {approval.payload || approval.rubric_scores ? ( +
+ + Details + + {approval.payload ? ( +
+                        Payload: {JSON.stringify(approval.payload, null, 2)}
+                      
+ ) : null} + {approval.rubric_scores ? ( +
+                        Rubric: {JSON.stringify(approval.rubric_scores, null, 2)}
+                      
+ ) : null} +
+ ) : null} + {approval.status === "pending" ? ( +
+ + +
+ ) : null} +
+ ))} +
+ )} +
+
+ ); +} + +export default BoardApprovalsPanel;