"use client"; /** * T-163 — the page review banner (T-162/T-163 web surface). * * A thin, self-contained strip above the page header that shows the page's * `reviewState` and the per-role actions. Client-side it only RENDERS — every * action is server-authorized (the A-003 reviewer-of-record gate, the * transition matrix, membership): a denied action is a toast, never a broken * UI. State comes from `GET /pages/:id/review` (`{ state, open, lastReview }`); * actions POST to `/pages/:id/review`. * * published/draft → "Request review" (pick members → submit) * in_review → chip; reviewer-of-record sees Approve / Request * changes; the requester sees Cancel * changes_requested → chip + the feedback comment; "Resubmit" (same * reviewers by default) * * Always rendered once `GET /pages/:id/review` resolves (every state has a * branch) — including fresh pages, which default to `published` with no * review history (the "No review yet" row). (An earlier "hide for legacy * published" early-return made the workflow unstartable — see the note in * the render body, 2026-10-12.) */ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Clock, RotateCcw, Send, Users, XCircle } from "lucide-react"; import { reviewApi, workspaceApi } from "@/lib/api"; import { useAuthStore } from "@/lib/auth-store"; import { toast } from "@/stores/ui-store"; const queryKey = (pageId: string) => ["review", pageId] as const; export function ReviewBanner({ pageId, workspaceId }: { pageId: string; workspaceId: string }) { const me = useAuthStore((s) => s.user?.id ?? null); const qc = useQueryClient(); const [picking, setPicking] = useState(false); const [selected, setSelected] = useState([]); const [comment, setComment] = useState(""); const review = useQuery({ queryKey: queryKey(pageId), queryFn: () => reviewApi.get(pageId), enabled: !!pageId, retry: false }); const members = useQuery({ queryKey: ["workspaces", workspaceId, "members"], queryFn: () => workspaceApi.members(workspaceId), enabled: !!workspaceId && review.data != null && review.data.state !== "changes_requested", retry: false }); const act = useMutation({ mutationFn: (input: Parameters[1]) => reviewApi.act(pageId, input), onSuccess: () => { qc.invalidateQueries({ queryKey: queryKey(pageId) }); setPicking(false); setComment(""); }, onError: (e) => toast("error", "Review action failed", e instanceof Error ? e.message : undefined) }); const data = review.data; if (!data) return null; // NOTE (2026-10-12): an earlier version early-returned for // `published && !review && !lastReview` (the "legacy no-review case"), which // also hid the banner on EVERY fresh page (the schema default is // `reviewState = "published"`) — making the documented `published/draft → // Request review` branch unreachable and the review workflow unstartable // from the UI (spec: "Submit a document for review and approve it"). The // "No review yet" state now renders; legacy pages gain the same small // affordance (additive, no behavior change to any existing state). const isReviewer = data.review ? data.review.reviewerIds.includes(me ?? "") : false; const isRequester = data.review ? data.review.requestedBy === me : false; const startPick = () => { // Default the selection to the last review's reviewers (one-click resubmit). setSelected(data.lastReview?.reviewerIds ?? []); setPicking(true); }; const toggle = (id: string) => setSelected((cur) => (cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id])); const submit = () => { if (!selected.length) return; act.mutate({ action: "submit", reviewerIds: selected, comment: comment.trim() || undefined }); }; const memberName = (id: string) => members.data?.find((m) => m.userId === id)?.user.name ?? "reviewer"; return (
{data.state === "in_review" && data.review ? (
In review {data.review.comment ? “{data.review.comment}” : null} {data.review.deadline ? ( Due {new Date(data.review.deadline).toLocaleDateString()} ) : null} {isReviewer ? ( <> ) : null} {isRequester ? ( ) : null} {!isReviewer && !isRequester ? Waiting on {data.review.reviewerIds.map(memberName).join(", ")} : null} {isReviewer ? ( setComment(e.target.value)} placeholder="Add a comment (optional)" className="w-full rounded-md border border-amber-300 bg-white px-2 py-1 text-xs" aria-label="Review comment" /> ) : null}
) : null} {data.state === "changes_requested" && !data.review ? (
Changes requested {data.lastReview?.comment ? “{data.lastReview.comment}” : null} {data.lastReview ? ( ) : null}
) : null} {(data.state === "published" || data.state === "draft") && !data.review ? (
{data.lastReview ? "Last review: approved" : "No review yet"} {picking ? ( {(members.data ?? []).map((m) => ( ))} setComment(e.target.value)} placeholder="Request note (optional)" className="min-w-[10rem] flex-1 rounded-md border border-slate-300 px-2 py-1 text-xs" aria-label="Request note" /> ) : ( )}
) : null}
); }