Initial commit: plan, spec, tickets and dashboard skill templates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 13:33:03 +05:30
commit 49f8f49b65
39 files changed
+10736

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
node_modules/
.venv/
@@ -0,0 +1,30 @@
# 01: No-AI skill, first full path: empty project → working dashboard with sample data
Part of: ../spec.md
**What to build:** A developer runs the no-AI skill in an empty project. The skill:
- scans the project and finds no frontend and no bot,
- asks for colours (Tebra Sky, Superbill Ember, or a custom brand hex),
- asks desktop or web, and which optional sections to build,
- produces a working dashboard page with the shell, KPI cards, status donut, run panel and report table, running on sample data.
An automated page check proves the page renders the standard data contract correctly. Before any of that, fix the desktop host template so it stays valid Python until its placeholders are filled.
**Blocked by:** None (can start immediately)
**Status:** ready-for-agent
- [ ] The desktop host template passes the lint hook unfilled. Its placeholders go inside string literals.
- [ ] The no-AI skill file exists with a trigger description ("RPA dashboard", "automation dashboard", "bot dashboard", "without AI") and the workflow in spec order: scan → report/keep-or-restyle → colours → stack → optional sections → build → wire → verify.
- [ ] The theme presets file exists with Tebra Sky and Superbill Ember, light and dark tokens for each.
- [ ] Custom palette: given one brand hex, the skill derives the rest of the palette and checks text contrast in light and dark.
- [ ] With no bot present, the generated page uses sample data and renders on first open.
- [ ] A headless-browser page check (Seam 1) pushes contract envelopes through the page's public entry point and asserts visible results:
- KPI counts match batch states;
- donut and legend counts match;
- the step list follows the step label;
- log lines appear with their type;
- report search filters rows and shows the correct bucket pill;
- the dark toggle flips the theme.
- [ ] The page check runs in light, dark and narrow width (≤820px).
- [ ] Skill eval on test project (a), an empty project: the skill asks colours, stack and sections, never reports a frontend, and its output passes the page check.
@@ -0,0 +1,22 @@
# 02: No-AI desktop path: PyQt bot connected to the page
Part of: ../spec.md
**What to build:** A developer runs the no-AI skill in a PyQt project that already has a bot printing step markers. The skill chooses the desktop path and connects the bot to the dashboard:
- The bot runs as its own child process, and its output is read line by line.
- Specific step markers move the progress bar and step list.
- A friendly-rules table turns raw output lines into plain-English log lines.
- Error lines always appear, even when no rule matches.
- Dark mode is remembered between launches.
**Blocked by:** 01
**Status:** ready-for-agent
- [ ] The skill detects the PyQt project and the bot entry point, and fills in the host template's bot command, steps, step markers and friendly rules from the bot's real output.
- [ ] Clicking Run starts the bot in a separate process. The Run button disables itself while running, and the status dot goes ready → running → done/error.
- [ ] Step markers match on specific substrings and the first match wins. An unrelated line containing a common word does not move progress.
- [ ] Unmatched error lines are always shown. Unmatched non-error lines are hidden.
- [ ] The dark-mode choice persists across app restarts.
- [ ] A Python assert-based self-check covers line classification, step-marker matching and friendly-rule matching, including the "never hide an error" case.
- [ ] Skill eval on test project (b), a PyQt project with a step-marker bot: the skill doesn't ask the stack question, and the running page shows real steps and log lines.
@@ -0,0 +1,15 @@
# 03: Existing frontend: report, then keep or restyle
Part of: ../spec.md
**What to build:** A developer runs the no-AI skill in a web project that already has a frontend with its own colours. Before changing anything, the skill reports where the frontend is, what stack it uses and what colours it uses, then asks: keep the existing look or restyle. "Keep" adds the dashboard page in the existing tokens, fonts and components. "Restyle" asks for a preset or custom colour and swaps only the theme tokens; layout and logic stay untouched.
**Blocked by:** 01
**Status:** ready-for-agent
- [ ] Detection covers plain HTML, React/Vue projects and an embedded PyQt web view, and reports location, stack and the main colour tokens.
- [ ] "Keep": the new dashboard page uses the project's existing tokens and fonts. No existing styles are changed.
- [ ] "Restyle": only token blocks change. A diff shows no layout or logic edits.
- [ ] The colour question is skipped when the user keeps the existing look. The stack question is skipped whenever a frontend exists.
- [ ] Skill eval on test project (c), a web project with an existing frontend and its own colours: the skill reports the frontend correctly. Both branches produce a page that passes the page check.
@@ -0,0 +1,20 @@
# 04: Optional sections: checkpoint tracker, admin/roles, login page
Part of: ../spec.md
**What to build:** The skill asks which optional sections to build and builds only those:
- **Checkpoint tracker:** numbered boxes that go pending → active → done, or stuck.
- **Admin/roles screen:** admin or operator. The admin nav item is hidden from operators.
- **Login page:** UI only. It calls whatever auth the project already has.
Sections that aren't picked leave no code behind.
**Blocked by:** 01
**Status:** ready-for-agent
- [ ] The skill asks for the optional sections with a multi-select and builds only the chosen ones.
- [ ] Checkpoint tracker: the page check pushes checkpoint-state events and asserts each box shows pending, active, done or stuck.
- [ ] Admin/roles: the page check asserts the admin nav and screen appear for the admin role and are hidden for operators.
- [ ] Login page: renders in the chosen theme, light and dark, and hands off to the project's auth or a stub.
- [ ] With no optional sections picked, the output contains no leftover markup, scripts or styles for them.
@@ -0,0 +1,27 @@
# 05: AI skill, first full path: confidence bands + review and approval (web, sample data)
Part of: ../spec.md
**What to build:** A developer runs the AI skill in a web project that has an LLM extraction call but no frontend. The skill follows the same question flow as the no-AI skill and builds a React + Vite + Tailwind dashboard with everything from the no-AI page, plus:
- **Confidence bands:** green ≥0.95, amber 0.90–0.95, red <0.90. A field takes its worst word score, and a blank mandatory field is always flagged. The legend shows the caveat text.
- **Review split pane:** the source document next to the extracted fields. Fields can be filtered by band. Edits stay drafts until Save, and leaving with unsaved edits asks first. Each field has Verify, and there's a "verify remaining" action.
- **Approve gate:** the server refuses approval while any required field is unverified.
**Blocked by:** 01
**Status:** ready-for-agent
- [ ] The AI skill file exists with a trigger description ("AI RPA dashboard", "extraction dashboard", "LLM/OCR review dashboard") and the shared workflow.
- [ ] An AI contract extends the no-AI contract. These shapes came from the Superbill prototype:
`Run{job_id,status:pending|queued|running|done|failed,model,pages_done,pages_total,summary:{green,amber,red,unscored}}`
`ReviewRow{row_id,field,value,band:green|amber|red|blank|unscored,score,reason,verified,corrected_value}`.
Actions: patch a review row, approve a run (server gate). A busy run is polled every ≈1.5s.
- [ ] Reference code: Superbill-only names (ECW/Tebra project split, table prefixes, patient/ticket wording) are stripped or marked as examples. A scan finds no patient data, internal IPs, credentials or keys.
- [ ] A band self-check asserts the thresholds, the worst-word rule and the blank-mandatory flag.
- [ ] Page check, AI cases:
- band counts and legend match;
- band filters show the right rows;
- a draft edit isn't saved until Save;
- approve stays blocked while rows are unverified (the server stub refuses);
- "page X of Y" updates while running.
- [ ] Skill eval on test project (d), a project with an LLM extraction call: the skill picks the AI path, asks the shared questions, and its output passes the page check with sample data.
@@ -0,0 +1,22 @@
# 06: AI cost page: tokens, cost, monthly spend, model choice
Part of: ../spec.md
**What to build:** The AI dashboard gets a cost/ROI page. It shows:
- input, output and thinking tokens and cost per run;
- total spend and this month's spend, with a month-by-month breakdown;
- a coverage pill ("N of M documents priced");
- an "against doing it by hand" panel with editable assumptions, clearly labelled as assumptions.
Cost comes from a pricing table that records its source and its as-of date. Optionally, the skill also adds a model picker for each batch and retry-on-another-model for failed runs.
**Blocked by:** 05
**Status:** ready-for-agent
- [ ] Cost per call = input tokens × input rate + (output + thinking tokens) × output rate, per million tokens. Long-context rates apply above the threshold.
- [ ] An unknown model raises an error that names the known models. Cost never silently defaults to zero.
- [ ] A Python self-check covers normal, long-context, thinking-token and unknown-model cases.
- [ ] Contract: `RoiRun{model,input_tokens,output_tokens,thinking_tokens,total_cost}` and a fetch-ROI action. (Shape from the Superbill prototype.)
- [ ] The page check asserts that the spend tiles sum the runs, the coverage pill counts priced vs. total documents, and the month breakdown groups runs correctly.
- [ ] The model picker and retry-on-other-model are offered as optional sections and built only when chosen.
@@ -0,0 +1,15 @@
# 07: AI desktop path: AI sections in plain HTML on the no-AI page
Part of: ../spec.md
**What to build:** A developer runs the AI skill in a PyQt project. The skill builds the Tebra-style no-AI page and adds plain-HTML versions of the AI sections: band legend, review split pane with drafts, Verify and approve gate, and the cost tiles. They're connected through the same Python bridge as the no-AI desktop path, so a desktop AI bot gets the full AI dashboard with no web server.
**Blocked by:** 02, 05
**Status:** ready-for-agent
- [ ] Plain-HTML AI sections exist, use the shared theme tokens, and render in light, dark and narrow width.
- [ ] The AI events and actions travel over the desktop bridge using the same envelope as the no-AI path.
- [ ] Approval is still enforced in Python on the bridge side. Changing the page cannot bypass it.
- [ ] The page check runs the AI cases from tickets 05 and 06 against the desktop page and they pass.
- [ ] Skill eval on a PyQt test project with an LLM extraction call: the skill picks the desktop AI path, and its output passes the page check.
+237
View File
@@ -0,0 +1,237 @@
# Spec: RPA dashboard skills (no-AI and AI)
Status: ready-for-agent
## Problem Statement
Every new RPA project at Ecare needs a dashboard: what the bot is doing now, how many batches finished or failed, a log people can read, and a report they can export. For projects that use AI extraction, it also needs a way for a person to review low-confidence fields and approve them, plus a view of what the AI is costing.
Today, each dashboard is built from scratch. Two good dashboards already exist, Tebra ERA (no AI) and Superbill Review (AI), but their designs, colours and data plumbing sit inside those projects. They can't be reused without hours of copy-and-strip work.
As a result:
- Each new project looks different.
- Each one re-learns the same lessons: never hide error lines, run the bot in its own process, poll only while busy, enforce the approval gate on the server.
- Developers spend days on UI instead of on the automation.
## Solution
Build two Claude skills that a developer runs inside any new RPA project to produce a working dashboard page:
- **`rpa-dashboard-no-ai`** for plain RPA bots. Based on the Tebra ERA dashboard.
- **`rpa-dashboard-ai`** for RPA bots that use an LLM or OCR. Based on Superbill Review. It includes everything in the no-AI skill, plus confidence bands, a review-and-approve screen, and a cost/ROI page.
When the developer runs a skill, it:
1. Scans the project.
2. Tells them whether a frontend already exists: where it is, what stack it uses, and what colours it uses.
3. Asks whether to keep that look or restyle it.
4. Asks for colours: a preset (Tebra Sky or Superbill Ember) or their own brand colour.
5. Asks desktop or web, if there's no frontend.
6. Asks which optional sections they want.
7. Builds the page and wires it to the bot using one standard data contract. If no bot exists yet, it uses sample data so the page works on day one.
## User Stories
1. As an RPA developer, I want to run one skill in a new project and get a working dashboard page, so that I don't build the UI from scratch.
2. As an RPA developer, I want to choose between a no-AI skill and an AI skill, so that the dashboard matches what my bot actually does.
3. As an RPA developer, I want the skill to scan my project before asking anything, so that it doesn't ask questions the code already answers.
4. As an RPA developer, I want the skill to tell me whether a frontend already exists, with its location, stack and colours, so that I know what it found before it changes anything.
5. As an RPA developer with an existing frontend, I want to choose between keeping its look and restyling it, so that the skill never overwrites my brand without asking.
6. As an RPA developer who keeps the existing look, I want the new dashboard page to match the existing tokens, fonts and components, so that it feels like part of the same app.
7. As an RPA developer who restyles, I want the skill to replace only the theme tokens, so that my layout and logic stay untouched.
8. As an RPA developer with no frontend, I want to pick the Tebra Sky preset, so that I get the blue glassmorphism look quickly.
9. As an RPA developer with no frontend, I want to pick the Superbill Ember preset, so that I get the red-orange brand gradient look quickly.
10. As an RPA developer, I want to give one brand hex and have the skill derive the rest of the palette, so that a client-branded dashboard needs no design work.
11. As an RPA developer, I want every palette to come with a matching dark mode, so that dark mode never looks broken.
12. As an RPA developer, I want the skill to check text contrast for a custom palette, so that the dashboard stays readable.
13. As an RPA developer with no frontend, I want to choose desktop (PyQt with an embedded web page) or web, so that the dashboard fits how the bot is deployed.
14. As an RPA developer on desktop, I want the page to talk to Python through a bridge, so that I don't need a web server.
15. As an RPA developer on web, I want the same page to poll the backend, so that one design serves both deployment styles.
16. As an RPA developer, I want to choose which optional sections get built (checkpoint tracker, admin/roles, login), so that I don't delete unused code afterwards.
17. As an RPA developer, I want the sidebar, top bar, dark toggle and status dot built every time, so that every dashboard has the same shell.
18. As an RPA developer, I want KPI cards for total, completed, in-progress and not-completed batches, so that the overview always answers "how are we doing?".
19. As an RPA developer, I want a status donut with a legend, so that the batch split is visible at a glance.
20. As an RPA developer, I want a run panel with a progress bar, a step list and a live log, so that operators can watch a run happen.
21. As an RPA developer, I want a report table with search and Excel export, so that results can be handed to the billing team.
22. As an RPA developer, I want one documented data contract (events in, actions out), so that I know exactly what my bot must send.
23. As an RPA developer, I want the skill to wire the contract to my existing bot, so that the dashboard shows real data immediately.
24. As an RPA developer without a bot yet, I want the page to ship with sample data, so that I can demo and style it before the bot exists.
25. As an RPA developer, I want my bot's raw output lines turned into friendly log messages by a rules table, so that operators see plain English.
26. As an RPA developer, I want error lines always shown even when no friendly rule matches, so that a real failure is never invisible.
27. As an RPA developer, I want step progress driven by specific markers in the bot's output, so that progress doesn't jump on unrelated lines.
28. As an RPA developer on desktop, I want the bot to run in its own process with its output tailed, so that UI automation reads stay reliable.
29. As an RPA developer on web, I want polling to speed up only while a run is busy, so that an idle dashboard doesn't hammer the backend.
30. As an operator, I want a status dot that says ready, running, done or error, so that I know at a glance whether the bot is working.
31. As an operator, I want a Run button that disables itself while a run is going, so that I can't start two runs by accident.
32. As an operator, I want my dark-mode choice remembered, so that I don't toggle it every time.
33. As an operator, I want each report row to show a coloured success, attention or failed pill, so that I can spot problems fast.
34. As an operator, I want the dashboard to stay usable on a narrow window, so that it works on a small laptop screen.
35. As an admin, I want an optional roles screen (admin/operator), so that only the right people see admin features.
36. As an AI-project developer, I want the AI skill to include everything from the no-AI skill, so that I don't have to merge two outputs.
37. As an AI-project developer, I want confidence bands (green ≥0.95, amber 0.90–0.95, red <0.90), so that reviewers only look at fields that need them.
38. As an AI-project developer, I want a field's band to come from its worst word score, so that one shaky word can't hide inside a clean-looking field.
39. As an AI-project developer, I want a blank mandatory field always flagged, so that a missing code never passes silently.
40. As an AI-project developer, I want the band caveat shown in the UI ("colour reflects print clarity, not correctness"), so that reviewers don't over-trust green.
41. As a reviewer, I want the source document next to the extracted fields, so that I can check each value against the original.
42. As a reviewer, I want to filter fields by band (all, red, amber, missing, unscored), so that I work the riskiest fields first.
43. As a reviewer, I want to correct a value and keep it as a draft until I save, so that I don't commit half-finished edits.
44. As a reviewer, I want a warning if I leave with unsaved edits, so that I don't lose work.
45. As a reviewer, I want to verify each field, so that there's a record of who checked what.
46. As a reviewer, I want a "verify remaining" action, so that I can finish a clean document quickly.
47. As a reviewer, I want approval to be refused by the server until every required field is verified, so that the gate can't be bypassed from the browser.
48. As a reviewer, I want to see "page X of Y" while extraction runs, so that I know how long to wait.
49. As a reviewer, I want to retry a failed run on the same or a different model, so that a quota or overload error doesn't block me.
50. As an AI-project developer, I want an optional model picker, so that users can choose a cheaper or stronger model per batch.
51. As a manager, I want a cost page with input, output and thinking tokens and cost per run, so that I know what the AI spends.
52. As a manager, I want this month's spend and a month-by-month breakdown, so that I can track the budget.
53. As a manager, I want a coverage pill ("N of M documents priced"), so that I know whether the totals are complete.
54. As a manager, I want an "against doing it by hand" panel with editable assumptions, clearly labelled as assumptions, so that the ROI story is honest.
55. As an AI-project developer, I want a pricing table with a clear error when a model is missing, so that cost is never silently wrong.
56. As an AI-project developer on desktop, I want the AI sections available as plain HTML, so that a PyQt AI project gets them too.
57. As an AI-project developer on web, I want the AI dashboard built in React with Vite and Tailwind, adapted from the Superbill components, so that it matches the proven design.
58. As a skill maintainer, I want the reference code bundled inside each skill, so that the skills work on any PC even if the source projects move.
59. As a skill maintainer, I want the bundled references free of patient data, internal IPs and keys, so that the skills are safe to share.
60. As a skill maintainer, I want project-specific names (ECW, Tebra, table prefixes, patient/ticket wording) stripped or clearly marked, so that new projects don't inherit them.
61. As a skill maintainer, I want an automated check that the generated page renders contract events correctly, so that I can change templates without fear.
62. As a skill maintainer, I want an eval that runs each skill on small fixture projects, so that I know the question flow and output still work.
63. As a team member, I want to install the skills by copying their folders into Claude's skills directory, so that setup is trivial.
## Implementation Decisions
**Modules**
- **Two skills, each self-contained:** a skill file holding the workflow and trigger description, plus a templates folder. The AI skill's desktop path reuses the no-AI dashboard page, so both skills must be installed together.
- **No-AI templates:**
- a single-file dashboard page (theme tokens, shell, KPI, donut, run panel, report);
- a minimal PyQt host with bridge and process tailing;
- the data contract;
- theme presets;
- raw Tebra extracts as reference.
- **AI templates:**
- an AI data contract that extends the no-AI one;
- plain-HTML AI sections for desktop (band legend, review split pane, ROI tiles);
- theme presets;
- Superbill reference components, styles, band logic and the pricing/cost function.
**Workflow order, identical in both skills**
1. Scan.
2. Report the existing frontend, then ask keep or restyle.
3. Ask colours. Skipped if the user keeps the existing theme.
4. Ask the stack question. Only when there's no frontend.
5. Ask for optional sections.
6. Build.
7. Wire the data.
8. Verify.
**Theme rules**
- The theme lives only in the token blocks, light plus dark. Restyling swaps tokens and nothing else.
- Presets:
- **Tebra Sky:** sky gradient, glass cards, accent `#3d9bd0`, Segoe UI.
- **Superbill Ember:** flat surfaces, gradient `#ff3049`→`#f26b21`, used only on the header, primary buttons, the active nav row and the progress fill; Sora and IBM Plex fonts.
- **Custom:** derived from one brand hex.
- Band colours are semantic only and never used on buttons or headings.
**Transport**
- One envelope in both directions: `{event, data}`.
- **Desktop:** Python pushes envelopes into the page; the page calls named bridge slots.
- **Web:** the page polls a dashboard endpoint and a run-status endpoint. Run-status returns `{running, events[]}`. Polling is fast (≈2s) only while running, otherwise slow.
- The same page file detects which transport is present.
**No-AI contract (events to the page)**
- `status {state: ready|running|done|error, msg}`
- `step {label, pct}`
- `log {msg, type: info|success|warn|error}`
- `dashboard_data {batches: [{batch_id, date, state: completed|inprogress|notcompleted|cleared, items_total, items_done}]}`
- `report_data {rows: [... , status, bucket: success|attention|failed]}`
- `set_user {username, emp_id, role}`
- `apply_dark {on}`
- Optional: `cpstate {kind: pending|active|done|stuck, cp}`
**No-AI contract (actions from the page)**
- `load_dashboard`, `start_automation`, `request_stop`, `load_latest_report`, `download_report`, `set_dark`, `logout`
**Derived rules**
- A Pending batch from today is `inprogress`; one from an older day is `notcompleted`.
- Report buckets map {processed successfully, posted, completed} to success and {error, failed} to failed; everything else is attention.
**AI contract additions.** These type shapes came from the Superbill prototype; trimmed:
```
Run { job_id, status: pending|queued|running|done|failed, model,
pages_done, pages_total, summary: { green, amber, red, unscored } }
ReviewRow { row_id, field, value, band: green|amber|red|blank|unscored,
score, reason, verified, corrected_value }
RoiRun { model, input_tokens, output_tokens, thinking_tokens, total_cost }
```
The AI skill adds these actions: patch one review row (corrected value, verified), approve a run (the server enforces the gate), and fetch ROI. While a single run is busy, it's polled every ≈1.5s.
**Banding**
- Green ≥0.95, amber 0.90–0.95, red <0.90.
- A multi-word field takes the minimum word score.
- A blank mandatory field is always flagged.
- The caveat text is shown next to the legend.
**Cost**
- Cost per call = input tokens × input rate + (output + thinking tokens) × output rate, per million tokens.
- The pricing table records its source and its as-of date. An unknown model raises an error naming the known models; it never defaults to zero.
**Bot integration on desktop**
- The bot runs as a child process, and its stdout is tailed.
- The raw line is kept for audit.
- The step-marker table and the friendly-rules table use specific substrings, and the first match wins.
- Unmatched non-error lines are hidden; unmatched error lines are always shown.
**Fill-in points**
- Template placeholders are filled by the skill: app name, subtitle, logo, steps, report columns, API base, bot module.
- Python templates must stay valid Python before they're filled, so placeholders go inside string literals. The current host template breaks this rule and is known lint debt.
**Safety**
- Bundled references contain no PHI, internal IPs, credentials or keys.
- Project-specific names are removed or marked as examples.
## Testing Decisions
- **Good tests check external behaviour only:** what the user sees on the page for a given set of contract events, and what files and questions a skill produces for a given project. They don't check internal function names, CSS class names or the template's internal structure.
- **Seam 1: the data contract (primary).**
- Load a generated dashboard page in a headless browser with no backend.
- Push contract envelopes through the page's public entry point.
- Assert on the visible result. KPI numbers match the batch states; donut segments and legend counts match; the active and done steps match the step label; log lines appear with the right type; error lines from an unmatched bot line appear; report rows filter on search and show the right bucket pill; dark mode flips the theme.
- The AI variant also checks: band counts and legend; review rows filtered by band; approve stays blocked while rows are unverified (server stub returns refusal); ROI tiles sum the runs; a missing-price model surfaces an error.
- Run each case in light and dark, and at a narrow width (≤820px).
- **Seam 2: skill eval.**
- Run each skill against small fixture projects:
- (a) empty project, no frontend, no bot;
- (b) PyQt project with a bot that prints step markers;
- (c) web project with an existing frontend and its own colours;
- (d) for the AI skill, a project with an LLM extraction call.
- Assert that the skill reports the existing frontend only when there is one, and asks keep-or-restyle in that case.
- Assert that it asks for colours and stack only when appropriate, and asks for optional sections.
- Assert that it produces a page that passes Seam 1 with sample data, or with the fixture bot's real output in case (b).
- Use the skill-creator eval tooling, or `claude plugin eval`.
- **Python logic tests:** the pure Python pieces bundled in the templates get a small assert-based self-check: line classification and friendly-rule matching, band assignment, and cost calculation. The prior art is the band and cost functions in Superbill and Tebra's pytest suite for report categorisation and batch state.
- **Prior art:** Tebra has pytest files covering batch clear, report scope and status categorisation. Superbill's band thresholds and its server-enforced approval gate define the expected AI behaviour.
## Out of Scope
- **Backend generation:** database tables, stored procedures, worker queues, and auth/login services. The skills wire to what exists or ship sample data; they don't build the backend. (The optional login page is UI only.)
- **The automation itself:** UI automation steps, SOAP/API posting, and extraction prompts and templates.
- **Deployment:** the auto-update launcher and Dokploy.
- **Frameworks other than plain HTML (desktop/web) and React (AI web).** For an existing Vue/Angular frontend, the skill ports sections using the HTML as reference, best effort, with no dedicated template.
- **Exact ports of every original feature:** PDF zoom/fullscreen polish, drag-to-stage in the sidebar, the animated extraction progress, demography and insurance lookups, and the batch-clear approval flow.
- **Installing the skills into Claude.** The user copies the folders.
## Further Notes
- **Source projects:** Tebra ERA, a PyQt6 desktop app whose dashboard is one embedded HTML page; and Superbill Review, FastAPI with a React 19 + Vite + Tailwind v4 front end and Gemini on Vertex AI. Their design docs (Superbill's design and product notes, Tebra's context notes) explain the reasoning behind the visual rules and should be read before changing presets.
- **Already done in this folder:**
- the no-AI dashboard page, the PyQt host and the no-AI contract;
- reference extracts for both skills.
- **Still to do:**
- both skill files;
- both theme preset files;
- the AI contract;
- the AI desktop sections;
- stripping project names from the AI references;
- fixing the host template's placeholder lint;
- both test seams.
- **Plan:** the planning record with every grill decision is the plan file at the repo root.
- No glossary or ADRs exist yet for this folder.
+13
View File
@@ -0,0 +1,13 @@
# RPA SKILLS
Shared Claude skills for building RPA dashboards: `rpa-dashboard-no-ai` and `rpa-dashboard-ai`. See `PLAN.md`.
## Agent skills
### Issue tracker
Issues and specs live as local markdown under `.scratch/<feature>/`. See `docs/agents/issue-tracker.md`.
### Domain docs
Single-context: `GLOSSARY.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`.
+115
View File
@@ -0,0 +1,115 @@
# Plan: two RPA dashboard skills
Goal: build two Claude skills. Future RPA projects use them to create a dashboard page.
- **`rpa-dashboard-no-ai`**: for RPA projects with no AI. Source project: `D:\Tebra_Auto_Post`.
- **`rpa-dashboard-ai`**: for RPA projects that use AI (LLM/OCR extraction). Source project: `D:\Superbill_extRACTION`.
Build both in `D:\RPA SKILLS\<skill-name>\`. The user installs them into Claude later.
## Decisions (from the grill)
| # | Question | Decision |
|---|---|---|
| Q1 | Where the skills live | `D:\RPA SKILLS` only. The user installs them into Claude later. |
| Q2 | Tech stack | **Detect, then adapt.** Scan the project first. If a frontend exists, use its stack. If not, ask the user: Desktop (PyQt6 + QWebEngine HTML) or Web. Both skills support both. |
| Q3 | Colours when there's no frontend | **Offer presets + custom:** 1) Tebra Sky, 2) Superbill Ember, 3) Custom (user gives a primary/brand hex). Always produce light and dark token blocks. |
| Q4 | Frontend already exists | **Report, then ask.** Say "Frontend found at X, stack Y, colours Z". Then ask: keep the existing theme and add the dashboard in the same style, or restyle to a preset/custom palette. |
| Q5 | Sections | **Core + ask for extras** (see below). |
| Q6 | Reference code | **Bundle templates** inside each skill (`templates/`). Do not read the D:\ source projects at runtime. |
| Q7 | Data | **Standard contract + wire it.** One event/JSON contract. Wire it to the project's bot if one exists. Otherwise ship sample data so the page works on day one. |
## Sections
**Both skills, always built:**
- Shell: sidebar, topbar, dark toggle, status dot
- KPI cards
- Status donut
- Run panel: progress bar, steps and live log
- Report table with search and Excel export
**Both skills, optional (ask the user):**
- Checkpoint tracker
- Admin/roles
- Login page
**AI skill, also always built:**
- Confidence bands. Scoring: green ≥0.95, amber 0.90–0.95, red <0.90. A field gets the worst word score. The band caveat text is shown to the user.
- Review split pane: source document next to the extracted fields. Edits stay drafts until Save. Each field has Verify. Approve is a gate enforced on the server.
- ROI/cost page: tokens (input/output/thinking) per run, cost per run, monthly spend, coverage pill, "vs by hand" assumptions.
**AI skill, also optional:**
- Model picker
- Retry on the other model
## Skill workflow (each SKILL.md must follow this order)
1. **Scan the project.** Look for an existing frontend: `*.html`, `web/`, `package.json` with react/vue, a PyQt `QWebEngineView`. Also find the bot entry point and any status/log output.
2. **Report what you found, then ask Q4.** Skip this step if there is no frontend.
3. **Ask for colours (Q3).** Skip this step if the user chose to keep the existing theme.
4. **Ask the stack question (Q2).** Only if there is no frontend.
5. **Ask which optional sections to build (Q5).**
6. **Build.** Copy the template, fill the `{{PLACEHOLDERS}}`, apply the tokens, remove the sections the user didn't choose.
7. **Wire the data.** Connect to the bot using the contract. If there is no bot yet, use sample data.
8. **Verify.** Open the page, check both light and dark mode, and check width ≤820px.
## Theme presets (put in `templates/themes.md` in BOTH skills)
**Tebra Sky.** Glass cards on a gradient background. Segoe UI font.
- Light: `--bg-top:#8fc9e8; --bg-mid:#bfe3f2; --bg-bottom:#eaf6fb; --ink:#1c2b33; --muted:#5c7382; --accent-dark:#16202a; --accent:#3d9bd0; --accent-2:#5cb2e2; --ok:#1f9d57; --warn:#b7791f; --bad:#c0392b`
- Dark: `--bg-top:#0f1b26; --bg-mid:#13212e; --bg-bottom:#182a38; --ink:#e6eef4; --muted:#8fa6b4; --accent-dark:#2e5d80; --ok:#2fbf72; --warn:#d9a13c; --bad:#e05a4b`
**Superbill Ember.** Flat surfaces. The brand gradient appears only on the header bar, primary buttons, the active nav row and the progress fill. Fonts: Sora for display, IBM Plex Sans for body.
- Light: `--ground:#f4f3f7; --surface:#ffffff; --surface-2:#f7f6fa; --line:#e2dfe9; --ink:#18151f; --ink-2:#3d3850; --muted:#66607a; --accent-1:#ff3049; --accent-2:#f26b21`
- Dark: `--ground:#0a0912; --surface:#151220; --surface-2:#1b1727; --line:#2a2438; --ink:#f3f1f8; --ink-2:#c9c4d8; --muted:#8d87a0`
- Band colours (AI only, semantic, never used on buttons): `--clean:#51a68f; --verify:#e39b34; --review:#de5243; --absent:#6b6580`
**Custom.** Take the user's primary hex and derive from it:
- `--accent`
- `--accent-2`: lighter
- `--accent-dark`: darker
- `--accent-soft`: tint
- Keep ok/warn/bad unless the user gives their own.
- Check text contrast in both light and dark.
## Status: what already exists
```
rpa-dashboard-no-ai/
templates/dashboard.html DONE: generic page, works in Qt (QWebChannel) or web (polling)
templates/contract.md DONE: event/JSON contract + Tebra lessons
templates/bridge.py DONE: minimal PyQt6 host + subprocess log tailing
templates/reference/ DONE: tebra_dashboard.html, tebra_bridge_part1/2.py (raw extracts)
rpa-dashboard-ai/
templates/reference/web/ DONE: Superbill index.css, DESIGN.md, bands.ts, theme.tsx, types.ts, api.ts,
components (BrandHeader, Sidebar, StatsBar, Donut, SplitPane,
RunPanel, ReviewPane, RoiTab, PdfPane, Tooltip, icons)
templates/reference/backend/ DONE: bands.py, gemini_cost.py (PRICING table + cost_for_call)
```
## TODO for the implement skill
1. **Fix the lint error in `rpa-dashboard-no-ai/templates/bridge.py:15`.** `STEPS = {{STEPS_PY}}` is not valid Python, so the lint hook flags it (F821). Change it to `STEPS = ["{{STEP_1}}", "{{STEP_2}}"]` or similar.
2. **Write `rpa-dashboard-no-ai/SKILL.md`.**
- Frontmatter `name` and `description`. The trigger should cover "RPA dashboard", "automation dashboard", "bot dashboard", and "without AI".
- The workflow above.
- Point to `templates/*`.
3. **Write `rpa-dashboard-ai/SKILL.md`.**
- Trigger: "AI RPA dashboard", "extraction dashboard", "LLM/OCR review dashboard".
- The workflow above.
- Web path: React + Vite + Tailwind v4, adapted from `reference/web`.
- Desktop path: copy the no-AI `dashboard.html`, then add the AI sections.
4. **Write `templates/themes.md` in both skills.** Content is in the Theme presets section above.
5. **Write `rpa-dashboard-ai/templates/contract.md`.** It extends the no-AI contract with:
- `Run{job_id,status:pending|queued|running|done|failed,model,pages_done,pages_total,summary:{green,amber,red,unscored}}`
- `ReviewRow{row_id,field,value,band,score,reason,verified,corrected_value}`
- `RoiRun{model,input_tokens,output_tokens,thinking_tokens,total_cost}`
- Endpoints: `PATCH /review/{row_id}`, `POST /runs/{id}/approve` (server gate), `GET /roi`.
- Polling: 1.5–2s only while a run is busy.
6. **Write `rpa-dashboard-ai/templates/ai-sections.html`.** Plain HTML/CSS/JS versions of the band legend, the review split pane and the ROI tiles, for the desktop path.
7. **Strip project-specific bits from the AI reference files.** Remove ECW/Tebra project names, SPB_* tables and patient/ticket wording, or mark them as such. **Never** ship real patient data (PHI), internal IPs or keys. The reference copies have already been checked for these, but check again after editing.
8. **Verify.** Open `dashboard.html` in a browser using sample data. Check light, dark and narrow width. Load each SKILL.md and make sure every path it references exists.
## Done when
- Both folders have a SKILL.md and all the templates it references.
- In a fresh test project, each skill:
- asks the questions in order,
- reports any existing frontend,
- produces a working dashboard page that renders in light and dark.
+51
View File
@@ -0,0 +1,51 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Before exploring, read these
- **`GLOSSARY.md`** at the repo root, or
- **`GLOSSARY-MAP.md`** at the repo root if it exists: it points at one `GLOSSARY.md` per context. Read each one relevant to the topic.
- **`docs/adr/`**: read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
## File structure
Single-context repo (most repos):
```
/
├── GLOSSARY.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Multi-context repo (presence of `GLOSSARY-MAP.md` at the root):
```
/
├── GLOSSARY-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── GLOSSARY.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── GLOSSARY.md
└── docs/adr/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `GLOSSARY.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_
+30
View File
@@ -0,0 +1,30 @@
# Issue tracker: Local Markdown
Issues and specs for this repo live as markdown files in `.scratch/`.
## Conventions
- One feature per directory: `.scratch/<feature-slug>/`
- The spec is `.scratch/<feature-slug>/spec.md`
- Implementation issues are one file per ticket at `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01`, never a single combined tickets file
- Triage state is recorded as a `Status:` line near the top of each issue file, using one of: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`
- Comments and conversation history append to the bottom of the file under a `## Comments` heading
## When a skill says "publish to the issue tracker"
Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).
## When a skill says "fetch the relevant ticket"
Read the file at the referenced path. The user will normally pass the path or the issue number directly.
## Wayfinding operations
Used by `/wayfinder`. The **map** is a file with one **child** file per ticket.
- **Map**: `.scratch/<effort>/map.md` (the Notes / Decisions-so-far / Fog body).
- **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`.
- **Frontier**: scan `.scratch/<effort>/issues/` for files that are open, unblocked, and unclaimed; first by number wins.
- **Claim**: set `Status: claimed` and save before any work.
- **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`.
@@ -0,0 +1,64 @@
"""Confidence-band classification — single source of truth for the cut points.
The workbook colours and the review queue MUST agree about what counts as red.
If each applied its own threshold they would drift the first time one is tuned,
and a reviewer would see a red cell the queue never asked them to check.
Everything that needs a band imports from here.
"""
from __future__ import annotations
from typing import Optional
# ---------------------------------------------------------------------------
# Cut points
# ---------------------------------------------------------------------------
# Calibrated against the measured distribution over all 225 extracted rows of
# the six sample documents:
#
# 1.00 69.8% crisp print
# 0.90-0.99 18.2% crisp print
# 0.80-0.89 11.0% almost entirely handwritten CPT and modifiers
# 0.70-0.79 0.0% <- empty; the natural gap in this data
# <0.70 1.1% handwritten modifiers
#
# 0.80 left only 1.1% of cells red so nearly everything went green and the
# colour carried no information. 0.90 sits in the empty band and separates
# the handwriting cluster from printed text.
CONFIDENCE_THRESHOLD: float = 0.90
# Above CONFIDENCE_THRESHOLD the 0.90-0.99 band is still 18.2% of cells, and
# routing treats it differently from a clean 1.00: reviewers sample it rather
# than either trusting it outright or checking every one. A second cut at
# 0.95 names that band so the sheet shows what the routing rule already assumes.
#
# GREEN >= 0.95 clean print, straight through
# AMBER 0.90-0.95 slightly unclear, sampled
# RED < 0.90 handwriting cluster, always reviewed
GREEN_THRESHOLD: float = 0.95
# ---------------------------------------------------------------------------
# Band name constants
# ---------------------------------------------------------------------------
BAND_GREEN = "green"
BAND_AMBER = "amber"
BAND_RED = "red"
def band_of(score: Optional[float]) -> Optional[str]:
"""Return the confidence band name, or ``None`` when the value was never scored.
WHAT THE BAND CLAIMS: green means the source text this value came from was
transcribed cleanly. It does NOT mean the value is correct. The score is
the model's own legibility rating, so a confident misread comes back scored
high. Verifying correctness needs a second, independent read. Red is the
useful direction: it reliably surfaces handwriting.
Amber does not mean "probably fine". It means the reading was good enough to
escape the handwriting cluster but not clean enough to trust unsampled, which
is a routing decision, not a quality claim.
"""
if score is None:
return None
if score >= GREEN_THRESHOLD:
return BAND_GREEN
return BAND_AMBER if score >= CONFIDENCE_THRESHOLD else BAND_RED
@@ -0,0 +1,60 @@
INCLUDE_THINKING_IN_COST = True # thinking tokens bill at output rate
# ═════════════════════════════════════════════════════════════════════════════
# GEMINI PRICING TABLE
# ═════════════════════════════════════════════════════════════════════════════
PRICING_SOURCE = "https://ai.google.dev/gemini-api/docs/pricing"
PRICING_AS_OF = "2026-08-11"
GEMINI_PRICING = {
# ---- Gemini 3.x family --------------------------------------------------
"gemini-3.6-flash": {"input": 1.50, "output": 7.50, "free_tier": True},
"gemini-3.5-flash": {"input": 1.50, "output": 9.00, "free_tier": True},
"gemini-3.5-flash-lite": {"input": 0.30, "output": 2.50, "free_tier": True},
"gemini-3-flash-preview": {"input": 0.50, "output": 3.00, "free_tier": True},
"gemini-3.1-flash-lite": {"input": 0.25, "output": 1.50, "free_tier": True},
"gemini-3.1-pro-preview": {
"input": 2.00, "output": 12.00, "free_tier": False,
"long_threshold": 200_000, "input_long": 4.00, "output_long": 18.00,
},
# ---- Gemini 2.5 family --------------------------------------------------
"gemini-2.5-pro": {
"input": 1.25, "output": 10.00, "free_tier": True,
"long_threshold": 200_000, "input_long": 2.50, "output_long": 15.00,
},
"gemini-2.5-flash": {"input": 0.30, "output": 2.50, "free_tier": True},
"gemini-2.5-flash-lite": {"input": 0.10, "output": 0.40, "free_tier": True},
"gemini-2.5-flash-lite-preview-09-2025": {"input": 0.10, "output": 0.40, "free_tier": True},
# ---- Gemini 2.0 family (DEPRECATED — shut down 2026-06-01) --------------
"gemini-2.0-flash": {"input": 0.10, "output": 0.40, "free_tier": True, "deprecated": True},
"gemini-2.0-flash-lite": {"input": 0.075, "output": 0.30, "free_tier": True, "deprecated": True},
}
def resolve_pricing(model: str, input_tokens: int):
if model not in GEMINI_PRICING:
raise KeyError(
f"No pricing entry for model '{model}'. Add it to GEMINI_PRICING "
f"from {PRICING_SOURCE}. Known models: {', '.join(GEMINI_PRICING)}"
)
p = GEMINI_PRICING[model]
note = ""
in_rate, out_rate = p["input"], p["output"]
threshold = p.get("long_threshold")
if threshold and input_tokens > threshold:
in_rate = p.get("input_long", in_rate)
out_rate = p.get("output_long", out_rate)
note = f"long-context rate (>{threshold:,} input tokens)"
return in_rate, out_rate, note
def cost_for_call(model: str, usage: dict):
in_rate, out_rate, note = resolve_pricing(model, usage["input"])
billable_output = usage["output"]
if INCLUDE_THINKING_IN_COST:
billable_output += usage.get("thinking", 0)
input_cost = usage["input"] / 1_000_000 * in_rate
output_cost = billable_output / 1_000_000 * out_rate
return input_cost + output_cost, input_cost, output_cost, note
@@ -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
}
+81
View File
@@ -0,0 +1,81 @@
"""Minimal PyQt6 host for dashboard.html (desktop mode). Pattern from Tebra main_window.py.
The bot runs as a child process that prints lines, and this window tails them.
Fill in STEP_MARKERS, FRIENDLY_RULES and BOT_CMD for the project.
"""
import json, subprocess, sys, threading
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal, QUrl, QSettings
from PyQt6.QtWidgets import QApplication, QMainWindow
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebChannel import QWebChannel
HTML_PATH = Path(__file__).with_name("dashboard.html")
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # child process
STEPS = {{STEPS_PY}} # same list as STEPS in dashboard.html
STEP_MARKERS = [("[step 1]", 0, 10), ("[all done]", len(STEPS) - 1, 100)] # (substring, step idx, pct)
FRIENDLY_RULES = [("[login] done", "Logged in.", "success")] # (substring, friendly msg | None, type)
def classify(line):
s = line.lower()
return "error" if ("error" in s or "traceback" in s) else "warn" if "warn" in s else "info"
class Bridge(QObject):
_emit = pyqtSignal(str) # thread-safe hop to the GUI thread
def __init__(self, view):
super().__init__()
self.view = view
self._emit.connect(lambda js: self.view.page().runJavaScript(f"window.__pyMsg({js})"))
def send_to_js(self, event, data):
self._emit.emit(json.dumps(json.dumps({"event": event, "data": data})))
@pyqtSlot()
def load_dashboard(self):
self.send_to_js("dashboard_data", {"batches": []}) # TODO: read from DB on a worker thread
self.send_to_js("apply_dark", {"on": QSettings("{{ORG}}", "{{APP_NAME}}").value("dark", False, bool)})
@pyqtSlot(bool)
def set_dark(self, on):
QSettings("{{ORG}}", "{{APP_NAME}}").setValue("dark", on)
@pyqtSlot()
def start_automation(self):
threading.Thread(target=self._run, daemon=True).start()
def _run(self):
self.send_to_js("status", {"state": "running", "msg": "Running…"})
p = subprocess.Popen(BOT_CMD, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
for raw in p.stdout:
self._on_line(raw.rstrip())
ok = p.wait() == 0
self.send_to_js("status", {"state": "done" if ok else "error", "msg": "Completed" if ok else "Failed"})
def _on_line(self, line):
# ponytail: raw line only printed here; persist to file/DB if audit is needed
low, kind = line.lower(), classify(line)
for marker, idx, pct in STEP_MARKERS:
if marker in low:
self.send_to_js("step", {"label": STEPS[idx], "pct": pct})
break
for marker, msg, t in FRIENDLY_RULES:
if marker in low:
if msg:
self.send_to_js("log", {"msg": msg, "type": t})
return
if kind == "error": # never hide a real failure
self.send_to_js("log", {"msg": line, "type": "error"})
if __name__ == "__main__":
app = QApplication(sys.argv)
win, view = QMainWindow(), QWebEngineView()
bridge, channel = Bridge(view), QWebChannel()
channel.registerObject("pyBridge", bridge)
view.page().setWebChannel(channel)
view.setUrl(QUrl.fromLocalFile(str(HTML_PATH)))
win.setCentralWidget(view); win.resize(1280, 820); win.setWindowTitle("{{APP_NAME}}"); win.show()
sys.exit(app.exec())
+31
View File
@@ -0,0 +1,31 @@
# Dashboard data contract (no-AI)
All updates use one envelope: `{"event": "<name>", "data": {...}}`.
- **Desktop (PyQt):** Python calls `window.__pyMsg(json)` via `runJavaScript` (see `bridge.py`).
- **Web:** the page polls `GET {API_BASE}/dashboard` (returns `dashboard_data`) and
`GET {API_BASE}/run/status` → `{"running": bool, "events": [envelope, ...]}`. Poll fast (2s) only while running.
## Bot/backend → page
| event | data |
|---|---|
| `status` | `{state: "ready"\|"running"\|"done"\|"error", msg}` |
| `step` | `{label: <one of STEPS>, pct: 0-100}` |
| `log` | `{msg: <plain English>, type: "info"\|"success"\|"warn"\|"error"}` |
| `dashboard_data` | `{batches: [{batch_id, date, state: "completed"\|"inprogress"\|"notcompleted"\|"cleared", items_total, items_done, ...}]}` |
| `report_data` | `{rows: [{...REPORT_COLUMNS keys, status, bucket: "success"\|"attention"\|"failed"}]}` |
| `set_user` | `{username, emp_id, role}` |
| `apply_dark` | `{on: bool}` |
Optional (only if the section was chosen): `cpstate {kind: pending|active|done|stuck, cp: int}`, `admin_data`, `batch_detail`.
## Page → bot/backend (Qt `@pyqtSlot` names / web `POST {API_BASE}/<slot-with-dashes>`)
`load_dashboard`, `start_automation`, `request_stop`, `load_latest_report`, `download_report`, `set_dark(on)`, `logout`.
## Rules (learned on Tebra)
- Log the raw bot line to a file/DB; show only **friendly** text on screen. Never hide an error line.
- Step markers: match the bot's printed lines by **specific** substring (e.g. `[step 3]`), not a bare word. A bare word matches unrelated lines.
- Derive `state`: Pending + today = `inprogress`; Pending + older day = `notcompleted`.
- Report `bucket`: success = {processed successfully, posted, completed}; failed = {error, failed}; everything else = attention.
- Run the bot in a **separate process** that writes to a log file, and have the UI tail that file. When pywinauto/UIA runs inside a Qt worker thread, its reads are unreliable (Tebra bug-094).
@@ -0,0 +1,273 @@
<!DOCTYPE html>
<!--
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.html).
One file: works inside PyQt6 QWebEngineView (QWebChannel bridge) OR served by any web
backend (falls back to polling GET {{API_BASE}}/dashboard and /run/status).
Replace every {{PLACEHOLDER}}. Theme = the :root / body.dark token blocks only.
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{APP_NAME}}</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
/* ===== THEME TOKENS (swap these for a preset/custom palette) ===== */
:root{
--bg-top:#8fc9e8; --bg-mid:#bfe3f2; --bg-bottom:#eaf6fb;
--glass:rgba(255,255,255,.60); --glass-strong:rgba(255,255,255,.78); --border:rgba(255,255,255,.9);
--ink:#1c2b33; --muted:#5c7382; --accent-dark:#16202a;
--accent:#3d9bd0; --accent-2:#5cb2e2; --accent-soft:#d4edfa; --accent-soft-2:#a9d8ee;
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd; --bad:#c0392b; --bad-bg:#fdecea;
--font:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif;
}
body.dark{
--bg-top:#0f1b26; --bg-mid:#13212e; --bg-bottom:#182a38;
--glass:rgba(22,34,44,.72); --glass-strong:rgba(30,45,58,.85); --border:rgba(120,160,190,.22);
--ink:#e6eef4; --muted:#8fa6b4; --accent-dark:#2e5d80;
--accent-soft:#2e5d90; --accent-soft-2:#1d3d5e;
--ok:#2fbf72; --ok-bg:rgba(31,157,87,.16); --warn:#d9a13c; --warn-bg:rgba(183,121,31,.16);
--bad:#e05a4b; --bad-bg:rgba(192,57,43,.16);
}
/* ===== BASE ===== */
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;font-family:var(--font);color:var(--ink);overflow:hidden;
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
.app{display:flex;height:100vh}
.hidden{display:none!important}
/* sidebar */
.sidebar{width:230px;min-width:230px;background:var(--glass);border-right:1px solid var(--border);backdrop-filter:blur(14px);display:flex;flex-direction:column}
.logo-area{padding:22px 20px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--border)}
.logo-box{width:40px;height:40px;border-radius:12px;background:var(--glass-strong);display:flex;align-items:center;justify-content:center;border:1px solid var(--border)}
.logo-box img{width:28px;height:28px;object-fit:contain}
.logo-title{font-size:15px;font-weight:700}
.logo-sub{font-size:10px;color:var(--muted);margin-top:2px;letter-spacing:.5px;text-transform:uppercase;font-weight:600}
.nav{padding:16px 12px;flex:1}
.nav-item{display:flex;align-items:center;gap:11px;padding:11px 13px;border-radius:10px;cursor:pointer;margin-bottom:3px;color:var(--muted);font-size:13.5px;font-weight:600;transition:all .15s;user-select:none}
.nav-item svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2}
.nav-item:hover{background:var(--glass-strong);color:var(--ink)}
.nav-item.active{background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#fff;box-shadow:0 4px 12px rgba(20,50,70,.22)}
.sidebar-foot{padding:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px}
.avatar{width:34px;height:34px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent-dark));display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff}
.u-name{font-size:13px;font-weight:700}.u-role{font-size:10.5px;color:var(--muted)}
/* main + topbar */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{background:var(--glass-strong);border-bottom:1px solid var(--border);backdrop-filter:blur(14px);padding:20px 28px;display:flex;align-items:center;gap:14px;box-shadow:0 6px 18px rgba(20,50,70,.10)}
.tb-title{font-size:21px;font-weight:800}.tb-sub{font-size:12.5px;color:var(--muted);margin-top:3px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.indicator{display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border);font-size:12px;font-weight:700}
.dot{width:8px;height:8px;border-radius:50%}
.dot.ready,.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)}
.dot.running{background:var(--warn);box-shadow:0 0 7px var(--warn);animation:blink 1.2s infinite}
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
@keyframes blink{50%{opacity:.4}}
.mode-btn{width:38px;height:38px;border-radius:11px;border:1px solid var(--border);background:var(--glass-strong);cursor:pointer;font-size:16px}
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px}
/* cards, buttons, pills */
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px}
.card{background:var(--glass);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:16px;backdrop-filter:blur(14px);box-shadow:0 10px 30px rgba(20,50,70,.08)}
.c-title{font-size:15px;font-weight:700;margin-bottom:14px}
.btn{padding:9px 15px;border-radius:11px;border:1px solid var(--accent-soft-2);background:linear-gradient(180deg,var(--accent-soft),var(--accent-soft-2));color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s}
.btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(20,50,70,.2)}
.btn.primary{background:linear-gradient(180deg,var(--accent-2),var(--accent));color:#fff;border-color:var(--accent)}
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none}
.pill{display:inline-block;padding:4px 11px;border-radius:20px;font-size:11px;font-weight:700;color:#fff}
.pill.success{background:var(--ok)}.pill.attention{background:var(--warn)}.pill.failed{background:var(--bad)}
/* KPI */
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}
.kpi{display:flex;align-items:center;gap:13px;padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border);box-shadow:0 4px 14px rgba(20,50,70,.10)}
.kpi-ic{width:42px;height:42px;border-radius:12px;position:relative}
.kpi.total .kpi-ic{background:linear-gradient(135deg,var(--accent-2),var(--accent))}
.kpi.done .kpi-ic{background:var(--ok)}.kpi.prog .kpi-ic{background:var(--warn)}.kpi.notdone .kpi-ic{background:var(--bad)}
.kpi.prog.live .kpi-ic::after{content:'';position:absolute;inset:-3px;border-radius:14px;border:2px solid var(--warn);animation:kpulse 1.6s ease-out infinite}
@keyframes kpulse{0%{transform:scale(.9);opacity:.6}100%{transform:scale(1.25);opacity:0}}
.kpi-val{font-size:26px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums}
.kpi-lbl{font-size:11.5px;color:var(--muted);font-weight:600;margin-top:2px}
/* donut */
.dash-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.donut-wrap{display:flex;align-items:center;gap:26px;flex-wrap:wrap}
.donut{width:150px;height:150px;border-radius:50%;position:relative;background:conic-gradient(var(--border) 0 100%)}
.donut-hole{position:absolute;inset:22px;border-radius:50%;background:var(--bg-bottom);display:flex;flex-direction:column;align-items:center;justify-content:center}
.dh-num{font-size:26px;font-weight:800}.dh-lbl{font-size:10px;color:var(--muted);font-weight:700;text-transform:uppercase}
.dl-row{display:flex;align-items:center;gap:9px;font-size:12.5px;font-weight:600;margin-bottom:9px}
.dl-dot{width:10px;height:10px;border-radius:3px}
/* run panel */
.prog-head{display:flex;gap:12px;margin-bottom:8px;font-size:13px;font-weight:700}
.prog-head .pct{margin-left:auto;color:var(--accent)}
.prog-bg{height:8px;background:var(--glass-strong);border-radius:8px;overflow:hidden;margin-bottom:18px}
.prog-fill{height:100%;width:0;background:linear-gradient(90deg,var(--ok),var(--ok));transition:width .5s}
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;margin-bottom:6px;font-size:13px;font-weight:600}
.step.done{background:var(--ok-bg);color:var(--ok)}.step.active{background:var(--warn-bg);color:var(--warn)}.step.pending{opacity:.5}
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--glass-strong)}
.step.done .step-dot{background:var(--ok);color:#fff}.step.active .step-dot{background:var(--warn);color:#fff}
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:220px;overflow-y:auto;font-family:Consolas,'Courier New',monospace;font-size:13px;line-height:1.85;color:#9fb6c4}
.log .info{color:#7fc4f0}.log .success{color:#5fe0a0}.log .warn{color:#f5c563}.log .error{color:#f68c82}
/* report table */
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.toolbar input{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.tbl-wrap{border:1px solid var(--border);border-radius:14px;overflow:auto;max-height:60vh;background:var(--glass)}
table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
td{padding:10px 14px;border-bottom:1px solid var(--border)}
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="logo-area">
<div class="logo-box"><img src="{{LOGO_SRC}}" alt=""></div>
<div><div class="logo-title">{{APP_NAME}}</div><div class="logo-sub">{{APP_SUBTITLE}}</div></div>
</div>
<nav class="nav">
<div class="nav-item active" data-tab="dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></div>
<div class="nav-item" data-tab="run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></div>
<div class="nav-item" data-tab="report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></div>
<!-- OPTIONAL: Admin nav item (roles) goes here -->
</nav>
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
</aside>
<main class="main">
<header class="topbar">
<div><div class="tb-title" id="tb-title">Dashboard</div><div class="tb-sub" id="tb-sub">Overview of every batch</div></div>
<div class="tb-right">
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button>
<button class="mode-btn" id="mode-btn" title="Dark mode" onclick="toggleDark()">🌙</button>
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
</div>
</header>
<section class="content">
<!-- DASHBOARD -->
<div id="tab-dashboard">
<div class="kpi-row">
<div class="kpi total"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-total">0</div><div class="kpi-lbl">Total batches</div></div></div>
<div class="kpi done"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-done">0</div><div class="kpi-lbl">Completed</div></div></div>
<div class="kpi prog" id="kpi-prog"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-prog">0</div><div class="kpi-lbl">In progress</div></div></div>
<div class="kpi notdone"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-fail">0</div><div class="kpi-lbl">Not completed</div></div></div>
</div>
<div class="dash-grid">
<div class="card"><div class="c-title">Batch status split</div>
<div class="donut-wrap"><div class="donut" id="donut"><div class="donut-hole"><div class="dh-num" id="dh-num">0</div><div class="dh-lbl">batches</div></div></div><div id="legend"></div></div>
</div>
<div class="card"><div class="c-title">Recent batches</div><div id="recent" class="empty">No batches yet</div></div>
</div>
</div>
<!-- RUN -->
<div id="tab-run" class="hidden">
<div class="card"><div class="c-title">Run automation</div>
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
<div id="steps"></div>
<!-- OPTIONAL: checkpoint tracker (see reference/tebra_dashboard.html .cp-track) -->
<div class="log" id="log"></div>
</div>
</div>
<!-- REPORT -->
<div id="tab-report" class="hidden">
<div class="card">
<div class="toolbar"><input id="r-search" placeholder="Search…" oninput="renderReport()"><span style="margin-left:auto"></span><button class="btn primary" onclick="api('download_report')">⬇ Download Excel</button></div>
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
</div>
</div>
</section>
</main>
</div>
<script>
// ===== CONFIG (fill per project) =====
const STEPS = {{STEPS_JSON}}; // e.g. ["Initializing","Login","Process items","Completed"]
const REPORT_COLUMNS = {{REPORT_COLUMNS_JSON}}; // e.g. [{key:"item",label:"Item"},{key:"status",label:"Status"}]
const API_BASE = "{{API_BASE}}"; // web mode only, e.g. "/api"
const TABS = {dashboard:["Dashboard","Overview of every batch"],run:["Run","Live progress and log"],report:["Report","Result of every processed item"]};
// ===== TRANSPORT: Qt bridge if present, else HTTP polling =====
let bridge = null, reportRows = [];
function api(slot, ...args){
if(bridge) return bridge[slot](...args);
return fetch(`${API_BASE}/${slot.replace(/_/g,'-')}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(args)}).catch(()=>{});
}
window.__pyMsg = (json)=>{ const m=JSON.parse(json); handle(m.event,m.data); };
if(window.qt && window.qt.webChannelTransport){
new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; bridge.load_dashboard(); });
}else{
const poll = async()=>{
try{
const d = await (await fetch(`${API_BASE}/dashboard`)).json(); handle('dashboard_data',d);
const s = await (await fetch(`${API_BASE}/run/status`)).json();
(s.events||[]).forEach(e=>handle(e.event,e.data));
setTimeout(poll, s.running ? 2000 : 10000); // poll fast only while busy
}catch(e){ setTimeout(poll, 10000); }
};
poll();
}
// ===== EVENT DISPATCH (see contract.md) =====
function handle(event, d){
switch(event){
case 'status': setStatus(d.state, d.msg); break;
case 'step': setStep(d.label, d.pct); break;
case 'log': addLog(d.msg, d.type); break;
case 'dashboard_data': renderDashboard(d.batches||[]); break;
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
case 'set_user': setUser(d); break;
case 'apply_dark': document.body.classList.toggle('dark', !!d.on); break;
}
}
// ===== UI =====
document.querySelectorAll('.nav-item').forEach(n=>n.onclick=()=>showTab(n.dataset.tab));
function showTab(t){
document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab===t));
Object.keys(TABS).forEach(k=>document.getElementById('tab-'+k).classList.toggle('hidden',k!==t));
tbTitle.textContent=TABS[t][0]; tbSub.textContent=TABS[t][1];
if(t==='dashboard') api('load_dashboard');
if(t==='report') api('load_latest_report');
}
const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub');
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function toggleDark(){ const on=document.body.classList.toggle('dark'); api('set_dark',on); }
function setUser(u){ $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
function renderSteps(active=-1){
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending';
return `<div class="step ${c}"><div class="step-dot">${c==='done'?'✓':i+1}</div>${esc(s)}</div>`}).join('');
}
function setStep(label,pct){ $('p-label').textContent=label; $('p-pct').textContent=pct+'%'; $('p-fill').style.width=pct+'%';
renderSteps(pct>=100?STEPS.length:STEPS.indexOf(label)); }
function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('beforeend',`<div class="${type}">${esc(msg)}</div>`); l.scrollTop=l.scrollHeight; }
function countUp(el,to){ const from=+el.textContent||0,t0=performance.now();
const f=t=>{const p=Math.min((t-t0)/500,1); el.textContent=Math.round(from+(to-from)*p); if(p<1)requestAnimationFrame(f)}; requestAnimationFrame(f); }
function renderDashboard(b){
const n=s=>b.filter(x=>x.state===s).length;
const c={completed:n('completed'),inprogress:n('inprogress'),notcompleted:n('notcompleted')};
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted);
$('kpi-prog').classList.toggle('live',c.inprogress>0); $('dh-num').textContent=b.length;
const css=getComputedStyle(document.body), col={completed:css.getPropertyValue('--ok'),inprogress:css.getPropertyValue('--warn'),notcompleted:css.getPropertyValue('--bad')};
const lbl={completed:'Completed',inprogress:'In progress',notcompleted:'Not completed'};
let acc=0; const stops=[];
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/b.length*100; acc+=c[k]; stops.push(`${col[k]} ${a}% ${acc/b.length*100}%`); });
$('donut').style.background = b.length?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
$('legend').innerHTML=Object.keys(c).map(k=>`<div class="dl-row"><span class="dl-dot" style="background:${col[k]}"></span>${lbl[k]} · ${c[k]}</div>`).join('');
$('recent').className = b.length?'':'empty';
$('recent').innerHTML = b.length? b.slice(0,6).map(x=>`<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${x.state==='completed'?'success':x.state==='inprogress'?'attention':'failed'}" style="margin-left:auto">${lbl[x.state]||esc(x.state)}</span></div>`).join('') : 'No batches yet';
}
function renderReport(){
const q=$('r-search').value.toLowerCase();
$('r-head').innerHTML='<tr>'+REPORT_COLUMNS.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
const rows=reportRows.filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q));
$('r-body').innerHTML = rows.length? rows.map(r=>'<tr>'+REPORT_COLUMNS.map(c=>c.key==='status'
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
}
renderSteps();
</script>
</body>
</html>
@@ -0,0 +1,251 @@
def categorize_status(status):
s = (status or "").strip().lower()
if s in SUCCESS_STATUSES:
return "success"
if s in FAILED_STATUSES:
return "failed"
return "attention"
# ── Progress heuristic: map printed markers -> step index + % ─────────────────
# (run_multi is one long call, so we infer progress from the lines it prints.)
STEP_MARKERS = [
("multi-company / multi-location", 0, 5),
("[input] login sheet", 1, 12),
("########## company", 2, 25),
("[choose company]", 2, 32),
("practice home", 3, 45),
("clearinghouse", 4, 55),
("unreviewed", 5, 62),
("posting", 5, 70),
("processed successfully", 5, 74),
("test-notposted", 5, 74),
("medicalperformer", 6, 82), # ERA -> EOB (PDF) phase
("era import ->", 6, 84),
("print-to-pdf", 6, 84),
("[all done]", 7, 100),
]
STEPS = [
"Initializing",
"Reading Excel",
"Login to Tebra",
"Select company",
"Open Clearinghouse",
"Post payments",
"ERA → EOB conversion",
"Completed",
]
# ── Friendly log translation ──────────────────────────────────────────────────
# The on-screen log is read by medical-billing users, not developers, so raw
# automation lines are translated to plain English here. Every RAW line is
# still stored in full in the BatchLog table (per batch) for debugging.
#
# Rules are (marker-substring, friendly-message, type) matched on the
# lowercased line, first match wins. friendly-message None = keep the line off
# the screen. Unmatched lines are hidden too, UNLESS they are errors — a real
# failure must never be invisible.
# IMPORTANT: markers are matched by SUBSTRING against the lowercased line, first
# match wins, so they MUST be specific enough not to catch unrelated lines. e.g.
# a bare "clearinghouse" wrongly matched "Total unreviewed clearinghouse reports:
# 10852" and produced a duplicate "Opening Clearinghouse" — always anchor on the
# distinct "[step N]" / "[medicalperformer] <verb>" prefix instead of a bare word.
_FRIENDLY_RULES = [
("multi-company / multi-location", "Starting the automation…", "info"),
("[input] login sheet", "Reading the Excel input file…", "info"),
("[choose company] screen", "Selecting the company in Tebra…", "info"),
("[login] done", "Logged in to Tebra.", "success"),
("[step 3] confirmed", "Practice opened.", "info"),
("[step 4+5] encounters", "Opening Clearinghouse Reports…", "info"),
("[step 6] checking", "Searching for unreviewed ERAs…", "info"),
("[step 9] downloading + posting", "Checking for ERAs to post…", "info"),
("no more unprocessed era rows", "No more ERAs to post here.", "info"),
("grid still not found", "The ERA list did not load — moving on.", "error"),
("grid not found", "The ERA list is slow to load — retrying…", "warn"),
("[step 9] downloaded:", "ERA file downloaded.", "info"),
("download failed", "Could not download the ERA file — continuing.", "warn"),
("didn't open", "The ERA is slow to open — trying again…", "warn"),
("still could not open view", "Could not open the ERA — marked as Error.", "error"),
("clicking 'post payment", "Clicking Post Payment…", "info"),
("post payment button not found", "Post Payment button not found.", "error"),
("-> era errors screen", "Tebra reported ERA Errors for this payment — cancelling it.", "warn"),
("-> payment screen", "Payment screen opened.", "info"),
("post-payment retry", "The payment screen didn't load — trying again…", "warn"),
("unrecognized screen after post payment", "Unexpected screen after Post Payment — retrying…", "warn"),
("batch # field / reference # not ready", "Batch # field not ready — retrying…", "warn"),
("batch# field or reference# value not found", "Batch # field not found — marked as Error.", "error"),
("filling batch #", "Entering the Batch number…", "info"),
("test mode — would have posted", "Test mode: payment ready — clicking Cancel instead of Save.", "warn"),
("save button not found", "Save button element not found.", "error"),
("[step 9] posted era", "Payment saved — ERA posted.", "success"),
("done posting era payments", "Finished posting for this practice.", "info"),
("[medicalperformer] launching", "Converting ERA files to EOB PDFs…", "info"),
("[medicalperformer] no .txt", "No ERA files to convert.", "info"),
("[done] no-location company complete", "Company finished.", "success"),
# The final line is emitted by Python (_run_automation) from the real run
# summary — hide the raw markers so a run that FAILED can never print a
# hardcoded green "completed".
("[run summary]", None, "info"),
("[all done]", None, "info"),
]
# Dynamic rules that pull a value out of the line (checked before the static
# list). Each is (compiled-regex, lambda match -> (msg, type)).
_FRIENDLY_DYNAMIC = [
(re.compile(r"#+\s*COMPANY\s+(\d+)/(\d+):\s*(.+?)\s*#*\s*$", re.I),
lambda m: (f"Working on company {m.group(1)} of {m.group(2)}: "
f"{m.group(3)}", "info")),
(re.compile(r"PROCESSING LOCATION:\s*(.+)", re.I),
lambda m: (f"Working on location: {m.group(1).strip()}", "info")),
# Login failure — make it clearly visible (the COM/network error the user
# sees when Tebra pops "connection error" and they click OK).
(re.compile(r"\[login\] FAILED for company '(.+?)':", re.I),
lambda m: (f"Could not log in to Tebra for {m.group(1)} — connection or "
f"Tebra error. This company was not processed.", "error")),
(re.compile(r"Unreviewed reports total\s*:\s*(\d+)", re.I),
lambda m: (f"Found {m.group(1)} unreviewed report(s) to check.", "info")),
(re.compile(r"\[Step 9\] Opening ERA row id=(\S+)", re.I),
lambda m: (f"Opening ERA {m.group(1)}…", "info")),
(re.compile(r"Check ID=(\S*)\s+Date=(\S*)\s+Method=.*Amount=(\S*)", re.I),
lambda m: (f"Check {m.group(1) or '—'} · Date {m.group(2) or '—'} · "
f"Amount {m.group(3) or '—'}", "info")),
(re.compile(r"\[Step 9\] Skipping\s+\S+:\s*(.+)", re.I),
lambda m: (f"Skipped — {m.group(1).strip()}", "warn")),
(re.compile(r"\[Done\] Location '(.+?)' complete", re.I),
lambda m: (f"Location finished: {m.group(1)}", "success")),
]
# Error-level lines that are pure developer noise — the friendly login-error
# line and the Python run-summary already tell the user what failed, so these
# raw dumps are kept in the BatchLog table but never shown on screen.
_ERROR_NOISE = (
"traceback (most recent call last)",
"during handling of the above",
"another exception occurred",
"comerror",
"[error] company '", # per-company COM dump — summary covers it
)
def _is_error_noise(low_line):
return any(marker in low_line for marker in _ERROR_NOISE)
def friendly_log_line(line):
"""Translate one raw automation line for the user-facing log.
Returns (message, type) to display, or None to keep it off the screen."""
for rx, fmt in _FRIENDLY_DYNAMIC:
m = rx.search(line)
if m:
return fmt(m)
low = line.lower()
for marker, msg, ltype in _FRIENDLY_RULES:
if marker in low:
return (msg, ltype) if msg else None
return None
class _StreamToBridge:
"""A stdout/stderr replacement that tees to the real stream AND forwards
every completed line to the UI as a log event."""
def __init__(self, bridge, original, level="info"):
self.bridge = bridge
self.original = original
self.level = level
self._buf = ""
def write(self, text):
try:
if self.original:
self.original.write(text)
except Exception:
pass
self._buf += text
while "\n" in self._buf:
line, self._buf = self._buf.split("\n", 1)
self._emit_line(line)
def _emit_line(self, line):
line = line.rstrip("\r")
if not line.strip():
return
self.bridge._on_automation_line(line, self.level)
def flush(self):
try:
if self.original:
self.original.flush()
except Exception:
pass
def isatty(self):
return False
class Bridge(QObject):
"""Exposed to JavaScript as 'pyBridge'."""
js_message = pyqtSignal(str)
def __init__(self, window):
super().__init__()
self.window = window
self.username = "User"
self._max_pct = 0
self._cur_step = 0
self.batch_id = None # set on a fresh Trigger or from a resume check
self._batch_log = None # BatchLogWriter while a run is active
self._run_log_file = None # plain-text run log (DB-independent) while running
self._last_friendly = None # dedupe consecutive identical friendly lines
self._auto_proc = None # the separate automation process (bug-094) while running
# ── Python -> JS ────────────────────────────────────────────────────────────
def send_to_js(self, event, data):
self.js_message.emit(json.dumps({"event": event, "data": data}))
def _log(self, msg, type="info"):
self.send_to_js("log", {"msg": msg, "type": type})
def _step(self, label, pct):
self.send_to_js("step", {"label": label, "pct": pct})
def _batch_chip(self, batch_id, live=False):
"""Persistent Run-tab chip showing the batch this run is tracked under.
`batch_id` None/0 CLEARS it. A stale number is worse than none, so every
site that assigns self.batch_id must call this in the same breath."""
self.send_to_js("batch_state", {
"batch_id": str(batch_id) if batch_id else "",
"live": bool(live)})
# ── JS -> Python ────────────────────────────────────────────────────────────
@pyqtSlot(str)
def log(self, msg):
print(f"[JS] {msg}")
@pyqtSlot(bool)
def set_dark(self, on):
"""Persist the dark-mode choice so the login page (a separate window)
can pick it up too."""
try:
from src.ui.prefs import set_dark
set_dark(on)
except Exception as e:
print(f"[prefs] could not save dark mode: {e}")
@pyqtSlot()
def logout(self):
"""JS clicked Logout — defer to the next event-loop tick so we don't
run a nested dialog from inside the web-channel callback."""
from PyQt6.QtCore import QTimer
QTimer.singleShot(0, self.window.do_logout)
@pyqtSlot()
def browse_file(self):
path, _ = QFileDialog.getOpenFileName(
self.window, "Select Input Excel File",
os.path.expanduser("~"),
"Excel files (*.xlsx *.xlsm *.xls)"
@@ -0,0 +1,171 @@
"""Drive the Run tab's 7-box checkpoint tracker.
kind: 'active' (box cp is now running) | 'done' (box cp finished) |
'stuck' (box cp overran its max time)."""
self.send_to_js("cpstate", {"kind": kind, "cp": int(cp)})
def _on_automation_line(self, line, level):
"""Called (from the worker thread) for every printed line."""
# Watchdog checkpoint state marker -> 7-box tracker in the Run tab
# (raw line still stored in the DB log below).
if "[CPSTATE]" in line:
try:
kind, cp = line.split("[CPSTATE]", 1)[1].split()
self._cpstate(kind, cp)
# Feed the parent-side stall watchdog. The automation runs in a
# CHILD process, so the in-process performer.WD_STATE the old
# watchdog read is ALWAYS empty here (it never fired — a stuck
# checkpoint never restarted). Track the active checkpoint + its
# start time from the STREAMED [CPSTATE] line instead.
if kind == "active":
self._wd_cp = int(cp)
self._wd_since = time.time()
except Exception:
pass
low = line.lower()
ltype = "info"
if "[error]" in low or "[fatal]" in low or "traceback" in low or "exception" in low:
ltype = "error"
elif "warning" in low or "[warn" in low:
ltype = "warn"
elif "processed successfully" in low or "[all done]" in low or "success" in low:
ltype = "success"
# The FULL raw technical line always goes to the per-batch DB log AND to
# the DB-independent file log (the file works even when the DB is down).
if self._batch_log is not None:
self._batch_log.log(ltype, line)
if self._run_log_file is not None:
try:
self._run_log_file.write(line + "\n")
except Exception:
pass
# The on-screen log gets the plain-English translation only; raw lines
# stay off the screen — except untranslated ERRORS, which are shown
# as-is (a failure must never be invisible to the user).
shown = friendly_log_line(line)
if shown is not None:
if shown[0] != self._last_friendly: # collapse repeated polling lines
self._log(shown[0], shown[1])
self._last_friendly = shown[0]
elif ltype == "error" and not _is_error_noise(low):
# An untranslated real error still shows (a failure must never be
# invisible) — but raw traceback/COM noise is filtered out.
self._log(line, "error")
self._last_friendly = None
# advance the progress bar / step list on known markers
for marker, step_idx, pct in STEP_MARKERS:
if marker in low:
if step_idx > self._cur_step:
self._cur_step = step_idx
if pct > self._max_pct:
self._max_pct = pct
self._step(STEPS[self._cur_step], self._max_pct)
break
def _resolve_input_path(self, p):
"""Drag-drop in QWebEngine often hides the real disk path, so we may only
get a bare filename. Resolve it against the known input folders so the
automation always receives a real, absolute path."""
if p and os.path.isabs(p) and os.path.exists(p):
return p
base = os.path.basename(p) if p else ""
for folder in (INPUT_DIR, _ROOT):
cand = os.path.join(folder, base) if base else ""
if cand and os.path.exists(cand):
return cand
return p # unresolved — caller will report a clear error
def _run_multi_subprocess(self, excel_path, emp):
"""Run performer.run_multi in a SEPARATE PROCESS (its own MAIN thread,
no Qt) and stream its stdout back through this run's log pipeline.
Why a subprocess: pywinauto/UIA reads are reliable on a process MAIN
thread but flaky on a Qt BACKGROUND thread (STA-without-a-message-pump
for cross-process UIA; forcing MTA dead-locks against the Qt main
thread's UIA object). The reference tool works because it is a separate
process with no Qt — this reproduces that. Every child stdout line is
re-printed here so it flows through _StreamToBridge → _on_automation_line
exactly as an in-process print did (friendly log, [CPSTATE] tracker,
per-batch DB log, file log). Returns the run_multi summary dict.
"""
import subprocess
import time as _time
proj_root = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
# The child writes ALL its output to THIS file and we tail it live. A
# --windowed PyInstaller child's stdout PIPE is block-buffered/unreliable
# (v1.1.5: nothing streamed back), but a line-buffered file always
# flushes per line and can be read while the process runs.
try:
os.makedirs(LOGS_DIR, exist_ok=True)
except Exception:
pass
child_log = os.path.join(
LOGS_DIR,
f"auto_child_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
f"_{self.batch_id or 0}.log")
try:
open(child_log, "w", encoding="utf-8").close() # start empty
except Exception:
pass
if getattr(sys, "frozen", False):
cmd = [sys.executable, "--run-automation"]
else:
cmd = [sys.executable, os.path.join(proj_root, "run_app.py"),
"--run-automation"]
cmd += ["--excel", str(excel_path),
"--batch", str(self.batch_id or ""),
"--emp", str(emp or ""),
"--log", child_log,
# Parent-death watchdog: the child exits itself the instant this
# GUI process dies (crash, taskkill, or a closeEvent we couldn't
# run) so automation can never keep driving Tebra orphaned.
"--parent-pid", str(os.getpid())]
env = dict(os.environ)
env["PYTHONUNBUFFERED"] = "1"
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) \
if sys.platform.startswith("win") else 0
print(f"[Automation] Launching automation in its own process "
f"(reliable UIA) — batch {self.batch_id} …")
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
cwd=proj_root, env=env, creationflags=creationflags)
except Exception as e:
print(f"[Automation] Could not launch automation process: {e}")
return {"total": 0, "ok": [],
"failed": [("Automation", f"launch failed: {e}")],
"stopped": False}
self._auto_proc = proc
summary = {}
got_summary = False
def _handle(line):
nonlocal summary, got_summary
if line.startswith("__WF_SUMMARY__"):
try:
summary = json.loads(line[len("__WF_SUMMARY__"):]) or {}
got_summary = True
except Exception:
pass
elif line.strip():
print(line) # → _StreamToBridge tee → _on_automation_line
# Tail the child log until the process exits, then drain the rest.
try:
with open(child_log, "r", encoding="utf-8", errors="replace") as f:
while True:
pos = f.tell()
line = f.readline()
if line and line.endswith("\n"):
_handle(line.rstrip("\r\n"))
elif line: # partial line (no newline yet)
if proc.poll() is not None:
_handle(line.rstrip("\r\n"))
File diff suppressed because it is too large. Load diff