278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
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 (
|
|
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-line bg-surface px-4 py-2">
|
|
<StatusPill run={run} />
|
|
|
|
{run.status === 'running' && (
|
|
<span className="tnum text-[12px] text-muted">
|
|
page {run.pages_done} of {run.pages_total || '?'}
|
|
</span>
|
|
)}
|
|
|
|
{run.summary && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<Chip band="green" n={run.summary.green} label="clean" />
|
|
<Chip band="amber" n={run.summary.amber} label="sample" />
|
|
<Chip band="red" n={run.summary.red} label="review" />
|
|
<Chip band="blank" n={run.summary.blank_critical} label="missing code" />
|
|
</div>
|
|
)}
|
|
|
|
{run.error && (
|
|
<span className="min-w-0 flex-1 truncate text-[12px] text-review" title={run.error}>
|
|
{run.error}
|
|
</span>
|
|
)}
|
|
|
|
{/* 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' && (
|
|
<a
|
|
href={`/api/runs/${jobId}/report.xlsx`}
|
|
download
|
|
title="Download a cost + extracted-values spreadsheet for this run"
|
|
className="sb-btn sb-btn-quiet sb-btn-sm shrink-0"
|
|
>
|
|
<IconDownload size={13} />
|
|
Report
|
|
</a>
|
|
)}
|
|
|
|
{run.status === 'failed' && (
|
|
<span className="flex shrink-0 items-center gap-1.5">
|
|
<button
|
|
onClick={() => retry.mutate(undefined)}
|
|
disabled={retry.isPending}
|
|
// A 503 spike or a per-minute window is weather, not a bad file. The
|
|
// uploaded PDF is still on the server, so retrying costs no upload.
|
|
title={`Run the same file again on ${run.model} — no re-upload needed`}
|
|
// min-h-[24px]: text-xs at this line-height renders at 17px tall,
|
|
// under the WCAG 2.5.8 24x24 minimum target size — the padding
|
|
// alone didn't clear it.
|
|
className="sb-btn sb-btn-quiet sb-btn-sm min-h-[24px]"
|
|
>
|
|
{retry.isPending ? 'Retrying…' : 'Retry'}
|
|
</button>
|
|
<button
|
|
onClick={() => retry.mutate(other)}
|
|
disabled={retry.isPending}
|
|
title={`Retry on ${other}, which has its own daily allowance`}
|
|
className={`sb-btn sb-btn-sm min-h-[24px] ${
|
|
quotaSpent ? 'sb-btn-primary' : 'sb-btn-quiet'
|
|
}`}
|
|
>
|
|
Retry on {other.replace('gemini-', '')}
|
|
</button>
|
|
</span>
|
|
)}
|
|
|
|
{run.review && run.review.total > 0 && (
|
|
<div className="ml-auto flex items-center gap-2.5">
|
|
{/* 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. */}
|
|
<div className="sb-meter w-32">
|
|
<div
|
|
className="h-full w-full origin-left rounded-full transition-transform duration-500 ease-out"
|
|
style={{
|
|
transform: `scaleX(${run.review.verified / run.review.total})`,
|
|
background: 'var(--brand-grad)',
|
|
}}
|
|
/>
|
|
</div>
|
|
<ApproveButton
|
|
onApprove={() => approve.mutate()}
|
|
pending={approve.isPending}
|
|
approved={approve.isSuccess}
|
|
remaining={left}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{run.review?.total === 0 && (
|
|
<div className="ml-auto flex items-center gap-2.5">
|
|
<span className="sb-pill sb-pill-ok">Nothing flagged</span>
|
|
<ApproveButton
|
|
onApprove={() => approve.mutate()}
|
|
pending={approve.isPending}
|
|
approved={approve.isSuccess}
|
|
remaining={0}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<p
|
|
role="alert"
|
|
className="w-full rounded-card border border-review/40 bg-review/10 px-3 py-1.5 text-[12px] font-medium text-review"
|
|
>
|
|
Not approved — {(approve.error as Error).message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<span className="sb-pulse sb-pill sb-pill-ok inline-flex items-center gap-1.5">
|
|
<span aria-hidden="true">✓</span> Approved
|
|
</span>
|
|
)
|
|
}
|
|
return (
|
|
<button
|
|
disabled={pending}
|
|
onClick={() => {
|
|
// States what OK actually does. The previous wording ("approve
|
|
// anyway?") promised something the server refuses, so OK appeared to
|
|
// do nothing at all.
|
|
const ok = window.confirm(
|
|
remaining > 0
|
|
? `${remaining} field(s) are still unverified.\n\n` +
|
|
`OK will sign them off as extracted, then approve this document ` +
|
|
`for posting. That is recorded as a bulk sign-off.\n\n` +
|
|
`Cancel to go back and check them individually.`
|
|
: 'Approve this document for posting?',
|
|
)
|
|
if (ok) onApprove()
|
|
}}
|
|
className="sb-btn sb-btn-primary sb-btn-sm"
|
|
>
|
|
{pending ? 'Approving…' : 'Approve'}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// 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 <span className={`sb-pill ${tone}`}>{run.status}</span>
|
|
}
|
|
|
|
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 (
|
|
<span className={`sb-pill ${tone}`}>
|
|
<b className="tnum">{n}</b> {label}
|
|
</span>
|
|
)
|
|
}
|