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,988 @@
|
||||
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<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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user