import { useEffect, useState } from 'react' import { ai, BANDS, BUSY, CAVEAT, type Band, type ReviewRow, type Run } from '../ai' const FILTERS: { label: string; band: Band | 'all' }[] = [ { label: 'All', band: 'all' }, { label: 'Red', band: 'red' }, { label: 'Amber', band: 'amber' }, { label: 'Missing', band: 'blank' }, { label: 'Unscored', band: 'unscored' }, ] const colourOf = (b: Band) => BANDS.find((x) => x.band === b)!.colour const settled = (r: ReviewRow) => r.corrected_value ?? r.value ?? '' /** * The review split pane: the source document beside its extracted fields. * Typing makes a draft; nothing is written until Save. Verify signs off one field * as it reads. Approve asks the server, which refuses while flagged fields are unverified. */ export function Review({ onUnsaved }: { onUnsaved: (n: number) => void }) { const [runs, setRuns] = useState() const [jobId, setJobId] = useState() const [run, setRun] = useState() const [rows, setRows] = useState([]) const [drafts, setDrafts] = useState>({}) const [filter, setFilter] = useState('all') const [refusal, setRefusal] = useState() const [error, setError] = useState() const [saving, setSaving] = useState(false) const dirty = rows.filter((r) => r.row_id in drafts && drafts[r.row_id] !== settled(r)) useEffect(() => onUnsaved(dirty.length), [dirty.length, onUnsaved]) useEffect(() => { if (!dirty.length) return const warn = (e: BeforeUnloadEvent) => e.preventDefault() window.addEventListener('beforeunload', warn) return () => window.removeEventListener('beforeunload', warn) }, [dirty.length]) useEffect(() => { ai.runs().then((d) => { setRuns(d.runs); setJobId(d.runs[0]?.job_id) }) .catch((e) => setError(`Can't load runs from ${API_BASE}: ${e.message}. Is the backend (or server_stub.py) running?`)) }, []) // Load the chosen run, and poll it every 1.5s while it is still extracting. useEffect(() => { if (!jobId) return let alive = true let timer: ReturnType setRun(undefined); setRows([]); setDrafts({}); setRefusal(undefined) const tick = async () => { try { const r = await ai.run(jobId) if (!alive) return setRun(r) if (BUSY.includes(r.status)) timer = setTimeout(tick, 1500) else { const d = await ai.rows(jobId); if (alive) setRows(d.rows) } } catch (e) { if (alive) setError((e as Error).message) } } tick() return () => { alive = false; clearTimeout(timer) } }, [jobId]) const reload = async () => { if (!jobId) return const [r, d] = await Promise.all([ai.run(jobId), ai.rows(jobId)]) setRun(r); setRows(d.rows) } const pick = (id: string) => { if (dirty.length && !window.confirm(`${dirty.length} unsaved change(s) on this document. Discard them and leave?`)) return setJobId(id) } const save = async () => { setSaving(true) try { await Promise.all(dirty.map((r) => { const text = drafts[r.row_id].trim() // Same as extracted (or emptied) means "the extracted value was right", not an edit. return ai.patchRow(r.row_id, { corrected_value: text === '' || text === (r.value ?? '') ? null : text, verified: true }) })) setDrafts({}) await reload() } finally { setSaving(false) } } const verify = async (r: ReviewRow) => { await ai.patchRow(r.row_id, { verified: !r.verified }); await reload() } const verifyRemaining = async () => { if (jobId) { await ai.verifyRemaining(jobId); await reload() } } const approve = async () => { if (!jobId) return setRefusal(undefined) try { await ai.approve(jobId); await reload() } catch (e) { setRefusal((e as Error).message) } } if (error) return
{error}
if (!runs) return
Loading runs…
if (!runs.length) return
No documents extracted yet.
const shown = filter === 'all' ? rows : rows.filter((r) => r.band === filter) const left = rows.filter((r) => r.band !== 'green' && !r.verified).length return (
{run && {run.status}} {run && BUSY.includes(run.status) && page {run.pages_done} of {run.pages_total || '?'}} {run?.error && {run.error}} {/* OPTIONAL: retry on another model (failed runs) goes here */} {run?.status === 'done' && (
{left} to verify {run.approved ? ✓ Approved : }
)} {refusal &&

Not approved: {refusal}

}
    {BANDS.map((b) => (
  • {b.label} · {run?.summary?.[b.band] ?? 0}
  • ))}
{CAVEAT}
{FILTERS.map((f) => ( ))}