'use client'; /** * Review-first record drawer — the fast triage surface for a status-carrying * entity type (bd startsim-768w.18.14 / .15). * * Opens READ-OPTIMISED, not as an edit form: the record's title / summary / * sources up top, then a sticky bar with ONE decision — Approve / Needs work / * Reject. Each decision writes BOTH underlying fields coherently (status = the * pipeline gate + team_verdict = the re-rank signal) in a single save, so there's * no separate "verdict" row duplicating Approve/Reject. Approve/Reject advance to * the next record; the current state shows as a badge that doubles as the rare * manual override (mark written, un-reject). ↑/↓ (or j/k) walk the queue; a / w / * x / e are bare-letter shortcuts (guarded while typing). Deep field-editing stays * behind "Edit fields" (the app injects its form via renderEditFields). * * Generic + client-injected: the consumer passes a CollectionClient, the ordered * queue (prev/next), and optional ReviewConfig / render-props. Defaults match the * OGMC content schema but everything is a prop — nothing here is tenant-specific. */ import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { ChevronLeft, ChevronRight, Check, X, PencilLine, ExternalLink, ShieldCheck, MessageSquarePlus, } from 'lucide-react'; import { Button } from '../ui/button'; import { Textarea } from '../ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../ui/select'; import { notify } from '../toast/use-notify'; import { cn } from '../../lib/utils'; import type { CollectionClient, EntityRecord, EntityTypeDef } from './types'; import { readData, toCamelKey } from './data'; import { parseSources } from './drafts'; import { resolveReviewConfig, reviewDecisions, shouldIgnoreShortcut, fieldStr, reviewHostOf, type ReviewConfig, type ReviewDecision, } from './review'; export interface ReviewDrawerProps { client: CollectionClient; type: EntityTypeDef; /** The ordered queue the drawer walks with prev/next (the current list/page). */ records: EntityRecord[]; /** The open record (null closes the drawer). */ record: EntityRecord | null; onClose: () => void; /** Move the open record to another (prev/next / auto-advance). */ onNavigate: (record: EntityRecord) => void; /** Fired after any successful save so the parent can refetch/invalidate. */ onSaved?: () => void; /** Field-map + transition overrides; defaults match the OGMC content schema. */ config?: ReviewConfig; /** Map of source host → tier (1 = approved) for the source chips. Optional. */ sourceTierByHost?: Map; /** The app's deep field editor, shown behind "Edit fields". Optional. */ renderEditFields?: (ctx: { record: EntityRecord; type: EntityTypeDef; /** Return to review mode without saving. */ back: () => void; /** Refetch the parent + return to review mode. */ saved: () => void; }) => ReactNode; /** App-specific extra content under the record (e.g. a topic's drafts). Optional. */ renderExtra?: (record: EntityRecord, type: EntityTypeDef) => ReactNode; } export function ReviewDrawer(props: ReviewDrawerProps) { if (!props.record) return null; return ; } function ReviewDrawerInner({ client, type, records, record, onClose, onNavigate, onSaved, config, sourceTierByHost, renderEditFields, renderExtra, }: ReviewDrawerProps & { record: EntityRecord }) { const cfg = useMemo(() => resolveReviewConfig(type, config), [type, config]); const decisions = useMemo(() => reviewDecisions(cfg), [cfg]); const [mode, setMode] = useState<'review' | 'edit'>('review'); // Local optimistic copy so a click reflects instantly (the parent also refetches). const [data, setData] = useState>(() => ({ ...record.data })); const [busy, setBusy] = useState(null); const [noteOpen, setNoteOpen] = useState(false); const [note, setNote] = useState(() => fieldStr(record.data, cfg.noteAttr)); const index = records.findIndex((r) => String(r.id) === String(record.id)); const prev = index > 0 ? records[index - 1] : null; const next = index >= 0 && index + 1 < records.length ? records[index + 1] : null; const status = fieldStr(data, cfg.statusName); const verdict = fieldStr(data, cfg.verdictAttr); const title = fieldStr(data, cfg.titleAttr) || record.name || `#${record.id}`; const summary = fieldStr(data, cfg.summaryAttr); const rank = fieldStr(data, cfg.rankAttr); /** Merge changes into the (camelCased) blob and PATCH; the backend REPLACES data. */ async function patch(changes: Record, key: string, advance = false) { const nextData: Record = { ...data }; for (const [k, v] of Object.entries(changes)) { const camel = toCamelKey(k); if (v === '' || v == null) { delete nextData[camel]; delete nextData[k]; } else { nextData[camel] = v; if (camel !== k) delete nextData[k]; } } setData(nextData); // optimistic setBusy(key); try { await client.updateEntity(record.id, { data: nextData }); onSaved?.(); if (advance && next) onNavigate(next); } catch (err) { setData({ ...record.data }); // roll back notify.error(err instanceof Error ? err.message : 'Could not save.'); } finally { setBusy(null); } } /** One decision → write status AND verdict together (the coherent pair). */ function applyDecision(d: ReviewDecision) { const changes: Record = {}; if (d.status) changes[cfg.statusName] = d.status; if (d.verdict) changes[cfg.verdictAttr] = d.verdict; if (d.promptNote) setNoteOpen(true); void patch(changes, `decision:${d.key}`, d.advance); } /** Manual state override from the badge — status only, no verdict, no advance. */ function overrideStatus(value: string) { if (value === status) return; void patch({ [cfg.statusName]: value }, `status:${value}`, false); } async function saveNote() { await patch({ [cfg.noteAttr]: note.trim() }, 'note'); setNoteOpen(false); } // Keyboard: walk the queue + one-key decide. Guarded so it never fires while typing. useEffect(() => { if (mode !== 'review') return; function onKey(e: KeyboardEvent) { if (shouldIgnoreShortcut(e)) return; const d = decisions.find((x) => x.shortcut === e.key); if (d) { e.preventDefault(); applyDecision(d); return; } switch (e.key) { case 'j': case 'ArrowDown': if (next) { e.preventDefault(); onNavigate(next); } break; case 'k': case 'ArrowUp': if (prev) { e.preventDefault(); onNavigate(prev); } break; case 'e': e.preventDefault(); setMode('edit'); break; case 'Escape': e.preventDefault(); onClose(); break; } } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode, prev, next, decisions, data]); const legend = [...decisions.map((d) => `${d.shortcut} ${d.label.toLowerCase()}`), 'e edit', 'j/k move'].join(' · '); return (