1"use client";
2
3
25import { useState } from "react";
26import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
27import { Check, Clock, RotateCcw, Send, Users, XCircle } from "lucide-react";
28import { reviewApi, workspaceApi } from "@/lib/api";
29import { useAuthStore } from "@/lib/auth-store";
30import { toast } from "@/stores/ui-store";
31
32const queryKey = (pageId: string) => ["review", pageId] as const;
33
34export function ReviewBanner({ pageId, workspaceId }: { pageId: string; workspaceId: string }) {
35 const me = useAuthStore((s) => s.user?.id ?? null);
36 const qc = useQueryClient();
37 const [picking, setPicking] = useState(false);
38 const [selected, setSelected] = useState<string[]>([]);
39 const [comment, setComment] = useState("");
40
41 const review = useQuery({ queryKey: queryKey(pageId), queryFn: () => reviewApi.get(pageId), enabled: !!pageId, retry: false });
42 const members = useQuery({
43 queryKey: ["workspaces", workspaceId, "members"],
44 queryFn: () => workspaceApi.members(workspaceId),
45 enabled: !!workspaceId && review.data != null && review.data.state !== "changes_requested",
46 retry: false
47 });
48
49 const act = useMutation({
50 mutationFn: (input: Parameters<typeof reviewApi.act>[1]) => reviewApi.act(pageId, input),
51 onSuccess: () => {
52 qc.invalidateQueries({ queryKey: queryKey(pageId) });
53 setPicking(false);
54 setComment("");
55 },
56 onError: (e) => toast("error", "Review action failed", e instanceof Error ? e.message : undefined)
57 });
58
59 const data = review.data;
60 if (!data) return null;
61
62
63
64
65
66
67
68
69
70 const isReviewer = data.review ? data.review.reviewerIds.includes(me ?? "") : false;
71 const isRequester = data.review ? data.review.requestedBy === me : false;
72
73 const startPick = () => {
74
75 setSelected(data.lastReview?.reviewerIds ?? []);
76 setPicking(true);
77 };
78 const toggle = (id: string) => setSelected((cur) => (cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]));
79 const submit = () => {
80 if (!selected.length) return;
81 act.mutate({ action: "submit", reviewerIds: selected, comment: comment.trim() || undefined });
82 };
83
84 const memberName = (id: string) => members.data?.find((m) => m.userId === id)?.user.name ?? "reviewer";
85
86 return (
87 <div className="mb-4" data-testid="review-banner">
88 {data.state === "in_review" && data.review ? (
89 <div className="flex flex-wrap items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
90 <span className="inline-flex items-center gap-1.5 font-medium">
91 <Users className="size-3.5" aria-hidden /> In review
92 </span>
93 {data.review.comment ? <span className="italic text-amber-800">“{data.review.comment}”</span> : null}
94 {data.review.deadline ? (
95 <span className="inline-flex items-center gap-1 text-amber-700">
96 <Clock className="size-3.5" aria-hidden /> Due {new Date(data.review.deadline).toLocaleDateString()}
97 </span>
98 ) : null}
99 <span className="ml-auto flex items-center gap-1.5">
100 {isReviewer ? (
101 <>
102 <button
103 onClick={() => act.mutate({ action: "approve", comment: comment.trim() || undefined })}
104 disabled={act.isPending}
105 className="rounded-md bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-emerald-700"
106 >
107 <Check className="mr-1 inline size-3.5" /> Approve
108 </button>
109 <button
110 onClick={() => act.mutate({ action: "request_changes", comment: comment.trim() || undefined })}
111 disabled={act.isPending}
112 className="rounded-md border border-amber-300 bg-white px-2.5 py-1 text-xs font-medium text-amber-800 hover:bg-amber-100"
113 >
114 <RotateCcw className="mr-1 inline size-3.5" /> Request changes
115 </button>
116 </>
117 ) : null}
118 {isRequester ? (
119 <button
120 onClick={() => act.mutate({ action: "cancel", comment: comment.trim() || undefined })}
121 disabled={act.isPending}
122 className="rounded-md border border-amber-300 bg-white px-2.5 py-1 text-xs font-medium text-amber-800 hover:bg-amber-100"
123 >
124 <XCircle className="mr-1 inline size-3.5" /> Cancel
125 </button>
126 ) : null}
127 {!isReviewer && !isRequester ? <span className="text-xs text-amber-700">Waiting on {data.review.reviewerIds.map(memberName).join(", ")}</span> : null}
128 </span>
129 {isReviewer ? (
130 <input
131 value={comment}
132 onChange={(e) => setComment(e.target.value)}
133 placeholder="Add a comment (optional)"
134 className="w-full rounded-md border border-amber-300 bg-white px-2 py-1 text-xs"
135 aria-label="Review comment"
136 />
137 ) : null}
138 </div>
139 ) : null}
140
141 {data.state === "changes_requested" && !data.review ? (
142 <div className="flex flex-wrap items-center gap-3 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-900">
143 <span className="inline-flex items-center gap-1.5 font-medium">
144 <RotateCcw className="size-3.5" aria-hidden /> Changes requested
145 </span>
146 {data.lastReview?.comment ? <span className="italic text-rose-800">“{data.lastReview.comment}”</span> : null}
147 {data.lastReview ? (
148 <button onClick={startPick} className="ml-auto rounded-md bg-slate-900 px-2.5 py-1 text-xs font-medium text-white hover:bg-slate-700">
149 <Send className="mr-1 inline size-3.5" /> Resubmit
150 </button>
151 ) : null}
152 </div>
153 ) : null}
154
155 {(data.state === "published" || data.state === "draft") && !data.review ? (
156 <div className="flex flex-wrap items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
157 <span className="text-xs text-slate-500">{data.lastReview ? "Last review: approved" : "No review yet"}</span>
158 {picking ? (
159 <span className="flex flex-1 flex-wrap items-center gap-2">
160 <span className="flex flex-wrap gap-1.5">
161 {(members.data ?? []).map((m) => (
162 <label key={m.userId} className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-slate-300 bg-white px-2 py-1 text-xs">
163 <input type="checkbox" checked={selected.includes(m.userId)} onChange={() => toggle(m.userId)} className="mr-0.5" />
164 {m.user.name}
165 </label>
166 ))}
167 </span>
168 <input
169 value={comment}
170 onChange={(e) => setComment(e.target.value)}
171 placeholder="Request note (optional)"
172 className="min-w-[10rem] flex-1 rounded-md border border-slate-300 px-2 py-1 text-xs"
173 aria-label="Request note"
174 />
175 <button onClick={submit} disabled={!selected.length || act.isPending} className="rounded-md bg-slate-900 px-2.5 py-1 text-xs font-medium text-white hover:bg-slate-700 disabled:opacity-50">
176 <Send className="mr-1 inline size-3.5" /> Submit
177 </button>
178 <button onClick={() => setPicking(false)} className="rounded-md border border-slate-300 px-2.5 py-1 text-xs">
179 Cancel
180 </button>
181 </span>
182 ) : (
183 <button onClick={startPick} className="ml-auto rounded-md border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 hover:bg-slate-100">
184 <Send className="mr-1 inline size-3.5" /> Request review
185 </button>
186 )}
187 </div>
188 ) : null}
189 </div>
190 );
191}
192