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:
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>
|
||||
)
|
||||
Reference in new issue
Block a user