Move all three skills into skills/; add root README; fix paths

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Sonnet 5.5 committed 2026-10-16 17:04:38 +05:30
1 parent 312742cfef
commit d95e5d452d
84 files changed
+97 -31

No files matched your search

@@ -0,0 +1,126 @@
/* 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 { ThemeSwitcher } from '../theme'
import { ROLE_LABEL } from '../auth'
/**
* The app shell's top bar: brand mark, title, and the signed-in user's
* controls. The board tab strip that used to live below it is now the left
* sidebar (`Sidebar.tsx`) — this component no longer owns navigation.
*
* The gradient is the one saturated surface in the product. Everything on it
* is white or a white alpha rather than a palette token, because a
* token-coloured control on the band reads as a hole punched through it —
* see `.sb-appbar` and `.sb-ghost-btn` in index.css.
*/
/** 'admin' is never a single global role any more — computed here from the
* two per-project flags so the header names exactly what this account can
* reach, rather than falling back to "Operator" for someone who is in fact
* an admin of one or both projects. */
function roleLabel(user: {
role: string
ecw_role?: string | null
tebra_role?: string | null
}): string {
if (user.role === 'super_admin') return ROLE_LABEL.super_admin
const ecw = user.ecw_role === 'admin'
const tebra = user.tebra_role === 'admin'
if (ecw && tebra) return 'Admin · Both'
if (ecw) return 'Admin · ECW'
if (tebra) return 'Admin · Tebra'
return ROLE_LABEL.operator
}
export function BrandHeader({
user,
onLogout,
sidebarOpen,
onToggleSidebar,
}: {
user: {
username: string
real_name: string | null
role: string
ecw_role?: string | null
tebra_role?: string | null
}
onLogout: () => void
/** Present only on screens that have the left sidebar. */
sidebarOpen?: boolean
onToggleSidebar?: () => void
}) {
return (
<header className="sb-appbar shrink-0">
<ShieldMark />
{onToggleSidebar && (
<button
type="button"
onClick={onToggleSidebar}
title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
aria-expanded={sidebarOpen}
className="sb-ghost-btn"
style={{ padding: '3px', lineHeight: 0 }}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
strokeWidth="1.6" aria-hidden="true">
<rect x="3.5" y="4.5" width="17" height="15" rx="2" />
<line x1="9" y1="4.5" x2="9" y2="19.5" />
</svg>
</button>
)}
{/* What the tool is, matching the browser tab and the sign-in screen.
The brand and EMR edition — "Superbill Automation for ECW" — live in
the sidebar's own group headers instead (`Sidebar.tsx`), which is
where the rail has room to state them without competing here. */}
<div className="min-w-0">
<div className="sb-appbar-title truncate">Extraction and Automation Tool</div>
<div className="sb-appbar-sub truncate">Extract · correct · post to eClinicalWorks</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-3">
<div className="sb-who hidden sm:block">
<b>{user.real_name ?? user.username}</b>
<span>{roleLabel(user)}</span>
</div>
<button type="button" onClick={onLogout} title="Sign out" className="sb-ghost-btn">
Sign out
</button>
<ThemeSwitcher />
</div>
</header>
)
}
/**
* The product mark: shield plus three ascending bars, drawn as vector.
*
* The same artwork the sign-in screen carries, so the two screens read as one
* product — but flat here, with no bob and no glow: on the sign-in screen it
* is the subject, and in a header it is a label for the page.
*
* Vector rather than the `logo-mark.png` raster it replaced. The raster could
* not take the header's drop-shadow without fringing along its antialiased
* edge, and it could not follow a theme.
*
* Colours are literal on purpose. The shield and its bars are artwork — the
* mark is the same mark in light and dark, the way a printed logo is — so
* these four values are deliberately NOT theme tokens. `--shield`, `--clean`,
* `--verify` and `--review` hold the same hexes for anything that needs to
* reference them.
*
* `aria-hidden` because the product is named in text immediately beside it.
*/
export function ShieldMark({ size = 34 }: { size?: number }) {
return (
<span className="sb-appbar-mark" style={{ width: size, height: size }}>
<svg viewBox="0 0 64 64" fill="none" aria-hidden="true" style={{ width: size, height: size }}>
<path
d="M32 5 L53 13 V34 C53 45.5 43.5 53.5 32 58.5 C20.5 53.5 11 45.5 11 34 V13 Z"
fill="#21395a"
/>
<rect x="19.5" y="32" width="7" height="10" rx="1.6" fill="#51a68f" />
<rect x="30" y="24" width="7" height="18" rx="1.6" fill="#e39b34" />
<rect x="40.5" y="18" width="7" height="24" rx="1.6" fill="#de5243" />
</svg>
</span>
)
}
@@ -0,0 +1,95 @@
/* 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. */
/**
* A proportional ring.
*
* Earns its place only because the quantity it draws is genuinely a share of a
* whole — the split of every graded field across the confidence bands. A ring
* standing in for a single number would be decoration; this one is the only
* shape on the board that shows three parts against their total at once.
*
* It is `aria-hidden`, and that is deliberate rather than lazy: the legend
* beside it states every segment's name and count as text, so a screen reader
* gets the data in full and is not read a list of arc lengths. The chart is the
* fast path for someone looking; the legend is the actual record.
*/
export interface Slice {
key: string
label: string
value: number
color: string
}
export function Donut({
slices,
centerValue,
centerLabel,
size = 150,
thickness = 20,
/** Arc-length notch between segments, so two adjacent colours stay countable. */
gap = 3,
}: {
slices: Slice[]
centerValue: string
centerLabel: string
size?: number
thickness?: number
gap?: number
}) {
const r = (size - thickness) / 2
const c = 2 * Math.PI * r
const total = slices.reduce((n, s) => n + s.value, 0)
// Running start angle, in arc length. Accumulated rather than derived per
// index so a zero-value slice consumes nothing at all instead of leaving a
// gap where no data is.
let at = 0
const drawn = slices
.filter((s) => s.value > 0)
.map((s) => {
const len = (s.value / total) * c
const seg = { ...s, len: Math.max(len - gap, 0.5), offset: at }
at += len
return seg
})
return (
<div className="relative shrink-0" style={{ width: size, height: size }}>
<svg width={size} height={size} aria-hidden="true" className="-rotate-90">
{/* The track carries the empty case on its own, so a board with nothing
graded yet still draws a ring rather than blank space. */}
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
strokeWidth={thickness}
stroke="var(--surface-2)"
/>
{drawn.map((s) => (
<circle
key={s.key}
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={s.color}
strokeWidth={thickness}
strokeDasharray={`${s.len} ${c - s.len}`}
strokeDashoffset={-s.offset}
strokeLinecap="butt"
/>
))}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="sb-display tnum text-[20px] font-bold leading-none tracking-tight text-ink">
{centerValue}
</span>
<span className="mt-1 text-[10px] font-medium uppercase tracking-[0.1em] text-muted">
{centerLabel}
</span>
</div>
</div>
)
}
@@ -0,0 +1,537 @@
/* 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 { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api'
import { BAND_VAR } from '../bands'
import type { ReviewActions } from './ReviewPane'
/**
* The source page, as pixels the page owns.
*
* This used to be the browser's own PDF plugin in an <iframe>. That viewer
* cannot be driven from the page: zoom, scroll and pan all live inside it, so
* pinch-to-zoom and drag-to-pan were impossible and the only control was a
* `#zoom=` fragment that forced a full reload on every step. Here the page is
* a PNG rendered by PyMuPDF on the server (already a dependency of the
* extractor, so no new package and no bundled renderer), and zoom/pan are a
* single CSS transform — continuous, immediate, and reversible.
*
* Verifying a handwritten code means getting close to it, so this is not a
* nicety: at 100% these scans are barely readable.
*/
const MIN_ZOOM = 0.25
const MAX_ZOOM = 8
export function PdfPane({
jobId,
page,
onPickPage,
document: docName,
fullscreen,
onToggleFullscreen,
onExit,
reviewActions,
}: {
jobId: string
page: number | null
onPickPage: (page: number) => void
document: string
fullscreen: boolean
onToggleFullscreen: () => void
onExit: () => void
/** The open patient's unsaved-edit state, so Save/Discard here act on the
* exact same drafts as the review list — one save action, not two. */
reviewActions?: ReviewActions | null
}) {
const [zoom, setZoom] = useState(1)
const [box, setBox] = useState({ w: 0, h: 0 })
// Aspect ratio of the rendered page, learned from the image itself. Pages in
// this set are not all the same shape, so it cannot be a constant.
const [aspect, setAspect] = useState(0)
// A callback ref, not a plain one. Observing `ref.current` inside a
// mount-only effect attached the ResizeObserver to whichever node existed
// first; when React later swapped that node the observer kept watching the
// detached original, which never resizes. The measured width then froze at
// its first value — the page stayed sized for a 344px pane inside a 462px
// one, leaving a dead strip down both sides. Keying the effect to the
// element itself re-attaches the observer whenever the node changes.
const viewportRef = useRef<HTMLDivElement | null>(null)
const [viewportEl, setViewportEl] = useState<HTMLDivElement | null>(null)
const setViewport = useCallback((el: HTMLDivElement | null) => {
viewportRef.current = el
setViewportEl(el)
}, [])
useEffect(() => {
if (!viewportEl) return
const measure = () =>
setBox({ w: viewportEl.clientWidth, h: viewportEl.clientHeight })
measure()
const ro = new ResizeObserver(measure)
ro.observe(viewportEl)
// The observer covers pane resizes; this covers the window itself, which
// some browsers report only here when the element's box is unchanged in
// the same frame.
window.addEventListener('resize', measure)
return () => {
ro.disconnect()
window.removeEventListener('resize', measure)
}
}, [viewportEl])
// The page at 100% fills the pane's WIDTH, and scrolls vertically for the
// rest. Fitting the whole page into the pane's height instead made a
// portrait scan only ~380px wide in a ~680px pane — the page came out small
// with a wide dead strip beside it, and the handwriting this product exists
// to read was too small to read. A document viewer's useful default is
// fit-width; the height is what scrolling is for.
const PAD = 32
const fit = (() => {
if (!aspect || !box.w) return { w: 0, h: 0 }
const w = Math.max(0, box.w - PAD)
return { w, h: w / aspect }
})()
const scaledW = fit.w * zoom
const scaledH = fit.h * zoom
const info = useQuery({
queryKey: ['pdf-info', jobId],
queryFn: () => api.pdfInfo(jobId),
retry: false,
})
const pages = info.data?.pages ?? 0
const current = Math.min(Math.max(page ?? 1, 1), Math.max(pages, 1))
// Render at a higher DPI as the reviewer zooms in, so magnification reaches
// real detail instead of enlarging the same soft pixels. Stepped, not
// continuous — every distinct value is a fresh request, and a smooth ramp
// would refetch the page on every pinch frame.
const dpi = zoom > 3 ? 300 : zoom > 1.5 ? 220 : 150
const src = api.pageImage(jobId, current, dpi)
const reset = useCallback(() => {
setZoom(1)
const el = viewportRef.current
if (el) el.scrollTo({ top: 0, left: 0 })
}, [])
// A new page starts at the top; carrying the previous page's scroll position
// over lands the reviewer somewhere arbitrary on a differently-sized page.
useEffect(() => {
reset()
}, [current, reset])
/**
* Zoom about a fixed point, so the pixel under the cursor stays under it.
*
* The page is laid out at its real size inside a scrolling box rather than
* being CSS-transformed inside a clipped one. That is what makes the mouse
* wheel, the scrollbar, Page Up/Down and keyboard scrolling all work on it
* for free — with a transform there is no scrollable overflow for any of
* them to act on, so the only way to move the page was to drag it, and a
* reviewer looking for the bottom of a page had nothing to grab.
*/
const zoomAt = useCallback((factor: number, clientX?: number, clientY?: number) => {
const el = viewportRef.current
setZoom((z) => {
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z * factor))
if (el) {
const box = el.getBoundingClientRect()
// Where in the CONTENT the anchor currently sits, then put that same
// content point back under the cursor at the new scale.
const ax = (clientX ?? box.left + box.width / 2) - box.left
const ay = (clientY ?? box.top + box.height / 2) - box.top
const cx = (el.scrollLeft + ax) / z
const cy = (el.scrollTop + ay) / z
requestAnimationFrame(() => {
el.scrollLeft = cx * next - ax
el.scrollTop = cy * next - ay
})
}
return next
})
}, [])
// Ctrl/⌘+wheel and trackpad pinch zoom; a plain wheel is left alone so it
// scrolls the page normally. A browser reports a trackpad pinch as a wheel
// event with ctrlKey set — the same gesture that would otherwise zoom the
// whole browser — so this must be a non-passive listener that can
// preventDefault(), which React's passive onWheel cannot do.
useEffect(() => {
const el = viewportRef.current
if (!el) return
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
zoomAt(Math.exp(-e.deltaY * 0.002), e.clientX, e.clientY)
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [zoomAt])
// Drag to pan, and two-finger pinch on a touchscreen. Panning now moves the
// scroll position rather than a transform, so it stays in sync with the
// scrollbar and with wheel scrolling instead of fighting them.
const pointers = useRef(new Map<number, { x: number; y: number }>())
const pinchStart = useRef<{ dist: number; zoom: number } | null>(null)
const dragFrom = useRef<{ x: number; y: number; left: number; top: number } | null>(null)
const onPointerDown = (e: React.PointerEvent) => {
// A drag starting on the scrollbar itself must stay the browser's job.
if (e.button !== 0 && e.pointerType === 'mouse') return
try {
;(e.currentTarget as Element).setPointerCapture?.(e.pointerId)
} catch {
/* drag still works, it just stops at the pane edge */
}
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
const el = viewportRef.current
if (pointers.current.size === 2) {
const [a, b] = [...pointers.current.values()]
pinchStart.current = { dist: Math.hypot(a.x - b.x, a.y - b.y), zoom }
dragFrom.current = null
} else if (el) {
dragFrom.current = {
x: e.clientX,
y: e.clientY,
left: el.scrollLeft,
top: el.scrollTop,
}
}
}
const onPointerMove = (e: React.PointerEvent) => {
if (!pointers.current.has(e.pointerId)) return
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
const el = viewportRef.current
if (!el) return
if (pointers.current.size === 2 && pinchStart.current) {
const [a, b] = [...pointers.current.values()]
const dist = Math.hypot(a.x - b.x, a.y - b.y)
const target = pinchStart.current.zoom * (dist / pinchStart.current.dist)
setZoom(Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, target)))
return
}
if (dragFrom.current) {
el.scrollLeft = dragFrom.current.left - (e.clientX - dragFrom.current.x)
el.scrollTop = dragFrom.current.top - (e.clientY - dragFrom.current.y)
}
}
const endPointer = (e: React.PointerEvent) => {
pointers.current.delete(e.pointerId)
if (pointers.current.size < 2) pinchStart.current = null
if (pointers.current.size === 0) dragFrom.current = null
}
// Prints the actual source PDF at full quality via the browser's own PDF
// renderer, not the on-screen PNG — a reviewer printing for a paper trail
// wants what came off the scanner, not a screenshot of whatever zoom level
// they happened to be looking at. A hidden iframe is the standard way to
// trigger a browser print for a document the page did not itself render.
const printPdf = useCallback(() => {
const frame = window.document.createElement('iframe')
frame.style.position = 'fixed'
frame.style.width = '0'
frame.style.height = '0'
frame.style.border = '0'
frame.src = `/api/runs/${jobId}/pdf`
frame.onload = () => {
try {
frame.contentWindow?.focus()
frame.contentWindow?.print()
} finally {
// The print dialog is modal in most browsers, but not guaranteed to
// be, so the frame is cleaned up on a delay rather than immediately.
setTimeout(() => frame.remove(), 60_000)
}
}
window.document.body.appendChild(frame)
}, [jobId])
if (info.isError) {
return (
<div className="flex h-full items-center justify-center bg-surface-2 p-6 text-center">
<p className="max-w-xs text-xs text-muted">
No source PDF on disk for this run, so there is no page to check the
values against.
</p>
</div>
)
}
return (
// min-w-0 / overflow-hidden all the way down: a flex item's default
// min-width is auto, i.e. "never smaller than my content". With a
// nowrap page at 800% that content is thousands of pixels wide, so
// without these the pane grew to fit the zoomed page and pushed the whole
// app into horizontal overflow instead of scrolling inside itself.
//
// w-full alongside them: this element is itself a flex item, and min-w-0
// freed it to shrink to its content — so it settled at 510px inside an
// 891px pane and the page sat in the middle of a dead strip. min-w-0 says
// "you may shrink"; w-full says "but fill what you are given".
<div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden bg-surface-2">
<div className="sb-rv-toolbar shrink-0">
{fullscreen && (
<button onClick={onExit} className="sb-btn sb-btn-quiet sb-btn-sm">
← Back
</button>
)}
<span className="mr-auto min-w-0 flex-1 truncate text-[12px] font-medium text-ink-2" title={docName}>
{docName}
</span>
{pages > 1 && (
<span className="flex shrink-0 items-center gap-1.5">
<Btn label="Previous page" onClick={() => onPickPage(Math.max(1, current - 1))} disabled={current <= 1}>
‹
</Btn>
<span className="tnum w-16 text-center text-[12px] text-muted">
{current} / {pages}
</span>
<Btn label="Next page" onClick={() => onPickPage(Math.min(pages, current + 1))} disabled={current >= pages}>
›
</Btn>
</span>
)}
<span className="flex shrink-0 items-center gap-1.5">
<Btn label="Zoom out" onClick={() => zoomAt(1 / 1.25)}>−</Btn>
<span className="tnum w-12 text-center text-[12px] text-muted">
{Math.round(zoom * 100)}%
</span>
<Btn label="Zoom in" onClick={() => zoomAt(1.25)}>+</Btn>
<Btn label="Reset zoom and position" onClick={reset} wide>Fit</Btn>
<Btn
label={fullscreen ? 'Leave fullscreen' : 'Fill the screen with this page'}
onClick={onToggleFullscreen}
>
{fullscreen ? '⤡' : '⤢'}
</Btn>
<a
href={`/api/runs/${jobId}/pdf`}
target="_blank"
rel="noreferrer"
aria-label="Open the PDF in a separate tab"
title="Open in a separate tab"
className="sb-icon-btn inline-flex items-center justify-center no-underline"
>
↗
</a>
</span>
</div>
<div className="flex min-h-0 min-w-0 flex-1">
<div
ref={setViewport}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endPointer}
onPointerCancel={endPointer}
onDoubleClick={(e) => zoomAt(zoom >= MAX_ZOOM ? MIN_ZOOM / zoom : 1.8, e.clientX, e.clientY)}
// pan-x pan-y (not none) keeps one-finger scrolling native while the
// two-pointer pinch is still delivered here rather than zooming the
// whole browser.
// whitespace-nowrap stops the inline-block page being wrapped onto
// its own line box at high zoom.
className="relative min-h-0 min-w-0 flex-1 overflow-auto overscroll-contain whitespace-nowrap bg-surface-2"
style={{
touchAction: 'pan-x pan-y',
cursor: zoom > 1 ? 'grab' : 'default',
// Centre the page only while it fits. Centring an overflowing
// inline box pushes its left edge to a negative offset, which no
// scrollbar can reach — at 800% the left third of the page became
// permanently invisible.
textAlign: scaledW + PAD > box.w ? 'left' : 'center',
}}
>
{/* The image IS the scrollable content — no spacer, no transform.
Earlier attempts sized a wrapper and scaled the image inside it;
in this subtree the wrapper's inline width was simply not honoured
(it reported 672px and laid out at 360px, and the transform came
back as an identity matrix), which left the page drawn at fit size
inside a much larger reserved area — the large empty gap beside a
zoomed page. Setting the <img>'s own width/height as an
inline-block inside a nowrap scroller is the plain version: the
element that shows the page is also the element that defines the
overflow, so the two cannot disagree. */}
<img
// Remounted on every zoom step, deliberately. Updating this node in
// place did not resize it: its inline style reported the new width
// while it kept laying out at the old one, and writing
// element.style directly did not move it either — only a freshly
// created node ever took the size. Re-keying sidesteps that
// entirely, and costs nothing visible because the src is unchanged
// for a given (page, dpi) so the bitmap is served from cache.
key={`p${current}-d${dpi}-z${Math.round(zoom * 1000)}`}
src={src}
alt={`Page ${current} of ${docName}`}
draggable={false}
onLoad={(e) => {
const el = e.currentTarget
if (el.naturalWidth && el.naturalHeight) {
setAspect(el.naturalWidth / el.naturalHeight)
}
}}
className="m-4 inline-block max-w-none select-none rounded-md bg-surface align-top shadow-2xl ring-1 ring-line"
style={{ width: scaledW || undefined, height: scaledH || undefined }}
/>
</div>
<SideRail reviewActions={reviewActions} onPrint={printPdf} />
</div>
<p className="shrink-0 border-t border-line-soft bg-rail px-3 py-1.5 text-[11px] text-muted">
Scroll or drag to move · pinch or Ctrl+scroll to zoom · double-click to zoom in
</p>
</div>
)
}
/**
* The common controls, always in view regardless of scroll or zoom: Save the
* open patient's edits, Print the source PDF, and the score legend. Save here
* drives the exact same state as the bar in ReviewPane — there is one save
* action, reachable from whichever pane the reviewer's eyes are already on.
*/
function SideRail({
reviewActions,
onPrint,
}: {
reviewActions?: ReviewActions | null
onPrint: () => void
}) {
const dirty = (reviewActions?.dirtyCount ?? 0) > 0
return (
<div className="flex w-12 shrink-0 flex-col items-center gap-2 border-l border-line bg-rail py-3">
<RailButton
label={
dirty
? `Save ${reviewActions!.dirtyCount} unsaved change(s) for ${reviewActions?.patientName ?? 'this patient'}`
: 'No unsaved changes'
}
onClick={() => reviewActions?.onSave()}
disabled={!dirty || reviewActions?.saving}
tone={dirty ? 'warn' : 'neutral'}
>
<span aria-hidden="true">💾</span>
{dirty && (
<span
className="tnum absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full text-[9px] font-bold"
style={{ background: 'var(--brand-grad)', color: 'var(--accent-fg)' }}
>
{reviewActions!.dirtyCount}
</span>
)}
</RailButton>
{dirty && (
<RailButton
label={`Discard unsaved changes for ${reviewActions?.patientName ?? 'this patient'}`}
onClick={() => reviewActions?.onDiscard()}
tone="neutral"
>
<span aria-hidden="true">↺</span>
</RailButton>
)}
<div className="my-1 h-px w-6 bg-line" aria-hidden="true" />
<RailButton label="Print the source PDF" onClick={onPrint} tone="neutral">
<span aria-hidden="true">🖨</span>
</RailButton>
<div className="my-1 h-px w-6 bg-line" aria-hidden="true" />
{/* The score legend, vertical to fit this rail — same meaning as the
horizontal one in the document list (StatsBar's BandLegend), just
laid out for a narrow column instead of a wide header. */}
<div
className="flex flex-col items-center gap-1.5"
title="Colour reflects print clarity, not correctness. A cleanly printed value can still be misread."
>
<ScoreDot band="green" label="≥0.95 clean" />
<ScoreDot band="amber" label="0.90–0.95 sample" />
<ScoreDot band="red" label="<0.90 review" />
</div>
</div>
)
}
function RailButton({
children,
label,
onClick,
disabled,
tone = 'neutral',
}: {
children: React.ReactNode
label: string
onClick: () => void
disabled?: boolean
tone?: 'neutral' | 'warn'
}) {
return (
<button
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className="sb-icon-btn relative h-8 w-8 shrink-0 text-sm"
style={
tone === 'warn'
? {
borderColor: 'color-mix(in srgb, var(--verify) 45%, transparent)',
background: 'color-mix(in srgb, var(--verify) 14%, var(--surface))',
}
: undefined
}
>
{children}
</button>
)
}
function ScoreDot({ band, label }: { band: 'green' | 'amber' | 'red'; label: string }) {
return (
<span
className="sb-swatch shrink-0"
title={label}
style={{ backgroundColor: BAND_VAR[band], marginRight: 0 }}
/>
)
}
function Btn({
children,
label,
onClick,
disabled,
wide,
}: {
children: React.ReactNode
label: string
onClick: () => void
disabled?: boolean
/** Word labels ("Fit") need more than the 26px icon square. */
wide?: boolean
}) {
return (
<button
onClick={onClick}
disabled={disabled}
// The visible glyph is a symbol, not a word — "−" reads as "minus sign"
// to a screen reader, so the accessible name has to be written out.
aria-label={label}
title={label}
className={`sb-icon-btn ${wide ? 'w-auto px-2' : ''}`}
>
{children}
</button>
)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,989 @@
/* 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>
)
}
@@ -0,0 +1,278 @@
/* 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 { 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>
)
}
@@ -0,0 +1,301 @@
/* 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 { useState } from 'react'
import type { ReactElement } from 'react'
import type { BatchStage, BoardTab } from '../types'
import type { Project } from '../project'
import { PROJECT_LABEL } from '../project'
import { IconSpend, IconPage, IconLoop, IconIdCard, IconPatients } from './icons'
/**
* The app-wide section rail, at a fixed 236px.
*
* It used to collapse to a 56px icon strip and expand to an overlay on hover.
* That is gone with the reskin: the rail is now always labelled, which is what
* the approved mockup shows and what removes the "hover to find out what these
* icons are" step from every navigation.
*
* Two projects, one rail: ECW's boards (templates 1-6) and Tebra's (template 7
* / Example Client), each under its own group header. See project.ts for how a
* batch or run is assigned to one or the other.
*
* Selection and drag-and-drop-to-move-stage behaviour are unchanged — only the
* shape of the control moved.
*/
export function Sidebar({
tab,
onTab,
counts,
onDropBatch,
onDropRun,
isAdmin,
projects,
}: {
tab: BoardTab
onTab: (t: BoardTab) => void
/** Per project, per stage — each section shows its own queue depth. */
counts: Record<Project, Record<BatchStage, number>>
/** A batch card dragged onto a section moves to that stage. */
onDropBatch: (batchId: string, stage: BatchStage) => void
/** A single document dragged onto Automation is gathered into its own batch. */
onDropRun?: (jobId: string) => void
/** Gates each section's ROI item, and the single account-level People
* item below them all — both are admin-and-above. */
isAdmin: boolean
/** Which sections render at all. A scoped admin sees one; everyone else sees
* both. Independent of `isAdmin`: an operator sees both sections' boards
* with ROI/People hidden inside each, while a scoped admin has a whole
* section absent regardless of role. */
projects: Project[]
}) {
return (
<nav
aria-label="Sections"
className="sb-rail z-10 w-[236px] shrink-0 overflow-y-auto"
>
{projects.includes('ecw') && (
<>
<SectionHead project="ecw" first={projects[0] === 'ecw'} />
{/* First, and deliberately not a drop target: ROI is a report over the
whole pipeline, not a place a batch can be put. Passing no
`dropStage` is what makes dropping onto it impossible, rather than
a handler that quietly does nothing. */}
{isAdmin && (
<Item
tab="roi"
icon={IconSpend}
label="ROI Dashboard"
hint="What the pipeline has cost, and what that bought"
active={tab === 'roi'}
onSelect={onTab}
/>
)}
<Item
tab="extraction"
dropStage="extraction"
dropTab="extraction"
icon={IconPage}
label="Charge Extraction"
hint="Drop PDFs, run the extractor, clear the flagged fields"
active={tab === 'extraction'}
n={counts.ecw.extraction}
onSelect={onTab}
onDropBatch={onDropBatch}
onDropRun={onDropRun}
/>
{/* Demography's own spend, apart from the charges ROI above (ticket
05). Admin only, the same rule as the other ROI items. */}
{isAdmin && (
<Item
tab="demography-roi"
icon={IconSpend}
label="Demography ROI"
hint="What demography extraction has cost, and what that bought"
active={tab === 'demography-roi'}
onSelect={onTab}
/>
)}
<Item
tab="demography"
icon={IconIdCard}
label="Demography Extraction"
hint="OCR-only extraction for facesheets and admission records"
active={tab === 'demography'}
onSelect={onTab}
/>
<Item
tab="automation"
dropStage="automation"
dropTab="automation"
icon={IconLoop}
label="Charge Automation"
hint="Batches that finished extraction and are ready to post"
active={tab === 'automation'}
n={counts.ecw.automation}
onSelect={onTab}
onDropBatch={onDropBatch}
onDropRun={onDropRun}
/>
{/* Not a drop target, same reasoning as Demography Extraction above:
a demography run is not a batch, so BATCH_DRAG_TYPE's payload
(a batch_id) has nowhere to go here. */}
<Item
tab="demography-automation"
icon={IconIdCard}
label="Demography Automation"
hint="Demography runs that finished and are ready to post"
active={tab === 'demography-automation'}
onSelect={onTab}
/>
</>
)}
{projects.includes('tebra') && (
<>
<SectionHead project="tebra" first={projects[0] === 'tebra'} />
{isAdmin && (
<Item
tab="tebra-roi"
icon={IconSpend}
label="ROI Dashboard"
hint="What the Example Client pipeline has cost, and what that bought"
active={tab === 'tebra-roi'}
onSelect={onTab}
/>
)}
<Item
tab="tebra-extraction"
dropStage="extraction"
dropTab="tebra-extraction"
icon={IconPage}
label="Example Client Charge Extraction"
hint="Example Client tickets: extract and clear the flagged fields"
active={tab === 'tebra-extraction'}
n={counts.tebra.extraction}
onSelect={onTab}
onDropBatch={onDropBatch}
onDropRun={onDropRun}
/>
<Item
tab="tebra-automation"
dropStage="automation"
dropTab="tebra-automation"
icon={IconLoop}
label="Example Client Charge Automation"
hint="Example Client batches that finished extraction and are ready to post"
active={tab === 'tebra-automation'}
n={counts.tebra.automation}
onSelect={onTab}
onDropBatch={onDropBatch}
onDropRun={onDropRun}
/>
</>
)}
{/* One People entry for the whole app, below every project section
rather than inside one — access is an account-level thing, not a
board, and a copy under each project only ever asked "which of these
two identical links did I mean". The screen itself is merged for
anyone who can see both projects (see UsersPanel); a single-project
admin lands on their own project's screen, which is why the tab id
follows `projects` rather than being fixed — 'people' maps to ECW,
and a Tebra-only admin must not be sent to a board they cannot see
(App.tsx bounces them straight back off it).
Not a drop target: this is settings. Admins need it to grant access;
operators have nothing to do here and the endpoint would refuse them
anyway. */}
{isAdmin && (
<>
<hr className="sb-rail-divider" />
<Item
tab={projects.includes('ecw') ? 'people' : 'tebra-people'}
icon={IconPatients}
label="User Access"
hint="Who can sign in, and what each of them may do"
active={tab === 'people' || tab === 'tebra-people'}
onSelect={onTab}
/>
</>
)}
</nav>
)
}
/** One project's name above its own nav list, with the bar glyph the mockup
* uses for a group header. The divider is skipped on the first section —
* there is nothing yet to divide it from. */
function SectionHead({ project, first }: { project: Project; first: boolean }) {
return (
<>
{!first && <hr className="sb-rail-divider" />}
<div className="sb-rail-group">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M5 20V11M12 20V5M19 20V14" />
</svg>
<span className="whitespace-pre-line">{PROJECT_LABEL[project]}</span>
</div>
</>
)
}
export const BATCH_DRAG_TYPE = 'application/x-superbill-batch'
const RUN_DRAG_TYPE = 'application/x-superbill-run'
function Item({
tab,
dropStage,
dropTab,
icon: IconEl,
label,
hint,
active,
n,
onSelect,
onDropBatch,
onDropRun,
}: {
tab: BoardTab
/** The stage a card dropped here should move to. Absent on sections that
* are a view rather than a place, which is what makes them reject a drop. */
dropStage?: BatchStage
/** The board to navigate to after a drop. Two projects can share a stage
* name (both have an "extraction" and an "automation"), so the stage alone
* no longer identifies which board a card landed on — this does. Defaults
* to `tab`, which was the drop target anyway on every item that has one. */
dropTab?: BoardTab
icon: (p: { size?: number; className?: string }) => ReactElement
label: string
hint: string
active: boolean
/** Omitted on sections that count nothing — a report has no queue depth. */
n?: number
onSelect: (t: BoardTab) => void
onDropBatch?: (batchId: string, stage: BatchStage) => void
onDropRun?: (jobId: string) => void
}) {
const [over, setOver] = useState(false)
return (
<button
type="button"
onClick={() => onSelect(tab)}
title={hint}
aria-current={active ? 'page' : undefined}
// A section is also a drop target, so a batch — or a single document —
// can be thrown at "Automation" without first switching to it and
// losing sight of the card being dragged.
onDragOver={(e) => {
if (!dropStage) return
const t = e.dataTransfer.types
if (t.includes(BATCH_DRAG_TYPE) || (onDropRun && t.includes(RUN_DRAG_TYPE))) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setOver(true)
}
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
if (!dropStage) return
setOver(false)
const batchId = e.dataTransfer.getData(BATCH_DRAG_TYPE)
const runId = e.dataTransfer.getData(RUN_DRAG_TYPE)
if (!batchId && !runId) return
e.preventDefault()
if (batchId && onDropBatch) onDropBatch(batchId, dropStage)
else if (runId && onDropRun) onDropRun(runId)
// Navigate to the board that was dropped ON, not to the stage name —
// see the `dropTab` prop doc above.
onSelect(dropTab ?? tab)
}}
className={`sb-nav-item ${over ? 'bg-surface-2 text-ink-2' : ''}`}
>
<IconEl size={16} className="shrink-0" />
<span className="truncate">{label}</span>
{n !== undefined && (
// Red only when the number is work somebody has to pick up. A badge
// that is red at zero teaches people to stop reading it.
<span className={`sb-count ${n > 0 ? 'sb-count-hot' : ''}`}>{n}</span>
)}
</button>
)
}
@@ -0,0 +1,147 @@
/* 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 { useCallback, useEffect, useRef, useState } from 'react'
const KEY = 'superbill.split'
/**
* Draggable two-pane split, PDF on the left and values on the right.
*
* The ratio is remembered: how much page a reviewer needs beside the values
* depends on their screen and their document, and re-dragging it every session
* would be a small tax paid hundreds of times.
*
* The drag listeners live on window, not the handle, so the pointer can outrun
* the 6px handle mid-drag without dropping it.
*
* Below `md`, side-by-side stops being usable at all — squeezing both panes
* into a 375px screen leaves the PDF at ~165px wide, unreadable. Below that
* breakpoint this becomes two full-width tabs instead of a shrinking split;
* both panes stay mounted (toggling `hidden`/`flex`, not unmounting) so
* switching tabs never resets the PDF viewer's scroll or zoom.
*/
export function SplitPane({
left,
right,
min = 25,
max = 75,
ratio,
}: {
left: React.ReactNode
right: React.ReactNode
min?: number
max?: number
/**
* Force a split for as long as it is set, without overwriting the reviewer's
* remembered one — fullscreen widens the page and docks the values to the
* right, and leaving it must restore the ratio they chose, not this one.
*/
ratio?: number
}) {
const [saved, setSaved] = useState(() => {
const stored = Number(localStorage.getItem(KEY))
// The scan is the thing being read; the values are short. The page gets
// the larger share by default.
return stored >= min && stored <= max ? stored : 60
})
const pct = ratio ?? saved
const setPct = setSaved
const [dragging, setDragging] = useState(false)
const [mobileTab, setMobileTab] = useState<'left' | 'right'>('left')
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
localStorage.setItem(KEY, String(saved))
}, [saved])
const onMove = useCallback(
(e: MouseEvent) => {
const rect = box.current?.getBoundingClientRect()
if (!rect) return
const next = ((e.clientX - rect.left) / rect.width) * 100
setPct(Math.min(max, Math.max(min, next)))
},
[min, max],
)
useEffect(() => {
if (!dragging) return
const stop = () => setDragging(false)
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', stop)
// The PDF pane has its own drag-to-pan handler; without this it would
// interpret the divider drag as a pan and text would select across both
// panes on the way.
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
return () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', stop)
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
}, [dragging, onMove])
return (
<div className="flex min-h-0 flex-1 flex-col">
{/* Tab bar: mobile only. The drag handle is the equivalent affordance on
desktop, so this disappears at md instead of coexisting with it. */}
<div className="flex shrink-0 border-b border-line-soft bg-rail md:hidden">
<button
onClick={() => setMobileTab('left')}
aria-current={mobileTab === 'left'}
className="flex-1 border-b-2 py-2.5 font-sora text-[13px] font-semibold transition-colors"
style={{
borderColor: mobileTab === 'left' ? 'var(--accent-1)' : 'transparent',
color: mobileTab === 'left' ? 'var(--ink)' : 'var(--muted)',
}}
>
Source page
</button>
<button
onClick={() => setMobileTab('right')}
aria-current={mobileTab === 'right'}
className="flex-1 border-b-2 py-2.5 font-sora text-[13px] font-semibold transition-colors"
style={{
borderColor: mobileTab === 'right' ? 'var(--accent-1)' : 'transparent',
color: mobileTab === 'right' ? 'var(--ink)' : 'var(--muted)',
}}
>
Fields to review
</button>
</div>
<div ref={box} className="flex min-h-0 flex-1 flex-col md:flex-row">
<div
style={{ '--split-w': `${pct}%` } as React.CSSProperties}
className={`min-h-0 min-w-0 flex-1 border-b border-line md:w-[var(--split-w)] md:flex-none md:border-b-0 md:border-r md:border-line ${
mobileTab === 'left' ? 'flex' : 'hidden'
} md:flex`}
>
{left}
</div>
<div
onMouseDown={() => setDragging(true)}
onDoubleClick={() => setPct(52)}
title="Drag to resize — double-click to reset"
className={`hidden w-1.5 shrink-0 cursor-col-resize transition-colors md:block ${
dragging ? 'bg-accent-1' : 'bg-line hover:bg-muted'
}`}
/>
{/* Pointer events off during the drag, so neither pane steals the move. */}
<div
style={{
'--split-w': `${100 - pct}%`,
pointerEvents: dragging ? 'none' : undefined,
} as React.CSSProperties}
className={`min-h-0 min-w-0 flex-1 md:w-[var(--split-w)] md:flex-none ${
mobileTab === 'right' ? 'flex' : 'hidden'
} md:flex`}
>
{right}
</div>
</div>
</div>
)
}
@@ -0,0 +1,101 @@
/* 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 { BAND_VAR } from '../bands'
import type { Run } from '../types'
/**
* The batch in one line: how much runs unattended, how much needs a person.
*
* This is the number the whole pipeline exists to produce. Reading it off a list
* of cards means counting, so it is stated once at the top — and stated as
* documents, not fields, because the decision it supports ("what do I have to
* look at today") is made per document.
*/
export function StatsBar({ runs }: { runs: Run[] }) {
const done = runs.filter((r) => r.status === 'done')
const clean = done.filter((r) => flagged(r) === 0)
const needs = done.filter((r) => flagged(r) > 0)
const busy = runs.filter((r) => r.status === 'queued' || r.status === 'running')
const failed = runs.filter((r) => r.status === 'failed')
const fields = done.reduce((n, r) => n + flagged(r), 0)
if (runs.length === 0) return null
return (
<div className="sb-runbar">
<Stat n={clean.length} label="run unattended" tone="accent" />
<Stat n={needs.length} label="need a reviewer" tone="amber" />
{fields > 0 && (
<Stat n={fields} label="fields flagged" tone="slate" />
)}
{busy.length > 0 && <Stat n={busy.length} label="extracting" tone="blue" />}
{failed.length > 0 && <Stat n={failed.length} label="failed" tone="red" />}
<BandLegend />
</div>
)
}
function flagged(run: Run): number {
if (run.review) return run.review.total
const s = run.summary
return s ? s.amber + s.red + s.unscored + s.blank_critical : 0
}
function Stat({
n,
label,
tone,
}: {
n: number
label: string
// "accent" ties to the brand ramp (this stat means "went straight through,"
// the same semantic as the confirm/approve action elsewhere); the rest are
// theme tokens for the state they report.
tone: 'accent' | 'amber' | 'slate' | 'blue' | 'red'
}) {
// `slate` stays on the runbar's own ink — a stat with no state to report
// should not borrow a colour that means one.
const colour = {
accent: 'var(--accent-1)',
amber: 'var(--verify)',
slate: '',
blue: 'var(--accent-2)',
red: 'var(--review)',
}[tone]
return (
<span>
<b style={colour ? { color: colour } : undefined}>{n}</b> {label}
</span>
)
}
/**
* What the colours mean, on the page rather than in a document nobody opens.
* The caveat is part of the legend on purpose: the bands rate how legibly the
* source was printed, and a reviewer who reads them as "verified" will trust a
* confident misread.
*/
export function BandLegend() {
return (
<div className="sb-keys">
<Swatch band="green" label="≥0.95 clean" />
<Swatch band="amber" label="0.90–0.95 sample" />
<Swatch band="red" label="<0.90 review" />
<span
className="cursor-help text-muted"
title="The score rates how legibly the source was printed, not whether the value is correct. A cleanly printed value can still be misread."
>
print clarity, not correctness ⓘ
</span>
</div>
)
}
function Swatch({ band, label }: { band: 'green' | 'amber' | 'red'; label: string }) {
return (
<span className="flex items-center text-ink-2">
<i className="sb-swatch" style={{ backgroundColor: BAND_VAR[band] }} />
{label}
</span>
)
}
@@ -0,0 +1,134 @@
/* 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 { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
const GAP = 8
type Rect = { top: number; bottom: number; left: number; width: number }
type Placed = { top: number; left: number }
/**
* A tooltip that is actually part of the design.
*
* Replaces `title=""`, which cannot be styled, cannot be themed, appears after a
* delay the page does not control, and renders in the OS's own font at the
* cursor rather than against the thing it describes.
*
* Positioning is deliberately in two stages, and the order matters:
*
* 1. The anchor's rect is captured in the open handler, and the panel is drawn
* immediately from it using a transform (`translate(-50%, -100%)`) — the
* browser centres and lifts it with no measurement at all, so it is
* correctly placed on the very first paint.
* 2. A layout effect then measures the rendered panel and only refines: flip
* below when there is no room above, clamp into the viewport at the edges.
*
* An earlier version skipped straight to stage 2 — render at 0,0 hidden, measure,
* then position. When that measurement did not land the tooltip stayed invisible
* in the corner, which is the worst failure available: the information silently
* disappears. Now a failed refinement costs at most some clipping at a screen
* edge, and the tooltip is always visible.
*
* Because the pane behind it scrolls, the captured rect can go stale — so any
* scroll closes the tooltip rather than leaving it pointing at nothing.
*
* It is `aria-hidden` on purpose: the same fact is in the trigger's own
* accessible name (see the field input's `aria-label`), so a screen reader loses
* nothing when this never appears. Opens on hover AND on keyboard focus.
*/
export function Tooltip({
label,
className,
children,
}: {
label: string
/** Applied to the wrapper, which stands in for the element it replaces. */
className?: string
children: React.ReactNode
}) {
const anchor = useRef<HTMLSpanElement>(null)
const panel = useRef<HTMLDivElement>(null)
const [rect, setRect] = useState<Rect | null>(null)
const [placed, setPlaced] = useState<Placed | null>(null)
const open = () => {
const r = anchor.current?.getBoundingClientRect()
if (r) setRect({ top: r.top, bottom: r.bottom, left: r.left, width: r.width })
}
const close = () => {
setRect(null)
setPlaced(null)
}
useLayoutEffect(() => {
if (!rect) return
const p = panel.current?.getBoundingClientRect()
if (!p) return
const flip = rect.top - p.height - GAP < GAP
const top = flip ? rect.bottom + GAP : rect.top - p.height - GAP
const wanted = rect.left + rect.width / 2 - p.width / 2
const left = Math.max(GAP, Math.min(wanted, window.innerWidth - p.width - GAP))
// Only take over from the transform when the transform would actually be
// wrong; otherwise leave the first-paint placement exactly as it is.
const drifted = Math.abs(left - wanted) > 0.5 || flip
setPlaced(drifted ? { top, left } : null)
}, [rect, label])
useEffect(() => {
if (!rect) return
// `true` for the capture phase: the scroll happens inside the review pane,
// and scroll events on an inner element do not bubble to window.
window.addEventListener('scroll', close, true)
window.addEventListener('resize', close)
return () => {
window.removeEventListener('scroll', close, true)
window.removeEventListener('resize', close)
}
}, [rect])
return (
<>
<span
ref={anchor}
className={className}
// focus/blur do not bubble natively, but React synthesises these from
// focusin/focusout, which do — so focusing the inner input opens it.
onMouseEnter={open}
onMouseLeave={close}
onFocus={open}
onBlur={close}
>
{children}
</span>
{rect &&
createPortal(
<div
ref={panel}
role="presentation"
aria-hidden="true"
className="pointer-events-none fixed z-50 w-max max-w-[18rem] rounded-lg px-2.5 py-1.5 font-plex text-[11px] font-medium leading-snug shadow-lg"
style={
placed
? { top: placed.top, left: placed.left, backgroundColor: 'var(--ink)', color: 'var(--surface)' }
: {
top: rect.top - GAP,
left: rect.left + rect.width / 2,
// Centres and lifts with no measurement — this is what makes
// the first paint correct rather than hidden.
transform: 'translate(-50%, -100%)',
// Inverted from the surface it floats over: a panel sharing
// its host's background reads as part of the page instead of
// above it. Both directions clear AAA against their own text.
backgroundColor: 'var(--ink)',
color: 'var(--surface)',
}
}
>
{label}
</div>,
document.body,
)}
</>
)
}
@@ -0,0 +1,160 @@
/* 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. */
/**
* The icon set.
*
* Drawn here rather than pulled from a package, and drawn rather than typed:
* `✓`, `✗` and `●` are punctuation borrowed as pictures — they carry a font's
* metrics, not a stroke weight, so they sit at different optical sizes from each
* other and cannot be aligned with anything. A screen reader also reads them
* aloud as the characters they are.
*
* One grid (24), one stroke (1.75), round caps and joins throughout, so any two
* of these placed side by side read as the same hand. `currentColor` everywhere,
* so an icon inherits whatever the surface already decided about contrast.
*/
function Icon({
children,
size = 18,
className,
}: {
children: React.ReactNode
size?: number
className?: string
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
// Decorative by default: every icon in this app sits beside its own
// label. An icon that ever stands alone gets a real name at the call
// site instead of a title buried in here.
aria-hidden="true"
className={className}
>
{children}
</svg>
)
}
type P = { size?: number; className?: string }
/** Stacked sheets — a count of documents. */
export const IconStack = (p: P) => (
<Icon {...p}>
<path d="M12 3 3 7.5l9 4.5 9-4.5L12 3Z" />
<path d="M3 12.5 12 17l9-4.5" />
<path d="M3 17.5 12 22l9-4.5" />
</Icon>
)
/** A file leaving the app for the reviewer's own disk. */
export const IconDownload = (p: P) => (
<Icon {...p}>
<path d="M12 3v12" />
<path d="m7.5 10.5 4.5 4.5 4.5-4.5" />
<path d="M4.5 19.5h15" />
</Icon>
)
/** A settled thing. */
export const IconCheck = (p: P) => (
<Icon {...p}>
<circle cx="12" cy="12" r="9" />
<path d="m8.5 12.5 2.5 2.5 4.5-5.5" />
</Icon>
)
/** Work still in flight. */
export const IconClock = (p: P) => (
<Icon {...p}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7.5V12l3 2" />
</Icon>
)
/** Something absent or wrong — never used for "red band", which is data. */
export const IconAlert = (p: P) => (
<Icon {...p}>
<circle cx="12" cy="12" r="9" />
<path d="M12 8v4.5" />
<path d="M12 16h.01" />
</Icon>
)
/** A single scanned page. */
export const IconPage = (p: P) => (
<Icon {...p}>
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z" />
<path d="M14 3v5h5" />
</Icon>
)
/** People, not rows: this counts patients. */
export const IconPatients = (p: P) => (
<Icon {...p}>
<path d="M16 20v-1.5a3.5 3.5 0 0 0-3.5-3.5h-4A3.5 3.5 0 0 0 5 18.5V20" />
<circle cx="10.5" cy="8" r="3.5" />
<path d="M19 20v-1.5a3.5 3.5 0 0 0-2.5-3.35" />
<path d="M15 4.6a3.5 3.5 0 0 1 0 6.65" />
</Icon>
)
/** Money spent. A coin edge-on, not a currency glyph. */
export const IconSpend = (p: P) => (
<Icon {...p}>
<ellipse cx="12" cy="6.5" rx="8" ry="3.5" />
<path d="M4 6.5v11c0 1.93 3.58 3.5 8 3.5s8-1.57 8-3.5v-11" />
<path d="M4 12c0 1.93 3.58 3.5 8 3.5s8-1.57 8-3.5" />
</Icon>
)
/** Two curved arrows chasing each other — a stage in a repeating flow. */
export const IconLoop = (p: P) => (
<Icon {...p}>
<path d="M4.5 12a7.5 7.5 0 0 1 12.5-5.6l2 1.9" />
<path d="M17.5 5.5v3.3h-3.3" />
<path d="M19.5 12a7.5 7.5 0 0 1-12.5 5.6l-2-1.9" />
<path d="M6.5 18.5v-3.3h3.3" />
</Icon>
)
/** A wallet card with a portrait square — a person's own identifying record. */
export const IconIdCard = (p: P) => (
<Icon {...p}>
<rect x="3" y="5.5" width="18" height="13" rx="2" />
<circle cx="9" cy="11.5" r="2" />
<path d="M6.5 15.5c0-1.4 1.1-2.2 2.5-2.2s2.5.8 2.5 2.2" />
<path d="M14.5 10h4" />
<path d="M14.5 13h4" />
</Icon>
)
export const IconChevronRight = (p: P) => (
<Icon {...p}>
<path d="m9 6 6 6-6 6" />
</Icon>
)
/**
* The legend and chip marker. A drawn disc rather than `●`, so it lands on the
* text baseline the same way at every size and takes its colour from the same
* `currentColor` chain as everything else.
*/
export const IconDot = ({ size = 8, className }: P) => (
<svg
width={size}
height={size}
viewBox="0 0 8 8"
aria-hidden="true"
className={className}
>
<circle cx="4" cy="4" r="4" fill="currentColor" />
</svg>
)