Initial commit: plan, spec, tickets and dashboard skill templates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
49f8f49b65
39 files changed
+10736
No files matched your search
@@ -0,0 +1,277 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user