feat(web): wire full review UX — batch select, review notes, batch approve/reject
- Reviews page: batch selection mode with Select All / Clear - Reviews page: batch Approve N / Reject N buttons - Reviews page: review note textarea shared by single + batch flows - review-client.ts: added batchReviewItems() calling POST /batch-review - review-client.ts: approve/reject now pass reviewNote to backend - Clears review note + batch selection after successful action Verification: web typecheck passes.
This commit is contained in:
parent
bdbf387f88
commit
ca3cdbad4e
@ -1,17 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { AgentTimeline } from "@/components/AgentTimeline";
|
||||
import { ProposalReviewCard } from "@/components/ProposalReviewCard";
|
||||
import { approveReviewItem, listAgentTimeline, listApprovalQueue, rejectReviewItem } from "@/lib/review-client";
|
||||
import { approveReviewItem, batchReviewItems, listAgentTimeline, listApprovalQueue, rejectReviewItem } from "@/lib/review-client";
|
||||
import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types";
|
||||
|
||||
export default function ReviewsPage() {
|
||||
const [approvalQueue, setApprovalQueue] = useState<ApprovalQueueItem[]>([]);
|
||||
const [timeline, setTimeline] = useState<AgentTimelineItem[]>([]);
|
||||
const [selectedApprovalId, setSelectedApprovalId] = useState<string | null>(null);
|
||||
const [selectedBatchIds, setSelectedBatchIds] = useState<Set<string>>(new Set());
|
||||
const [reviewNote, setReviewNote] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -37,6 +39,29 @@ export default function ReviewsPage() {
|
||||
() => approvalQueue.find((item) => item.id === selectedApprovalId) ?? approvalQueue[0] ?? null,
|
||||
[approvalQueue, selectedApprovalId],
|
||||
);
|
||||
|
||||
const batchMode = selectedBatchIds.size > 0;
|
||||
|
||||
const toggleBatchItem = useCallback((id: string) => {
|
||||
setSelectedBatchIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectAllForBatch = useCallback(() => {
|
||||
setSelectedBatchIds(new Set(approvalQueue.map((item) => item.id)));
|
||||
}, [approvalQueue]);
|
||||
|
||||
const clearBatch = useCallback(() => {
|
||||
setSelectedBatchIds(new Set());
|
||||
}, []);
|
||||
|
||||
const operatorWorkflows = [
|
||||
{
|
||||
id: "workflow-approvals",
|
||||
@ -70,12 +95,13 @@ export default function ReviewsPage() {
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
const note = reviewNote.trim() || undefined;
|
||||
|
||||
try {
|
||||
const updated =
|
||||
decision === "approved"
|
||||
? await approveReviewItem(featuredProposal)
|
||||
: await rejectReviewItem(featuredProposal);
|
||||
? await approveReviewItem(featuredProposal, note)
|
||||
: await rejectReviewItem(featuredProposal, note);
|
||||
|
||||
setApprovalQueue((current) => {
|
||||
const nextQueue = current.filter((item) => item.id !== featuredProposal.id);
|
||||
@ -88,13 +114,14 @@ export default function ReviewsPage() {
|
||||
{
|
||||
id: updated.id,
|
||||
actor: updated.owner,
|
||||
action: `human ${decision === "approved" ? "approved" : "rejected"} proposal`,
|
||||
action: `human ${decision} proposal`,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: updated.status,
|
||||
summary: updated.after ?? updated.title,
|
||||
},
|
||||
...current,
|
||||
]);
|
||||
setReviewNote("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unable to update review state");
|
||||
} finally {
|
||||
@ -102,6 +129,41 @@ export default function ReviewsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDecision(decision: "approved" | "rejected") {
|
||||
const batchItems = approvalQueue.filter((item) => selectedBatchIds.has(item.id));
|
||||
if (batchItems.length === 0) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
const note = reviewNote.trim() || undefined;
|
||||
|
||||
try {
|
||||
await batchReviewItems(batchItems, decision, note);
|
||||
|
||||
setApprovalQueue((current) => {
|
||||
const nextQueue = current.filter((item) => !selectedBatchIds.has(item.id));
|
||||
setSelectedApprovalId(nextQueue[0]?.id ?? null);
|
||||
return nextQueue;
|
||||
});
|
||||
setTimeline((current) => [
|
||||
{
|
||||
id: `batch-${Date.now()}`,
|
||||
actor: "reviewer",
|
||||
action: `batch ${decision} ${batchItems.length} proposals`,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: decision,
|
||||
summary: note ?? `${batchItems.length} items ${decision}`,
|
||||
},
|
||||
...current,
|
||||
]);
|
||||
setSelectedBatchIds(new Set());
|
||||
setReviewNote("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Batch review failed");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Agent review"
|
||||
@ -134,20 +196,62 @@ export default function ReviewsPage() {
|
||||
<section className="surface-card" style={{ padding: "var(--ml-space-5)", display: "grid", gap: "var(--ml-space-4)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--ml-space-3)", flexWrap: "wrap" }}>
|
||||
<div style={{ fontWeight: 700 }}>Approval queue</div>
|
||||
<div style={{ display: "flex", gap: "var(--ml-space-2)", flexWrap: "wrap" }}>
|
||||
<span className="badge">severity:medium+</span>
|
||||
<div style={{ display: "flex", gap: "var(--ml-space-2)", flexWrap: "wrap", alignItems: "center" }}>
|
||||
{batchMode ? (
|
||||
<>
|
||||
<span className="badge">{selectedBatchIds.size} selected</span>
|
||||
<button type="button" className="badge" onClick={clearBatch} style={{ cursor: "pointer" }}>Clear</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="badge" onClick={selectAllForBatch} style={{ cursor: "pointer" }}>Select all</button>
|
||||
)}
|
||||
<span className="badge">status:pending</span>
|
||||
<span className="badge">owner:any</span>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <div style={{ color: "var(--ml-text-secondary)" }}>{error}</div> : null}
|
||||
|
||||
{batchMode ? (
|
||||
<div style={{ display: "flex", gap: "var(--ml-space-2)", flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void handleBatchDecision("approved")}
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "var(--ml-radius-md)",
|
||||
padding: "8px 16px",
|
||||
background: "var(--ml-accent-primary)",
|
||||
color: "var(--ml-text-primary)",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Approve {selectedBatchIds.size}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void handleBatchDecision("rejected")}
|
||||
className="surface-muted"
|
||||
style={{
|
||||
border: "1px solid var(--ml-border-subtle)",
|
||||
borderRadius: "var(--ml-radius-md)",
|
||||
padding: "8px 16px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Reject {selectedBatchIds.size}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
|
||||
{approvalQueue.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="surface-muted"
|
||||
onClick={() => setSelectedApprovalId(item.id)}
|
||||
onClick={() => batchMode ? toggleBatchItem(item.id) : setSelectedApprovalId(item.id)}
|
||||
style={{
|
||||
padding: "var(--ml-space-4)",
|
||||
display: "flex",
|
||||
@ -157,8 +261,12 @@ export default function ReviewsPage() {
|
||||
flexWrap: "wrap",
|
||||
textAlign: "left",
|
||||
width: "100%",
|
||||
borderColor: featuredProposal?.id === item.id ? "var(--ml-accent-primary)" : undefined,
|
||||
background: featuredProposal?.id === item.id ? "rgba(90, 140, 255, 0.12)" : undefined,
|
||||
borderColor: batchMode
|
||||
? selectedBatchIds.has(item.id) ? "var(--ml-accent-primary)" : undefined
|
||||
: featuredProposal?.id === item.id ? "var(--ml-accent-primary)" : undefined,
|
||||
background: batchMode
|
||||
? selectedBatchIds.has(item.id) ? "rgba(90, 140, 255, 0.12)" : undefined
|
||||
: featuredProposal?.id === item.id ? "rgba(90, 140, 255, 0.12)" : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gap: 4 }}>
|
||||
@ -166,6 +274,9 @@ export default function ReviewsPage() {
|
||||
<span style={{ color: "var(--ml-text-secondary)" }}>{item.owner}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "var(--ml-space-2)", flexWrap: "wrap" }}>
|
||||
{batchMode ? (
|
||||
<span className="badge">{selectedBatchIds.has(item.id) ? "selected" : "unselected"}</span>
|
||||
) : null}
|
||||
<span className="badge">{item.severity}</span>
|
||||
<span className="badge">{item.status}</span>
|
||||
</div>
|
||||
@ -175,7 +286,29 @@ export default function ReviewsPage() {
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{featuredProposal ? (
|
||||
<section className="surface-card" style={{ padding: "var(--ml-space-5)", display: "grid", gap: "var(--ml-space-3)" }}>
|
||||
<label htmlFor="review-note" style={{ fontWeight: 600 }}>Review note</label>
|
||||
<textarea
|
||||
id="review-note"
|
||||
value={reviewNote}
|
||||
onChange={(e) => setReviewNote(e.target.value)}
|
||||
placeholder="Optional: add a note explaining your decision…"
|
||||
rows={2}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "var(--ml-space-3)",
|
||||
background: "var(--ml-bg-elevated)",
|
||||
color: "var(--ml-text-primary)",
|
||||
border: "1px solid var(--ml-border-subtle)",
|
||||
borderRadius: "var(--ml-radius-md)",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{featuredProposal && !batchMode ? (
|
||||
<ProposalReviewCard
|
||||
title={featuredProposal.title}
|
||||
before={featuredProposal.before ?? "No prior summary captured."}
|
||||
|
||||
@ -81,6 +81,7 @@ async function updateAgentActionState(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
state: "approved" | "rejected",
|
||||
reviewNote?: string,
|
||||
): Promise<NoteAgentActionDoc> {
|
||||
const api = createNotesApiClient();
|
||||
|
||||
@ -91,6 +92,7 @@ async function updateAgentActionState(
|
||||
body: JSON.stringify({
|
||||
state,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
...(reviewNote ? { reviewNote } : {}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
@ -130,12 +132,32 @@ export async function listAgentTimeline(noteId?: string): Promise<AgentTimelineI
|
||||
.map(toTimelineItem);
|
||||
}
|
||||
|
||||
export async function approveReviewItem(item: ApprovalQueueItem): Promise<ApprovalQueueItem> {
|
||||
const updated = await updateAgentActionState(item.id, item.workspaceId, "approved");
|
||||
export async function approveReviewItem(item: ApprovalQueueItem, reviewNote?: string): Promise<ApprovalQueueItem> {
|
||||
const updated = await updateAgentActionState(item.id, item.workspaceId, "approved", reviewNote);
|
||||
return toApprovalQueueItem(updated);
|
||||
}
|
||||
|
||||
export async function rejectReviewItem(item: ApprovalQueueItem): Promise<ApprovalQueueItem> {
|
||||
const updated = await updateAgentActionState(item.id, item.workspaceId, "rejected");
|
||||
export async function rejectReviewItem(item: ApprovalQueueItem, reviewNote?: string): Promise<ApprovalQueueItem> {
|
||||
const updated = await updateAgentActionState(item.id, item.workspaceId, "rejected", reviewNote);
|
||||
return toApprovalQueueItem(updated);
|
||||
}
|
||||
|
||||
export async function batchReviewItems(
|
||||
items: ApprovalQueueItem[],
|
||||
state: "approved" | "rejected",
|
||||
reviewNote?: string,
|
||||
): Promise<{ updated: number; total: number }> {
|
||||
const api = createNotesApiClient();
|
||||
const result = await api.fetch<{ updated: number; total: number }>(
|
||||
"/note-agent-actions/batch-review",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
ids: items.map((item) => ({ id: item.id, workspaceId: item.workspaceId })),
|
||||
state,
|
||||
...(reviewNote ? { reviewNote } : {}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user