import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '../api' import { IconDownload } from './icons' import type { Run } from '../types' // Each model carries its OWN free-tier daily allowance, so when one is spent — // or overloaded — switching is the way through rather than waiting for a reset. const MODELS = ['gemini-3.5-flash', 'gemini-2.5-flash'] as const /** One compact strip: status, page progress, band counts, and the approve gate. */ export function RunHeader({ jobId, onApproved, }: { jobId: string /** Approving is the end of the human's job on this document, so the app * follows through to the automation board rather than leaving the reviewer * on a screen they are finished with. */ onApproved?: () => void }) { const { data: run } = useQuery({ queryKey: ['run', jobId], queryFn: () => api.getRun(jobId), // Poll only while the answer can still change. A finished run polled every // second is a request that can never return anything new. refetchInterval: (q) => { const s = q.state.data?.status return s === 'done' || s === 'failed' ? false : 1500 }, }) const qc = useQueryClient() const approve = useMutation({ // Approving with fields still unverified used to prompt "approve anyway?", // then fail: the gate is enforced server-side and returned 409, so OK did // nothing and the refusal was easy to miss in the row. The prompt now // states exactly what OK does, and OK does it — the remaining fields are // signed off as extracted first (audited separately as `bulk_verified`), // which satisfies the gate rather than pretending to bypass it. mutationFn: async () => { if ((run?.review?.remaining ?? 0) > 0) await api.verifyRemaining(jobId) return api.approve(jobId) }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['run', jobId] }) qc.invalidateQueries({ queryKey: ['batches'] }) // Held briefly so the "Approved ✓" state is actually seen before the // view changes underneath it — an instant jump reads as the click having // done something unrelated. setTimeout(() => onApproved?.(), 700) }, }) const retry = useMutation({ mutationFn: (model?: string) => api.retryRun(jobId, model), onSuccess: () => { qc.invalidateQueries({ queryKey: ['run', jobId] }) qc.invalidateQueries({ queryKey: ['runs'] }) }, }) if (!run) return null const left = run.review?.remaining ?? 0 // A spent daily allowance is per-model, so the same model will just fail // again. Offer the switch right where the failure is shown. const quotaSpent = /quota/i.test(run.error ?? '') const other = MODELS.find((m) => m !== run.model) ?? MODELS[0] return (
{run.status === 'running' && ( page {run.pages_done} of {run.pages_total || '?'} )} {run.summary && (
)} {run.error && ( {run.error} )} {/* Reads the same SPB_Roi + SPB_Extraction rows the review pane does — nothing re-extracts, no API call happens. Gated on 'done' rather than shown always: costs and every field are null until the run finishes, so a report requested earlier would just be empty. */} {run.status === 'done' && ( Report )} {run.status === 'failed' && ( )} {run.review && run.review.total > 0 && (
{/* The bar alone, no "N left" readout beside it. The number was redundant with the review pane's own "9/230 fields" and turned into noise ("222 left") on a document nobody had started. The proportion is what a glance actually needs. */}
approve.mutate()} pending={approve.isPending} approved={approve.isSuccess} remaining={left} />
)} {run.review?.total === 0 && (
Nothing flagged approve.mutate()} pending={approve.isPending} approved={approve.isSuccess} remaining={0} />
)} {/* Full-width so a refused approve cannot hide at the end of a crowded row — the previous inline span was the only signal that the click had failed, and it was routinely missed. */} {approve.error && (

Not approved — {(approve.error as Error).message}

)}
) } /** * Approving releases a charge, so it asks first rather than firing on a single * click. The button is never disabled: hiding the gate behind a dead control * left a reviewer with a greyed-out button and no statement of what was * blocking it. Now the confirm says exactly how many fields are still * unverified, and the server still refuses (409) if they try anyway — the gate * stays where it is enforced, not where it is merely displayed. */ function ApproveButton({ onApprove, pending, approved, remaining, }: { onApprove: () => void pending: boolean approved: boolean remaining: number }) { if (approved) { return ( Approved ) } return ( ) } // Fixed, not accent-driven: these are STATE semantics (queued/in-progress/done/ // failed), read the same way a CI pipeline's status colors are read. Tying them // to the switchable accent would make "failed" turn the same red as "confirm" // whenever someone picks the red accent — a real confusion, not a style choice. function StatusPill({ run }: { run: Run }) { const tone = { // Uploaded but not yet handed to the extractor — the batch's Run button // has not been pressed. Deliberately the quietest of the five. pending: 'sb-pill-mute opacity-70', queued: 'sb-pill-mute', running: 'sb-pill-warn', done: 'sb-pill-ok', failed: 'sb-pill-bad', }[run.status] return {run.status} } function Chip({ band, n, label, }: { band: 'green' | 'amber' | 'red' | 'blank' n: number label: string }) { // "blank" is not a grade — nothing was read, so there is no clarity to score. // It takes the neutral pill rather than one of the three grading colours. const tone = { green: 'sb-pill-ok', amber: 'sb-pill-warn', red: 'sb-pill-bad', blank: 'sb-pill-mute', }[band] return ( {n} {label} ) }