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 (Gulf Coast'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 — Gulf Coast 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(loadRates) const [filter, setFilter] = useState('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

Loading…

} if (!t || t.documents === 0) { return (

Nothing extracted yet

{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.'}

) } 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 (
{/* ── Page head ────────────────────────────────────────────────────── */}

{demography ? 'Demography ROI Dashboard' : 'ROI Dashboard'}

What the pipeline costs — and what it replaces

{/* States coverage before any figure is read, because "0 of 9 priced" is the difference between a small total and an incomplete one. */} {num(t.measured_documents)} of {num(t.documents)} documents priced
{/* ── Headline tiles ───────────────────────────────────────────────── */}

Spend overview

} 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 /> } tone="var(--accent-2)" value={fine(thisMonthCost)} label={`This month · ${monthLabel(thisMonth)} · ${showMonths ? 'hide' : 'click for all'} months`} onClick={() => setShowMonths((v) => !v)} expanded={showMonths} /> } tone="var(--accent-2)" value={num(t.documents)} label={`${t.documents === 1 ? 'Document' : 'Documents'}${ t.deleted_documents ? ` · ${num(t.deleted_documents)} deleted` : '' }`} /> } tone="var(--verify)" value={num(t.pages)} label={t.pages === 1 ? 'Page read' : 'Pages read'} /> {!demography && ( } tone="var(--clean)" value={num(t.graded_fields)} label="Fields graded" /> )}
{/* ── Month by month ───────────────────────────────────────────────── */} {showMonths && ( {months.length === 0 && (

No spend recorded yet.

)}
{months.map((m) => ( ))}
Month Documents Cost
{monthLabel(m.month)} {m.month === thisMonth && ( this month )} {num(m.documents)} {fine(m.cost)}
)} {/* ── Split + cost totals ──────────────────────────────────────────── */}
{!demography && ( {t.graded_fields === 0 ? (

No graded fields yet.

) : (
{slices.filter((s) => s.value > 0).map((s) => (
{s.label}
{num(s.value)}
{Math.round((s.value / t.graded_fields) * 100)}%
))}
)}

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.

)} {t.measured_documents === 0 ? ( <>

No cost recorded for these {num(t.documents)} documents

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.

) : ( <> {demography ? ( ) : (
)} {/* 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 && (
)} {/* 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 && (
{/* ── The comparison, on the reader's own numbers ───────────────────── */}
setRate('minutesPerPatient', v)} /> {!demography && ( setRate('secondsPerField', v)} /> )} setRate('hourlyRate', v)} />
{model.patients === 0 ? (

Nothing to compare yet — this needs at least one document with a recorded patient count.

) : ( <>
0 ? `${(model.manualCost / model.pipelineCost).toFixed(1)}× cheaper` : '—', `on ${num(priced.length)} priced ${priced.length === 1 ? 'document' : 'documents'}`, ]} emphasis={model.saved >= 0} />

{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.'}

)}
{/* ── Per document ─────────────────────────────────────────────────── */}
setFilter('all')}> All setFilter('priced')}> Priced setFilter('unmeasured')}> Not measured{' '} setFilter('failed')}> Failed
setSort('recent')}>Newest setSort('cost')}>Costliest
{shown.length === 0 ? (

No documents in this filter.

) : ( shown.map((r) => ( onOpenRun(r.job_id))} showFields={!demography} /> )) )}
) } /** * 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 (
{running && }

{run.document}

{isToday(run.created_at) && ( Today )} {run.deleted && ( Deleted{run.deleted_by ? ` · ${run.deleted_by}` : ''} )}

{run.template ?? '—'} · {run.model.replace('gemini-', '')} {run.batch_name && ` · ${run.batch_name}`}

{run.patients != null ? `${num(run.patients)} patients` : `${num(run.graded_fields)} fields`} {run.pages > 0 && ` · ${run.pages} ${run.pages === 1 ? 'page' : 'pages'}`}

{/* 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 ? (
) : run.human_fields > 0 ? ( <>
{pct}%
} n={run.verified_fields} title="Fields signed off" /> } n={run.human_fields - run.verified_fields} title="Fields still waiting on a person" />
) : (
{run.graded_fields > 0 ? ( <> Nothing flagged ) : ( No fields extracted )}
)}
{failed ? ( Failed ) : run.total_cost != null ? ( {fine(run.total_cost)} {perPatient != null && ( {fine(perPatient)}/patient )} ) : ( // Not a zero and not a bare dash: the reader has to know the number is // absent rather than small. not measured )}
) } /** * 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 ( {icon}
{value}
{label}
) } function MoneyTile({ label, value, tone }: { label: string; value: string; tone: string }) { return (
{value} {label}
) } function Panel({ title, note, dashed, children, }: { title: string note?: string dashed?: boolean children: React.ReactNode }) { return (

{title}

{note && {note}}
{children}
) } function Unit({ label, value }: { label: string; value: string }) { return (
{label}
{value}
) } function Side({ heading, total, lines, emphasis, }: { heading: string total: string lines: string[] emphasis?: boolean }) { return (

{heading}

{total}

    {lines.map((l) => (
  • {l}
  • ))}
) } function Chip({ tone, icon, n, title, }: { tone: string icon: React.ReactNode n: number title: string }) { return ( {icon} {num(n)} ) } function Pill({ active, onClick, children, }: { active: boolean onClick: () => void children: React.ReactNode }) { return ( ) } function Count({ n, active }: { n: number; active: boolean }) { return ( {n} ) } function Sort({ active, onClick, children, }: { active: boolean onClick: () => void children: React.ReactNode }) { return ( ) } function RateInput({ label, value, step, prefix, onChange, }: { label: string value: number step: number prefix?: string onChange: (v: number) => void }) { return ( ) }