Files
Rpa_Skills/skills/rpa-dashboard-ai/templates/web/src/components/Review.tsx
T

185 lines
9.7 KiB
TypeScript

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<Run[]>()
const [jobId, setJobId] = useState<string>()
const [run, setRun] = useState<Run>()
const [rows, setRows] = useState<ReviewRow[]>([])
const [drafts, setDrafts] = useState<Record<number, string>>({})
const [filter, setFilter] = useState<Band | 'all'>('all')
const [refusal, setRefusal] = useState<string>()
const [error, setError] = useState<string>()
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<typeof setTimeout>
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 <div className="card text-[13px] text-bad">{error}</div>
if (!runs) return <div className="card text-[13px] text-muted">Loading runs…</div>
if (!runs.length) return <div className="card text-[13px] text-muted">No documents extracted yet.</div>
const shown = filter === 'all' ? rows : rows.filter((r) => r.band === filter)
const left = rows.filter((r) => r.band !== 'green' && !r.verified).length
return (
<div className="flex min-h-[560px] flex-1 flex-col gap-3">
<div className="card mb-0 flex flex-wrap items-center gap-3 py-3">
<label className="flex items-center gap-2 text-[12.5px] font-semibold">
Document
<select aria-label="Document" className="input" value={jobId} onChange={(e) => pick(e.target.value)}>
{runs.map((r) => <option key={r.job_id} value={r.job_id}>{r.document}</option>)}
</select>
</label>
{run && <span className={`pill ${run.status === 'done' ? 'bg-ok' : run.status === 'failed' ? 'bg-bad' : 'bg-warn'}`}>{run.status}</span>}
{run && BUSY.includes(run.status) && <span className="text-[12.5px] text-muted tabular-nums">page {run.pages_done} of {run.pages_total || '?'}</span>}
{run?.error && <span className="text-[12px] text-bad">{run.error}</span>}
{/* OPTIONAL: retry on another model (failed runs) goes here */}
{run?.status === 'done' && (
<div className="ml-auto flex items-center gap-2">
<span className="text-[12px] text-muted">{left} to verify</span>
<button className="btn btn-sm" onClick={verifyRemaining} disabled={!left || run.approved}>Verify remaining</button>
{run.approved
? <span className="pill bg-ok">✓ Approved</span>
: <button className="btn btn-primary btn-sm" onClick={approve} disabled={dirty.length > 0}
title={dirty.length ? 'Save or discard your edits first' : undefined}>Approve</button>}
</div>
)}
{refusal && <p role="alert" className="w-full rounded-lg bg-bad-bg px-3 py-1.5 text-[12px] font-semibold text-bad">Not approved: {refusal}</p>}
</div>
<div className="card mb-0 flex flex-wrap items-center gap-x-5 gap-y-2 py-3">
<ul aria-label="Confidence bands" className="flex flex-wrap gap-x-4 gap-y-1">
{BANDS.map((b) => (
<li key={b.band} className="flex items-center gap-1.5 text-[12px] font-semibold">
<i className="h-2.5 w-2.5 rounded-[3px]" style={{ background: b.colour }} />
{b.label} · {run?.summary?.[b.band] ?? 0}
</li>
))}
</ul>
<span className="text-[11.5px] text-muted">{CAVEAT}</span>
<div role="group" aria-label="Filter by band" className="ml-auto flex gap-1">
{FILTERS.map((f) => (
<button key={f.band} aria-pressed={filter === f.band} onClick={() => setFilter(f.band)}
className={`btn btn-sm ${filter === f.band ? 'btn-primary' : ''}`}>{f.label}</button>
))}
</div>
</div>
<div className="flex min-h-0 flex-1 gap-3 max-[820px]:flex-col">
<iframe title="Source document" src={jobId && ai.documentUrl(jobId)}
className="min-h-[420px] flex-1 rounded-2xl border border-line bg-white" />
<div className="card mb-0 flex min-h-0 flex-1 flex-col overflow-hidden p-0">
{run && run.status !== 'done' && <p className="p-5 text-[13px] text-muted">Fields appear when extraction finishes.</p>}
<ul aria-label="Extracted fields" className="flex-1 overflow-y-auto p-4">
{shown.map((r) => {
const draft = drafts[r.row_id] ?? settled(r)
const isDirty = draft !== settled(r)
return (
<li key={r.row_id} className={`mb-2 grid grid-cols-[minmax(80px,130px)_1fr_auto_auto] items-center gap-2 ${r.verified && !isDirty ? 'opacity-70' : ''}`}>
<span className="truncate text-[12px] font-semibold text-muted" title={r.field}>{r.field}</span>
<input aria-label={r.field} value={draft} placeholder="empty" title={r.reason ?? undefined}
onChange={(e) => setDrafts((d) => ({ ...d, [r.row_id]: e.target.value }))}
onKeyDown={(e) => e.key === 'Escape' && setDrafts((d) => ({ ...d, [r.row_id]: settled(r) }))}
className={`input min-w-0 border-l-4 ${isDirty ? 'outline outline-warn' : ''}`} style={{ borderLeftColor: colourOf(r.band) }} />
<span className="w-9 text-right text-[11px] text-muted tabular-nums" title="Legibility score: how clearly it was read, not whether it is right">
{r.score?.toFixed(2) ?? '—'}
</span>
<button className="btn btn-sm w-[86px]" aria-label={`Verify ${r.field}`} aria-pressed={r.verified}
disabled={isDirty} title={isDirty ? 'Save or discard the edit first' : undefined} onClick={() => verify(r)}>
{r.verified ? '✓ Verified' : 'Verify'}
</button>
{(r.reason || r.corrected_value != null) && (
<span className="col-span-4 -mt-1 text-[11px] text-muted">
{r.reason}{r.reason && r.corrected_value != null && ' · '}
{r.corrected_value != null && <>extracted <s>{r.value || 'empty'}</s></>}
</span>
)}
</li>
)
})}
</ul>
{dirty.length > 0 && (
<div className="flex items-center gap-2.5 border-t border-line px-4 py-2.5">
<span className="text-[12px] font-semibold text-warn">{dirty.length} unsaved change{dirty.length === 1 ? '' : 's'}</span>
<button className="btn btn-sm ml-auto" onClick={() => setDrafts({})} disabled={saving}>Discard</button>
<button className="btn btn-primary btn-sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div>
)}
</div>
</div>
</div>
)
}