Initial commit: plan, spec, tickets and dashboard skill templates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
49f8f49b65
39 files changed
+10736
No files matched your search
@@ -0,0 +1,451 @@
|
||||
---
|
||||
name: Superbill Extraction and Automation Tool
|
||||
description: Extract billing fields from a scan, verify them against the page, post to eClinicalWorks.
|
||||
# ── Colour ───────────────────────────────────────────────────────────────────
|
||||
# Two appearances, light and dark, declared as CSS variables in web/src/index.css
|
||||
# and registered as Tailwind utilities there via `@theme inline`. A component
|
||||
# names a token (`bg-surface`, `text-muted`); it never names a hex.
|
||||
colors:
|
||||
# Surfaces and ink — light
|
||||
ground: "#F4F3F7"
|
||||
rail: "#FFFFFF"
|
||||
surface: "#FFFFFF"
|
||||
surface-2: "#F7F6FA"
|
||||
line: "#E2DFE9"
|
||||
line-soft: "#ECEAF1"
|
||||
ink: "#18151F"
|
||||
ink-2: "#3D3850"
|
||||
muted: "#66607A"
|
||||
# Surfaces and ink — dark
|
||||
dark-ground: "#0A0912"
|
||||
dark-rail: "#100E1A"
|
||||
dark-surface: "#151220"
|
||||
dark-surface-2: "#1B1727"
|
||||
dark-line: "#2A2438"
|
||||
dark-line-soft: "#221D31"
|
||||
dark-ink: "#F3F1F8"
|
||||
dark-ink-2: "#C9C4D8"
|
||||
dark-muted: "#8D87A0"
|
||||
# The brand ramp. Identical in both appearances, and the only saturated thing
|
||||
# on a page: header bar, primary buttons, active nav bar, super-admin badge,
|
||||
# progress fill. Nothing else gets the gradient.
|
||||
accent-1: "#FF3049"
|
||||
accent-2: "#F26B21"
|
||||
# Grading. Semantic, never decorative — print clarity, not correctness.
|
||||
clean: "#51A68F"
|
||||
verify: "#E39B34"
|
||||
review: "#DE5243"
|
||||
absent: "#6B6580"
|
||||
# Artwork. The logo shield is the same mark in both appearances.
|
||||
shield: "#21395A"
|
||||
# The workbook's own cell fills. NOT interface colours — these are the
|
||||
# highlight chips drawn over the scanned page, matching what
|
||||
# patient_extractor_v2.py writes into the spreadsheet, so a reviewer moving
|
||||
# between the sheet and the screen sees one language.
|
||||
confidence-green: "#B6E2B6"
|
||||
confidence-green-border: "#9AC79A"
|
||||
confidence-amber: "#F4E2B6"
|
||||
confidence-amber-border: "#D9C79A"
|
||||
confidence-red: "#F4B6B6"
|
||||
confidence-red-border: "#D99A9A"
|
||||
confidence-unscored: "#FFFFFF"
|
||||
confidence-unscored-border: "#D0D0D0"
|
||||
# ── Type ─────────────────────────────────────────────────────────────────────
|
||||
# Two families, both loaded from Google Fonts with display=swap and a full
|
||||
# system fallback. Sora is display — headings, figures, brand name, buttons,
|
||||
# batch ids. IBM Plex Sans is everything read as prose — body, labels, table
|
||||
# cells, captions. Numbers that sit in a column carry tabular-nums.
|
||||
typography:
|
||||
display-page: { fontFamily: "'Sora'", fontSize: "22px", fontWeight: 700, lineHeight: 1.2 }
|
||||
display-figure: { fontFamily: "'Sora'", fontSize: "21px", fontWeight: 700, lineHeight: 1.1 }
|
||||
display-spend: { fontFamily: "'Sora'", fontSize: "24px", fontWeight: 700, lineHeight: 1.1 }
|
||||
display-appbar: { fontFamily: "'Sora'", fontSize: "17px", fontWeight: 700, lineHeight: 1.15 }
|
||||
display-runbar: { fontFamily: "'Sora'", fontSize: "17px", fontWeight: 700, lineHeight: 1.2 }
|
||||
display-drop: { fontFamily: "'Sora'", fontSize: "16px", fontWeight: 700, lineHeight: 1.2 }
|
||||
display-panel: { fontFamily: "'Sora'", fontSize: "14px", fontWeight: 600, lineHeight: 1.25 }
|
||||
display-group: { fontFamily: "'Sora'", fontSize: "11.5px", fontWeight: 600, lineHeight: 1.25 }
|
||||
button: { fontFamily: "'Sora'", fontSize: "13px", fontWeight: 600, lineHeight: 1.2 }
|
||||
button-sm: { fontFamily: "'Sora'", fontSize: "12px", fontWeight: 600, lineHeight: 1.2 }
|
||||
body: { fontFamily: "'IBM Plex Sans'", fontSize: "14px", fontWeight: 400, lineHeight: 1.5 }
|
||||
body-doc: { fontFamily: "'IBM Plex Sans'", fontSize: "13.5px", fontWeight: 600, lineHeight: 1.3 }
|
||||
body-table: { fontFamily: "'IBM Plex Sans'", fontSize: "13px", fontWeight: 400, lineHeight: 1.4 }
|
||||
nav: { fontFamily: "'IBM Plex Sans'", fontSize: "13px", fontWeight: 500, lineHeight: 1.3 }
|
||||
meta: { fontFamily: "'IBM Plex Sans'", fontSize: "12.5px", fontWeight: 400, lineHeight: 1.4 }
|
||||
caption: { fontFamily: "'IBM Plex Sans'", fontSize: "11.5px", fontWeight: 400, lineHeight: 1.4 }
|
||||
label: { fontFamily: "'IBM Plex Sans'", fontSize: "12px", fontWeight: 600, lineHeight: 1.3 }
|
||||
# Uppercase, letter-spaced. Table headers and section eyebrows.
|
||||
eyebrow: { fontFamily: "'IBM Plex Sans'", fontSize: "10.5px", fontWeight: 600, lineHeight: 1.3, textTransform: "uppercase", letterSpacing: "0.1em" }
|
||||
badge: { fontFamily: "'IBM Plex Sans'", fontSize: "11px", fontWeight: 600, lineHeight: 1.2 }
|
||||
# ── Sign-in screen only ──
|
||||
# Its own scale, deliberately an island — see Typography > "Sign-in scale".
|
||||
signin-title: { fontFamily: "'Sora'", fontSize: "clamp(22px, 3.2vw, 26px)", fontWeight: 700, lineHeight: 1.2 }
|
||||
signin-brand: { fontFamily: "'Sora'", fontSize: "16px", fontWeight: 700, lineHeight: 1.2 }
|
||||
signin-body: { fontFamily: "'IBM Plex Sans'", fontSize: "14.5px", fontWeight: 400, lineHeight: 1.55 }
|
||||
signin-label: { fontFamily: "'IBM Plex Sans'", fontSize: "12.5px", fontWeight: 600, lineHeight: 1.3, textTransform: "uppercase", letterSpacing: "0.06em" }
|
||||
# ── Shape ────────────────────────────────────────────────────────────────────
|
||||
# ONE card radius. Everything that is a card, panel, table container, dropzone
|
||||
# or empty state is 14px; the smaller steps are for controls inside them.
|
||||
rounded:
|
||||
card: "14px"
|
||||
field: "11px"
|
||||
control: "9px"
|
||||
input: "8px"
|
||||
icon: "7px"
|
||||
chip: "10px"
|
||||
swatch: "3px"
|
||||
full: "9999px"
|
||||
# Sign-in only.
|
||||
signin-card: "20px"
|
||||
signin-control: "10px"
|
||||
spacing:
|
||||
xs: "4px"
|
||||
sm: "8px"
|
||||
md: "12px"
|
||||
lg: "16px"
|
||||
xl: "18px"
|
||||
xxl: "22px"
|
||||
components:
|
||||
app-bar:
|
||||
background: "linear-gradient(100deg, {colors.accent-1}, {colors.accent-2})"
|
||||
textColor: "#FFFFFF"
|
||||
padding: "12px 18px"
|
||||
nav-item-active:
|
||||
borderLeft: "3px solid {colors.accent-1}"
|
||||
background: "linear-gradient(90deg, color-mix(in srgb, {colors.accent-1} 12%, transparent), transparent)"
|
||||
button-primary:
|
||||
background: "linear-gradient(115deg, {colors.accent-1}, {colors.accent-2})"
|
||||
textColor: "#FFFFFF"
|
||||
rounded: "{rounded.control}"
|
||||
padding: "9px 16px"
|
||||
button-quiet:
|
||||
backgroundColor: "{colors.surface-2}"
|
||||
textColor: "{colors.ink}"
|
||||
border: "1px solid {colors.line}"
|
||||
rounded: "{rounded.control}"
|
||||
card:
|
||||
backgroundColor: "{colors.surface}"
|
||||
border: "1px solid {colors.line}"
|
||||
rounded: "{rounded.card}"
|
||||
padding: "14px 16px"
|
||||
pill-clean:
|
||||
backgroundColor: "color-mix(in srgb, {colors.clean} 22%, transparent)"
|
||||
textColor: "{colors.clean}"
|
||||
rounded: "{rounded.full}"
|
||||
pill-verify:
|
||||
backgroundColor: "color-mix(in srgb, {colors.verify} 20%, transparent)"
|
||||
textColor: "{colors.verify}"
|
||||
rounded: "{rounded.full}"
|
||||
pill-review:
|
||||
backgroundColor: "color-mix(in srgb, {colors.review} 20%, transparent)"
|
||||
textColor: "{colors.review}"
|
||||
rounded: "{rounded.full}"
|
||||
badge-super-admin:
|
||||
background: "linear-gradient(115deg, {colors.accent-1}, {colors.accent-2})"
|
||||
textColor: "#FFFFFF"
|
||||
rounded: "{rounded.full}"
|
||||
field-row:
|
||||
backgroundColor: "{colors.surface}"
|
||||
border: "1px solid {colors.line}"
|
||||
rounded: "{rounded.field}"
|
||||
gridTemplateColumns: "78px minmax(0,1fr) 42px 70px"
|
||||
---
|
||||
|
||||
# Design System: Superbill Review
|
||||
|
||||
## Overview
|
||||
|
||||
**Creative North Star: "The Triage Desk"**
|
||||
|
||||
Superbill Review reads like an emergency triage desk, not a spreadsheet. Every
|
||||
extracted field arrives already sorted by how urgently it needs attention —
|
||||
green passes through untouched, amber gets a spot-check, red stops for a full
|
||||
look — so a reviewer's eye lands exactly where judgment is actually required
|
||||
and nowhere else. The interface itself stays out of the way: a dark, quiet
|
||||
"console" surface with almost no decoration, so the one signal that is allowed
|
||||
to be loud — the confidence band on a field — is never competing with anything
|
||||
else for attention.
|
||||
|
||||
The system is calm, precise, and unhurried by design, because the job it
|
||||
supports is not: a reviewer working through dozens of patients per document
|
||||
needs an interface that does not add its own visual noise on top of already
|
||||
noisy handwritten source material. Confirmed rejection: no bright accent
|
||||
palette, no illustrative flourishes, no "friendly SaaS" warmth — this is an
|
||||
operations tool for people doing careful, repetitive verification work, and it
|
||||
should feel like a well-run night shift, not a marketing site.
|
||||
|
||||
**Key Characteristics:**
|
||||
- One loud signal only — the green/amber/red confidence band — everything else recedes
|
||||
- Flat by default; depth appears only as a response to state, not as decoration
|
||||
- Dense, scannable typography tuned for many short fields in a row, not prose
|
||||
- Every screen answers "what needs me right now" before anything else
|
||||
|
||||
## Colors
|
||||
|
||||
Two appearances — **Light and Dark, and only those two** — declared once as CSS
|
||||
variables in `web/src/index.css` and registered there as Tailwind utilities via
|
||||
`@theme inline`. A component names a token (`bg-surface`, `text-muted`,
|
||||
`border-line`); it never names a hex. If a colour decision is not in that file,
|
||||
it is not a colour decision this product makes.
|
||||
|
||||
`@theme inline` rather than plain `@theme` is load-bearing: inline emits each
|
||||
utility as `var(--surface)` at the use site instead of snapshotting the value,
|
||||
which is what lets `.dark` reassign a token and repaint every utility built on
|
||||
it. Plain `@theme` would freeze the light value into the stylesheet.
|
||||
|
||||
### Surfaces and ink
|
||||
|
||||
| token | light | dark | what it is |
|
||||
|---|---|---|---|
|
||||
| `ground` | `#F4F3F7` | `#0A0912` | the page behind everything |
|
||||
| `rail` | `#FFFFFF` | `#100E1A` | the sidebar, and the review screen's own strips |
|
||||
| `surface` | `#FFFFFF` | `#151220` | cards, panels, tables |
|
||||
| `surface-2` | `#F7F6FA` | `#1B1727` | inset things: inputs, chips, wells |
|
||||
| `line` | `#E2DFE9` | `#2A2438` | the one border colour |
|
||||
| `line-soft` | `#ECEAF1` | `#221D31` | the divider between rows of a list |
|
||||
| `ink` | `#18151F` | `#F3F1F8` | anything being read |
|
||||
| `ink-2` | `#3D3850` | `#C9C4D8` | text that supports it |
|
||||
| `muted` | `#66607A` | `#8D87A0` | labels and captions, found rather than read |
|
||||
|
||||
The ink ramp is three steps on purpose. Anything reaching for a fourth is
|
||||
usually reaching for the wrong one of the three.
|
||||
|
||||
### Brand ramp — the only saturated thing on a page
|
||||
|
||||
- **Accent 1** (`#FF3049`) → **Accent 2** (`#F26B21`), identical in both
|
||||
appearances, and the same two hues the sign-in screen uses — so signing in is
|
||||
a continuation rather than a change of product.
|
||||
- `linear-gradient(115deg, …)` on controls, `100deg` on the wide header band.
|
||||
- It appears on exactly six things: the header bar, primary buttons, the active
|
||||
nav row's 3px bar and 12% wash, the super-admin badge, the review progress
|
||||
fill, and a batch id. **Nothing else gets the gradient.**
|
||||
|
||||
### Grading — semantic, never decorative
|
||||
|
||||
- **Clean** (`#51A68F`): legibility ≥0.95 — clean print, passes straight through.
|
||||
- **Verify** (`#E39B34`): 0.90–0.95 — sampled rather than checked one by one.
|
||||
- **Review** (`#DE5243`): <0.90 — handwritten or hard to read, always reviewed.
|
||||
- **Absent** (`#6B6580`): no legibility signal at all. Grey because nothing was
|
||||
read, so there is no clarity to grade — distinct from "read badly".
|
||||
|
||||
These appear on status pills, the 3px grade rule down a field input, legend
|
||||
swatches, donut segments and quality meters. **Never on a button, never on a
|
||||
heading.** The moment green also means "primary action", neither reading
|
||||
survives. `BAND_VAR` in `web/src/bands.ts` is the one map from a band to its
|
||||
token.
|
||||
|
||||
### The workbook fills are not interface colours
|
||||
|
||||
`confidence-green/amber/red` (`#B6E2B6` / `#F4E2B6` / `#F4B6B6`) and their
|
||||
borders are still in the token list and still used — but only for the highlight
|
||||
chips drawn **over the scanned page**. They mirror what
|
||||
`patient_extractor_v2.py` fills spreadsheet cells with, so a reviewer moving
|
||||
between the sheet and this screen sees one language. Interface tokens would
|
||||
break that, and these pastels would be invisible on a white scan besides.
|
||||
|
||||
### Named Rules
|
||||
|
||||
**The One Loud Colour Rule.** The brand ramp is the only saturated thing on a
|
||||
page, and grading is the only colour carrying meaning. A third loud hue would
|
||||
compete with the one signal the product exists to surface.
|
||||
|
||||
**Grading Never Becomes Chrome.** A grading colour on a button or a heading
|
||||
destroys both readings at once. The only adjacent case is a destructive action
|
||||
(`Remove` on the People screen), which uses `review` as text — not as a fill.
|
||||
|
||||
**One Border, One Radius.** Every card, panel, table container, dropzone and
|
||||
empty state is `1px solid line` at `14px`. Hierarchy comes from spacing, not
|
||||
from stacking shadows: a page of cards at three elevations reads as a pile, and
|
||||
every screen in this product is a list of peers.
|
||||
|
||||
**There Is No Third Appearance.** An `eye` mode — the light palette under a
|
||||
warm sepia filter — was removed. It was a third surface to keep in sync
|
||||
forever, it tinted the scanned page a reviewer is trying to read accurately,
|
||||
and both the OS and the monitor already offer the same warmth system-wide.
|
||||
|
||||
## Typography
|
||||
|
||||
**Display Font:** Sora 600/700 — page titles, panel headings, figures, the app-bar title, buttons, batch ids, group headers in the rail.
|
||||
|
||||
**Body Font:** IBM Plex Sans 400/500/600 — everything read as prose: body copy, nav labels, table cells, captions, form labels, metadata.
|
||||
|
||||
Both load from Google Fonts with `display=swap`, and every rule that names one carries the full system stack (`ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Arial, sans-serif`) behind it.
|
||||
|
||||
**This changed.** The interface used to run entirely on the system stack — a density argument for 11–13px lists: at label size, Inter beside Segoe UI is a distinction nobody reading a field list notices, and it cost a network dependency on an internal deployment. That argument was sound in isolation and wrong in aggregate. Once the sign-in screen was rebuilt on Sora/IBM Plex, signing in read as leaving one product and arriving at another. Two families across the whole product is the cheaper inconsistency.
|
||||
|
||||
**Why these two.** They are the sign-in screen's pairing, which is the approved mockup's pairing, carried inward rather than re-chosen. Sora has a real 600 and 700 so weights are drawn rather than synthesised, and its figures hold up at 21–24px where this product puts its money. IBM Plex Sans was drawn for interfaces and stays legible at the 10.5–13px the tables and rails live at.
|
||||
|
||||
**The rule that survives.** A blocked CDN must cost voice and nothing else. No layout figure may depend on either font's metrics without a re-measurement noted beside it, and adding a third family needs a reason written down here first.
|
||||
|
||||
**Numbers.** Anything that sits in a column — money, counts, timestamps, confidence figures, percentages — carries `tabular-nums` (the `tnum` class). Figures that shift width as they update are the single most common reason a dashboard looks unstable.
|
||||
|
||||
### Sign-in scale — a documented exception
|
||||
|
||||
The sign-in screen shares the two families but not the size or radius scales. It carries the mockup's own geometry so implementation and mockup stay comparable: **radius** 20px (card) and 10px (fields, button), **type** `clamp(22px, 3.2vw, 26px)` headline, 16px brand name, 14.5px lede/fields/button, 12.5px uppercase labels.
|
||||
|
||||
This is a deliberate island, not drift. It is the only surface that is neither a list nor a board, it is seen once per session, and it never renders beside a component built from the scales below — so there is nothing for it to be inconsistent *with*. A value from that list appearing on any other surface is a bug.
|
||||
|
||||
### Hierarchy
|
||||
- **Figure Lead** (700 weight, 26px, 1.15 line-height): the one dominant quantity on a reporting surface — total spend on the ROI board. At most one per screen; a second one means neither is the answer.
|
||||
- **Figure** (700 weight, 20px, 1.2 line-height): a supporting quantity that still has to be read before its own label — the settled-field percentage, each side of the by-hand comparison.
|
||||
- **Heading** (700 weight, 16px, 1.25 line-height): patient name in the review header, document title, the brand wordmark.
|
||||
- **Control** (600 weight, 13px, 1.3 line-height): navigation that has to be found before it is read — the Extraction/Automation tabs, a batch card's name, a primary button on the batch boards.
|
||||
- **Body** (400 weight, 12px, 1.4 line-height): field values, document metadata, most UI copy.
|
||||
- **Label** (500 weight, 11px, 1.3 line-height): field names, filter pills, band captions, timestamps.
|
||||
|
||||
### Named Rules
|
||||
**The Flat Hierarchy Problem — partially addressed.** The shipped UI used only 11px, 12px, and 16px, roughly 1.45:1 from smallest to heading. A hierarchy that flat asks color and weight to do all the work vision normally gets from size. The **Control** step (13px/600) was added for the batch-board and tab-bar chrome, which sits above the data-dense lists and needs to be found without competing with a heading. The ramp is now 11/12/13/16/20/26. 16px stays the ceiling inside any data-dense list, reserved for the one heading that anchors the view; 20px and 26px appear only on surfaces that are not lists — today that is the ROI board, which carries both. The sign-in screen no longer counts toward this ramp: it runs on the `signin-*` steps instead, for the reasons in Typography > "Sign-in scale".
|
||||
|
||||
### Sign-in screen
|
||||
|
||||
The one screen with nothing else on it, so the brand sweep carries the whole
|
||||
left panel rather than a bar across the top — the only place in the product
|
||||
where the palette is decorative rather than functional. Below `lg` that panel
|
||||
collapses entirely and the form takes the full width: a half-screen of
|
||||
decoration would push the fields under the fold on a laptop. Type runs
|
||||
26px headline / 20px title / 13px controls / 12px supporting copy.
|
||||
|
||||
**Display — removed.** There used to be a 44–72px step
|
||||
(`clamp(2.75rem, 4.6vw, 4.5rem)`) for a product wordmark set across the waist
|
||||
of a viewport-tall monogram. Both are gone: the letter spanned the screen with
|
||||
the card sitting on its spine, so the parts that showed around the card read as
|
||||
a ring rather than as a letter. Nothing now exceeds the 26px ceiling, and the
|
||||
sign-in screen's own type is the `signin-*` steps in the token block above.
|
||||
|
||||
**The rule divides the lockup.** The name sits above the brand rule and
|
||||
`AUTOMATION` below it, so the rule that cuts the S is also the line of the
|
||||
wordmark — one gesture doing both jobs. The whole block is therefore rotated
|
||||
onto the rule's own −8° axis: set horizontal, the rule climbs ~53px across the
|
||||
width of the name and clips a letter at one end or the other, and the dodge
|
||||
(parking the wordmark in the clear space above) reads as a wordmark avoiding a
|
||||
line. Parallel, the gap stays constant at every viewport width. The tilt is
|
||||
structural, not stylistic — it is the only reason the geometry holds without
|
||||
pinning a gap to a text width the font resolves at runtime.
|
||||
|
||||
Implementation note: the lockup's container is `h-0` at `top-1/2`, so it shares
|
||||
the rule's rotation pivot and its own axis *is* the rule. The two lines are then
|
||||
offset by fixed distances (0.5rem / 0.85rem) rather than by anything computed
|
||||
per breakpoint. The offsets are deliberately unequal because cap height is not
|
||||
box height — uppercase leaves ~8px of empty descender space below the 72px line
|
||||
and only ~2px above the 20px one, so equal offsets read as unequal air.
|
||||
|
||||
It is sized like a headline but weighted like a background: white at 88%
|
||||
stepping to 52% across a gradient raked to match the light behind it, so the
|
||||
card's 26px pure-white headline is still the loudest type on the screen. That
|
||||
the sign-in headline leads is unchanged — the wordmark is the room, not the
|
||||
sentence. A wordmark at full white here reverses that and the screen becomes a
|
||||
poster rather than a sign-in.
|
||||
|
||||
Gated at `xl`, not `lg`. Between the two the card's left edge lands roughly
|
||||
where the wordmark ends; below `xl` the S carries the screen alone and the
|
||||
card's own header names the product.
|
||||
|
||||
**The reveal covers the whole hero, not just the letter.** The cursor torch
|
||||
lights the monogram and the wordmark together, as one field: the letter goes
|
||||
brand red, the name goes red-into-orange on the same 150° rake as its resting
|
||||
white gradient. It is one mask on one unrotated, full-size layer that carries
|
||||
both marks — which is the only arrangement that works, since a mask on the
|
||||
wordmark's own container would rotate with it and the torch would slide off the
|
||||
cursor. Everything in the hero is therefore rendered twice, once per layer.
|
||||
|
||||
**Where the name overlaps the letter, a halo separates them.** The name is
|
||||
placed to clear the monogram's painted edge where the screen is wide enough,
|
||||
but on squarer screens the letter grows until no channel remains between it and
|
||||
the card, and the name sits on the letter instead. A single tight dark halo
|
||||
behind the glyphs keeps the two from tangling. It stays tight on purpose: a
|
||||
wider one reaches the brand rule ~16px below and veils the very gesture the
|
||||
lockup is built on. The halo is a `filter`, never a `text-shadow` — these glyphs
|
||||
are a background clipped to text, and a text shadow on transparent text paints
|
||||
straight through the letterform.
|
||||
|
||||
## Layout
|
||||
|
||||
The document list is a vertical stack of full-width rows on a light `Paper`
|
||||
background, each row a single click target. Opening a document replaces the
|
||||
list entirely with a two-pane, full-window split (draggable divider, ratio
|
||||
persisted in `localStorage`): the source PDF on the left inside a `Midnight
|
||||
Console Deep` canvas, the field-by-field review queue on the right on
|
||||
`Midnight Console`. Density is high by design — the review pane favors many
|
||||
short rows over few tall ones, since the job is scanning many fields fast, not
|
||||
reading long-form content.
|
||||
|
||||
Within the review pane, navigation is patient-by-patient rather than a long
|
||||
scroll: one patient's full field record fills the pane at a time, with
|
||||
Prev/Next controls, a jump dropdown, and left/right arrow-key navigation. The
|
||||
PDF pane follows the active patient's page automatically.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
Flat by default. Surfaces sit at the same visual plane at rest — no ambient
|
||||
drop shadows on cards, panels, or list rows. Depth appears only as a direct
|
||||
response to state: the currently-open patient card gets `shadow-lg
|
||||
shadow-black/20` to read as "in focus," and the PDF page itself gets
|
||||
`shadow-xl shadow-black/40` specifically to read as a physical page sitting on
|
||||
the dark canvas rather than blending into it. Nothing gets a shadow simply for
|
||||
decoration.
|
||||
|
||||
### Named Rules
|
||||
**The Flat-at-Rest Rule.** Depth is a state signal, not a texture. If a shadow is not communicating "this is the thing that's active/focused/physical right now," it does not belong.
|
||||
|
||||
## Shapes
|
||||
|
||||
Corners are consistently soft but modest: `rounded-md` (6px) is the default for
|
||||
inputs, value chips, and buttons; `rounded-lg` (8px) for cards and the larger
|
||||
patient/document containers; `rounded-full` for pill-shaped filter buttons and
|
||||
status badges. No sharp corners, no aggressively rounded (>8px) surfaces —
|
||||
the form language stays understated rather than either clinical-sharp or
|
||||
playful-round.
|
||||
|
||||
## Components
|
||||
|
||||
Every interactive element is restrained and functional: minimal ornament, a
|
||||
single clear affordance, and just enough state feedback to confirm what
|
||||
happened. Nothing asks to be noticed on its own — the confidence bands already
|
||||
carry that job.
|
||||
|
||||
### Buttons
|
||||
- **Shape:** `rounded-md` (6px), except pill-shaped filters (`rounded-full`).
|
||||
- **Primary (Confirm/OK/Approve):** `accent-confirm` emerald background, dark text, used for "Verify," "OK," and "Approve for posting."
|
||||
- **Secondary/Neutral:** `bg-slate-800` with `text-slate-300`, used for Undo, Retry, and non-destructive secondary actions.
|
||||
- **Hover / Focus:** background lightens one step (`hover:bg-slate-700`, `hover:bg-emerald-400`); no transform or shadow added on hover, consistent with the flat-at-rest rule.
|
||||
- **Disabled:** `opacity-40` plus `cursor-not-allowed`, used specifically to make the Approve gate's "you can't do this yet" state legible.
|
||||
|
||||
### Filter Pills
|
||||
- **Style:** pill (`rounded-full`), `bg-slate-800`/`text-slate-300` at rest, inverted to `bg-slate-200`/`text-slate-900` when active.
|
||||
- **State:** exactly one pill active at a time (Red / Amber / Missing code / Unscored / All).
|
||||
|
||||
### Cards / Containers (Patient record)
|
||||
- **Corner Style:** `rounded-lg` (8px).
|
||||
- **Background:** `bg-slate-900` (open) / `bg-slate-900/60` (collapsed, in the list-style views).
|
||||
- **Shadow Strategy:** none at rest; `shadow-lg shadow-black/20` only on the currently-open item.
|
||||
- **Border:** 1px, `border-slate-800` at rest, `border-slate-700` when open or hovered.
|
||||
- **Internal Padding:** `px-3.5 py-3` header, `px-2.5 py-2` per field row.
|
||||
|
||||
### Inputs / Fields (correction input)
|
||||
- **Style:** `rounded-md`, `border-slate-700`, `bg-slate-800`, `text-slate-100`.
|
||||
- **Focus:** border shifts to `border-slate-500`; no glow/ring — consistent with the flat, restrained component philosophy.
|
||||
- **Placeholder:** `text-slate-500`, always "correct value" — never blank, so an empty input still communicates its purpose.
|
||||
|
||||
### Confidence Band Chip (signature component)
|
||||
The one component that is allowed to be visually loud. A field's extracted
|
||||
value renders inside a solid chip colored by its band (`confidence-green` /
|
||||
`-amber` / `-red`), dark text for contrast, with a 1px border one shade darker
|
||||
than the fill. On dark surfaces, `unscored`/`blank` never uses the literal
|
||||
white token — see the Literal-White Trap rule under Colors.
|
||||
|
||||
### Navigation (document-open header)
|
||||
- Back control (`← All documents`, bordered, neutral) plus the current document's name and template, band legend right-aligned on wide viewports.
|
||||
- No persistent sidebar or tab bar — this is a single-task, single-document focus model, not a multi-section app shell.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### Do:
|
||||
- **Do** treat the confidence band as the only color allowed to demand attention on a review screen.
|
||||
- **Do** render `unscored`/`blank` fields as a dashed muted chip on dark surfaces, never as literal white.
|
||||
- **Do** use shadow only as a state signal (open/active/physical), never as ambient decoration.
|
||||
- **Do** keep every button, pill, and input restrained — one clear affordance, minimal ornament.
|
||||
- **Do** animate `transform`/`opacity` for progress and state changes, not `width`/`height` — a `width` transition re-triggers layout on every frame, a `transform: scaleX()` does not.
|
||||
|
||||
### Don't:
|
||||
- **Don't** introduce a second loud accent hue anywhere near the confidence bands — it competes with the one signal the product depends on.
|
||||
- **Don't** add ambient drop shadows to cards or panels at rest; depth is earned by state, not applied by default.
|
||||
- **Don't** rely on a single font family across every size and weight without also varying size deliberately — see the Flat Hierarchy Problem: the current 11/12/16px scale is undersized for a system this data-dense and should widen (e.g., toward a 11/13/16/20px, ~1.8:1 progression) the next time typography is revisited.
|
||||
@@ -0,0 +1,364 @@
|
||||
import type {
|
||||
AuditEntry, Batch, BatchStage, DemographyRecord, DemographyRun, InsuranceMasterRow, LastRun,
|
||||
ManagedUser,
|
||||
Project, ResultRow, ReviewProgress, ReviewRow, RoiMonth, RoiRun, RoiTotals, Run,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Every call goes to a same-origin /api path. In development Vite proxies that
|
||||
* to the FastAPI port, in production both are served from one origin — so no
|
||||
* base URL is configured anywhere and there is nothing to get wrong per
|
||||
* environment.
|
||||
*/
|
||||
/**
|
||||
* FastAPI's `detail` in readable form.
|
||||
*
|
||||
* Most of this API refuses with a plain sentence, and for those this is the
|
||||
* identity function. A 422 is the exception: validation errors arrive as an
|
||||
* array of `{loc, msg}` objects, and handing that to `new Error()` produced
|
||||
* the literal string "[object Object]" — which is how a missing request field
|
||||
* spent a while looking like an unexplained failure.
|
||||
*/
|
||||
function errorText(detail: unknown): string | null {
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
const parts = detail
|
||||
.map((d) => {
|
||||
if (typeof d === 'string') return d
|
||||
const msg = (d as { msg?: string })?.msg
|
||||
// `loc` is ["body", "role"]; the last hop is the field, and naming it
|
||||
// is the difference between "field required" and knowing which one.
|
||||
const loc = (d as { loc?: unknown[] })?.loc
|
||||
const field = Array.isArray(loc) ? String(loc[loc.length - 1]) : null
|
||||
return msg ? (field ? `${field}: ${msg}` : msg) : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
return parts.length ? parts.join('; ') : null
|
||||
}
|
||||
if (detail && typeof detail === 'object') {
|
||||
const msg = (detail as { msg?: string; message?: string })
|
||||
return msg.msg ?? msg.message ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// The review screen's two reads give up after this long. fetch has no timeout
|
||||
// of its own, and a response whose body stalls never rejects, so a stalled read
|
||||
// left "Loading review queue…" up forever — react-query never got the failure
|
||||
// its retry (QueryClient retry: 1) needs. Scoped to those two reads on purpose:
|
||||
// they are a handful of queries each, while /api/batches and /api/roi scale with
|
||||
// the data and could legitimately pass 20s, where a cap would turn "slow" into a
|
||||
// false empty board. Writes are never capped — retrying a write is not safe.
|
||||
const REVIEW_READ_TIMEOUT_MS = 20_000
|
||||
const reviewRead = () => ({ signal: AbortSignal.timeout(REVIEW_READ_TIMEOUT_MS) })
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
// The session lives in a cookie, and a cross-origin dev request drops
|
||||
// cookies unless asked to send them. Without this every call is anonymous
|
||||
// and the whole app 401s behind the Vite proxy.
|
||||
credentials: 'include',
|
||||
headers: init?.body instanceof FormData
|
||||
? init.headers
|
||||
: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
})
|
||||
if (!res.ok) {
|
||||
// The API says WHY it refused — an approve blocked by unverified fields
|
||||
// returns 409 with the count. Surfacing that beats a generic failure.
|
||||
const body = await res.json().catch(() => null)
|
||||
throw new Error(errorText(body?.detail) ?? `${res.status} ${res.statusText}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// ── batches ───────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Upload a stack of files as ONE batch. Nothing starts extracting yet — the
|
||||
* batch has its own Run button, so a reviewer who just dropped twenty files
|
||||
* can look at the list before spending twenty documents' worth of quota.
|
||||
*/
|
||||
createBatch: (files: File[], model = 'gemini-3.5-flash', project = 'ecw') => {
|
||||
const body = new FormData()
|
||||
for (const f of files) body.append('files', f)
|
||||
// `project` is which board received the drop. Nothing has detected a
|
||||
// template yet, and the board a batch appears on is derived from that, so
|
||||
// without it a Gulf Coast upload surfaces under ECW and reads as a failed
|
||||
// upload on the Tebra board.
|
||||
return req<Batch>(`/api/batches?${new URLSearchParams({ model, project })}`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
},
|
||||
|
||||
/** Gather loose documents into a new batch — the only route by which a
|
||||
* document not uploaded as part of a batch can reach the automation board. */
|
||||
batchFromRuns: (jobIds: string[], stage: BatchStage = 'automation') =>
|
||||
req<Batch>('/api/batches/from-runs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ job_ids: jobIds, stage }),
|
||||
}),
|
||||
|
||||
listBatches: () => req<{ batches: Batch[] }>('/api/batches'),
|
||||
|
||||
getBatch: (batchId: string) => req<Batch>(`/api/batches/${batchId}`),
|
||||
|
||||
runBatch: (batchId: string, model?: string) =>
|
||||
req<Batch & { started: string[] }>(`/api/batches/${batchId}/run`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model: model ?? null }),
|
||||
}),
|
||||
|
||||
/** Tick or untick a batch as cleared to run. Admin and above. */
|
||||
setRunApproval: (batchId: string, approved: boolean) =>
|
||||
req<Batch>(`/api/batches/${batchId}/run-approval`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ approved }),
|
||||
}),
|
||||
|
||||
/** Every ticket's latest result, in reading order: the Download summary's
|
||||
* rows (ADR 0003). */
|
||||
batchResults: (batchId: string) =>
|
||||
// counts: the header's three groups, one per encounter, as the Excel writes them.
|
||||
req<{ rows: ResultRow[]; counts: Record<ResultRow['Group'], number>; last_run: LastRun | null }>(
|
||||
`/api/batches/${batchId}/results`,
|
||||
),
|
||||
|
||||
/** Sign off every field of every document in the batch and clear it to
|
||||
* run, so it can be posted without opening each one. */
|
||||
approveAll: (batchId: string) =>
|
||||
req<Batch>(`/api/batches/${batchId}/approve-all`, { method: 'POST' }),
|
||||
|
||||
setBatchStage: (batchId: string, stage: BatchStage) =>
|
||||
req<Batch>(`/api/batches/${batchId}/stage`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ stage }),
|
||||
}),
|
||||
|
||||
/** 'precheck' reads every patient out of Tebra and posts nothing;
|
||||
* 'post' creates the encounters. Defaults to the harmless one. */
|
||||
runAutomation: (
|
||||
batchId: string,
|
||||
mode: 'precheck' | 'post' = 'precheck',
|
||||
ticket?: string,
|
||||
allowDuplicate = false,
|
||||
) =>
|
||||
req<Batch & { note: string; mode: string }>(
|
||||
`/api/batches/${batchId}/run-automation`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode, ticket, allow_duplicate: allowDuplicate }),
|
||||
},
|
||||
),
|
||||
|
||||
// ── runs ──────────────────────────────────────────────────────────────────
|
||||
startRun: (file: File, model = 'gemini-3.5-flash', thinking = false) => {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
const q = new URLSearchParams({ model, thinking: String(thinking) })
|
||||
return req<{ job_id: string; status: string }>(`/api/runs?${q}`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
},
|
||||
|
||||
listRuns: () => req<{ runs: Run[] }>('/api/runs'),
|
||||
|
||||
rerunMissingPages: (jobId: string) =>
|
||||
req<{ job_id: string; status: string }>(`/api/runs/${jobId}/missing-pages`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
retryRun: (jobId: string, model?: string) =>
|
||||
req<{ job_id: string; status: string; model: string }>(
|
||||
`/api/runs/${jobId}/retry`,
|
||||
{ method: 'POST', body: JSON.stringify({ model: model ?? null }) },
|
||||
),
|
||||
|
||||
getRun: (jobId: string) => req<Run>(`/api/runs/${jobId}`),
|
||||
|
||||
deleteRun: (jobId: string) =>
|
||||
req<{ job_id: string; deleted: boolean }>(
|
||||
`/api/runs/${jobId}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
|
||||
getAllFields: (jobId: string) =>
|
||||
req<{ rows: ReviewRow[] }>(`/api/runs/${jobId}/all-fields`, reviewRead()),
|
||||
|
||||
getReview: (jobId: string, opts?: { band?: string; verified?: boolean }) => {
|
||||
const q = new URLSearchParams()
|
||||
if (opts?.band) q.set('band', opts.band)
|
||||
if (opts?.verified !== undefined) q.set('verified', String(opts.verified))
|
||||
const qs = q.toString()
|
||||
return req<{ rows: ReviewRow[]; progress: ReviewProgress }>(
|
||||
`/api/runs/${jobId}/review${qs ? `?${qs}` : ''}`,
|
||||
reviewRead(),
|
||||
)
|
||||
},
|
||||
|
||||
patchRow: (rowId: number, body: {
|
||||
corrected_value?: string | null
|
||||
verified: boolean
|
||||
actor?: string
|
||||
}) =>
|
||||
req<ReviewRow>(`/api/review/${rowId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
/**
|
||||
* Correct any field, including one that was never flagged.
|
||||
*
|
||||
* Green means the print was legible, not that the value is right, so a
|
||||
* cleanly-read field still has to be overridable. Those fields have no
|
||||
* review row until somebody edits one, so they are addressed by the cell
|
||||
* they occupy rather than by row_id.
|
||||
*/
|
||||
editField: (jobId: string, body: {
|
||||
page: number | null
|
||||
patient: string | null
|
||||
field: string
|
||||
value: string | null
|
||||
band: string
|
||||
score: number | null
|
||||
corrected_value: string | null
|
||||
verified: boolean
|
||||
actor?: string
|
||||
line_index?: number
|
||||
}) =>
|
||||
req<ReviewRow>(`/api/runs/${jobId}/fields`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
pdfInfo: (jobId: string) =>
|
||||
req<{ pages: number; name: string }>(`/api/runs/${jobId}/pdf-info`),
|
||||
|
||||
pageImage: (jobId: string, page: number, dpi: number) =>
|
||||
`/api/runs/${jobId}/page/${page}?dpi=${dpi}`,
|
||||
|
||||
/** Sign off every remaining flagged field as extracted, in one deliberate
|
||||
* decision. Satisfies the approve gate rather than bypassing it, and is
|
||||
* audited as `bulk_verified` so it stays distinguishable afterwards. */
|
||||
verifyRemaining: (jobId: string, actor?: string) =>
|
||||
req<{ verified: number; progress: ReviewProgress }>(
|
||||
`/api/runs/${jobId}/verify-remaining`,
|
||||
{ method: 'POST', body: JSON.stringify({ actor }) },
|
||||
),
|
||||
|
||||
approve: (jobId: string, actor?: string) =>
|
||||
req<{ ok: boolean; approved: number }>(`/api/runs/${jobId}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ actor }),
|
||||
}),
|
||||
|
||||
/** The cost ledger. Measured figures only — the saving is the client's own
|
||||
* arithmetic over its own editable rate, and is never returned by the API. */
|
||||
roi: () => req<{ runs: RoiRun[]; totals: RoiTotals; monthly: RoiMonth[] }>('/api/roi'),
|
||||
/** The same shape for demography runs: one total per run, no field figures. */
|
||||
demographyRoi: () =>
|
||||
req<{ runs: RoiRun[]; totals: RoiTotals; monthly: RoiMonth[] }>('/api/demography/roi'),
|
||||
|
||||
// ── people and access ─────────────────────────────────────────────────────
|
||||
/** `can_grant` is what the SERVER says this caller may grant. The client
|
||||
* renders exactly those options rather than re-deriving the hierarchy.
|
||||
* `project` names which screen is asking; a scoped admin's own project
|
||||
* always wins server-side regardless of what is passed here. */
|
||||
/** `'all'` asks for the merged, both-projects list — the server only
|
||||
* honours that for a super_admin or an admin of BOTH projects; anyone
|
||||
* else gets their own single project's list regardless of what is sent
|
||||
* here, so passing 'all' cannot widen a scoped admin's view. */
|
||||
listUsers: (project: Project | 'all') =>
|
||||
req<{ users: ManagedUser[]; can_grant: string[] }>(
|
||||
`/api/users?project=${encodeURIComponent(project)}`,
|
||||
),
|
||||
|
||||
/** Staff directory autocomplete. Returns [] rather than failing when the
|
||||
* directory is unreachable, so the field stays usable by hand. */
|
||||
searchEmployees: (q: string) =>
|
||||
req<{ employees: { username: string; real_name: string | null }[] }>(
|
||||
`/api/users/search?q=${encodeURIComponent(q)}`,
|
||||
),
|
||||
|
||||
/** `project` is required by the server when `role` is 'admin', and ignored
|
||||
* otherwise — passed unconditionally here so the caller never has to know
|
||||
* which roles care. */
|
||||
/** `realName` is set only for a brand-new grant made through the directory
|
||||
* picker; the server only ever uses it to fill a blank name, never to
|
||||
* overwrite one it already has. */
|
||||
assignRole: (username: string, role: string, project?: Project,
|
||||
realName?: string | null) =>
|
||||
req<ManagedUser>(`/api/users/${encodeURIComponent(username)}/role`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ role, project, real_name: realName }),
|
||||
}),
|
||||
|
||||
/** Removes admin standing in ONE project only. A person still admin of the
|
||||
* other project keeps that access — it does not stop them signing in
|
||||
* either way; they return as an operator (of this project, at least). */
|
||||
removeAccess: (username: string, project: Project) =>
|
||||
req<{ ok: boolean; removed: string }>(
|
||||
`/api/users/${encodeURIComponent(username)}?project=${encodeURIComponent(project)}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
|
||||
audit: (jobId?: string) =>
|
||||
req<{ entries: AuditEntry[] }>(
|
||||
`/api/audit${jobId ? `?job_id=${jobId}` : ''}`,
|
||||
),
|
||||
|
||||
// ── demography ────────────────────────────────────────────────────────────
|
||||
/** Upload one facesheet. Unlike a batch, this starts immediately: OCR spends
|
||||
* CPU rather than API quota, so there is nothing for an approval to guard. */
|
||||
startDemographyRun: (file: File) => {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
return req<{ job_id: string; status: string }>('/api/demography/runs', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
},
|
||||
|
||||
/** `stage` omitted lists every run; passed, it filters server-side —
|
||||
* a NULL stage counts as 'extraction' there, same as an unmoved batch. */
|
||||
listDemographyRuns: (stage?: 'extraction' | 'automation') =>
|
||||
req<{ runs: DemographyRun[] }>(
|
||||
stage ? `/api/demography/runs?stage=${stage}` : '/api/demography/runs',
|
||||
),
|
||||
|
||||
patchDemographyField: (jobId: string, rowId: number, correctedValue: string | null) =>
|
||||
req<{ row_id: number; corrected_value: string | null }>(
|
||||
`/api/demography/runs/${jobId}/fields/${rowId}`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ corrected_value: correctedValue }) },
|
||||
),
|
||||
|
||||
setDemographyStage: (jobId: string, stage: 'extraction' | 'automation') =>
|
||||
req<DemographyRun>(`/api/demography/runs/${jobId}/stage`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ stage }),
|
||||
}),
|
||||
|
||||
demographyRecords: (jobId: string) =>
|
||||
req<{ run: DemographyRun; records: DemographyRecord[] }>(
|
||||
`/api/demography/runs/${jobId}/records`,
|
||||
),
|
||||
|
||||
/** Every Insurance Master row, for the insurance finder. */
|
||||
insuranceMaster: () =>
|
||||
req<{ insurances: InsuranceMasterRow[] }>('/api/demography/insurance-master'),
|
||||
|
||||
deleteDemographyRun: (jobId: string) =>
|
||||
req<{ job_id: string; deleted: boolean }>(
|
||||
`/api/demography/runs/${jobId}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
|
||||
/** A URL string, not a promise — used as an <a href> so the browser's own
|
||||
* download handling takes over, the same way `pageImage` is used as a src. */
|
||||
demographyExportUrl: (jobId: string) =>
|
||||
`/api/demography/runs/${jobId}/export.xlsx`,
|
||||
|
||||
/** The uploaded facesheet, shown in the browser's own PDF viewer. */
|
||||
demographyPdfUrl: (jobId: string) => `/api/demography/runs/${jobId}/pdf`,
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The confidence bands, and the exact colours the workbook paints.
|
||||
*
|
||||
* These hexes are not a design choice made here — they are the same values
|
||||
* patient_extractor_v2.py fills cells with. A reviewer moves between the sheet
|
||||
* and this dashboard looking at the same field; if the two disagreed about what
|
||||
* red looks like, the colour would stop being a shared language.
|
||||
*
|
||||
* GREEN B6E2B6 green channel high
|
||||
* AMBER F4E2B6 red + green high <- sits visually between the other two
|
||||
* RED F4B6B6 red channel high
|
||||
*/
|
||||
export const BAND_COLOR = {
|
||||
green: '#B6E2B6',
|
||||
amber: '#F4E2B6',
|
||||
red: '#F4B6B6',
|
||||
blank: '#FFFFFF',
|
||||
unscored: '#FFFFFF',
|
||||
} as const
|
||||
|
||||
/** Border, so a white "blank" cell is still visibly a cell. */
|
||||
export const BAND_BORDER = {
|
||||
green: '#9AC79A',
|
||||
amber: '#D9C79A',
|
||||
red: '#D99A9A',
|
||||
blank: '#D0D0D0',
|
||||
unscored: '#D0D0D0',
|
||||
} as const
|
||||
|
||||
export const BAND_LABEL = {
|
||||
green: 'Clean print',
|
||||
amber: 'Slightly unclear',
|
||||
red: 'Handwritten / messy',
|
||||
blank: 'Billing code missing',
|
||||
unscored: 'Not found in transcription',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* What the colour does NOT say. Kept next to the palette on purpose: the score
|
||||
* rates how legibly the text was printed, not whether the value is right. A
|
||||
* confident misread comes back green — measured on this document set, "J27.20"
|
||||
* scored 0.9 while the correct "I27.20" sat on the same page.
|
||||
*/
|
||||
export const BAND_CAVEAT =
|
||||
'Colour reflects print clarity, not correctness. A cleanly printed value can still be misread.'
|
||||
|
||||
export type Band = keyof typeof BAND_COLOR
|
||||
|
||||
/**
|
||||
* The band as a THEME TOKEN, for anything drawn in the interface rather than on
|
||||
* the scan: status pills, the 3px grade rule down a field input, legend
|
||||
* swatches, donut segments, quality meters.
|
||||
*
|
||||
* These reference the CSS variables rather than carrying hexes, so a band
|
||||
* follows the appearance the same way every other surface does, and so the
|
||||
* grading triple is declared in exactly one place (index.css).
|
||||
*
|
||||
* `blank` and `unscored` collapse to the same grey: nothing was read, so there
|
||||
* is no print clarity to grade, and inventing a fourth hue for "no data" would
|
||||
* put a colour on the legend that means nothing a reviewer can act on.
|
||||
*
|
||||
* BAND_COLOR / BAND_BORDER above are NOT replaced by this and are still the
|
||||
* right choice in one place: the highlight chips drawn over the scanned page
|
||||
* itself. Those sit on the document, mirror what the workbook fills, and are
|
||||
* the shared language between the sheet and this dashboard — interface tokens
|
||||
* would break that and would be unreadable on a white scan besides.
|
||||
*/
|
||||
export const BAND_VAR = {
|
||||
green: 'var(--clean)',
|
||||
amber: 'var(--verify)',
|
||||
red: 'var(--review)',
|
||||
blank: 'var(--absent)',
|
||||
unscored: 'var(--absent)',
|
||||
} as const
|
||||
@@ -0,0 +1,125 @@
|
||||
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,94 @@
|
||||
/**
|
||||
* 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,536 @@
|
||||
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,988 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api'
|
||||
import { BAND_VAR } from '../bands'
|
||||
import { Donut, type Slice } from './Donut'
|
||||
import { ExtractionFire } from './ExtractionFire'
|
||||
import {
|
||||
IconAlert, IconCheck, IconChevronRight, IconDot, IconPage, IconSpend, IconStack,
|
||||
} from './icons'
|
||||
import type { RoiRun, RoiTotals } from '../types'
|
||||
import type { Project } from '../project'
|
||||
import { runProject } from '../project'
|
||||
|
||||
/**
|
||||
* The cost ledger, laid out as an overview board: headline tiles, a proportional
|
||||
* split, then the line-by-line list.
|
||||
*
|
||||
* Two kinds of number live here and the layout keeps them apart, because mixing
|
||||
* them is how a cost report starts being quoted as a fact it never was:
|
||||
*
|
||||
* Measured every dollar, token, page and field is either a billed figure
|
||||
* the API returned or a row that exists in the database.
|
||||
* Assumed what an hour of a reviewer's time is worth, and how long the
|
||||
* same work takes by hand. The API refuses to guess at these, so
|
||||
* they are inputs the reader can see and change, and the panel
|
||||
* using them says so in its own heading.
|
||||
*
|
||||
* Runs extracted before the pipeline recorded token usage carry no cost at all.
|
||||
* They are reported as unmeasured rather than given the average of their
|
||||
* neighbours — an averaged cost is indistinguishable from a real one once
|
||||
* somebody has read it out in a meeting.
|
||||
*
|
||||
* On colour: the four headline tiles use the brand's own warm run, because this
|
||||
* whole board is shell — a report about the pipeline, not a surface where a
|
||||
* reviewer reads a scanned value. The one place band colour appears is the
|
||||
* field-split ring, where it IS the data. Those two palettes never mix inside a
|
||||
* single element here.
|
||||
*/
|
||||
|
||||
const RATES_KEY = 'superbill.roi.rates'
|
||||
|
||||
/**
|
||||
* Starting points, not findings. Roughly a US medical-records clerk: four
|
||||
* minutes to key one patient's row off a scan by hand, twenty seconds to check
|
||||
* one field the pipeline already flagged and positioned for them.
|
||||
*/
|
||||
const DEFAULT_RATES = { minutesPerPatient: 4, secondsPerField: 20, hourlyRate: 22 }
|
||||
type Rates = typeof DEFAULT_RATES
|
||||
|
||||
function loadRates(): Rates {
|
||||
debugger;
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(RATES_KEY) ?? 'null')
|
||||
// Spread over the defaults rather than trusting the parse: a stored object
|
||||
// from an older shape would leave a field undefined and turn every figure
|
||||
// downstream into NaN.
|
||||
return saved && typeof saved === 'object' ? { ...DEFAULT_RATES, ...saved } : DEFAULT_RATES
|
||||
} catch {
|
||||
return DEFAULT_RATES
|
||||
}
|
||||
}
|
||||
|
||||
const usd = (n: number, dp = 2) =>
|
||||
n.toLocaleString('en-US', {
|
||||
style: 'currency', currency: 'USD',
|
||||
minimumFractionDigits: dp, maximumFractionDigits: dp,
|
||||
})
|
||||
|
||||
const num = (n: number) => n.toLocaleString('en-US')
|
||||
|
||||
/** Money below a cent needs four places, or a per-page figure reads as $0.00. */
|
||||
const fine = (n: number) => usd(n, n > 0 && n < 0.01 ? 4 : 2)
|
||||
|
||||
const hours = (h: number) => (h >= 1 ? `${h.toFixed(1)} h` : `${Math.round(h * 60)} min`)
|
||||
|
||||
/** 'YYYY-MM' of the current month in India time — the server buckets spend
|
||||
* the same way (UTC + 5:30), so "This month" resets at midnight IST on the 1st. */
|
||||
const istMonth = () => new Date(Date.now() + 330 * 60_000).toISOString().slice(0, 7)
|
||||
|
||||
/** '2026-10' -> 'Oct 2026'. */
|
||||
const monthLabel = (ym: string) =>
|
||||
new Date(`${ym}-01T00:00:00Z`).toLocaleString('en-US', {
|
||||
month: 'short', year: 'numeric', timeZone: 'UTC',
|
||||
})
|
||||
|
||||
const isToday = (iso: string) => {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
return d.getFullYear() === now.getFullYear()
|
||||
&& d.getMonth() === now.getMonth()
|
||||
&& d.getDate() === now.getDate()
|
||||
}
|
||||
|
||||
type Filter = 'all' | 'priced' | 'unmeasured' | 'failed'
|
||||
|
||||
/**
|
||||
* api.roi() has no project filter — it totals every document. The ROI board
|
||||
* is per-project (Gulf Coast's spend is not eClinicalWorks' spend), so the
|
||||
* totals are recomputed here from the already-fetched runs instead of a new
|
||||
* query param. Every field is a plain sum or count over RoiRun — nothing
|
||||
* server-side is being second-guessed, just re-scoped to fewer rows.
|
||||
*/
|
||||
function totalsFor(runs: RoiRun[]): RoiTotals {
|
||||
const sum = (f: (r: RoiRun) => number | null) =>
|
||||
runs.reduce((n, r) => n + (f(r) ?? 0), 0)
|
||||
return {
|
||||
documents: runs.length,
|
||||
measured_documents: runs.filter((r) => r.measured).length,
|
||||
pages: sum((r) => r.pages),
|
||||
graded_fields: sum((r) => r.graded_fields),
|
||||
clean_fields: sum((r) => r.clean_fields),
|
||||
human_fields: sum((r) => r.human_fields),
|
||||
verified_fields: sum((r) => r.verified_fields),
|
||||
failed_documents: runs.filter((r) => r.status === 'failed').length,
|
||||
bands: {
|
||||
green: sum((r) => r.bands.green),
|
||||
amber: sum((r) => r.bands.amber),
|
||||
red: sum((r) => r.bands.red),
|
||||
unscored: sum((r) => r.bands.unscored),
|
||||
blank_critical: sum((r) => r.bands.blank_critical),
|
||||
},
|
||||
patients: sum((r) => r.patients),
|
||||
input_tokens: sum((r) => r.input_tokens),
|
||||
output_tokens: sum((r) => r.output_tokens),
|
||||
thinking_tokens: sum((r) => r.thinking_tokens),
|
||||
ocr_cost: sum((r) => r.ocr_cost),
|
||||
json_cost: sum((r) => r.json_cost),
|
||||
total_cost: sum((r) => r.total_cost),
|
||||
deleted_documents: runs.filter((r) => r.deleted).length,
|
||||
deleted_cost: sum((r) => (r.deleted ? r.total_cost : 0)),
|
||||
}
|
||||
}
|
||||
|
||||
export function RoiTab({
|
||||
onOpenRun,
|
||||
project,
|
||||
demography = false,
|
||||
}: {
|
||||
/** Absent on the demography board: its runs have no charge review to open. */
|
||||
onOpenRun?: (jobId: string) => void
|
||||
/** Which project's spend this board reports — Gulf Coast tickets and
|
||||
* eClinicalWorks documents never share a total. */
|
||||
project: Project
|
||||
/** The Demography ROI (ticket 05): its own payload, one Gemini call per PDF
|
||||
* so one total with no read/map split, and no field figures at all. */
|
||||
demography?: boolean
|
||||
}) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [demography ? 'demography-roi' : 'roi'],
|
||||
queryFn: demography ? api.demographyRoi : api.roi,
|
||||
// A document's cost lands only when it finishes, so the ledger keeps
|
||||
// refreshing while anything is in flight and stops the moment nothing is.
|
||||
refetchInterval: (q) =>
|
||||
(q.state.data?.runs ?? []).some((r) => r.status === 'queued' || r.status === 'running')
|
||||
? 3000
|
||||
: false,
|
||||
})
|
||||
|
||||
const [rates, setRates] = useState<Rates>(loadRates)
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [showMonths, setShowMonths] = useState(false)
|
||||
const [sort, setSort] = useState<'recent' | 'cost'>('recent')
|
||||
|
||||
const setRate = (key: keyof Rates, value: number) => {
|
||||
const next = { ...rates, [key]: value }
|
||||
setRates(next)
|
||||
localStorage.setItem(RATES_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
const runs = useMemo(
|
||||
() => (data?.runs ?? []).filter((r) => runProject(r) === project),
|
||||
[data?.runs, project],
|
||||
)
|
||||
const t = useMemo(() => totalsFor(runs), [runs])
|
||||
|
||||
// Spend per month from the server's whole-table sums. `runs` stops at the
|
||||
// newest 500, so the all-time Total comes from here, not from `t`.
|
||||
const months = useMemo(
|
||||
() => (data?.monthly ?? []).filter((m) => m.project === project),
|
||||
[data?.monthly, project],
|
||||
)
|
||||
const allTimeCost = months.reduce((sum, m) => sum + m.cost, 0)
|
||||
const thisMonth = istMonth()
|
||||
const thisMonthCost = months.find((m) => m.month === thisMonth)?.cost ?? 0
|
||||
|
||||
// The comparison is derived from the priced runs alone, not from the totals:
|
||||
// a patient count only exists where a cost does, and dividing a measured cost
|
||||
// by an all-runs field count would understate every unit figure on screen.
|
||||
const priced = useMemo(() => runs.filter((r) => r.measured), [runs])
|
||||
|
||||
const model = useMemo(() => {
|
||||
debugger;
|
||||
const patients = priced.reduce((n, r) => n + (r.patients ?? 0), 0)
|
||||
const humanFields = priced.reduce((n, r) => n + r.human_fields, 0)
|
||||
const apiCost = priced.reduce((n, r) => n + (r.total_cost ?? 0), 0)
|
||||
const manualHours = (patients * rates.minutesPerPatient) / 60
|
||||
const reviewHours = (humanFields * rates.secondsPerField) / 3600
|
||||
const manualCost = manualHours * rates.hourlyRate
|
||||
const reviewCost = reviewHours * rates.hourlyRate
|
||||
return {
|
||||
patients, humanFields, apiCost, manualHours, reviewHours, manualCost, reviewCost,
|
||||
pipelineCost: apiCost + reviewCost,
|
||||
saved: manualCost - (apiCost + reviewCost),
|
||||
}
|
||||
}, [priced, rates])
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const kept = runs.filter((r) =>
|
||||
filter === 'all' ? true
|
||||
: filter === 'priced' ? r.measured
|
||||
: filter === 'unmeasured' ? !r.measured && r.status !== 'failed'
|
||||
: r.status === 'failed',
|
||||
)
|
||||
return sort === 'recent'
|
||||
? kept
|
||||
: [...kept].sort((a, b) => (b.total_cost ?? -1) - (a.total_cost ?? -1))
|
||||
}, [runs, filter, sort])
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="py-16 text-center text-[13px] text-muted">Loading…</p>
|
||||
}
|
||||
|
||||
if (!t || t.documents === 0) {
|
||||
return (
|
||||
<div className="sb-empty mx-auto max-w-md">
|
||||
<h2 className="sb-display text-[16px] font-bold text-ink">
|
||||
Nothing extracted yet
|
||||
</h2>
|
||||
<p className="mt-1.5 text-[13px] leading-relaxed text-muted">
|
||||
{demography
|
||||
? 'Cost is recorded as each facesheet is read. Upload one on the Demography Extraction board and its spend appears here.'
|
||||
: 'Cost is recorded as each document is read. Run a batch on the Charge Extraction board and its spend appears here.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const unpriced = t.documents - t.measured_documents
|
||||
// Demography divides by its priced runs only: an OCR-era run has pages and
|
||||
// patients but no cost, and would dilute every unit figure.
|
||||
const unitPages = demography ? priced.reduce((n, r) => n + r.pages, 0) : t.pages
|
||||
const unitPatients = demography ? model.patients : t.patients
|
||||
const bands = t.bands
|
||||
const slices: Slice[] = [
|
||||
{ key: 'green', label: 'Clean print', value: bands.green, color: BAND_VAR.green },
|
||||
{ key: 'amber', label: 'Slightly unclear', value: bands.amber, color: BAND_VAR.amber },
|
||||
{ key: 'red', label: 'Handwritten / messy', value: bands.red, color: BAND_VAR.red },
|
||||
{ key: 'blank', label: 'Code missing', value: bands.blank_critical, color: BAND_VAR.blank },
|
||||
{ key: 'unscored', label: 'Not in transcription', value: bands.unscored, color: BAND_VAR.unscored },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[1600px] flex-col gap-5">
|
||||
{/* ── Page head ────────────────────────────────────────────────────── */}
|
||||
<header className="flex flex-wrap items-start gap-x-4 gap-y-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="sb-page-title">{demography ? 'Demography ROI Dashboard' : 'ROI Dashboard'}</h2>
|
||||
<p className="sb-page-sub !mb-0">
|
||||
What the pipeline costs — and what it replaces
|
||||
</p>
|
||||
</div>
|
||||
{/* States coverage before any figure is read, because "0 of 9 priced" is
|
||||
the difference between a small total and an incomplete one. */}
|
||||
<span
|
||||
className={`sb-pill ml-auto flex shrink-0 items-center gap-1.5 ${
|
||||
unpriced === 0 ? 'sb-pill-ok' : 'sb-pill-warn'
|
||||
}`}
|
||||
>
|
||||
<IconDot />
|
||||
<span className="tnum">
|
||||
{num(t.measured_documents)} of {num(t.documents)} documents priced
|
||||
</span>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* ── Headline tiles ───────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<h3 className="sb-eyebrow">Spend overview</h3>
|
||||
<div className="sb-tiles !mb-0">
|
||||
<StatTile
|
||||
icon={<IconSpend />}
|
||||
tone="var(--accent-1)"
|
||||
value={months.length ? fine(allTimeCost) : t.measured_documents ? fine(t.total_cost) : '—'}
|
||||
label={
|
||||
!t.measured_documents
|
||||
? 'Total spend — not yet recorded'
|
||||
: t.deleted_documents
|
||||
? 'Total spend, deleted included'
|
||||
: 'Total spend'
|
||||
}
|
||||
lead
|
||||
/>
|
||||
<StatTile
|
||||
icon={<IconSpend />}
|
||||
tone="var(--accent-2)"
|
||||
value={fine(thisMonthCost)}
|
||||
label={`This month · ${monthLabel(thisMonth)} · ${showMonths ? 'hide' : 'click for all'} months`}
|
||||
onClick={() => setShowMonths((v) => !v)}
|
||||
expanded={showMonths}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<IconStack />}
|
||||
tone="var(--accent-2)"
|
||||
value={num(t.documents)}
|
||||
label={`${t.documents === 1 ? 'Document' : 'Documents'}${
|
||||
t.deleted_documents ? ` · ${num(t.deleted_documents)} deleted` : ''
|
||||
}`}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<IconPage />}
|
||||
tone="var(--verify)"
|
||||
value={num(t.pages)}
|
||||
label={t.pages === 1 ? 'Page read' : 'Pages read'}
|
||||
/>
|
||||
{!demography && (
|
||||
<StatTile
|
||||
icon={<IconCheck />}
|
||||
tone="var(--clean)"
|
||||
value={num(t.graded_fields)}
|
||||
label="Fields graded"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Month by month ───────────────────────────────────────────────── */}
|
||||
{showMonths && (
|
||||
<Panel title="Month-wise cost" note="India time · resets on the 1st">
|
||||
{months.length === 0 && (
|
||||
<p className="text-[13px] text-muted">No spend recorded yet.</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="text-left text-[11.5px] text-muted">
|
||||
<th className="pb-2 font-medium">Month</th>
|
||||
<th className="pb-2 text-right font-medium">Documents</th>
|
||||
<th className="pb-2 text-right font-medium">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{months.map((m) => (
|
||||
<tr key={m.month} className="border-t border-line">
|
||||
<td className="py-1.5 text-ink">
|
||||
{monthLabel(m.month)}
|
||||
{m.month === thisMonth && (
|
||||
<span className="ml-2 text-[11px] text-muted">this month</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="tnum py-1.5 text-right text-ink">{num(m.documents)}</td>
|
||||
<td className="sb-display tnum py-1.5 text-right font-semibold text-ink">
|
||||
{fine(m.cost)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* ── Split + cost totals ──────────────────────────────────────────── */}
|
||||
<div className={`grid gap-3 ${demography ? '' : 'lg:grid-cols-[minmax(0,1fr)_minmax(0,1.25fr)]'}`}>
|
||||
{!demography && (
|
||||
<Panel title="Field split (all documents)">
|
||||
{t.graded_fields === 0 ? (
|
||||
<p className="text-[13px] text-muted">No graded fields yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<Donut
|
||||
slices={slices}
|
||||
centerValue={num(t.graded_fields)}
|
||||
centerLabel="Fields"
|
||||
/>
|
||||
<dl className="sb-legend">
|
||||
{slices.filter((s) => s.value > 0).map((s) => (
|
||||
<div key={s.key}>
|
||||
<span className="sb-dot" style={{ backgroundColor: s.color }} />
|
||||
<dt className="min-w-0 truncate">{s.label}</dt>
|
||||
<dd className="sb-n shrink-0">{num(s.value)}</dd>
|
||||
<dd className="sb-p shrink-0">
|
||||
{Math.round((s.value / t.graded_fields) * 100)}%
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3.5 max-w-xl text-[11.5px] leading-relaxed text-muted">
|
||||
Clean means the print was legible, not that the value is right — the
|
||||
score rates the model's own reading. It measures how much work the
|
||||
pipeline removed, never accuracy.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel title="Cost totals (all documents)">
|
||||
{t.measured_documents === 0 ? (
|
||||
<>
|
||||
<p className="sb-display text-[16px] font-semibold text-ink">
|
||||
No cost recorded for these {num(t.documents)} documents
|
||||
</p>
|
||||
<p className="mt-1.5 max-w-xl text-[13px] leading-relaxed text-muted">
|
||||
They were extracted before the pipeline began storing token
|
||||
usage, so there is no figure to report and none is estimated
|
||||
here. The next extraction records its own cost.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{demography ? (
|
||||
<MoneyTile label="Total" value={fine(t.total_cost)} tone="var(--accent-2)" />
|
||||
) : (
|
||||
<div className="grid gap-2.5 sm:grid-cols-3">
|
||||
<MoneyTile label="Reading the page" value={fine(t.ocr_cost)} tone="var(--accent-1)" />
|
||||
<MoneyTile label="Mapping to fields" value={fine(t.json_cost)} tone="var(--verify)" />
|
||||
<MoneyTile label="Total" value={fine(t.total_cost)} tone="var(--accent-2)" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A deleted document was still paid for, so it stays in every
|
||||
total above; this is where the two are told apart. */}
|
||||
{t.deleted_documents > 0 && (
|
||||
<div className="mt-2.5 grid gap-2.5 sm:grid-cols-2">
|
||||
<MoneyTile
|
||||
label={`Active · ${num(t.documents - t.deleted_documents)} documents`}
|
||||
value={fine(t.total_cost - t.deleted_cost)}
|
||||
tone="var(--clean)"
|
||||
/>
|
||||
<MoneyTile
|
||||
label={`Deleted · ${num(t.deleted_documents)} documents`}
|
||||
value={fine(t.deleted_cost)}
|
||||
tone="var(--muted)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two calls per page — a transcription and a mapping — and they
|
||||
do not cost the same, so the split says which one to attack if
|
||||
this ever has to be cheaper. */}
|
||||
{!demography && (
|
||||
<span className="sb-meter mt-3.5 flex" aria-hidden="true">
|
||||
<span style={{ flexGrow: Math.max(t.ocr_cost, 1e-9), backgroundColor: 'var(--accent-1)' }} />
|
||||
<span style={{ flexGrow: Math.max(t.json_cost, 1e-9), backgroundColor: 'var(--verify)' }} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<p className="tnum mt-[7px] text-[11.5px] text-muted">
|
||||
{num(t.input_tokens + t.output_tokens)} tokens across{' '}
|
||||
{num(t.measured_documents)}{' '}
|
||||
{t.measured_documents === 1 ? 'document' : 'documents'}
|
||||
{t.thinking_tokens > 0 && ` · ${num(t.thinking_tokens)} thinking`}
|
||||
{unpriced > 0 && ` · ${num(unpriced)} older not included`}
|
||||
</p>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-3 gap-2.5">
|
||||
<Unit label="per page" value={unitPages ? fine(t.total_cost / unitPages) : '—'} />
|
||||
<Unit label="per patient" value={unitPatients ? fine(t.total_cost / unitPatients) : '—'} />
|
||||
<Unit label="per 1,000 patients"
|
||||
value={unitPatients ? usd((t.total_cost / unitPatients) * 1000) : '—'} />
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* ── The comparison, on the reader's own numbers ───────────────────── */}
|
||||
<Panel
|
||||
title="Against doing it by hand"
|
||||
note="Your assumptions, not a measurement"
|
||||
dashed
|
||||
>
|
||||
<div className="flex flex-wrap items-end gap-x-5 gap-y-3">
|
||||
<RateInput
|
||||
label="Minutes to key one patient by hand"
|
||||
value={rates.minutesPerPatient}
|
||||
step={0.5}
|
||||
onChange={(v) => setRate('minutesPerPatient', v)}
|
||||
/>
|
||||
{!demography && (
|
||||
<RateInput
|
||||
label="Seconds to check one flagged field"
|
||||
value={rates.secondsPerField}
|
||||
step={1}
|
||||
onChange={(v) => setRate('secondsPerField', v)}
|
||||
/>
|
||||
)}
|
||||
<RateInput
|
||||
label="Reviewer cost per hour"
|
||||
value={rates.hourlyRate}
|
||||
step={1}
|
||||
prefix="$"
|
||||
onChange={(v) => setRate('hourlyRate', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{model.patients === 0 ? (
|
||||
<p className="mt-4 text-[13px] text-muted">
|
||||
Nothing to compare yet — this needs at least one document with a
|
||||
recorded patient count.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-4 grid gap-3 border-t border-line-soft pt-3.5 sm:grid-cols-3">
|
||||
<Side
|
||||
heading="By hand"
|
||||
total={usd(model.manualCost)}
|
||||
lines={[
|
||||
`${num(model.patients)} patients × ${rates.minutesPerPatient} min`,
|
||||
`${hours(model.manualHours)} of keying`,
|
||||
]}
|
||||
/>
|
||||
<Side
|
||||
heading="With the pipeline"
|
||||
total={usd(model.pipelineCost)}
|
||||
lines={[
|
||||
`${fine(model.apiCost)} of model calls`,
|
||||
...(demography
|
||||
? []
|
||||
: [`${hours(model.reviewHours)} checking ${num(model.humanFields)} fields`]),
|
||||
]}
|
||||
/>
|
||||
<Side
|
||||
heading="Difference"
|
||||
total={usd(model.saved)}
|
||||
lines={[
|
||||
model.pipelineCost > 0
|
||||
? `${(model.manualCost / model.pipelineCost).toFixed(1)}× cheaper`
|
||||
: '—',
|
||||
`on ${num(priced.length)} priced ${priced.length === 1 ? 'document' : 'documents'}`,
|
||||
]}
|
||||
emphasis={model.saved >= 0}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] leading-relaxed text-muted">
|
||||
{demography
|
||||
? 'The pipeline column is model calls only: this board does not count reviewer time. Only the model-call figure is measured; the keying time comes from the rates you entered.'
|
||||
: 'The pipeline column charges for reviewer time as well as model calls, because a flagged field still costs somebody a minute. Only the model-call figure is measured; every duration above comes from the rates you entered.'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* ── Per document ─────────────────────────────────────────────────── */}
|
||||
<section className="flex flex-col gap-2.5">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||
<Pill active={filter === 'all'} onClick={() => setFilter('all')}>
|
||||
All <Count n={t.documents} active={filter === 'all'} />
|
||||
</Pill>
|
||||
<Pill active={filter === 'priced'} onClick={() => setFilter('priced')}>
|
||||
Priced <Count n={t.measured_documents} active={filter === 'priced'} />
|
||||
</Pill>
|
||||
<Pill active={filter === 'unmeasured'} onClick={() => setFilter('unmeasured')}>
|
||||
Not measured{' '}
|
||||
<Count n={unpriced - t.failed_documents} active={filter === 'unmeasured'} />
|
||||
</Pill>
|
||||
<Pill active={filter === 'failed'} onClick={() => setFilter('failed')}>
|
||||
Failed <Count n={t.failed_documents} active={filter === 'failed'} />
|
||||
</Pill>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Sort active={sort === 'recent'} onClick={() => setSort('recent')}>Newest</Sort>
|
||||
<Sort active={sort === 'cost'} onClick={() => setSort('cost')}>Costliest</Sort>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<p className="sb-empty !py-10">No documents in this filter.</p>
|
||||
) : (
|
||||
shown.map((r) => (
|
||||
<DocLine
|
||||
key={r.job_id}
|
||||
run={r}
|
||||
onOpen={onOpenRun && (() => onOpenRun(r.job_id))}
|
||||
showFields={!demography}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One document, as a line in the ledger.
|
||||
*
|
||||
* Reads left to right the way the question is asked: which file, how big, how
|
||||
* far through review, what it cost.
|
||||
*/
|
||||
function DocLine({
|
||||
run,
|
||||
onOpen,
|
||||
showFields,
|
||||
}: {
|
||||
run: RoiRun
|
||||
onOpen?: () => void
|
||||
/** False on the demography board, which carries no field figures. */
|
||||
showFields: boolean
|
||||
}) {
|
||||
const openable = !!onOpen && run.status === 'done' && !run.deleted
|
||||
const failed = run.status === 'failed'
|
||||
const running = run.status === 'running'
|
||||
const perPatient =
|
||||
run.total_cost != null && run.patients ? run.total_cost / run.patients : null
|
||||
|
||||
const pct = run.human_fields
|
||||
? Math.round((run.verified_fields / run.human_fields) * 100)
|
||||
: run.graded_fields ? 100 : 0
|
||||
|
||||
// The left rule states the document's condition at a glance down the column.
|
||||
// 4px, matching the band edge the document list already uses — the same
|
||||
// language, not a second one invented here.
|
||||
const rule = run.deleted
|
||||
? BAND_VAR.blank
|
||||
: failed
|
||||
? BAND_VAR.red
|
||||
: run.bands.red ? BAND_VAR.red
|
||||
: run.bands.amber ? BAND_VAR.amber
|
||||
: run.graded_fields ? BAND_VAR.green
|
||||
: BAND_VAR.blank
|
||||
|
||||
const extractPct = run.pages ? Math.round((run.pages_done / run.pages) * 100) : 0
|
||||
|
||||
return (
|
||||
<article
|
||||
onClick={openable ? onOpen : undefined}
|
||||
className={`sb-card group relative flex flex-wrap items-center gap-x-4 gap-y-2 overflow-hidden !pl-5 !pr-3.5 !py-3 transition-colors duration-200 ${
|
||||
openable ? 'cursor-pointer hover:border-muted' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="absolute inset-y-0 left-0 w-1" style={{ backgroundColor: rule }} />
|
||||
{running && <ExtractionFire pct={extractPct ?? 0} />}
|
||||
|
||||
<div className="relative min-w-0 flex-1 basis-64">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="sb-doc-name min-w-0 truncate">{run.document}</h4>
|
||||
{isToday(run.created_at) && (
|
||||
<span
|
||||
className="shrink-0 rounded-full px-1.5 py-px text-[10.5px] font-semibold uppercase tracking-[0.1em]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--accent-1) 12%, transparent)',
|
||||
color: 'var(--accent-1)',
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</span>
|
||||
)}
|
||||
{run.deleted && (
|
||||
<span
|
||||
className="shrink-0 rounded-full px-1.5 py-px text-[10.5px] font-semibold uppercase tracking-[0.1em]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--muted) 16%, transparent)',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
title={`Deleted${run.deleted_by ? ` by ${run.deleted_by}` : ''}${
|
||||
run.deleted_at ? ` on ${new Date(run.deleted_at).toLocaleString()}` : ''
|
||||
} — its cost still counts`}
|
||||
>
|
||||
Deleted{run.deleted_by ? ` · ${run.deleted_by}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[11.5px] text-muted">
|
||||
{run.template ?? '—'} · {run.model.replace('gemini-', '')}
|
||||
{run.batch_name && ` · ${run.batch_name}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="tnum relative shrink-0 text-[11.5px] text-muted">
|
||||
{run.patients != null ? `${num(run.patients)} patients` : `${num(run.graded_fields)} fields`}
|
||||
{run.pages > 0 && ` · ${run.pages} ${run.pages === 1 ? 'page' : 'pages'}`}
|
||||
</p>
|
||||
|
||||
{/* Review progress, not extraction progress: how much of what a human was
|
||||
handed has actually been signed off.
|
||||
A document with nothing flagged has no such fraction. It used to render
|
||||
as "100% · 0 · 0", which is three figures agreeing on nothing — the bar
|
||||
was full because the queue was empty, not because anyone did the work.
|
||||
It says so in words instead. */}
|
||||
{!showFields ? (
|
||||
<div className="relative min-w-[140px] flex-1 basis-40" />
|
||||
) : run.human_fields > 0 ? (
|
||||
<>
|
||||
<div className="relative flex min-w-[140px] flex-1 basis-40 items-center gap-2.5">
|
||||
<span className="sb-meter min-w-0 flex-1">
|
||||
<span
|
||||
className="block h-full w-full origin-left rounded-full transition-transform duration-500"
|
||||
style={{
|
||||
transform: `scaleX(${pct / 100})`,
|
||||
backgroundColor: pct === 100 ? BAND_VAR.green : 'var(--accent-2)',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="tnum w-9 shrink-0 text-right text-[11.5px] text-muted">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative flex shrink-0 items-center gap-1.5">
|
||||
<Chip
|
||||
tone={BAND_VAR.green}
|
||||
icon={<IconCheck size={12} />}
|
||||
n={run.verified_fields}
|
||||
title="Fields signed off"
|
||||
/>
|
||||
<Chip
|
||||
tone="var(--accent-2)"
|
||||
icon={<IconDot size={7} />}
|
||||
n={run.human_fields - run.verified_fields}
|
||||
title="Fields still waiting on a person"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="relative flex min-w-[140px] flex-1 basis-40 items-center">
|
||||
<span className="inline-flex items-center gap-1.5 text-[11.5px] font-medium text-clean">
|
||||
{run.graded_fields > 0 ? (
|
||||
<>
|
||||
<IconCheck size={13} /> Nothing flagged
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted">No fields extracted</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-28 shrink-0 text-right">
|
||||
{failed ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11.5px] font-medium text-review">
|
||||
<IconAlert size={13} /> Failed
|
||||
</span>
|
||||
) : run.total_cost != null ? (
|
||||
<span className="sb-display tnum text-[13.5px] font-bold text-ink">
|
||||
{fine(run.total_cost)}
|
||||
{perPatient != null && (
|
||||
<span className="ml-1 block font-plex text-[11.5px] font-normal text-muted">
|
||||
{fine(perPatient)}/patient
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
// Not a zero and not a bare dash: the reader has to know the number is
|
||||
// absent rather than small.
|
||||
<span
|
||||
className="text-[11.5px] text-muted"
|
||||
title="Extracted before cost was recorded"
|
||||
>
|
||||
not measured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`relative shrink-0 transition-transform ${
|
||||
openable
|
||||
? 'text-muted group-hover:translate-x-0.5 group-hover:text-ink-2'
|
||||
: 'text-transparent'
|
||||
}`}
|
||||
>
|
||||
<IconChevronRight size={16} />
|
||||
</span>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A headline figure with its own icon.
|
||||
*
|
||||
* `lead` marks the one tile that is the board's subject. Exactly one gets it —
|
||||
* a second would mean neither is the answer.
|
||||
*/
|
||||
function StatTile({
|
||||
icon, tone, value, label, lead, onClick, expanded,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
tone: string
|
||||
value: string
|
||||
label: string
|
||||
lead?: boolean
|
||||
/** Makes the tile a toggle button, e.g. to open the month list. */
|
||||
onClick?: () => void
|
||||
expanded?: boolean
|
||||
}) {
|
||||
const Tag = onClick ? 'button' : 'div'
|
||||
return (
|
||||
<Tag
|
||||
className={`sb-tile ${onClick ? 'w-full cursor-pointer text-left hover:border-ink-2' : ''}`}
|
||||
{...(onClick ? { type: 'button' as const, onClick, 'aria-expanded': expanded } : {})}
|
||||
>
|
||||
<span
|
||||
className="sb-chip"
|
||||
style={{
|
||||
color: tone,
|
||||
backgroundColor: `color-mix(in srgb, ${tone} 18%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className={`sb-tile-num ${lead ? 'sb-spend' : ''}`}>{value}</div>
|
||||
<div className="sb-tile-cap truncate">{label}</div>
|
||||
</div>
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
function MoneyTile({ label, value, tone }: { label: string; value: string; tone: string }) {
|
||||
return (
|
||||
<div className="rounded-[10px] border border-line bg-surface-2 p-3">
|
||||
<b className="sb-display tnum block text-[18px] font-bold leading-tight" style={{ color: tone }}>
|
||||
{value}
|
||||
</b>
|
||||
<span className="mt-1 flex items-center gap-1.5 text-[11.5px] text-muted">
|
||||
<span className="sb-dot" style={{ backgroundColor: tone }} />
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Panel({
|
||||
title, note, dashed, children,
|
||||
}: {
|
||||
title: string
|
||||
note?: string
|
||||
dashed?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={`sb-panel ${dashed ? 'border-dashed bg-surface-2' : ''}`}
|
||||
>
|
||||
<div className="mb-3.5 flex flex-wrap items-baseline gap-x-2.5">
|
||||
<h3 className="sb-panel-title !mb-0">{title}</h3>
|
||||
{note && <span className="sb-eyebrow !mb-0">{note}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Unit({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-[11px] text-muted">{label}</dt>
|
||||
<dd className="sb-display tnum text-[15px] font-semibold text-ink">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Side({
|
||||
heading, total, lines, emphasis,
|
||||
}: {
|
||||
heading: string
|
||||
total: string
|
||||
lines: string[]
|
||||
emphasis?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-[11px] text-muted">{heading}</h4>
|
||||
<p
|
||||
className="sb-display tnum mt-0.5 text-[20px] font-bold leading-none tracking-tight"
|
||||
style={{ color: emphasis ? 'var(--accent-1)' : 'var(--ink)' }}
|
||||
>
|
||||
{total}
|
||||
</p>
|
||||
<ul className="mt-1.5 space-y-0.5">
|
||||
{lines.map((l) => (
|
||||
<li key={l} className="tnum text-[11.5px] text-muted">
|
||||
{l}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({
|
||||
tone, icon, n, title,
|
||||
}: {
|
||||
tone: string
|
||||
icon: React.ReactNode
|
||||
n: number
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
className="tnum inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-semibold"
|
||||
style={{
|
||||
backgroundColor: `color-mix(in srgb, ${tone} 18%, transparent)`,
|
||||
color: tone,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{num(n)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Pill({
|
||||
active, onClick, children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={`sb-btn sb-btn-sm !rounded-full ${active ? 'sb-btn-primary' : 'sb-btn-quiet'}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Count({ n, active }: { n: number; active: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className="tnum rounded-full px-1.5 text-[11px]"
|
||||
style={
|
||||
// A translucent veil over the brand gradient lands too close to the
|
||||
// white label beside it. A solid surface chip carrying the accent reads
|
||||
// as a separate object and keeps its contrast.
|
||||
active
|
||||
? { backgroundColor: 'var(--surface)', color: 'var(--accent-1)' }
|
||||
: { backgroundColor: 'var(--surface)', color: 'var(--muted)' }
|
||||
}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Sort({
|
||||
active, onClick, children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={`min-h-[24px] rounded-md border px-2.5 py-1 text-[11.5px] font-medium transition-colors ${
|
||||
active
|
||||
? 'border-line bg-surface-2 text-ink'
|
||||
: 'border-transparent text-muted hover:text-ink-2'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function RateInput({
|
||||
label, value, step, prefix, onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
step: number
|
||||
prefix?: string
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-[11px] text-muted">{label}</span>
|
||||
<span className="sb-input mt-1 flex items-center !py-0 !px-2.5 focus-within:border-accent-1">
|
||||
{prefix && <span className="text-[13px] text-muted">{prefix}</span>}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={step}
|
||||
value={value}
|
||||
// A cleared box parses as NaN and would take every figure on the
|
||||
// board with it, so it reads as zero until something is typed.
|
||||
onChange={(e) => onChange(Number(e.target.value) || 0)}
|
||||
className="tnum w-16 bg-transparent py-2 text-[13px] font-semibold text-ink outline-none"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
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,300 @@
|
||||
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
|
||||
* / Gulf Coast), 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 Gulf Coast 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="Gulf Coast Charge Extraction"
|
||||
hint="Gulf Coast 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="Gulf Coast Charge Automation"
|
||||
hint="Gulf Coast 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,146 @@
|
||||
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,100 @@
|
||||
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,133 @@
|
||||
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,159 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/logo-mark.png" />
|
||||
<!--
|
||||
Two webfonts, and only for the sign-in screen — Sora for its display type
|
||||
(brand name, headline, button) and IBM Plex Sans for its body. Both are
|
||||
carried over from the approved login mockup, which is the whole reason
|
||||
they are here: the screen is meant to render identically to it.
|
||||
|
||||
The INTERFACE behind sign-in stays on the system stack, which is a
|
||||
documented density decision for 11-13px data-dense lists, where a webfont
|
||||
costs a network dependency to look almost identical to Segoe UI (see
|
||||
DESIGN.md Typography). Neither family is set on `body`.
|
||||
|
||||
`display=swap` plus the full local fallback in each `.sb-login` rule is
|
||||
what makes this safe on an internal network: if fonts.googleapis.com is
|
||||
blocked the sign-in screen falls back to the system stack and every other
|
||||
surface never wanted a webfont in the first place.
|
||||
-->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Extraction and Automation Tool</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Two named appearances, not a palette picker.
|
||||
*
|
||||
* The original control was a row of six raw colour dots that changed which hue
|
||||
* the confirm/approve buttons used. It read as decoration sitting next to the
|
||||
* confidence-band legend — two colour controls side by side, only one of which
|
||||
* meant anything. The accent is now fixed to the brand, and this control does
|
||||
* the thing a reviewer actually wants: choose the light level for the room.
|
||||
*
|
||||
* There was a third mode, `eye` — the light palette under a warm sepia filter,
|
||||
* for long shifts. It is gone. It was a third appearance to keep in sync
|
||||
* forever, it tinted the scanned page a reviewer is trying to read accurately,
|
||||
* and both the OS and the monitor already offer the same warmth system-wide.
|
||||
*/
|
||||
export type Mode = 'light' | 'dark'
|
||||
|
||||
const MODE_KEY = 'superbill.theme.mode'
|
||||
const MODES: { value: Mode; label: string; hint: string }[] = [
|
||||
{ value: 'light', label: 'Light', hint: 'Bright rooms' },
|
||||
{ value: 'dark', label: 'Dark', hint: 'Low-light rooms' },
|
||||
]
|
||||
|
||||
type ThemeContextValue = {
|
||||
mode: Mode
|
||||
setMode: (m: Mode) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
function readStored<T extends string>(key: string, valid: readonly T[], fallback: T): T {
|
||||
const v = localStorage.getItem(key)
|
||||
return (valid as readonly string[]).includes(v ?? '') ? (v as T) : fallback
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
// A stored `eye` from before that mode was removed is not in the valid list,
|
||||
// so readStored falls back to `dark` rather than leaving the app in a mode
|
||||
// with no styles behind it.
|
||||
const [mode, setMode] = useState<Mode>(() =>
|
||||
readStored(MODE_KEY, ['light', 'dark'] as const, 'dark'),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
root.classList.toggle('dark', mode === 'dark')
|
||||
root.setAttribute('data-mode', mode)
|
||||
localStorage.setItem(MODE_KEY, mode)
|
||||
}, [mode])
|
||||
|
||||
return <ThemeContext.Provider value={{ mode, setMode }}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Two text pills in a pill, matching the mockup's header control. The icons
|
||||
* that used to sit beside each label are gone with the third mode: with only
|
||||
* Light and Dark left, the two words are already unambiguous and the glyphs
|
||||
* were competing with the Sign out button next to them.
|
||||
*
|
||||
* Colours here are literal white-on-black alphas rather than tokens, and have
|
||||
* to be: this control sits ON the brand gradient, which is the one saturated
|
||||
* surface in the product, and a token-coloured control there reads as a hole
|
||||
* punched through the band. See `.sb-themes` in index.css. */
|
||||
export function ThemeSwitcher() {
|
||||
const { mode, setMode } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="sb-themes" role="radiogroup" aria-label="Appearance">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mode === m.value}
|
||||
aria-pressed={mode === m.value}
|
||||
title={m.hint}
|
||||
onClick={() => setMode(m.value)}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import type { Band } from './bands'
|
||||
|
||||
/**
|
||||
* `pending` is "uploaded, waiting for the batch's Run button" — distinct from
|
||||
* `queued`, which means already handed to the extraction worker.
|
||||
*/
|
||||
export type RunStatus = 'pending' | 'queued' | 'running' | 'done' | 'failed'
|
||||
|
||||
export type BatchStage = 'extraction' | 'automation'
|
||||
|
||||
/**
|
||||
* Which product a document belongs to. Declared here rather than in project.ts
|
||||
* because Run and Batch carry it, and project.ts already imports from this file
|
||||
* — it re-exports this type so existing importers are unaffected.
|
||||
*/
|
||||
export type Project = 'ecw' | 'tebra'
|
||||
|
||||
/**
|
||||
* What the sidebar can show. ROI is a report over the whole pipeline, not a
|
||||
* place a batch can be — so it is a board a reviewer can look at, and never a
|
||||
* stage a card can be dropped into.
|
||||
*
|
||||
* Two projects live in one rail: the ECW boards (templates 1-6) and the Tebra
|
||||
* boards (template 7 / Gulf Coast). Tebra ids are prefixed because a bare
|
||||
* 'extraction' can no longer identify a board on its own — see project.ts for
|
||||
* the tab -> (project, stage) mapping.
|
||||
*/
|
||||
export type BoardTab =
|
||||
| 'roi'
|
||||
| 'people'
|
||||
| 'demography'
|
||||
| 'demography-automation'
|
||||
| 'demography-roi'
|
||||
| BatchStage
|
||||
| 'tebra-roi'
|
||||
| 'tebra-people'
|
||||
| 'tebra-extraction'
|
||||
| 'tebra-automation'
|
||||
|
||||
export interface Batch {
|
||||
batch_id: string
|
||||
/** Sequential, human-facing: "BATCH-0007". What a reviewer says out loud. */
|
||||
name: string
|
||||
seq: number
|
||||
stage: BatchStage
|
||||
/** 'failed' means the run finished with at least one charge unposted — the
|
||||
* batch is all-or-nothing, so a partial post is never 'done'. */
|
||||
automation: 'idle' | 'running' | 'done' | 'failed'
|
||||
created_at: string
|
||||
moved_at: string | null
|
||||
/** The project every document in the batch agrees on, or 'mixed' when they
|
||||
* do not — a batch is a GROUP BY, so disagreement is a data fault and shows
|
||||
* as belonging to neither board rather than quietly to both. */
|
||||
project: Project | 'mixed' | null
|
||||
runs: Run[]
|
||||
/** Cleared to run by an admin. Extraction is gated on this AND on the caller
|
||||
* being the super admin — two people, because a run spends API quota. */
|
||||
run_approved: boolean
|
||||
run_approved_by: string | null
|
||||
run_approved_at: string | null
|
||||
/** A Tebra run waiting ('queued') or going ('precheck' | 'post'); null when idle. */
|
||||
automation_active?: 'queued' | 'precheck' | 'post' | null
|
||||
documents: number
|
||||
flagged: number
|
||||
verified: number
|
||||
remaining: number
|
||||
/** Every document finished extracting — the gate on moving to Automation. */
|
||||
extracted: boolean
|
||||
}
|
||||
|
||||
export interface BandCounts {
|
||||
green: number
|
||||
amber: number
|
||||
red: number
|
||||
unscored: number
|
||||
blank_critical: number
|
||||
}
|
||||
|
||||
export interface ReviewProgress {
|
||||
total: number
|
||||
verified: number
|
||||
remaining: number
|
||||
/** Pages with any field. Below the run's pages_total = pages were skipped. */
|
||||
pages?: number
|
||||
}
|
||||
|
||||
export interface Run {
|
||||
job_id: string
|
||||
/** Null for runs made before batches existed, and for the seed rows. */
|
||||
batch_id?: string | null
|
||||
/** The PDF's own filename — what a reviewer recognises. */
|
||||
document: string
|
||||
/** The template detected inside it, shown as context beside the name.
|
||||
* Detection output only — never read this to decide which project a
|
||||
* document belongs to; that is `project` below. */
|
||||
template: string | null
|
||||
/** Which product this belongs to. Stored on the row, stamped from the board
|
||||
* it was uploaded on. Null on a legacy row the backfill has not classified,
|
||||
* which `runProject()` reads as 'ecw'. */
|
||||
project: Project | null
|
||||
model: string
|
||||
status: RunStatus
|
||||
pages_done: number
|
||||
pages_total: number
|
||||
error: string | null
|
||||
summary: BandCounts | null
|
||||
created_at: string
|
||||
finished_at: string | null
|
||||
review?: ReviewProgress
|
||||
}
|
||||
|
||||
export interface ReviewRow {
|
||||
row_id: number
|
||||
job_id: string
|
||||
sheet: string | null
|
||||
template: string | null
|
||||
page: number | null
|
||||
patient: string | null
|
||||
/**
|
||||
* 0 for every template except one that can carry more than one row per
|
||||
* (page, patient, field) — Gulf Coast's repeated CPT lines, one per coder
|
||||
* annotation on the same ticket.
|
||||
*/
|
||||
line_index: number
|
||||
field: string
|
||||
value: string | null
|
||||
band: Band
|
||||
score: number | null
|
||||
reason: string | null
|
||||
verified: boolean
|
||||
corrected_value: string | null
|
||||
verified_by: string | null
|
||||
verified_at: string | null
|
||||
/**
|
||||
* Whether this field is part of the review queue. Absent on rows from the
|
||||
* persisted queue (everything there needs review by definition); present and
|
||||
* false on the green fields that /all-fields adds for context, which were
|
||||
* never queued and so have no real row_id to patch.
|
||||
*/
|
||||
needs_review?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* One document's line in the cost ledger.
|
||||
*
|
||||
* The nullable money and token fields are the whole reason `measured` exists:
|
||||
* runs extracted before the pipeline recorded token usage carry no figure at
|
||||
* all, and the board shows them as unmeasured rather than implying a cost it
|
||||
* never observed.
|
||||
*/
|
||||
export interface RoiRun {
|
||||
job_id: string
|
||||
document: string
|
||||
template: string | null
|
||||
/** Which product this run's spend belongs to. Stored, not derived from
|
||||
* `template` — see the note on Run.project. */
|
||||
project: Project | null
|
||||
model: string
|
||||
status: RunStatus
|
||||
created_at: string
|
||||
batch_id: string | null
|
||||
batch_name: string | null
|
||||
pages: number
|
||||
/** Pages read so far. Only moves while the run is in flight. */
|
||||
pages_done: number
|
||||
patients: number | null
|
||||
input_tokens: number | null
|
||||
output_tokens: number | null
|
||||
thinking_tokens: number | null
|
||||
ocr_cost: number | null
|
||||
json_cost: number | null
|
||||
total_cost: number | null
|
||||
/** Every field the extractor graded, from the workbook's band counts. */
|
||||
graded_fields: number
|
||||
/** The green band — legible print the pipeline resolved without help. */
|
||||
clean_fields: number
|
||||
/** Always all five keys, zero-filled — never a partial object. */
|
||||
bands: BandCounts
|
||||
/** Fields that actually reached a person. A different count from the bands. */
|
||||
human_fields: number
|
||||
verified_fields: number
|
||||
measured: boolean
|
||||
/** Deleted from the boards; its cost still counts here. */
|
||||
deleted: boolean
|
||||
deleted_at: string | null
|
||||
deleted_by: string | null
|
||||
}
|
||||
|
||||
/** Spend for one project in one India-time month ('YYYY-MM'), summed over the
|
||||
* whole table on the server — not cut off at the newest runs like `runs`. */
|
||||
export interface RoiMonth {
|
||||
project: Project
|
||||
month: string
|
||||
cost: number
|
||||
documents: number
|
||||
}
|
||||
|
||||
export interface RoiTotals {
|
||||
documents: number
|
||||
measured_documents: number
|
||||
pages: number
|
||||
graded_fields: number
|
||||
clean_fields: number
|
||||
human_fields: number
|
||||
verified_fields: number
|
||||
failed_documents: number
|
||||
bands: BandCounts
|
||||
patients: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
thinking_tokens: number
|
||||
ocr_cost: number
|
||||
json_cost: number
|
||||
total_cost: number
|
||||
deleted_documents: number
|
||||
deleted_cost: number
|
||||
}
|
||||
|
||||
/** One row of a batch's result list (ADR 0003): the rows the Download summary
|
||||
* writes, keyed by its column labels, so the board and the Excel agree. */
|
||||
export interface ResultRow {
|
||||
'PDF file': string
|
||||
Page: number | null
|
||||
Ticket: string | null
|
||||
/** E1, E2 ... from the Office Location note. */
|
||||
Encounter: string | null
|
||||
Patient: string | null
|
||||
DOS: string | null
|
||||
CPTs: string | null
|
||||
'Tebra encounter ID': string | null
|
||||
/** The summary's word: Posted, Line could not be read, Not run ... */
|
||||
Result: string
|
||||
/** The driver's code behind it (REVIEW, UNMAPPED ...); null when not run. */
|
||||
Status: string | null
|
||||
Reason: string | null
|
||||
/** Where the ticket was read, for Go to page. */
|
||||
job_id: string | null
|
||||
Group: 'Posted' | 'Need you' | 'Not run'
|
||||
Tone: 'good' | 'wait' | 'bad'
|
||||
}
|
||||
|
||||
/** How the newest Check & Post ended (its run-end entry). A crash or a
|
||||
* worker restart leaves complete false with an error. */
|
||||
export interface LastRun {
|
||||
mode: string | null
|
||||
complete: boolean
|
||||
/** False only when the run-end entry has an error: the run did not finish. */
|
||||
finished: boolean
|
||||
error: string | null
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number
|
||||
job_id: string
|
||||
action: string
|
||||
patient: string | null
|
||||
detail: unknown
|
||||
actor: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* One facesheet run through the OCR pipeline.
|
||||
*
|
||||
* No band counts and no cost, unlike `Run`: regex extraction against OCR text
|
||||
* is deterministic, so there is nothing to grade and no tokens to bill.
|
||||
*/
|
||||
export interface DemographyRun {
|
||||
job_id: string
|
||||
document: string
|
||||
/** 'baycare_mph' | 'north_bay' | 'largo' | 'mixed' (older runs: 'baycare'),
|
||||
* or 'castlemanor'. Null until detection has run, and it
|
||||
* stays null on a run that failed because no marker matched. */
|
||||
template: string | null
|
||||
status: 'queued' | 'running' | 'done' | 'failed'
|
||||
/** Whether this document needed OCR, or had a real text layer. Null until the
|
||||
* route has been chosen — which is not the same as "no". */
|
||||
used_ocr: boolean | null
|
||||
pages_done: number
|
||||
pages_total: number
|
||||
patients_found: number
|
||||
error: string | null
|
||||
/** 'extraction' | 'automation'. Mirrors BatchStage in meaning, but demography
|
||||
* runs are not batches — this lives on the run itself, not a grouping. */
|
||||
stage: string
|
||||
created_at: string
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
/** One patient found inside a document, with their fields in form order. */
|
||||
export interface DemographyRecord {
|
||||
patient_index: number
|
||||
patient_name: string | null
|
||||
/** The page this record came from, for one-patient-per-page templates. Null
|
||||
* where fields were matched against the whole document. */
|
||||
page: number | null
|
||||
fields: {
|
||||
row_id: number
|
||||
field: string
|
||||
value: string | null
|
||||
/** A person's fix, kept beside the OCR read rather than replacing it. */
|
||||
corrected_value: string | null
|
||||
/** Weakest OCR word confidence under the value; null for a text-layer
|
||||
* PDF (nothing to score), a blank, or a run read before scoring existed. */
|
||||
score: number | null
|
||||
/** ECW cut points. Null = unscored. A failed format check is always red. */
|
||||
band: 'green' | 'amber' | 'red' | null
|
||||
/** Why the field needs review: a format warning or an unclear read. */
|
||||
reason: string | null
|
||||
}[]
|
||||
}
|
||||
|
||||
/** One Insurance Master row, as the insurance finder lists it. */
|
||||
export interface InsuranceMasterRow {
|
||||
id: number
|
||||
name: string
|
||||
/** Street or PO box. */
|
||||
address: string
|
||||
city: string
|
||||
state: string
|
||||
zip: string
|
||||
}
|
||||
|
||||
/** A row of the people-and-access list. */
|
||||
export interface ManagedUser {
|
||||
username: string
|
||||
/** Computed by the server. On a SCOPED (single-project) list, 'admin'
|
||||
* means admin of that project specifically, so the value already carries
|
||||
* the scope. On the MERGED (all-projects) list it is 'operator' for
|
||||
* everyone except the one super_admin row — a merged row can be admin of
|
||||
* one project and not the other, so this field alone cannot say which;
|
||||
* read ecw_role/tebra_role below for that. */
|
||||
role: 'super_admin' | 'admin' | 'operator'
|
||||
/** Only populated on the MERGED list (UsersPanel's `scope === 'all'`) —
|
||||
* always 'admin' or 'operator' there, never null. Meaningless on a scoped
|
||||
* single-project list, which carries the scope in `role` instead. */
|
||||
ecw_role?: 'admin' | 'operator'
|
||||
tebra_role?: 'admin' | 'operator'
|
||||
real_name: string | null
|
||||
assigned_by: string | null
|
||||
assigned_at: string | null
|
||||
last_login_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
Reference in new issue
Block a user