Files
Rpa_Skills/skills/rpa-dashboard-ai/templates/reference/web/components/RoiTab.tsx
T

990 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api'
import { BAND_VAR } from '../bands'
import { Donut, type Slice } from './Donut'
import { ExtractionFire } from './ExtractionFire'
import {
IconAlert, IconCheck, IconChevronRight, IconDot, IconPage, IconSpend, IconStack,
} from './icons'
import type { RoiRun, RoiTotals } from '../types'
import type { Project } from '../project'
import { runProject } from '../project'
/**
* The cost ledger, laid out as an overview board: headline tiles, a proportional
* split, then the line-by-line list.
*
* Two kinds of number live here and the layout keeps them apart, because mixing
* them is how a cost report starts being quoted as a fact it never was:
*
* Measured every dollar, token, page and field is either a billed figure
* the API returned or a row that exists in the database.
* Assumed what an hour of a reviewer's time is worth, and how long the
* same work takes by hand. The API refuses to guess at these, so
* they are inputs the reader can see and change, and the panel
* using them says so in its own heading.
*
* Runs extracted before the pipeline recorded token usage carry no cost at all.
* They are reported as unmeasured rather than given the average of their
* neighbours — an averaged cost is indistinguishable from a real one once
* somebody has read it out in a meeting.
*
* On colour: the four headline tiles use the brand's own warm run, because this
* whole board is shell — a report about the pipeline, not a surface where a
* reviewer reads a scanned value. The one place band colour appears is the
* field-split ring, where it IS the data. Those two palettes never mix inside a
* single element here.
*/
const RATES_KEY = 'superbill.roi.rates'
/**
* Starting points, not findings. Roughly a US medical-records clerk: four
* minutes to key one patient's row off a scan by hand, twenty seconds to check
* one field the pipeline already flagged and positioned for them.
*/
const DEFAULT_RATES = { minutesPerPatient: 4, secondsPerField: 20, hourlyRate: 22 }
type Rates = typeof DEFAULT_RATES
function loadRates(): Rates {
debugger;
try {
const saved = JSON.parse(localStorage.getItem(RATES_KEY) ?? 'null')
// Spread over the defaults rather than trusting the parse: a stored object
// from an older shape would leave a field undefined and turn every figure
// downstream into NaN.
return saved && typeof saved === 'object' ? { ...DEFAULT_RATES, ...saved } : DEFAULT_RATES
} catch {
return DEFAULT_RATES
}
}
const usd = (n: number, dp = 2) =>
n.toLocaleString('en-US', {
style: 'currency', currency: 'USD',
minimumFractionDigits: dp, maximumFractionDigits: dp,
})
const num = (n: number) => n.toLocaleString('en-US')
/** Money below a cent needs four places, or a per-page figure reads as $0.00. */
const fine = (n: number) => usd(n, n > 0 && n < 0.01 ? 4 : 2)
const hours = (h: number) => (h >= 1 ? `${h.toFixed(1)} h` : `${Math.round(h * 60)} min`)
/** 'YYYY-MM' of the current month in India time — the server buckets spend
* the same way (UTC + 5:30), so "This month" resets at midnight IST on the 1st. */
const istMonth = () => new Date(Date.now() + 330 * 60_000).toISOString().slice(0, 7)
/** '2026-10' -> 'Oct 2026'. */
const monthLabel = (ym: string) =>
new Date(`${ym}-01T00:00:00Z`).toLocaleString('en-US', {
month: 'short', year: 'numeric', timeZone: 'UTC',
})
const isToday = (iso: string) => {
const d = new Date(iso)
const now = new Date()
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate()
}
type Filter = 'all' | 'priced' | 'unmeasured' | 'failed'
/**
* api.roi() has no project filter — it totals every document. The ROI board
* is per-project (Example Client's spend is not eClinicalWorks' spend), so the
* totals are recomputed here from the already-fetched runs instead of a new
* query param. Every field is a plain sum or count over RoiRun — nothing
* server-side is being second-guessed, just re-scoped to fewer rows.
*/
function totalsFor(runs: RoiRun[]): RoiTotals {
const sum = (f: (r: RoiRun) => number | null) =>
runs.reduce((n, r) => n + (f(r) ?? 0), 0)
return {
documents: runs.length,
measured_documents: runs.filter((r) => r.measured).length,
pages: sum((r) => r.pages),
graded_fields: sum((r) => r.graded_fields),
clean_fields: sum((r) => r.clean_fields),
human_fields: sum((r) => r.human_fields),
verified_fields: sum((r) => r.verified_fields),
failed_documents: runs.filter((r) => r.status === 'failed').length,
bands: {
green: sum((r) => r.bands.green),
amber: sum((r) => r.bands.amber),
red: sum((r) => r.bands.red),
unscored: sum((r) => r.bands.unscored),
blank_critical: sum((r) => r.bands.blank_critical),
},
patients: sum((r) => r.patients),
input_tokens: sum((r) => r.input_tokens),
output_tokens: sum((r) => r.output_tokens),
thinking_tokens: sum((r) => r.thinking_tokens),
ocr_cost: sum((r) => r.ocr_cost),
json_cost: sum((r) => r.json_cost),
total_cost: sum((r) => r.total_cost),
deleted_documents: runs.filter((r) => r.deleted).length,
deleted_cost: sum((r) => (r.deleted ? r.total_cost : 0)),
}
}
export function RoiTab({
onOpenRun,
project,
demography = false,
}: {
/** Absent on the demography board: its runs have no charge review to open. */
onOpenRun?: (jobId: string) => void
/** Which project's spend this board reports — Example Client tickets and
* eClinicalWorks documents never share a total. */
project: Project
/** The Demography ROI (ticket 05): its own payload, one Gemini call per PDF
* so one total with no read/map split, and no field figures at all. */
demography?: boolean
}) {
const { data, isLoading } = useQuery({
queryKey: [demography ? 'demography-roi' : 'roi'],
queryFn: demography ? api.demographyRoi : api.roi,
// A document's cost lands only when it finishes, so the ledger keeps
// refreshing while anything is in flight and stops the moment nothing is.
refetchInterval: (q) =>
(q.state.data?.runs ?? []).some((r) => r.status === 'queued' || r.status === 'running')
? 3000
: false,
})
const [rates, setRates] = useState<Rates>(loadRates)
const [filter, setFilter] = useState<Filter>('all')
const [showMonths, setShowMonths] = useState(false)
const [sort, setSort] = useState<'recent' | 'cost'>('recent')
const setRate = (key: keyof Rates, value: number) => {
const next = { ...rates, [key]: value }
setRates(next)
localStorage.setItem(RATES_KEY, JSON.stringify(next))
}
const runs = useMemo(
() => (data?.runs ?? []).filter((r) => runProject(r) === project),
[data?.runs, project],
)
const t = useMemo(() => totalsFor(runs), [runs])
// Spend per month from the server's whole-table sums. `runs` stops at the
// newest 500, so the all-time Total comes from here, not from `t`.
const months = useMemo(
() => (data?.monthly ?? []).filter((m) => m.project === project),
[data?.monthly, project],
)
const allTimeCost = months.reduce((sum, m) => sum + m.cost, 0)
const thisMonth = istMonth()
const thisMonthCost = months.find((m) => m.month === thisMonth)?.cost ?? 0
// The comparison is derived from the priced runs alone, not from the totals:
// a patient count only exists where a cost does, and dividing a measured cost
// by an all-runs field count would understate every unit figure on screen.
const priced = useMemo(() => runs.filter((r) => r.measured), [runs])
const model = useMemo(() => {
debugger;
const patients = priced.reduce((n, r) => n + (r.patients ?? 0), 0)
const humanFields = priced.reduce((n, r) => n + r.human_fields, 0)
const apiCost = priced.reduce((n, r) => n + (r.total_cost ?? 0), 0)
const manualHours = (patients * rates.minutesPerPatient) / 60
const reviewHours = (humanFields * rates.secondsPerField) / 3600
const manualCost = manualHours * rates.hourlyRate
const reviewCost = reviewHours * rates.hourlyRate
return {
patients, humanFields, apiCost, manualHours, reviewHours, manualCost, reviewCost,
pipelineCost: apiCost + reviewCost,
saved: manualCost - (apiCost + reviewCost),
}
}, [priced, rates])
const shown = useMemo(() => {
const kept = runs.filter((r) =>
filter === 'all' ? true
: filter === 'priced' ? r.measured
: filter === 'unmeasured' ? !r.measured && r.status !== 'failed'
: r.status === 'failed',
)
return sort === 'recent'
? kept
: [...kept].sort((a, b) => (b.total_cost ?? -1) - (a.total_cost ?? -1))
}, [runs, filter, sort])
if (isLoading) {
return <p className="py-16 text-center text-[13px] text-muted">Loading…</p>
}
if (!t || t.documents === 0) {
return (
<div className="sb-empty mx-auto max-w-md">
<h2 className="sb-display text-[16px] font-bold text-ink">
Nothing extracted yet
</h2>
<p className="mt-1.5 text-[13px] leading-relaxed text-muted">
{demography
? 'Cost is recorded as each facesheet is read. Upload one on the Demography Extraction board and its spend appears here.'
: 'Cost is recorded as each document is read. Run a batch on the Charge Extraction board and its spend appears here.'}
</p>
</div>
)
}
const unpriced = t.documents - t.measured_documents
// Demography divides by its priced runs only: an OCR-era run has pages and
// patients but no cost, and would dilute every unit figure.
const unitPages = demography ? priced.reduce((n, r) => n + r.pages, 0) : t.pages
const unitPatients = demography ? model.patients : t.patients
const bands = t.bands
const slices: Slice[] = [
{ key: 'green', label: 'Clean print', value: bands.green, color: BAND_VAR.green },
{ key: 'amber', label: 'Slightly unclear', value: bands.amber, color: BAND_VAR.amber },
{ key: 'red', label: 'Handwritten / messy', value: bands.red, color: BAND_VAR.red },
{ key: 'blank', label: 'Code missing', value: bands.blank_critical, color: BAND_VAR.blank },
{ key: 'unscored', label: 'Not in transcription', value: bands.unscored, color: BAND_VAR.unscored },
]
return (
<div className="mx-auto flex max-w-[1600px] flex-col gap-5">
{/* ── Page head ────────────────────────────────────────────────────── */}
<header className="flex flex-wrap items-start gap-x-4 gap-y-2">
<div className="min-w-0">
<h2 className="sb-page-title">{demography ? 'Demography ROI Dashboard' : 'ROI Dashboard'}</h2>
<p className="sb-page-sub !mb-0">
What the pipeline costs — and what it replaces
</p>
</div>
{/* States coverage before any figure is read, because "0 of 9 priced" is
the difference between a small total and an incomplete one. */}
<span
className={`sb-pill ml-auto flex shrink-0 items-center gap-1.5 ${
unpriced === 0 ? 'sb-pill-ok' : 'sb-pill-warn'
}`}
>
<IconDot />
<span className="tnum">
{num(t.measured_documents)} of {num(t.documents)} documents priced
</span>
</span>
</header>
{/* ── Headline tiles ───────────────────────────────────────────────── */}
<section>
<h3 className="sb-eyebrow">Spend overview</h3>
<div className="sb-tiles !mb-0">
<StatTile
icon={<IconSpend />}
tone="var(--accent-1)"
value={months.length ? fine(allTimeCost) : t.measured_documents ? fine(t.total_cost) : '—'}
label={
!t.measured_documents
? 'Total spend — not yet recorded'
: t.deleted_documents
? 'Total spend, deleted included'
: 'Total spend'
}
lead
/>
<StatTile
icon={<IconSpend />}
tone="var(--accent-2)"
value={fine(thisMonthCost)}
label={`This month · ${monthLabel(thisMonth)} · ${showMonths ? 'hide' : 'click for all'} months`}
onClick={() => setShowMonths((v) => !v)}
expanded={showMonths}
/>
<StatTile
icon={<IconStack />}
tone="var(--accent-2)"
value={num(t.documents)}
label={`${t.documents === 1 ? 'Document' : 'Documents'}${
t.deleted_documents ? ` · ${num(t.deleted_documents)} deleted` : ''
}`}
/>
<StatTile
icon={<IconPage />}
tone="var(--verify)"
value={num(t.pages)}
label={t.pages === 1 ? 'Page read' : 'Pages read'}
/>
{!demography && (
<StatTile
icon={<IconCheck />}
tone="var(--clean)"
value={num(t.graded_fields)}
label="Fields graded"
/>
)}
</div>
</section>
{/* ── Month by month ───────────────────────────────────────────────── */}
{showMonths && (
<Panel title="Month-wise cost" note="India time · resets on the 1st">
{months.length === 0 && (
<p className="text-[13px] text-muted">No spend recorded yet.</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead>
<tr className="text-left text-[11.5px] text-muted">
<th className="pb-2 font-medium">Month</th>
<th className="pb-2 text-right font-medium">Documents</th>
<th className="pb-2 text-right font-medium">Cost</th>
</tr>
</thead>
<tbody>
{months.map((m) => (
<tr key={m.month} className="border-t border-line">
<td className="py-1.5 text-ink">
{monthLabel(m.month)}
{m.month === thisMonth && (
<span className="ml-2 text-[11px] text-muted">this month</span>
)}
</td>
<td className="tnum py-1.5 text-right text-ink">{num(m.documents)}</td>
<td className="sb-display tnum py-1.5 text-right font-semibold text-ink">
{fine(m.cost)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Panel>
)}
{/* ── Split + cost totals ──────────────────────────────────────────── */}
<div className={`grid gap-3 ${demography ? '' : 'lg:grid-cols-[minmax(0,1fr)_minmax(0,1.25fr)]'}`}>
{!demography && (
<Panel title="Field split (all documents)">
{t.graded_fields === 0 ? (
<p className="text-[13px] text-muted">No graded fields yet.</p>
) : (
<div className="flex flex-wrap items-center gap-5">
<Donut
slices={slices}
centerValue={num(t.graded_fields)}
centerLabel="Fields"
/>
<dl className="sb-legend">
{slices.filter((s) => s.value > 0).map((s) => (
<div key={s.key}>
<span className="sb-dot" style={{ backgroundColor: s.color }} />
<dt className="min-w-0 truncate">{s.label}</dt>
<dd className="sb-n shrink-0">{num(s.value)}</dd>
<dd className="sb-p shrink-0">
{Math.round((s.value / t.graded_fields) * 100)}%
</dd>
</div>
))}
</dl>
</div>
)}
<p className="mt-3.5 max-w-xl text-[11.5px] leading-relaxed text-muted">
Clean means the print was legible, not that the value is right — the
score rates the model's own reading. It measures how much work the
pipeline removed, never accuracy.
</p>
</Panel>
)}
<Panel title="Cost totals (all documents)">
{t.measured_documents === 0 ? (
<>
<p className="sb-display text-[16px] font-semibold text-ink">
No cost recorded for these {num(t.documents)} documents
</p>
<p className="mt-1.5 max-w-xl text-[13px] leading-relaxed text-muted">
They were extracted before the pipeline began storing token
usage, so there is no figure to report and none is estimated
here. The next extraction records its own cost.
</p>
</>
) : (
<>
{demography ? (
<MoneyTile label="Total" value={fine(t.total_cost)} tone="var(--accent-2)" />
) : (
<div className="grid gap-2.5 sm:grid-cols-3">
<MoneyTile label="Reading the page" value={fine(t.ocr_cost)} tone="var(--accent-1)" />
<MoneyTile label="Mapping to fields" value={fine(t.json_cost)} tone="var(--verify)" />
<MoneyTile label="Total" value={fine(t.total_cost)} tone="var(--accent-2)" />
</div>
)}
{/* A deleted document was still paid for, so it stays in every
total above; this is where the two are told apart. */}
{t.deleted_documents > 0 && (
<div className="mt-2.5 grid gap-2.5 sm:grid-cols-2">
<MoneyTile
label={`Active · ${num(t.documents - t.deleted_documents)} documents`}
value={fine(t.total_cost - t.deleted_cost)}
tone="var(--clean)"
/>
<MoneyTile
label={`Deleted · ${num(t.deleted_documents)} documents`}
value={fine(t.deleted_cost)}
tone="var(--muted)"
/>
</div>
)}
{/* Two calls per page — a transcription and a mapping — and they
do not cost the same, so the split says which one to attack if
this ever has to be cheaper. */}
{!demography && (
<span className="sb-meter mt-3.5 flex" aria-hidden="true">
<span style={{ flexGrow: Math.max(t.ocr_cost, 1e-9), backgroundColor: 'var(--accent-1)' }} />
<span style={{ flexGrow: Math.max(t.json_cost, 1e-9), backgroundColor: 'var(--verify)' }} />
</span>
)}
<p className="tnum mt-[7px] text-[11.5px] text-muted">
{num(t.input_tokens + t.output_tokens)} tokens across{' '}
{num(t.measured_documents)}{' '}
{t.measured_documents === 1 ? 'document' : 'documents'}
{t.thinking_tokens > 0 && ` · ${num(t.thinking_tokens)} thinking`}
{unpriced > 0 && ` · ${num(unpriced)} older not included`}
</p>
<dl className="mt-4 grid grid-cols-3 gap-2.5">
<Unit label="per page" value={unitPages ? fine(t.total_cost / unitPages) : '—'} />
<Unit label="per patient" value={unitPatients ? fine(t.total_cost / unitPatients) : '—'} />
<Unit label="per 1,000 patients"
value={unitPatients ? usd((t.total_cost / unitPatients) * 1000) : '—'} />
</dl>
</>
)}
</Panel>
</div>
{/* ── The comparison, on the reader's own numbers ───────────────────── */}
<Panel
title="Against doing it by hand"
note="Your assumptions, not a measurement"
dashed
>
<div className="flex flex-wrap items-end gap-x-5 gap-y-3">
<RateInput
label="Minutes to key one patient by hand"
value={rates.minutesPerPatient}
step={0.5}
onChange={(v) => setRate('minutesPerPatient', v)}
/>
{!demography && (
<RateInput
label="Seconds to check one flagged field"
value={rates.secondsPerField}
step={1}
onChange={(v) => setRate('secondsPerField', v)}
/>
)}
<RateInput
label="Reviewer cost per hour"
value={rates.hourlyRate}
step={1}
prefix="$"
onChange={(v) => setRate('hourlyRate', v)}
/>
</div>
{model.patients === 0 ? (
<p className="mt-4 text-[13px] text-muted">
Nothing to compare yet — this needs at least one document with a
recorded patient count.
</p>
) : (
<>
<div className="mt-4 grid gap-3 border-t border-line-soft pt-3.5 sm:grid-cols-3">
<Side
heading="By hand"
total={usd(model.manualCost)}
lines={[
`${num(model.patients)} patients × ${rates.minutesPerPatient} min`,
`${hours(model.manualHours)} of keying`,
]}
/>
<Side
heading="With the pipeline"
total={usd(model.pipelineCost)}
lines={[
`${fine(model.apiCost)} of model calls`,
...(demography
? []
: [`${hours(model.reviewHours)} checking ${num(model.humanFields)} fields`]),
]}
/>
<Side
heading="Difference"
total={usd(model.saved)}
lines={[
model.pipelineCost > 0
? `${(model.manualCost / model.pipelineCost).toFixed(1)}× cheaper`
: '—',
`on ${num(priced.length)} priced ${priced.length === 1 ? 'document' : 'documents'}`,
]}
emphasis={model.saved >= 0}
/>
</div>
<p className="mt-3 text-[11.5px] leading-relaxed text-muted">
{demography
? 'The pipeline column is model calls only: this board does not count reviewer time. Only the model-call figure is measured; the keying time comes from the rates you entered.'
: 'The pipeline column charges for reviewer time as well as model calls, because a flagged field still costs somebody a minute. Only the model-call figure is measured; every duration above comes from the rates you entered.'}
</p>
</>
)}
</Panel>
{/* ── Per document ─────────────────────────────────────────────────── */}
<section className="flex flex-col gap-2.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
<Pill active={filter === 'all'} onClick={() => setFilter('all')}>
All <Count n={t.documents} active={filter === 'all'} />
</Pill>
<Pill active={filter === 'priced'} onClick={() => setFilter('priced')}>
Priced <Count n={t.measured_documents} active={filter === 'priced'} />
</Pill>
<Pill active={filter === 'unmeasured'} onClick={() => setFilter('unmeasured')}>
Not measured{' '}
<Count n={unpriced - t.failed_documents} active={filter === 'unmeasured'} />
</Pill>
<Pill active={filter === 'failed'} onClick={() => setFilter('failed')}>
Failed <Count n={t.failed_documents} active={filter === 'failed'} />
</Pill>
<div className="ml-auto flex items-center gap-1">
<Sort active={sort === 'recent'} onClick={() => setSort('recent')}>Newest</Sort>
<Sort active={sort === 'cost'} onClick={() => setSort('cost')}>Costliest</Sort>
</div>
</div>
{shown.length === 0 ? (
<p className="sb-empty !py-10">No documents in this filter.</p>
) : (
shown.map((r) => (
<DocLine
key={r.job_id}
run={r}
onOpen={onOpenRun && (() => onOpenRun(r.job_id))}
showFields={!demography}
/>
))
)}
</section>
</div>
)
}
/**
* One document, as a line in the ledger.
*
* Reads left to right the way the question is asked: which file, how big, how
* far through review, what it cost.
*/
function DocLine({
run,
onOpen,
showFields,
}: {
run: RoiRun
onOpen?: () => void
/** False on the demography board, which carries no field figures. */
showFields: boolean
}) {
const openable = !!onOpen && run.status === 'done' && !run.deleted
const failed = run.status === 'failed'
const running = run.status === 'running'
const perPatient =
run.total_cost != null && run.patients ? run.total_cost / run.patients : null
const pct = run.human_fields
? Math.round((run.verified_fields / run.human_fields) * 100)
: run.graded_fields ? 100 : 0
// The left rule states the document's condition at a glance down the column.
// 4px, matching the band edge the document list already uses — the same
// language, not a second one invented here.
const rule = run.deleted
? BAND_VAR.blank
: failed
? BAND_VAR.red
: run.bands.red ? BAND_VAR.red
: run.bands.amber ? BAND_VAR.amber
: run.graded_fields ? BAND_VAR.green
: BAND_VAR.blank
const extractPct = run.pages ? Math.round((run.pages_done / run.pages) * 100) : 0
return (
<article
onClick={openable ? onOpen : undefined}
className={`sb-card group relative flex flex-wrap items-center gap-x-4 gap-y-2 overflow-hidden !pl-5 !pr-3.5 !py-3 transition-colors duration-200 ${
openable ? 'cursor-pointer hover:border-muted' : ''
}`}
>
<span className="absolute inset-y-0 left-0 w-1" style={{ backgroundColor: rule }} />
{running && <ExtractionFire pct={extractPct ?? 0} />}
<div className="relative min-w-0 flex-1 basis-64">
<div className="flex items-center gap-2">
<h4 className="sb-doc-name min-w-0 truncate">{run.document}</h4>
{isToday(run.created_at) && (
<span
className="shrink-0 rounded-full px-1.5 py-px text-[10.5px] font-semibold uppercase tracking-[0.1em]"
style={{
backgroundColor: 'color-mix(in srgb, var(--accent-1) 12%, transparent)',
color: 'var(--accent-1)',
}}
>
Today
</span>
)}
{run.deleted && (
<span
className="shrink-0 rounded-full px-1.5 py-px text-[10.5px] font-semibold uppercase tracking-[0.1em]"
style={{
backgroundColor: 'color-mix(in srgb, var(--muted) 16%, transparent)',
color: 'var(--muted)',
}}
title={`Deleted${run.deleted_by ? ` by ${run.deleted_by}` : ''}${
run.deleted_at ? ` on ${new Date(run.deleted_at).toLocaleString()}` : ''
} — its cost still counts`}
>
Deleted{run.deleted_by ? ` · ${run.deleted_by}` : ''}
</span>
)}
</div>
<p className="mt-0.5 truncate text-[11.5px] text-muted">
{run.template ?? '—'} · {run.model.replace('gemini-', '')}
{run.batch_name && ` · ${run.batch_name}`}
</p>
</div>
<p className="tnum relative shrink-0 text-[11.5px] text-muted">
{run.patients != null ? `${num(run.patients)} patients` : `${num(run.graded_fields)} fields`}
{run.pages > 0 && ` · ${run.pages} ${run.pages === 1 ? 'page' : 'pages'}`}
</p>
{/* Review progress, not extraction progress: how much of what a human was
handed has actually been signed off.
A document with nothing flagged has no such fraction. It used to render
as "100% · 0 · 0", which is three figures agreeing on nothing — the bar
was full because the queue was empty, not because anyone did the work.
It says so in words instead. */}
{!showFields ? (
<div className="relative min-w-[140px] flex-1 basis-40" />
) : run.human_fields > 0 ? (
<>
<div className="relative flex min-w-[140px] flex-1 basis-40 items-center gap-2.5">
<span className="sb-meter min-w-0 flex-1">
<span
className="block h-full w-full origin-left rounded-full transition-transform duration-500"
style={{
transform: `scaleX(${pct / 100})`,
backgroundColor: pct === 100 ? BAND_VAR.green : 'var(--accent-2)',
}}
/>
</span>
<span className="tnum w-9 shrink-0 text-right text-[11.5px] text-muted">
{pct}%
</span>
</div>
<div className="relative flex shrink-0 items-center gap-1.5">
<Chip
tone={BAND_VAR.green}
icon={<IconCheck size={12} />}
n={run.verified_fields}
title="Fields signed off"
/>
<Chip
tone="var(--accent-2)"
icon={<IconDot size={7} />}
n={run.human_fields - run.verified_fields}
title="Fields still waiting on a person"
/>
</div>
</>
) : (
<div className="relative flex min-w-[140px] flex-1 basis-40 items-center">
<span className="inline-flex items-center gap-1.5 text-[11.5px] font-medium text-clean">
{run.graded_fields > 0 ? (
<>
<IconCheck size={13} /> Nothing flagged
</>
) : (
<span className="text-muted">No fields extracted</span>
)}
</span>
</div>
)}
<div className="relative w-28 shrink-0 text-right">
{failed ? (
<span className="inline-flex items-center gap-1 text-[11.5px] font-medium text-review">
<IconAlert size={13} /> Failed
</span>
) : run.total_cost != null ? (
<span className="sb-display tnum text-[13.5px] font-bold text-ink">
{fine(run.total_cost)}
{perPatient != null && (
<span className="ml-1 block font-plex text-[11.5px] font-normal text-muted">
{fine(perPatient)}/patient
</span>
)}
</span>
) : (
// Not a zero and not a bare dash: the reader has to know the number is
// absent rather than small.
<span
className="text-[11.5px] text-muted"
title="Extracted before cost was recorded"
>
not measured
</span>
)}
</div>
<span
className={`relative shrink-0 transition-transform ${
openable
? 'text-muted group-hover:translate-x-0.5 group-hover:text-ink-2'
: 'text-transparent'
}`}
>
<IconChevronRight size={16} />
</span>
</article>
)
}
/**
* A headline figure with its own icon.
*
* `lead` marks the one tile that is the board's subject. Exactly one gets it —
* a second would mean neither is the answer.
*/
function StatTile({
icon, tone, value, label, lead, onClick, expanded,
}: {
icon: React.ReactNode
tone: string
value: string
label: string
lead?: boolean
/** Makes the tile a toggle button, e.g. to open the month list. */
onClick?: () => void
expanded?: boolean
}) {
const Tag = onClick ? 'button' : 'div'
return (
<Tag
className={`sb-tile ${onClick ? 'w-full cursor-pointer text-left hover:border-ink-2' : ''}`}
{...(onClick ? { type: 'button' as const, onClick, 'aria-expanded': expanded } : {})}
>
<span
className="sb-chip"
style={{
color: tone,
backgroundColor: `color-mix(in srgb, ${tone} 18%, transparent)`,
}}
>
{icon}
</span>
<div className="min-w-0">
<div className={`sb-tile-num ${lead ? 'sb-spend' : ''}`}>{value}</div>
<div className="sb-tile-cap truncate">{label}</div>
</div>
</Tag>
)
}
function MoneyTile({ label, value, tone }: { label: string; value: string; tone: string }) {
return (
<div className="rounded-[10px] border border-line bg-surface-2 p-3">
<b className="sb-display tnum block text-[18px] font-bold leading-tight" style={{ color: tone }}>
{value}
</b>
<span className="mt-1 flex items-center gap-1.5 text-[11.5px] text-muted">
<span className="sb-dot" style={{ backgroundColor: tone }} />
{label}
</span>
</div>
)
}
function Panel({
title, note, dashed, children,
}: {
title: string
note?: string
dashed?: boolean
children: React.ReactNode
}) {
return (
<section
className={`sb-panel ${dashed ? 'border-dashed bg-surface-2' : ''}`}
>
<div className="mb-3.5 flex flex-wrap items-baseline gap-x-2.5">
<h3 className="sb-panel-title !mb-0">{title}</h3>
{note && <span className="sb-eyebrow !mb-0">{note}</span>}
</div>
{children}
</section>
)
}
function Unit({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-[11px] text-muted">{label}</dt>
<dd className="sb-display tnum text-[15px] font-semibold text-ink">{value}</dd>
</div>
)
}
function Side({
heading, total, lines, emphasis,
}: {
heading: string
total: string
lines: string[]
emphasis?: boolean
}) {
return (
<div>
<h4 className="text-[11px] text-muted">{heading}</h4>
<p
className="sb-display tnum mt-0.5 text-[20px] font-bold leading-none tracking-tight"
style={{ color: emphasis ? 'var(--accent-1)' : 'var(--ink)' }}
>
{total}
</p>
<ul className="mt-1.5 space-y-0.5">
{lines.map((l) => (
<li key={l} className="tnum text-[11.5px] text-muted">
{l}
</li>
))}
</ul>
</div>
)
}
function Chip({
tone, icon, n, title,
}: {
tone: string
icon: React.ReactNode
n: number
title: string
}) {
return (
<span
title={title}
className="tnum inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-semibold"
style={{
backgroundColor: `color-mix(in srgb, ${tone} 18%, transparent)`,
color: tone,
}}
>
{icon}
{num(n)}
</span>
)
}
function Pill({
active, onClick, children,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
aria-pressed={active}
className={`sb-btn sb-btn-sm !rounded-full ${active ? 'sb-btn-primary' : 'sb-btn-quiet'}`}
>
{children}
</button>
)
}
function Count({ n, active }: { n: number; active: boolean }) {
return (
<span
className="tnum rounded-full px-1.5 text-[11px]"
style={
// A translucent veil over the brand gradient lands too close to the
// white label beside it. A solid surface chip carrying the accent reads
// as a separate object and keeps its contrast.
active
? { backgroundColor: 'var(--surface)', color: 'var(--accent-1)' }
: { backgroundColor: 'var(--surface)', color: 'var(--muted)' }
}
>
{n}
</span>
)
}
function Sort({
active, onClick, children,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
aria-pressed={active}
className={`min-h-[24px] rounded-md border px-2.5 py-1 text-[11.5px] font-medium transition-colors ${
active
? 'border-line bg-surface-2 text-ink'
: 'border-transparent text-muted hover:text-ink-2'
}`}
>
{children}
</button>
)
}
function RateInput({
label, value, step, prefix, onChange,
}: {
label: string
value: number
step: number
prefix?: string
onChange: (v: number) => void
}) {
return (
<label className="block">
<span className="block text-[11px] text-muted">{label}</span>
<span className="sb-input mt-1 flex items-center !py-0 !px-2.5 focus-within:border-accent-1">
{prefix && <span className="text-[13px] text-muted">{prefix}</span>}
<input
type="number"
min={0}
step={step}
value={value}
// A cleared box parses as NaN and would take every figure on the
// board with it, so it reads as zero until something is typed.
onChange={(e) => onChange(Number(e.target.value) || 0)}
className="tnum w-16 bg-transparent py-2 text-[13px] font-semibold text-ink outline-none"
/>
</span>
</label>
)
}