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.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. */}
)}
{/* 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}
)
}