Move all three skills into skills/; add root README; fix paths
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
312742cfef
commit
d95e5d452d
84 files changed
+97
-31
No files matched your search
@@ -0,0 +1,89 @@
|
||||
# Dashboard data contract (AI)
|
||||
|
||||
Everything in the no-AI contract (`../rpa-dashboard-no-ai/templates/contract.md`)
|
||||
applies unchanged: the `{event, data}` envelope, its events, its actions and its rules.
|
||||
The AI dashboard adds extraction runs, review rows and the approve gate. These shapes
|
||||
come from the Superbill prototype, trimmed.
|
||||
|
||||
## Shapes
|
||||
|
||||
```
|
||||
Run { job_id, document, status: pending|queued|running|done|failed, model,
|
||||
pages_done, pages_total, summary: { green, amber, red, blank, unscored } | null,
|
||||
approved, error }
|
||||
ReviewRow { row_id, field, value, band: green|amber|red|blank|unscored,
|
||||
score, reason, verified, corrected_value }
|
||||
RoiRun { job_id, document, created_at, model, input_tokens, output_tokens,
|
||||
thinking_tokens, total_cost }
|
||||
```
|
||||
|
||||
- `summary` counts the run's rows per band. It is `null` until the run is `done`.
|
||||
- `score` is the field's worst word score, or `null` when it has none.
|
||||
- `corrected_value` is `null` until a reviewer saves a value different from `value`.
|
||||
- `created_at` is ISO 8601 in UTC. Months on the cost page are UTC months.
|
||||
- `RoiRun` token counts and `total_cost` (USD) are `null` when the run recorded no token
|
||||
usage. The page shows it as "not priced" and counts it in the coverage pill, never as $0.
|
||||
|
||||
## Endpoints (web: `{API_BASE}` + path, JSON in and out)
|
||||
|
||||
| Method and path | What it does |
|
||||
|---|---|
|
||||
| `GET /runs` | `{runs: [Run]}`. The page opens the first one. |
|
||||
| `GET /runs/{job_id}` | One `Run`. |
|
||||
| `GET /runs/{job_id}/review` | `{rows: [ReviewRow]}`. Empty until the run is `done`. |
|
||||
| `GET /runs/{job_id}/document` | The source document (PDF or image) shown beside the fields. |
|
||||
| `PATCH /review/{row_id}` | Body `{corrected_value?, verified}`. Patches one row, returns it. |
|
||||
| `POST /runs/{job_id}/verify-remaining` | Verifies every flagged row not yet verified. Returns `{verified: n}`. |
|
||||
| `GET /roi` | **Fetch ROI.** `{runs: [RoiRun], pricing: {source, as_of}}`, every run, newest first. The page sums spend, groups months and computes the by-hand panel itself. |
|
||||
| `POST /runs/{job_id}/approve` | **The approve gate.** `409 {detail}` while the run isn't done or any flagged row is unverified, else `200 {ok: true}`. |
|
||||
|
||||
Optional, only when the user chose those sections:
|
||||
|
||||
| Method and path | What it does |
|
||||
|---|---|
|
||||
| `GET /models` | `{models: [name]}`: the keys of the pricing table, so only priced models can be picked. |
|
||||
| `POST /runs/{job_id}/retry` | Body `{model}`. Re-queues a `failed` run on that model, returns the `Run`. |
|
||||
|
||||
Errors answer with a non-2xx status and `{detail: "<why>"}`. The page shows `detail`.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Bands** come from `templates/web/bands.py` and nowhere else: green ≥0.95, amber
|
||||
0.90–0.95, red <0.90. A field takes its worst word score. A blank mandatory field
|
||||
is `blank`, whatever its score. No scores is `unscored`.
|
||||
- **Flagged** = every band but green. Each flagged row must be verified before approval.
|
||||
- **The server enforces the gate.** The page disables nothing that matters for safety:
|
||||
it asks, and shows the server's refusal.
|
||||
- **Drafts:** the page sends nothing while a reviewer types. Save sends one PATCH per
|
||||
edited row (with `verified: true`). Leaving with unsaved edits asks first.
|
||||
- **Polling:** while a run is `queued` or `running`, the page polls `GET /runs/{job_id}`
|
||||
every 1.5s and shows "page X of Y". It stops when the run settles.
|
||||
- **Cost** comes from `templates/web/pricing.py` and nowhere else: per million tokens,
|
||||
input × input rate + (output + thinking) × output rate, long-context rates above the
|
||||
model's threshold. An unknown model is an error naming the known models, never $0.
|
||||
The table records its source URL and as-of date; `GET /roi` returns both.
|
||||
- **Assumptions** (minutes per document, hourly rate) live only on the page, typed by the
|
||||
reader and labelled as assumptions. The backend never stores or guesses them.
|
||||
- **Caveat:** the legend always shows that colour reflects print clarity, not
|
||||
correctness.
|
||||
|
||||
`templates/web/server_stub.py` implements all of this on sample data, standard library
|
||||
only. A real backend serves the same endpoints with the same rules.
|
||||
|
||||
## Desktop (PyQt, no web server)
|
||||
|
||||
The same endpoints and rules travel over the Qt bridge instead of HTTP:
|
||||
|
||||
- Page to Python: one slot, `ai_request(id, method, path, body)`. `path` is the endpoint
|
||||
path above without `{API_BASE}`, `body` is a JSON string (`"{}"` when there is none).
|
||||
- Python to page: one envelope, `{event: "ai_response", data: {id, status, body}}`, with the
|
||||
same `status` and JSON `body` the web endpoint would answer.
|
||||
- Every endpoint above uses this pair, the cost page's `GET /roi` included (the page sends
|
||||
`ai_request(id, "GET", "/roi", "{}")` and gets `{runs: [RoiRun], pricing: {source, as_of}}`
|
||||
back as the `body`). No other AI events or slots exist.
|
||||
- `GET /runs/{job_id}/document` answers an SVG string or a URL the page can load (for
|
||||
example a `file:///` path to the PDF).
|
||||
|
||||
`templates/desktop/ai_bridge.py` adds that slot to the no-AI `Bridge`. It hands each
|
||||
request to the review backend's `dispatch(method, path, body)` (`server_stub.py` until the
|
||||
project has its own), so the approve gate is enforced in Python, never in the page.
|
||||
@@ -0,0 +1,37 @@
|
||||
"""PyQt6 host for the AI desktop dashboard: the no-AI Bridge plus one slot for the AI contract.
|
||||
|
||||
Copy next to the no-AI bridge.py and bot_runner.py, with server_stub.py and bands.py.
|
||||
Run it with: python dashboard/ai_bridge.py
|
||||
|
||||
The page sends each AI request (templates/contract.md: runs, review rows, the approve gate)
|
||||
as ai_request(id, method, path, body) and gets one `ai_response` envelope back. The review
|
||||
backend answers it in Python, so the approve gate holds whatever the page does.
|
||||
"""
|
||||
import json, sys, threading
|
||||
from PyQt6.QtCore import QUrl, pyqtSlot
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||
from PyQt6.QtWebChannel import QWebChannel
|
||||
|
||||
import bridge
|
||||
import server_stub as review # sample data. Once the project has its own review backend, import that (same dispatch()).
|
||||
|
||||
|
||||
class AiBridge(bridge.Bridge):
|
||||
@pyqtSlot(int, str, str, str)
|
||||
def ai_request(self, req_id, method, path, body):
|
||||
# ponytail: answered on the GUI thread; move to a worker if the backend gets slow.
|
||||
status, payload = review.dispatch(method, path, json.loads(body or "{}"))
|
||||
self.send_to_js("ai_response", {"id": req_id, "status": status, "body": payload})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Thread(target=review.simulate_extraction, daemon=True).start() # sample data only: delete with server_stub
|
||||
app = QApplication(sys.argv)
|
||||
win, view = QMainWindow(), QWebEngineView()
|
||||
host, channel = AiBridge(view), QWebChannel()
|
||||
channel.registerObject("pyBridge", host)
|
||||
view.page().setWebChannel(channel)
|
||||
view.setUrl(QUrl.fromLocalFile(str(bridge.HTML_PATH)))
|
||||
win.setCentralWidget(view); win.resize(1280, 820); win.setWindowTitle("{{APP_NAME}}"); win.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,276 @@
|
||||
<!-- ===== AI SECTIONS (desktop) =====
|
||||
Paste this whole block into the no-AI dashboard.html, just before </body>. It adds a Review
|
||||
tab: band legend, band filters, the source document beside its extracted fields, drafts,
|
||||
Verify, and Approve. Plain HTML, the page's own theme tokens, no build step.
|
||||
In PyQt, every request goes over the Qt bridge as one slot call, ai_request(id, method, path,
|
||||
body), and comes back as an `ai_response` envelope (see ../contract.md, "Desktop"). With no
|
||||
Qt (a plain-HTML web project), it fetches {API_BASE}{path} from the backend instead, the
|
||||
web endpoints in ../contract.md. The approve gate is the backend's: this page only asks,
|
||||
and shows the refusal.
|
||||
-->
|
||||
<style>
|
||||
/* BAND COLOURS: semantic only (swatches, the rule down a field). The same in every preset and both modes. */
|
||||
body{--clean:#51a68f;--verify:#e39b34;--review:#de5243;--absent:#6b6580}
|
||||
.rv-bar{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px;padding:14px 18px}
|
||||
.rv-bar label{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:600}
|
||||
.rv-bar select{padding:7px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
|
||||
.rv-muted{font-size:12px;color:var(--muted)}
|
||||
.rv-right{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||||
.rv-alert{width:100%;border-radius:10px;padding:7px 12px;background:var(--bad-bg);color:var(--bad);font-size:12px;font-weight:700}
|
||||
.rv-legend{display:flex;flex-wrap:wrap;gap:4px 16px}
|
||||
.rv-legend li{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600}
|
||||
.rv-sw{width:10px;height:10px;border-radius:3px;display:inline-block}
|
||||
.rv-filters{margin-left:auto;display:flex;gap:4px}
|
||||
.rv-filters .btn,.rv-bar .btn,.rv-fields .btn,.rv-save .btn{padding:6px 11px;font-size:12px}
|
||||
.rv-split{display:flex;gap:14px;min-height:460px}
|
||||
.rv-doc{flex:1;min-height:420px;border:1px solid var(--border);border-radius:16px;background:#fff}
|
||||
.rv-pane{flex:1;display:flex;flex-direction:column;padding:0;overflow:hidden;margin-bottom:0}
|
||||
.rv-fields{flex:1;overflow-y:auto;padding:16px}
|
||||
.rv-fields li{display:grid;grid-template-columns:minmax(80px,130px) 1fr auto auto;align-items:center;gap:8px;margin-bottom:9px}
|
||||
.rv-fields li.done{opacity:.7}
|
||||
.rv-name{font-size:12px;font-weight:600;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.rv-fields input{min-width:0;padding:7px 10px;border:1px solid var(--border);border-left-width:4px;border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
|
||||
.rv-fields input.dirty{outline:2px solid var(--warn)}
|
||||
.rv-score{width:36px;text-align:right;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
.rv-note{grid-column:1/-1;margin-top:-4px;font-size:11px;color:var(--muted)}
|
||||
.rv-fields .btn{width:92px}
|
||||
.rv-save{display:flex;align-items:center;gap:10px;border-top:1px solid var(--border);padding:10px 16px}
|
||||
.rv-save .rv-unsaved{font-size:12px;font-weight:700;color:var(--warn)}
|
||||
.co-tile{padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border)}
|
||||
.co-tile .kpi-val{margin-top:4px}
|
||||
.co-r{text-align:right}
|
||||
@media (max-width:820px){.rv-split{flex-direction:column}.rv-filters{margin-left:0}}
|
||||
</style>
|
||||
|
||||
<template id="ai-review-tab">
|
||||
<div id="tab-review" class="hidden">
|
||||
<div class="card rv-bar" id="rv-head">
|
||||
<label>Document <select id="rv-doc-pick" aria-label="Document"></select></label>
|
||||
<span id="rv-state"></span>
|
||||
<div class="rv-right" id="rv-actions"></div>
|
||||
<!-- OPTIONAL: retry on another model (failed runs) goes here -->
|
||||
|
||||
<p class="rv-alert hidden" id="rv-alert" role="alert"></p>
|
||||
</div>
|
||||
<div class="card rv-bar">
|
||||
<ul class="rv-legend" id="rv-legend" aria-label="Confidence bands"></ul>
|
||||
<span class="rv-muted">Colour reflects print clarity, not correctness. A cleanly printed value can still be misread.</span>
|
||||
<div class="rv-filters" id="rv-filters" role="group" aria-label="Filter by band"></div>
|
||||
</div>
|
||||
<div class="rv-split">
|
||||
<iframe class="rv-doc" id="rv-doc" title="Source document"></iframe>
|
||||
<div class="card rv-pane">
|
||||
<p class="rv-muted hidden" id="rv-wait" style="padding:18px">Fields appear when extraction finishes.</p>
|
||||
<ul class="rv-fields" id="rv-fields" aria-label="Extracted fields"></ul>
|
||||
<div class="rv-save hidden" id="rv-save"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="ai-cost-tab">
|
||||
<div id="tab-cost" class="hidden">
|
||||
<div class="toolbar"><span class="pill" id="co-cover"></span><span class="rv-muted" id="co-source"></span></div>
|
||||
<div class="kpi-row" id="co-tiles"></div>
|
||||
<div class="card"><div class="c-title">Month by month</div>
|
||||
<div class="tbl-wrap"><table aria-label="Spend by month"><thead><tr><th>Month</th><th>Documents priced</th><th class="co-r">Spend</th></tr></thead><tbody id="co-months"></tbody></table></div>
|
||||
</div>
|
||||
<section class="card" aria-labelledby="co-by-hand">
|
||||
<div class="c-title" id="co-by-hand">Against doing it by hand</div>
|
||||
<p class="rv-muted" style="margin-bottom:12px">These are your assumptions, not a measurement. Change them to match the team.</p>
|
||||
<div class="toolbar">
|
||||
<label class="rv-muted">Minutes per document <input id="co-min" type="number" min="0" value="5" style="width:80px"></label>
|
||||
<label class="rv-muted">Hourly rate <input id="co-rate" type="number" min="0" value="20" style="width:80px"></label>
|
||||
</div>
|
||||
<p id="co-hand" style="font-size:13px"></p>
|
||||
</section>
|
||||
<!-- OPTIONAL: model picker (per batch) goes here -->
|
||||
<div class="card"><div class="c-title">Cost per run</div>
|
||||
<div class="tbl-wrap"><table aria-label="Cost per run"><thead><tr><th>Document</th><th>Date</th><th>Model</th><th class="co-r">Input</th><th class="co-r">Output</th><th class="co-r">Thinking</th><th class="co-r">Cost</th></tr></thead><tbody id="co-runs"></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
(()=>{
|
||||
// Legend order, label and colour token. Cut points come from bands.py on the Python side.
|
||||
const BANDS=[['green','Clean ≥0.95','var(--clean)'],['amber','Check 0.90–0.95','var(--verify)'],
|
||||
['red','Review <0.90','var(--review)'],['blank','Missing (required)','var(--absent)'],['unscored','Unscored','var(--absent)']];
|
||||
const FILTERS=[['All','all'],['Red','red'],['Amber','amber'],['Missing','blank'],['Unscored','unscored']];
|
||||
const BUSY=['queued','running']; // polled every 1.5s while in one of these
|
||||
const colourOf=b=>BANDS.find(x=>x[0]===b)[2];
|
||||
const settled=r=>r.corrected_value??r.value??'';
|
||||
|
||||
// ----- the tabs: nav buttons after Report, sections in the content area -----
|
||||
TABS.review=['Review','Check extracted fields, then approve'];
|
||||
TABS.cost=['Cost','What the AI cost, run by run'];
|
||||
document.querySelector('.nav-item[data-tab="report"]').insertAdjacentHTML('afterend',
|
||||
'<button class="nav-item" data-tab="review" title="Review"><svg viewBox="0 0 24 24"><path d="M9 11l3 3 8-8"/><path d="M20 12v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h11"/></svg><span>Review</span></button>'
|
||||
+'<button class="nav-item" data-tab="cost" title="Cost"><svg viewBox="0 0 24 24"><path d="M12 2v20M17 6H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg><span>Cost</span></button>');
|
||||
['review','cost'].forEach(t=>{ document.querySelector(`.nav-item[data-tab="${t}"]`).onclick=()=>showTab(t);
|
||||
document.querySelector('.content').append($(`ai-${t}-tab`).content.cloneNode(true)); });
|
||||
const baseShow=showTab;
|
||||
showTab=t=>{
|
||||
const onReview=!$('tab-review').classList.contains('hidden');
|
||||
if(onReview && t!=='review' && !leaveOk()) return;
|
||||
baseShow(t);
|
||||
if(t==='review') loadRuns();
|
||||
if(t==='cost') loadRoi();
|
||||
};
|
||||
window.addEventListener('beforeunload',e=>{ if(dirty().length) e.preventDefault(); });
|
||||
|
||||
// ----- transport: in PyQt, one bridge slot out, one `ai_response` envelope back;
|
||||
// with no Qt (a plain web page), fetch {API_BASE}{path} from the backend -----
|
||||
const pending={}; let seq=0;
|
||||
async function aiFetch(method,path,body){
|
||||
const r=await fetch(API_BASE+path,{method,headers:{'Content-Type':'application/json'},
|
||||
body:method==='GET'?undefined:JSON.stringify(body||{})});
|
||||
const b=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(b.detail||`Error ${r.status}`);
|
||||
return b;
|
||||
}
|
||||
function ai(method,path,body){
|
||||
if(!window.qt) return aiFetch(method,path,body);
|
||||
if(!bridge) return Promise.reject(new Error('The Qt bridge is not ready yet. Try again.'));
|
||||
return new Promise((ok,fail)=>{ const id=++seq; pending[id]=[ok,fail];
|
||||
bridge.ai_request(id,method,path,JSON.stringify(body||{})); });
|
||||
}
|
||||
const baseHandle=handle;
|
||||
handle=(event,d)=>{
|
||||
if(event!=='ai_response') return baseHandle(event,d);
|
||||
const p=pending[d.id]; delete pending[d.id];
|
||||
if(p) d.status<300 ? p[0](d.body) : p[1](new Error((d.body&&d.body.detail)||`Error ${d.status}`));
|
||||
};
|
||||
|
||||
// ----- state -----
|
||||
let runs=[], jobId=null, run=null, rows=[], drafts={}, filter='all', refusal=null, timer=null;
|
||||
const dirty=()=>rows.filter(r=>r.row_id in drafts && drafts[r.row_id]!==settled(r));
|
||||
const leaveOk=()=>!dirty().length || confirm(`${dirty().length} unsaved change(s) on this document. Discard them and leave?`);
|
||||
const fail=e=>{ refusal=e.message; renderHead(); };
|
||||
|
||||
async function loadRuns(){
|
||||
try{ runs=(await ai('GET','/runs')).runs; }catch(e){ return fail(e); }
|
||||
$('rv-doc-pick').innerHTML=runs.map(r=>`<option value="${esc(r.job_id)}">${esc(r.document)}</option>`).join('');
|
||||
if(!runs.length){ refusal=null; $('rv-state').innerHTML='<span class="rv-muted">No documents extracted yet.</span>'; return; }
|
||||
openRun(runs.some(r=>r.job_id===jobId) ? jobId : runs[0].job_id);
|
||||
}
|
||||
$('rv-doc-pick').onchange=e=>{ if(leaveOk()) openRun(e.target.value); else e.target.value=jobId; };
|
||||
|
||||
function openRun(id){
|
||||
clearTimeout(timer);
|
||||
jobId=id; run=null; rows=[]; drafts={}; refusal=null;
|
||||
$('rv-doc-pick').value=id;
|
||||
if(!window.qt) $('rv-doc').src=`${API_BASE}/runs/${encodeURIComponent(id)}/document`; // web: the URL itself
|
||||
else ai('GET',`/runs/${id}/document`).then(doc=>{ if(id===jobId) $('rv-doc').src=
|
||||
String(doc).startsWith('<') ? 'data:image/svg+xml;charset=utf-8,'+encodeURIComponent(doc) : doc; }).catch(()=>{});
|
||||
render(); tick(id);
|
||||
}
|
||||
async function tick(id){ // load the run, and poll it while it is still extracting
|
||||
try{
|
||||
const r=await ai('GET',`/runs/${id}`); if(id!==jobId) return;
|
||||
run=r;
|
||||
if(BUSY.includes(r.status)) timer=setTimeout(()=>tick(id),1500);
|
||||
else{ const d=await ai('GET',`/runs/${id}/review`); if(id!==jobId) return; rows=d.rows; }
|
||||
render();
|
||||
}catch(e){ if(id===jobId) fail(e); }
|
||||
}
|
||||
async function reload(){
|
||||
const id=jobId, [r,d]=await Promise.all([ai('GET',`/runs/${id}`),ai('GET',`/runs/${id}/review`)]);
|
||||
if(id===jobId){ run=r; rows=d.rows; render(); }
|
||||
}
|
||||
|
||||
// ----- actions -----
|
||||
async function act(fn){ refusal=null; try{ await fn(); }catch(e){ refusal=e.message; } await reload().catch(fail); }
|
||||
const save=()=>act(()=>Promise.all(dirty().map(r=>{ const text=drafts[r.row_id].trim();
|
||||
// Same as extracted (or emptied) means "the extracted value was right", not an edit.
|
||||
return ai('PATCH',`/review/${r.row_id}`,{corrected_value: text===''||text===(r.value??'') ? null : text, verified:true});
|
||||
})).then(()=>{ drafts={}; }));
|
||||
const verify=r=>act(()=>ai('PATCH',`/review/${r.row_id}`,{verified:!r.verified}));
|
||||
const verifyRemaining=()=>act(()=>ai('POST',`/runs/${jobId}/verify-remaining`));
|
||||
const approve=()=>act(()=>ai('POST',`/runs/${jobId}/approve`).catch(e=>{ throw new Error('Not approved: '+e.message); }));
|
||||
|
||||
// ----- render -----
|
||||
function render(){ renderHead(); renderLegend(); renderFields(); renderSave(); }
|
||||
function renderHead(){
|
||||
const busy=run && BUSY.includes(run.status), pill=!run?'':run.status==='done'?'success':run.status==='failed'?'failed':'attention';
|
||||
$('rv-state').innerHTML = run ? `<span class="pill ${pill}">${esc(run.status)}</span>`
|
||||
+ (busy ? ` <span class="rv-muted">page ${esc(run.pages_done)} of ${esc(run.pages_total||'?')}</span>` : '')
|
||||
+ (run.error ? ` <span class="rv-muted" style="color:var(--bad)">${esc(run.error)}</span>` : '') : '';
|
||||
const left=rows.filter(r=>r.band!=='green' && !r.verified).length, n=dirty().length, acts=$('rv-actions');
|
||||
acts.innerHTML = run && run.status==='done' ? `<span class="rv-muted">${left} to verify</span>
|
||||
<button class="btn" id="rv-vr"${!left||run.approved?' disabled':''}>Verify remaining</button>`
|
||||
+ (run.approved ? '<span class="pill success">✓ Approved</span>'
|
||||
: `<button class="btn primary" id="rv-ap"${n?' disabled title="Save or discard your edits first"':''}>Approve</button>`) : '';
|
||||
if($('rv-vr')) $('rv-vr').onclick=verifyRemaining;
|
||||
if($('rv-ap')) $('rv-ap').onclick=approve;
|
||||
$('rv-alert').textContent=refusal||''; $('rv-alert').classList.toggle('hidden',!refusal);
|
||||
}
|
||||
function renderLegend(){
|
||||
const s=(run&&run.summary)||{};
|
||||
$('rv-legend').innerHTML=BANDS.map(([b,label,c])=>`<li><i class="rv-sw" style="background:${c}"></i>${label} · ${s[b]||0}</li>`).join('');
|
||||
$('rv-filters').innerHTML=FILTERS.map(([label,b])=>`<button class="btn${filter===b?' primary':''}" aria-pressed="${filter===b}" data-band="${b}">${label}</button>`).join('');
|
||||
$('rv-filters').querySelectorAll('button').forEach(btn=>btn.onclick=()=>{ filter=btn.dataset.band; renderLegend(); renderFields(); });
|
||||
}
|
||||
function renderFields(){
|
||||
$('rv-wait').classList.toggle('hidden',!run||run.status==='done');
|
||||
const shown=filter==='all'?rows:rows.filter(r=>r.band===filter);
|
||||
$('rv-fields').innerHTML=shown.map(r=>{ const draft=drafts[r.row_id]??settled(r), isDirty=draft!==settled(r);
|
||||
const note=[r.reason, r.corrected_value!=null ? `extracted <s>${esc(r.value||'empty')}</s>` : ''].filter(Boolean);
|
||||
return `<li data-row="${r.row_id}" class="${r.verified&&!isDirty?'done':''}">
|
||||
<span class="rv-name" title="${esc(r.field)}">${esc(r.field)}</span>
|
||||
<input aria-label="${esc(r.field)}" value="${esc(draft)}" placeholder="empty" title="${esc(r.reason||'')}" class="${isDirty?'dirty':''}" style="border-left-color:${colourOf(r.band)}">
|
||||
<span class="rv-score" title="Legibility score: how clearly it was read, not whether it is right">${r.score==null?'—':Number(r.score).toFixed(2)}</span>
|
||||
<button class="btn" aria-label="Verify ${esc(r.field)}" aria-pressed="${!!r.verified}"${isDirty?' disabled':''}>${r.verified?'✓ Verified':'Verify'}</button>
|
||||
${note.length?`<span class="rv-note">${note.map(x=>x===r.reason?esc(x):x).join(' · ')}</span>`:''}</li>`; }).join('');
|
||||
$('rv-fields').querySelectorAll('li').forEach(li=>{ const r=rows.find(x=>x.row_id===+li.dataset.row), box=li.querySelector('input');
|
||||
box.oninput=()=>{ drafts[r.row_id]=box.value; const d=box.value!==settled(r); // a draft: nothing is sent until Save
|
||||
box.classList.toggle('dirty',d); li.querySelector('button').disabled=d; renderSave(); renderHead(); };
|
||||
box.onkeydown=e=>{ if(e.key==='Escape'){ box.value=settled(r); box.oninput(); } };
|
||||
li.querySelector('button').onclick=()=>verify(r); });
|
||||
}
|
||||
function renderSave(){
|
||||
const n=dirty().length;
|
||||
$('rv-save').classList.toggle('hidden',!n);
|
||||
$('rv-save').innerHTML = n ? `<span class="rv-unsaved">${n} unsaved change${n===1?'':'s'}</span>
|
||||
<button class="btn" id="rv-discard" style="margin-left:auto">Discard</button><button class="btn primary" id="rv-save-btn">Save</button>` : '';
|
||||
if(n){ $('rv-discard').onclick=()=>{ drafts={}; render(); }; $('rv-save-btn').onclick=save; }
|
||||
}
|
||||
|
||||
// ----- cost: measured (tokens and cost per run, priced in Python) kept apart from assumed (typed here) -----
|
||||
// A run with no recorded token usage is "not priced", never $0, and the coverage pill says how many.
|
||||
let roi=[];
|
||||
const money=n=>`$${n.toFixed(n>0&&n<0.01?4:2)}`, num=n=>n==null?'—':n.toLocaleString('en-US');
|
||||
const monthOf=iso=>iso.slice(0,7); // 'YYYY-MM', UTC
|
||||
const monthLabel=ym=>new Date(`${ym}-01T00:00:00Z`).toLocaleString('en-US',{month:'short',year:'numeric',timeZone:'UTC'});
|
||||
const sum=rs=>rs.reduce((n,r)=>n+(r.total_cost??0),0);
|
||||
const tileHtml=(name,value,note)=>`<div class="co-tile" role="group" aria-label="${name}"><div class="kpi-lbl">${name}</div><div class="kpi-val">${value}</div><div class="kpi-lbl">${esc(note)}</div></div>`;
|
||||
async function loadRoi(){
|
||||
let d; try{ d=await ai('GET','/roi'); }catch(e){ $('co-source').textContent=`Can't load costs: ${e.message}`; return; }
|
||||
roi=d.runs; const p=d.pricing||{};
|
||||
$('co-source').innerHTML=`Rates from ${esc(p.source)}, as of ${esc(p.as_of)}`;
|
||||
renderCost();
|
||||
}
|
||||
function renderCost(){
|
||||
const priced=roi.filter(r=>r.total_cost!=null), thisMonth=new Date().toISOString().slice(0,7);
|
||||
const tok=k=>priced.reduce((n,r)=>n+(r[k]??0),0);
|
||||
$('co-cover').textContent=`${priced.length} of ${roi.length} documents priced`;
|
||||
$('co-cover').className='pill '+(priced.length===roi.length?'success':'attention');
|
||||
$('co-tiles').innerHTML=tileHtml('Total spend',money(sum(priced)),`${priced.length} priced runs`)
|
||||
+tileHtml('This month',money(sum(roi.filter(r=>monthOf(r.created_at)===thisMonth))),monthLabel(thisMonth))
|
||||
+tileHtml('Tokens',num(tok('input_tokens')+tok('output_tokens')+tok('thinking_tokens')),`in ${num(tok('input_tokens'))} · out ${num(tok('output_tokens'))} · thinking ${num(tok('thinking_tokens'))}`)
|
||||
+tileHtml('Per document',priced.length?money(sum(priced)/priced.length):'—','average, priced runs');
|
||||
$('co-months').innerHTML=[...new Set(roi.map(r=>monthOf(r.created_at)))].sort().reverse().map(m=>{
|
||||
const rs=roi.filter(r=>monthOf(r.created_at)===m), n=rs.filter(r=>r.total_cost!=null).length;
|
||||
return `<tr><td>${monthLabel(m)}${m===thisMonth?' <span class="rv-muted">this month</span>':''}</td><td>${n} of ${rs.length}</td><td class="co-r">${n?money(sum(rs)):'—'}</td></tr>`; }).join('');
|
||||
$('co-runs').innerHTML=roi.map(r=>`<tr><td>${esc(r.document)}</td><td>${esc(r.created_at.slice(0,10))}</td><td>${esc(r.model)}</td>
|
||||
<td class="co-r">${num(r.input_tokens)}</td><td class="co-r">${num(r.output_tokens)}</td><td class="co-r">${num(r.thinking_tokens)}</td>
|
||||
<td class="co-r">${r.total_cost==null?'<span class="rv-muted">not priced</span>':money(r.total_cost)}</td></tr>`).join('');
|
||||
renderByHand();
|
||||
}
|
||||
function renderByHand(){
|
||||
const n=roi.filter(r=>r.total_cost!=null).length, min=+$('co-min').value||0, rate=+$('co-rate').value||0;
|
||||
$('co-hand').innerHTML=`By hand, ${n} documents would take ${num(Math.round(n*min/6)/10)} h, about <b>${money(n*min/60*rate)}</b>. The AI spent <b>${money(sum(roi))}</b> on them.`;
|
||||
}
|
||||
$('co-min').oninput=$('co-rate').oninput=renderByHand;
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,61 @@
|
||||
# EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project.
|
||||
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,452 @@
|
||||
<!-- EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. -->
|
||||
---
|
||||
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,365 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
import 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 Example Client 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,75 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
/**
|
||||
* The 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,126 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
import { ThemeSwitcher } from '../theme'
|
||||
import { ROLE_LABEL } from '../auth'
|
||||
|
||||
/**
|
||||
* The app shell's top bar: brand mark, title, and the signed-in user's
|
||||
* controls. The board tab strip that used to live below it is now the left
|
||||
* sidebar (`Sidebar.tsx`) — this component no longer owns navigation.
|
||||
*
|
||||
* The gradient is the one saturated surface in the product. Everything on it
|
||||
* is white or a white alpha rather than a palette token, because a
|
||||
* token-coloured control on the band reads as a hole punched through it —
|
||||
* see `.sb-appbar` and `.sb-ghost-btn` in index.css.
|
||||
*/
|
||||
/** 'admin' is never a single global role any more — computed here from the
|
||||
* two per-project flags so the header names exactly what this account can
|
||||
* reach, rather than falling back to "Operator" for someone who is in fact
|
||||
* an admin of one or both projects. */
|
||||
function roleLabel(user: {
|
||||
role: string
|
||||
ecw_role?: string | null
|
||||
tebra_role?: string | null
|
||||
}): string {
|
||||
if (user.role === 'super_admin') return ROLE_LABEL.super_admin
|
||||
const ecw = user.ecw_role === 'admin'
|
||||
const tebra = user.tebra_role === 'admin'
|
||||
if (ecw && tebra) return 'Admin · Both'
|
||||
if (ecw) return 'Admin · ECW'
|
||||
if (tebra) return 'Admin · Tebra'
|
||||
return ROLE_LABEL.operator
|
||||
}
|
||||
|
||||
export function BrandHeader({
|
||||
user,
|
||||
onLogout,
|
||||
sidebarOpen,
|
||||
onToggleSidebar,
|
||||
}: {
|
||||
user: {
|
||||
username: string
|
||||
real_name: string | null
|
||||
role: string
|
||||
ecw_role?: string | null
|
||||
tebra_role?: string | null
|
||||
}
|
||||
onLogout: () => void
|
||||
/** Present only on screens that have the left sidebar. */
|
||||
sidebarOpen?: boolean
|
||||
onToggleSidebar?: () => void
|
||||
}) {
|
||||
return (
|
||||
<header className="sb-appbar shrink-0">
|
||||
<ShieldMark />
|
||||
{onToggleSidebar && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
|
||||
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
|
||||
aria-expanded={sidebarOpen}
|
||||
className="sb-ghost-btn"
|
||||
style={{ padding: '3px', lineHeight: 0 }}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" aria-hidden="true">
|
||||
<rect x="3.5" y="4.5" width="17" height="15" rx="2" />
|
||||
<line x1="9" y1="4.5" x2="9" y2="19.5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{/* What the tool is, matching the browser tab and the sign-in screen.
|
||||
The brand and EMR edition — "Superbill Automation for ECW" — live in
|
||||
the sidebar's own group headers instead (`Sidebar.tsx`), which is
|
||||
where the rail has room to state them without competing here. */}
|
||||
<div className="min-w-0">
|
||||
<div className="sb-appbar-title truncate">Extraction and Automation Tool</div>
|
||||
<div className="sb-appbar-sub truncate">Extract · correct · post to eClinicalWorks</div>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-3">
|
||||
<div className="sb-who hidden sm:block">
|
||||
<b>{user.real_name ?? user.username}</b>
|
||||
<span>{roleLabel(user)}</span>
|
||||
</div>
|
||||
<button type="button" onClick={onLogout} title="Sign out" className="sb-ghost-btn">
|
||||
Sign out
|
||||
</button>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The product mark: shield plus three ascending bars, drawn as vector.
|
||||
*
|
||||
* The same artwork the sign-in screen carries, so the two screens read as one
|
||||
* product — but flat here, with no bob and no glow: on the sign-in screen it
|
||||
* is the subject, and in a header it is a label for the page.
|
||||
*
|
||||
* Vector rather than the `logo-mark.png` raster it replaced. The raster could
|
||||
* not take the header's drop-shadow without fringing along its antialiased
|
||||
* edge, and it could not follow a theme.
|
||||
*
|
||||
* Colours are literal on purpose. The shield and its bars are artwork — the
|
||||
* mark is the same mark in light and dark, the way a printed logo is — so
|
||||
* these four values are deliberately NOT theme tokens. `--shield`, `--clean`,
|
||||
* `--verify` and `--review` hold the same hexes for anything that needs to
|
||||
* reference them.
|
||||
*
|
||||
* `aria-hidden` because the product is named in text immediately beside it.
|
||||
*/
|
||||
export function ShieldMark({ size = 34 }: { size?: number }) {
|
||||
return (
|
||||
<span className="sb-appbar-mark" style={{ width: size, height: size }}>
|
||||
<svg viewBox="0 0 64 64" fill="none" aria-hidden="true" style={{ width: size, height: size }}>
|
||||
<path
|
||||
d="M32 5 L53 13 V34 C53 45.5 43.5 53.5 32 58.5 C20.5 53.5 11 45.5 11 34 V13 Z"
|
||||
fill="#21395a"
|
||||
/>
|
||||
<rect x="19.5" y="32" width="7" height="10" rx="1.6" fill="#51a68f" />
|
||||
<rect x="30" y="24" width="7" height="18" rx="1.6" fill="#e39b34" />
|
||||
<rect x="40.5" y="18" width="7" height="24" rx="1.6" fill="#de5243" />
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
/**
|
||||
* A proportional ring.
|
||||
*
|
||||
* Earns its place only because the quantity it draws is genuinely a share of a
|
||||
* whole — the split of every graded field across the confidence bands. A ring
|
||||
* standing in for a single number would be decoration; this one is the only
|
||||
* shape on the board that shows three parts against their total at once.
|
||||
*
|
||||
* It is `aria-hidden`, and that is deliberate rather than lazy: the legend
|
||||
* beside it states every segment's name and count as text, so a screen reader
|
||||
* gets the data in full and is not read a list of arc lengths. The chart is the
|
||||
* fast path for someone looking; the legend is the actual record.
|
||||
*/
|
||||
|
||||
export interface Slice {
|
||||
key: string
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export function Donut({
|
||||
slices,
|
||||
centerValue,
|
||||
centerLabel,
|
||||
size = 150,
|
||||
thickness = 20,
|
||||
/** Arc-length notch between segments, so two adjacent colours stay countable. */
|
||||
gap = 3,
|
||||
}: {
|
||||
slices: Slice[]
|
||||
centerValue: string
|
||||
centerLabel: string
|
||||
size?: number
|
||||
thickness?: number
|
||||
gap?: number
|
||||
}) {
|
||||
const r = (size - thickness) / 2
|
||||
const c = 2 * Math.PI * r
|
||||
const total = slices.reduce((n, s) => n + s.value, 0)
|
||||
|
||||
// Running start angle, in arc length. Accumulated rather than derived per
|
||||
// index so a zero-value slice consumes nothing at all instead of leaving a
|
||||
// gap where no data is.
|
||||
let at = 0
|
||||
const drawn = slices
|
||||
.filter((s) => s.value > 0)
|
||||
.map((s) => {
|
||||
const len = (s.value / total) * c
|
||||
const seg = { ...s, len: Math.max(len - gap, 0.5), offset: at }
|
||||
at += len
|
||||
return seg
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} aria-hidden="true" className="-rotate-90">
|
||||
{/* The track carries the empty case on its own, so a board with nothing
|
||||
graded yet still draws a ring rather than blank space. */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
strokeWidth={thickness}
|
||||
stroke="var(--surface-2)"
|
||||
/>
|
||||
{drawn.map((s) => (
|
||||
<circle
|
||||
key={s.key}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={thickness}
|
||||
strokeDasharray={`${s.len} ${c - s.len}`}
|
||||
strokeDashoffset={-s.offset}
|
||||
strokeLinecap="butt"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="sb-display tnum text-[20px] font-bold leading-none tracking-tight text-ink">
|
||||
{centerValue}
|
||||
</span>
|
||||
<span className="mt-1 text-[10px] font-medium uppercase tracking-[0.1em] text-muted">
|
||||
{centerLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api'
|
||||
import { BAND_VAR } from '../bands'
|
||||
import type { ReviewActions } from './ReviewPane'
|
||||
|
||||
/**
|
||||
* The source page, as pixels the page owns.
|
||||
*
|
||||
* This used to be the browser's own PDF plugin in an <iframe>. That viewer
|
||||
* cannot be driven from the page: zoom, scroll and pan all live inside it, so
|
||||
* pinch-to-zoom and drag-to-pan were impossible and the only control was a
|
||||
* `#zoom=` fragment that forced a full reload on every step. Here the page is
|
||||
* a PNG rendered by PyMuPDF on the server (already a dependency of the
|
||||
* extractor, so no new package and no bundled renderer), and zoom/pan are a
|
||||
* single CSS transform — continuous, immediate, and reversible.
|
||||
*
|
||||
* Verifying a handwritten code means getting close to it, so this is not a
|
||||
* nicety: at 100% these scans are barely readable.
|
||||
*/
|
||||
|
||||
const MIN_ZOOM = 0.25
|
||||
const MAX_ZOOM = 8
|
||||
|
||||
export function PdfPane({
|
||||
jobId,
|
||||
page,
|
||||
onPickPage,
|
||||
document: docName,
|
||||
fullscreen,
|
||||
onToggleFullscreen,
|
||||
onExit,
|
||||
reviewActions,
|
||||
}: {
|
||||
jobId: string
|
||||
page: number | null
|
||||
onPickPage: (page: number) => void
|
||||
document: string
|
||||
fullscreen: boolean
|
||||
onToggleFullscreen: () => void
|
||||
onExit: () => void
|
||||
/** The open patient's unsaved-edit state, so Save/Discard here act on the
|
||||
* exact same drafts as the review list — one save action, not two. */
|
||||
reviewActions?: ReviewActions | null
|
||||
}) {
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [box, setBox] = useState({ w: 0, h: 0 })
|
||||
// Aspect ratio of the rendered page, learned from the image itself. Pages in
|
||||
// this set are not all the same shape, so it cannot be a constant.
|
||||
const [aspect, setAspect] = useState(0)
|
||||
|
||||
// A callback ref, not a plain one. Observing `ref.current` inside a
|
||||
// mount-only effect attached the ResizeObserver to whichever node existed
|
||||
// first; when React later swapped that node the observer kept watching the
|
||||
// detached original, which never resizes. The measured width then froze at
|
||||
// its first value — the page stayed sized for a 344px pane inside a 462px
|
||||
// one, leaving a dead strip down both sides. Keying the effect to the
|
||||
// element itself re-attaches the observer whenever the node changes.
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const [viewportEl, setViewportEl] = useState<HTMLDivElement | null>(null)
|
||||
const setViewport = useCallback((el: HTMLDivElement | null) => {
|
||||
viewportRef.current = el
|
||||
setViewportEl(el)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewportEl) return
|
||||
const measure = () =>
|
||||
setBox({ w: viewportEl.clientWidth, h: viewportEl.clientHeight })
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(viewportEl)
|
||||
// The observer covers pane resizes; this covers the window itself, which
|
||||
// some browsers report only here when the element's box is unchanged in
|
||||
// the same frame.
|
||||
window.addEventListener('resize', measure)
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
window.removeEventListener('resize', measure)
|
||||
}
|
||||
}, [viewportEl])
|
||||
|
||||
// The page at 100% fills the pane's WIDTH, and scrolls vertically for the
|
||||
// rest. Fitting the whole page into the pane's height instead made a
|
||||
// portrait scan only ~380px wide in a ~680px pane — the page came out small
|
||||
// with a wide dead strip beside it, and the handwriting this product exists
|
||||
// to read was too small to read. A document viewer's useful default is
|
||||
// fit-width; the height is what scrolling is for.
|
||||
const PAD = 32
|
||||
const fit = (() => {
|
||||
if (!aspect || !box.w) return { w: 0, h: 0 }
|
||||
const w = Math.max(0, box.w - PAD)
|
||||
return { w, h: w / aspect }
|
||||
})()
|
||||
const scaledW = fit.w * zoom
|
||||
const scaledH = fit.h * zoom
|
||||
|
||||
const info = useQuery({
|
||||
queryKey: ['pdf-info', jobId],
|
||||
queryFn: () => api.pdfInfo(jobId),
|
||||
retry: false,
|
||||
})
|
||||
const pages = info.data?.pages ?? 0
|
||||
const current = Math.min(Math.max(page ?? 1, 1), Math.max(pages, 1))
|
||||
|
||||
// Render at a higher DPI as the reviewer zooms in, so magnification reaches
|
||||
// real detail instead of enlarging the same soft pixels. Stepped, not
|
||||
// continuous — every distinct value is a fresh request, and a smooth ramp
|
||||
// would refetch the page on every pinch frame.
|
||||
const dpi = zoom > 3 ? 300 : zoom > 1.5 ? 220 : 150
|
||||
const src = api.pageImage(jobId, current, dpi)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setZoom(1)
|
||||
const el = viewportRef.current
|
||||
if (el) el.scrollTo({ top: 0, left: 0 })
|
||||
}, [])
|
||||
|
||||
// A new page starts at the top; carrying the previous page's scroll position
|
||||
// over lands the reviewer somewhere arbitrary on a differently-sized page.
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [current, reset])
|
||||
|
||||
/**
|
||||
* Zoom about a fixed point, so the pixel under the cursor stays under it.
|
||||
*
|
||||
* The page is laid out at its real size inside a scrolling box rather than
|
||||
* being CSS-transformed inside a clipped one. That is what makes the mouse
|
||||
* wheel, the scrollbar, Page Up/Down and keyboard scrolling all work on it
|
||||
* for free — with a transform there is no scrollable overflow for any of
|
||||
* them to act on, so the only way to move the page was to drag it, and a
|
||||
* reviewer looking for the bottom of a page had nothing to grab.
|
||||
*/
|
||||
const zoomAt = useCallback((factor: number, clientX?: number, clientY?: number) => {
|
||||
const el = viewportRef.current
|
||||
setZoom((z) => {
|
||||
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z * factor))
|
||||
if (el) {
|
||||
const box = el.getBoundingClientRect()
|
||||
// Where in the CONTENT the anchor currently sits, then put that same
|
||||
// content point back under the cursor at the new scale.
|
||||
const ax = (clientX ?? box.left + box.width / 2) - box.left
|
||||
const ay = (clientY ?? box.top + box.height / 2) - box.top
|
||||
const cx = (el.scrollLeft + ax) / z
|
||||
const cy = (el.scrollTop + ay) / z
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollLeft = cx * next - ax
|
||||
el.scrollTop = cy * next - ay
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Ctrl/⌘+wheel and trackpad pinch zoom; a plain wheel is left alone so it
|
||||
// scrolls the page normally. A browser reports a trackpad pinch as a wheel
|
||||
// event with ctrlKey set — the same gesture that would otherwise zoom the
|
||||
// whole browser — so this must be a non-passive listener that can
|
||||
// preventDefault(), which React's passive onWheel cannot do.
|
||||
useEffect(() => {
|
||||
const el = viewportRef.current
|
||||
if (!el) return
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey && !e.metaKey) return
|
||||
e.preventDefault()
|
||||
zoomAt(Math.exp(-e.deltaY * 0.002), e.clientX, e.clientY)
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}, [zoomAt])
|
||||
|
||||
// Drag to pan, and two-finger pinch on a touchscreen. Panning now moves the
|
||||
// scroll position rather than a transform, so it stays in sync with the
|
||||
// scrollbar and with wheel scrolling instead of fighting them.
|
||||
const pointers = useRef(new Map<number, { x: number; y: number }>())
|
||||
const pinchStart = useRef<{ dist: number; zoom: number } | null>(null)
|
||||
const dragFrom = useRef<{ x: number; y: number; left: number; top: number } | null>(null)
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
// A drag starting on the scrollbar itself must stay the browser's job.
|
||||
if (e.button !== 0 && e.pointerType === 'mouse') return
|
||||
try {
|
||||
;(e.currentTarget as Element).setPointerCapture?.(e.pointerId)
|
||||
} catch {
|
||||
/* drag still works, it just stops at the pane edge */
|
||||
}
|
||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
const el = viewportRef.current
|
||||
if (pointers.current.size === 2) {
|
||||
const [a, b] = [...pointers.current.values()]
|
||||
pinchStart.current = { dist: Math.hypot(a.x - b.x, a.y - b.y), zoom }
|
||||
dragFrom.current = null
|
||||
} else if (el) {
|
||||
dragFrom.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
left: el.scrollLeft,
|
||||
top: el.scrollTop,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!pointers.current.has(e.pointerId)) return
|
||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
const el = viewportRef.current
|
||||
if (!el) return
|
||||
|
||||
if (pointers.current.size === 2 && pinchStart.current) {
|
||||
const [a, b] = [...pointers.current.values()]
|
||||
const dist = Math.hypot(a.x - b.x, a.y - b.y)
|
||||
const target = pinchStart.current.zoom * (dist / pinchStart.current.dist)
|
||||
setZoom(Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, target)))
|
||||
return
|
||||
}
|
||||
if (dragFrom.current) {
|
||||
el.scrollLeft = dragFrom.current.left - (e.clientX - dragFrom.current.x)
|
||||
el.scrollTop = dragFrom.current.top - (e.clientY - dragFrom.current.y)
|
||||
}
|
||||
}
|
||||
|
||||
const endPointer = (e: React.PointerEvent) => {
|
||||
pointers.current.delete(e.pointerId)
|
||||
if (pointers.current.size < 2) pinchStart.current = null
|
||||
if (pointers.current.size === 0) dragFrom.current = null
|
||||
}
|
||||
|
||||
// Prints the actual source PDF at full quality via the browser's own PDF
|
||||
// renderer, not the on-screen PNG — a reviewer printing for a paper trail
|
||||
// wants what came off the scanner, not a screenshot of whatever zoom level
|
||||
// they happened to be looking at. A hidden iframe is the standard way to
|
||||
// trigger a browser print for a document the page did not itself render.
|
||||
const printPdf = useCallback(() => {
|
||||
const frame = window.document.createElement('iframe')
|
||||
frame.style.position = 'fixed'
|
||||
frame.style.width = '0'
|
||||
frame.style.height = '0'
|
||||
frame.style.border = '0'
|
||||
frame.src = `/api/runs/${jobId}/pdf`
|
||||
frame.onload = () => {
|
||||
try {
|
||||
frame.contentWindow?.focus()
|
||||
frame.contentWindow?.print()
|
||||
} finally {
|
||||
// The print dialog is modal in most browsers, but not guaranteed to
|
||||
// be, so the frame is cleaned up on a delay rather than immediately.
|
||||
setTimeout(() => frame.remove(), 60_000)
|
||||
}
|
||||
}
|
||||
window.document.body.appendChild(frame)
|
||||
}, [jobId])
|
||||
|
||||
if (info.isError) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-surface-2 p-6 text-center">
|
||||
<p className="max-w-xs text-xs text-muted">
|
||||
No source PDF on disk for this run, so there is no page to check the
|
||||
values against.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// min-w-0 / overflow-hidden all the way down: a flex item's default
|
||||
// min-width is auto, i.e. "never smaller than my content". With a
|
||||
// nowrap page at 800% that content is thousands of pixels wide, so
|
||||
// without these the pane grew to fit the zoomed page and pushed the whole
|
||||
// app into horizontal overflow instead of scrolling inside itself.
|
||||
//
|
||||
// w-full alongside them: this element is itself a flex item, and min-w-0
|
||||
// freed it to shrink to its content — so it settled at 510px inside an
|
||||
// 891px pane and the page sat in the middle of a dead strip. min-w-0 says
|
||||
// "you may shrink"; w-full says "but fill what you are given".
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden bg-surface-2">
|
||||
<div className="sb-rv-toolbar shrink-0">
|
||||
{fullscreen && (
|
||||
<button onClick={onExit} className="sb-btn sb-btn-quiet sb-btn-sm">
|
||||
← Back
|
||||
</button>
|
||||
)}
|
||||
<span className="mr-auto min-w-0 flex-1 truncate text-[12px] font-medium text-ink-2" title={docName}>
|
||||
{docName}
|
||||
</span>
|
||||
|
||||
{pages > 1 && (
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<Btn label="Previous page" onClick={() => onPickPage(Math.max(1, current - 1))} disabled={current <= 1}>
|
||||
‹
|
||||
</Btn>
|
||||
<span className="tnum w-16 text-center text-[12px] text-muted">
|
||||
{current} / {pages}
|
||||
</span>
|
||||
<Btn label="Next page" onClick={() => onPickPage(Math.min(pages, current + 1))} disabled={current >= pages}>
|
||||
›
|
||||
</Btn>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<Btn label="Zoom out" onClick={() => zoomAt(1 / 1.25)}>−</Btn>
|
||||
<span className="tnum w-12 text-center text-[12px] text-muted">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<Btn label="Zoom in" onClick={() => zoomAt(1.25)}>+</Btn>
|
||||
<Btn label="Reset zoom and position" onClick={reset} wide>Fit</Btn>
|
||||
<Btn
|
||||
label={fullscreen ? 'Leave fullscreen' : 'Fill the screen with this page'}
|
||||
onClick={onToggleFullscreen}
|
||||
>
|
||||
{fullscreen ? '⤡' : '⤢'}
|
||||
</Btn>
|
||||
<a
|
||||
href={`/api/runs/${jobId}/pdf`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Open the PDF in a separate tab"
|
||||
title="Open in a separate tab"
|
||||
className="sb-icon-btn inline-flex items-center justify-center no-underline"
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1">
|
||||
<div
|
||||
ref={setViewport}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endPointer}
|
||||
onPointerCancel={endPointer}
|
||||
onDoubleClick={(e) => zoomAt(zoom >= MAX_ZOOM ? MIN_ZOOM / zoom : 1.8, e.clientX, e.clientY)}
|
||||
// pan-x pan-y (not none) keeps one-finger scrolling native while the
|
||||
// two-pointer pinch is still delivered here rather than zooming the
|
||||
// whole browser.
|
||||
// whitespace-nowrap stops the inline-block page being wrapped onto
|
||||
// its own line box at high zoom.
|
||||
className="relative min-h-0 min-w-0 flex-1 overflow-auto overscroll-contain whitespace-nowrap bg-surface-2"
|
||||
style={{
|
||||
touchAction: 'pan-x pan-y',
|
||||
cursor: zoom > 1 ? 'grab' : 'default',
|
||||
// Centre the page only while it fits. Centring an overflowing
|
||||
// inline box pushes its left edge to a negative offset, which no
|
||||
// scrollbar can reach — at 800% the left third of the page became
|
||||
// permanently invisible.
|
||||
textAlign: scaledW + PAD > box.w ? 'left' : 'center',
|
||||
}}
|
||||
>
|
||||
{/* The image IS the scrollable content — no spacer, no transform.
|
||||
Earlier attempts sized a wrapper and scaled the image inside it;
|
||||
in this subtree the wrapper's inline width was simply not honoured
|
||||
(it reported 672px and laid out at 360px, and the transform came
|
||||
back as an identity matrix), which left the page drawn at fit size
|
||||
inside a much larger reserved area — the large empty gap beside a
|
||||
zoomed page. Setting the <img>'s own width/height as an
|
||||
inline-block inside a nowrap scroller is the plain version: the
|
||||
element that shows the page is also the element that defines the
|
||||
overflow, so the two cannot disagree. */}
|
||||
<img
|
||||
// Remounted on every zoom step, deliberately. Updating this node in
|
||||
// place did not resize it: its inline style reported the new width
|
||||
// while it kept laying out at the old one, and writing
|
||||
// element.style directly did not move it either — only a freshly
|
||||
// created node ever took the size. Re-keying sidesteps that
|
||||
// entirely, and costs nothing visible because the src is unchanged
|
||||
// for a given (page, dpi) so the bitmap is served from cache.
|
||||
key={`p${current}-d${dpi}-z${Math.round(zoom * 1000)}`}
|
||||
src={src}
|
||||
alt={`Page ${current} of ${docName}`}
|
||||
draggable={false}
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget
|
||||
if (el.naturalWidth && el.naturalHeight) {
|
||||
setAspect(el.naturalWidth / el.naturalHeight)
|
||||
}
|
||||
}}
|
||||
className="m-4 inline-block max-w-none select-none rounded-md bg-surface align-top shadow-2xl ring-1 ring-line"
|
||||
style={{ width: scaledW || undefined, height: scaledH || undefined }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SideRail reviewActions={reviewActions} onPrint={printPdf} />
|
||||
</div>
|
||||
|
||||
<p className="shrink-0 border-t border-line-soft bg-rail px-3 py-1.5 text-[11px] text-muted">
|
||||
Scroll or drag to move · pinch or Ctrl+scroll to zoom · double-click to zoom in
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The common controls, always in view regardless of scroll or zoom: Save the
|
||||
* open patient's edits, Print the source PDF, and the score legend. Save here
|
||||
* drives the exact same state as the bar in ReviewPane — there is one save
|
||||
* action, reachable from whichever pane the reviewer's eyes are already on.
|
||||
*/
|
||||
function SideRail({
|
||||
reviewActions,
|
||||
onPrint,
|
||||
}: {
|
||||
reviewActions?: ReviewActions | null
|
||||
onPrint: () => void
|
||||
}) {
|
||||
const dirty = (reviewActions?.dirtyCount ?? 0) > 0
|
||||
|
||||
return (
|
||||
<div className="flex w-12 shrink-0 flex-col items-center gap-2 border-l border-line bg-rail py-3">
|
||||
<RailButton
|
||||
label={
|
||||
dirty
|
||||
? `Save ${reviewActions!.dirtyCount} unsaved change(s) for ${reviewActions?.patientName ?? 'this patient'}`
|
||||
: 'No unsaved changes'
|
||||
}
|
||||
onClick={() => reviewActions?.onSave()}
|
||||
disabled={!dirty || reviewActions?.saving}
|
||||
tone={dirty ? 'warn' : 'neutral'}
|
||||
>
|
||||
<span aria-hidden="true">💾</span>
|
||||
{dirty && (
|
||||
<span
|
||||
className="tnum absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full text-[9px] font-bold"
|
||||
style={{ background: 'var(--brand-grad)', color: 'var(--accent-fg)' }}
|
||||
>
|
||||
{reviewActions!.dirtyCount}
|
||||
</span>
|
||||
)}
|
||||
</RailButton>
|
||||
|
||||
{dirty && (
|
||||
<RailButton
|
||||
label={`Discard unsaved changes for ${reviewActions?.patientName ?? 'this patient'}`}
|
||||
onClick={() => reviewActions?.onDiscard()}
|
||||
tone="neutral"
|
||||
>
|
||||
<span aria-hidden="true">↺</span>
|
||||
</RailButton>
|
||||
)}
|
||||
|
||||
<div className="my-1 h-px w-6 bg-line" aria-hidden="true" />
|
||||
|
||||
<RailButton label="Print the source PDF" onClick={onPrint} tone="neutral">
|
||||
<span aria-hidden="true">🖨</span>
|
||||
</RailButton>
|
||||
|
||||
<div className="my-1 h-px w-6 bg-line" aria-hidden="true" />
|
||||
|
||||
{/* The score legend, vertical to fit this rail — same meaning as the
|
||||
horizontal one in the document list (StatsBar's BandLegend), just
|
||||
laid out for a narrow column instead of a wide header. */}
|
||||
<div
|
||||
className="flex flex-col items-center gap-1.5"
|
||||
title="Colour reflects print clarity, not correctness. A cleanly printed value can still be misread."
|
||||
>
|
||||
<ScoreDot band="green" label="≥0.95 clean" />
|
||||
<ScoreDot band="amber" label="0.90–0.95 sample" />
|
||||
<ScoreDot band="red" label="<0.90 review" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailButton({
|
||||
children,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
tone?: 'neutral' | 'warn'
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="sb-icon-btn relative h-8 w-8 shrink-0 text-sm"
|
||||
style={
|
||||
tone === 'warn'
|
||||
? {
|
||||
borderColor: 'color-mix(in srgb, var(--verify) 45%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--verify) 14%, var(--surface))',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ScoreDot({ band, label }: { band: 'green' | 'amber' | 'red'; label: string }) {
|
||||
return (
|
||||
<span
|
||||
className="sb-swatch shrink-0"
|
||||
title={label}
|
||||
style={{ backgroundColor: BAND_VAR[band], marginRight: 0 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Btn({
|
||||
children,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
wide,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
/** Word labels ("Fit") need more than the 26px icon square. */
|
||||
wide?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
// The visible glyph is a symbol, not a word — "−" reads as "minus sign"
|
||||
// to a screen reader, so the accessible name has to be written out.
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`sb-icon-btn ${wide ? 'w-auto px-2' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,989 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api'
|
||||
import { BAND_VAR } from '../bands'
|
||||
import { Donut, type Slice } from './Donut'
|
||||
import { ExtractionFire } from './ExtractionFire'
|
||||
import {
|
||||
IconAlert, IconCheck, IconChevronRight, IconDot, IconPage, IconSpend, IconStack,
|
||||
} from './icons'
|
||||
import type { RoiRun, RoiTotals } from '../types'
|
||||
import type { Project } from '../project'
|
||||
import { runProject } from '../project'
|
||||
|
||||
/**
|
||||
* The cost ledger, laid out as an overview board: headline tiles, a proportional
|
||||
* split, then the line-by-line list.
|
||||
*
|
||||
* Two kinds of number live here and the layout keeps them apart, because mixing
|
||||
* them is how a cost report starts being quoted as a fact it never was:
|
||||
*
|
||||
* Measured every dollar, token, page and field is either a billed figure
|
||||
* the API returned or a row that exists in the database.
|
||||
* Assumed what an hour of a reviewer's time is worth, and how long the
|
||||
* same work takes by hand. The API refuses to guess at these, so
|
||||
* they are inputs the reader can see and change, and the panel
|
||||
* using them says so in its own heading.
|
||||
*
|
||||
* Runs extracted before the pipeline recorded token usage carry no cost at all.
|
||||
* They are reported as unmeasured rather than given the average of their
|
||||
* neighbours — an averaged cost is indistinguishable from a real one once
|
||||
* somebody has read it out in a meeting.
|
||||
*
|
||||
* On colour: the four headline tiles use the brand's own warm run, because this
|
||||
* whole board is shell — a report about the pipeline, not a surface where a
|
||||
* reviewer reads a scanned value. The one place band colour appears is the
|
||||
* field-split ring, where it IS the data. Those two palettes never mix inside a
|
||||
* single element here.
|
||||
*/
|
||||
|
||||
const RATES_KEY = 'superbill.roi.rates'
|
||||
|
||||
/**
|
||||
* Starting points, not findings. Roughly a US medical-records clerk: four
|
||||
* minutes to key one patient's row off a scan by hand, twenty seconds to check
|
||||
* one field the pipeline already flagged and positioned for them.
|
||||
*/
|
||||
const DEFAULT_RATES = { minutesPerPatient: 4, secondsPerField: 20, hourlyRate: 22 }
|
||||
type Rates = typeof DEFAULT_RATES
|
||||
|
||||
function loadRates(): Rates {
|
||||
debugger;
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(RATES_KEY) ?? 'null')
|
||||
// Spread over the defaults rather than trusting the parse: a stored object
|
||||
// from an older shape would leave a field undefined and turn every figure
|
||||
// downstream into NaN.
|
||||
return saved && typeof saved === 'object' ? { ...DEFAULT_RATES, ...saved } : DEFAULT_RATES
|
||||
} catch {
|
||||
return DEFAULT_RATES
|
||||
}
|
||||
}
|
||||
|
||||
const usd = (n: number, dp = 2) =>
|
||||
n.toLocaleString('en-US', {
|
||||
style: 'currency', currency: 'USD',
|
||||
minimumFractionDigits: dp, maximumFractionDigits: dp,
|
||||
})
|
||||
|
||||
const num = (n: number) => n.toLocaleString('en-US')
|
||||
|
||||
/** Money below a cent needs four places, or a per-page figure reads as $0.00. */
|
||||
const fine = (n: number) => usd(n, n > 0 && n < 0.01 ? 4 : 2)
|
||||
|
||||
const hours = (h: number) => (h >= 1 ? `${h.toFixed(1)} h` : `${Math.round(h * 60)} min`)
|
||||
|
||||
/** 'YYYY-MM' of the current month in India time — the server buckets spend
|
||||
* the same way (UTC + 5:30), so "This month" resets at midnight IST on the 1st. */
|
||||
const istMonth = () => new Date(Date.now() + 330 * 60_000).toISOString().slice(0, 7)
|
||||
|
||||
/** '2026-10' -> 'Oct 2026'. */
|
||||
const monthLabel = (ym: string) =>
|
||||
new Date(`${ym}-01T00:00:00Z`).toLocaleString('en-US', {
|
||||
month: 'short', year: 'numeric', timeZone: 'UTC',
|
||||
})
|
||||
|
||||
const isToday = (iso: string) => {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
return d.getFullYear() === now.getFullYear()
|
||||
&& d.getMonth() === now.getMonth()
|
||||
&& d.getDate() === now.getDate()
|
||||
}
|
||||
|
||||
type Filter = 'all' | 'priced' | 'unmeasured' | 'failed'
|
||||
|
||||
/**
|
||||
* api.roi() has no project filter — it totals every document. The ROI board
|
||||
* is per-project (Example Client's spend is not eClinicalWorks' spend), so the
|
||||
* totals are recomputed here from the already-fetched runs instead of a new
|
||||
* query param. Every field is a plain sum or count over RoiRun — nothing
|
||||
* server-side is being second-guessed, just re-scoped to fewer rows.
|
||||
*/
|
||||
function totalsFor(runs: RoiRun[]): RoiTotals {
|
||||
const sum = (f: (r: RoiRun) => number | null) =>
|
||||
runs.reduce((n, r) => n + (f(r) ?? 0), 0)
|
||||
return {
|
||||
documents: runs.length,
|
||||
measured_documents: runs.filter((r) => r.measured).length,
|
||||
pages: sum((r) => r.pages),
|
||||
graded_fields: sum((r) => r.graded_fields),
|
||||
clean_fields: sum((r) => r.clean_fields),
|
||||
human_fields: sum((r) => r.human_fields),
|
||||
verified_fields: sum((r) => r.verified_fields),
|
||||
failed_documents: runs.filter((r) => r.status === 'failed').length,
|
||||
bands: {
|
||||
green: sum((r) => r.bands.green),
|
||||
amber: sum((r) => r.bands.amber),
|
||||
red: sum((r) => r.bands.red),
|
||||
unscored: sum((r) => r.bands.unscored),
|
||||
blank_critical: sum((r) => r.bands.blank_critical),
|
||||
},
|
||||
patients: sum((r) => r.patients),
|
||||
input_tokens: sum((r) => r.input_tokens),
|
||||
output_tokens: sum((r) => r.output_tokens),
|
||||
thinking_tokens: sum((r) => r.thinking_tokens),
|
||||
ocr_cost: sum((r) => r.ocr_cost),
|
||||
json_cost: sum((r) => r.json_cost),
|
||||
total_cost: sum((r) => r.total_cost),
|
||||
deleted_documents: runs.filter((r) => r.deleted).length,
|
||||
deleted_cost: sum((r) => (r.deleted ? r.total_cost : 0)),
|
||||
}
|
||||
}
|
||||
|
||||
export function RoiTab({
|
||||
onOpenRun,
|
||||
project,
|
||||
demography = false,
|
||||
}: {
|
||||
/** Absent on the demography board: its runs have no charge review to open. */
|
||||
onOpenRun?: (jobId: string) => void
|
||||
/** Which project's spend this board reports — Example Client tickets and
|
||||
* eClinicalWorks documents never share a total. */
|
||||
project: Project
|
||||
/** The Demography ROI (ticket 05): its own payload, one Gemini call per PDF
|
||||
* so one total with no read/map split, and no field figures at all. */
|
||||
demography?: boolean
|
||||
}) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [demography ? 'demography-roi' : 'roi'],
|
||||
queryFn: demography ? api.demographyRoi : api.roi,
|
||||
// A document's cost lands only when it finishes, so the ledger keeps
|
||||
// refreshing while anything is in flight and stops the moment nothing is.
|
||||
refetchInterval: (q) =>
|
||||
(q.state.data?.runs ?? []).some((r) => r.status === 'queued' || r.status === 'running')
|
||||
? 3000
|
||||
: false,
|
||||
})
|
||||
|
||||
const [rates, setRates] = useState<Rates>(loadRates)
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [showMonths, setShowMonths] = useState(false)
|
||||
const [sort, setSort] = useState<'recent' | 'cost'>('recent')
|
||||
|
||||
const setRate = (key: keyof Rates, value: number) => {
|
||||
const next = { ...rates, [key]: value }
|
||||
setRates(next)
|
||||
localStorage.setItem(RATES_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
const runs = useMemo(
|
||||
() => (data?.runs ?? []).filter((r) => runProject(r) === project),
|
||||
[data?.runs, project],
|
||||
)
|
||||
const t = useMemo(() => totalsFor(runs), [runs])
|
||||
|
||||
// Spend per month from the server's whole-table sums. `runs` stops at the
|
||||
// newest 500, so the all-time Total comes from here, not from `t`.
|
||||
const months = useMemo(
|
||||
() => (data?.monthly ?? []).filter((m) => m.project === project),
|
||||
[data?.monthly, project],
|
||||
)
|
||||
const allTimeCost = months.reduce((sum, m) => sum + m.cost, 0)
|
||||
const thisMonth = istMonth()
|
||||
const thisMonthCost = months.find((m) => m.month === thisMonth)?.cost ?? 0
|
||||
|
||||
// The comparison is derived from the priced runs alone, not from the totals:
|
||||
// a patient count only exists where a cost does, and dividing a measured cost
|
||||
// by an all-runs field count would understate every unit figure on screen.
|
||||
const priced = useMemo(() => runs.filter((r) => r.measured), [runs])
|
||||
|
||||
const model = useMemo(() => {
|
||||
debugger;
|
||||
const patients = priced.reduce((n, r) => n + (r.patients ?? 0), 0)
|
||||
const humanFields = priced.reduce((n, r) => n + r.human_fields, 0)
|
||||
const apiCost = priced.reduce((n, r) => n + (r.total_cost ?? 0), 0)
|
||||
const manualHours = (patients * rates.minutesPerPatient) / 60
|
||||
const reviewHours = (humanFields * rates.secondsPerField) / 3600
|
||||
const manualCost = manualHours * rates.hourlyRate
|
||||
const reviewCost = reviewHours * rates.hourlyRate
|
||||
return {
|
||||
patients, humanFields, apiCost, manualHours, reviewHours, manualCost, reviewCost,
|
||||
pipelineCost: apiCost + reviewCost,
|
||||
saved: manualCost - (apiCost + reviewCost),
|
||||
}
|
||||
}, [priced, rates])
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const kept = runs.filter((r) =>
|
||||
filter === 'all' ? true
|
||||
: filter === 'priced' ? r.measured
|
||||
: filter === 'unmeasured' ? !r.measured && r.status !== 'failed'
|
||||
: r.status === 'failed',
|
||||
)
|
||||
return sort === 'recent'
|
||||
? kept
|
||||
: [...kept].sort((a, b) => (b.total_cost ?? -1) - (a.total_cost ?? -1))
|
||||
}, [runs, filter, sort])
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="py-16 text-center text-[13px] text-muted">Loading…</p>
|
||||
}
|
||||
|
||||
if (!t || t.documents === 0) {
|
||||
return (
|
||||
<div className="sb-empty mx-auto max-w-md">
|
||||
<h2 className="sb-display text-[16px] font-bold text-ink">
|
||||
Nothing extracted yet
|
||||
</h2>
|
||||
<p className="mt-1.5 text-[13px] leading-relaxed text-muted">
|
||||
{demography
|
||||
? 'Cost is recorded as each facesheet is read. Upload one on the Demography Extraction board and its spend appears here.'
|
||||
: 'Cost is recorded as each document is read. Run a batch on the Charge Extraction board and its spend appears here.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const unpriced = t.documents - t.measured_documents
|
||||
// Demography divides by its priced runs only: an OCR-era run has pages and
|
||||
// patients but no cost, and would dilute every unit figure.
|
||||
const unitPages = demography ? priced.reduce((n, r) => n + r.pages, 0) : t.pages
|
||||
const unitPatients = demography ? model.patients : t.patients
|
||||
const bands = t.bands
|
||||
const slices: Slice[] = [
|
||||
{ key: 'green', label: 'Clean print', value: bands.green, color: BAND_VAR.green },
|
||||
{ key: 'amber', label: 'Slightly unclear', value: bands.amber, color: BAND_VAR.amber },
|
||||
{ key: 'red', label: 'Handwritten / messy', value: bands.red, color: BAND_VAR.red },
|
||||
{ key: 'blank', label: 'Code missing', value: bands.blank_critical, color: BAND_VAR.blank },
|
||||
{ key: 'unscored', label: 'Not in transcription', value: bands.unscored, color: BAND_VAR.unscored },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[1600px] flex-col gap-5">
|
||||
{/* ── Page head ────────────────────────────────────────────────────── */}
|
||||
<header className="flex flex-wrap items-start gap-x-4 gap-y-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="sb-page-title">{demography ? 'Demography ROI Dashboard' : 'ROI Dashboard'}</h2>
|
||||
<p className="sb-page-sub !mb-0">
|
||||
What the pipeline costs — and what it replaces
|
||||
</p>
|
||||
</div>
|
||||
{/* States coverage before any figure is read, because "0 of 9 priced" is
|
||||
the difference between a small total and an incomplete one. */}
|
||||
<span
|
||||
className={`sb-pill ml-auto flex shrink-0 items-center gap-1.5 ${
|
||||
unpriced === 0 ? 'sb-pill-ok' : 'sb-pill-warn'
|
||||
}`}
|
||||
>
|
||||
<IconDot />
|
||||
<span className="tnum">
|
||||
{num(t.measured_documents)} of {num(t.documents)} documents priced
|
||||
</span>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* ── Headline tiles ───────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<h3 className="sb-eyebrow">Spend overview</h3>
|
||||
<div className="sb-tiles !mb-0">
|
||||
<StatTile
|
||||
icon={<IconSpend />}
|
||||
tone="var(--accent-1)"
|
||||
value={months.length ? fine(allTimeCost) : t.measured_documents ? fine(t.total_cost) : '—'}
|
||||
label={
|
||||
!t.measured_documents
|
||||
? 'Total spend — not yet recorded'
|
||||
: t.deleted_documents
|
||||
? 'Total spend, deleted included'
|
||||
: 'Total spend'
|
||||
}
|
||||
lead
|
||||
/>
|
||||
<StatTile
|
||||
icon={<IconSpend />}
|
||||
tone="var(--accent-2)"
|
||||
value={fine(thisMonthCost)}
|
||||
label={`This month · ${monthLabel(thisMonth)} · ${showMonths ? 'hide' : 'click for all'} months`}
|
||||
onClick={() => setShowMonths((v) => !v)}
|
||||
expanded={showMonths}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<IconStack />}
|
||||
tone="var(--accent-2)"
|
||||
value={num(t.documents)}
|
||||
label={`${t.documents === 1 ? 'Document' : 'Documents'}${
|
||||
t.deleted_documents ? ` · ${num(t.deleted_documents)} deleted` : ''
|
||||
}`}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<IconPage />}
|
||||
tone="var(--verify)"
|
||||
value={num(t.pages)}
|
||||
label={t.pages === 1 ? 'Page read' : 'Pages read'}
|
||||
/>
|
||||
{!demography && (
|
||||
<StatTile
|
||||
icon={<IconCheck />}
|
||||
tone="var(--clean)"
|
||||
value={num(t.graded_fields)}
|
||||
label="Fields graded"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Month by month ───────────────────────────────────────────────── */}
|
||||
{showMonths && (
|
||||
<Panel title="Month-wise cost" note="India time · resets on the 1st">
|
||||
{months.length === 0 && (
|
||||
<p className="text-[13px] text-muted">No spend recorded yet.</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="text-left text-[11.5px] text-muted">
|
||||
<th className="pb-2 font-medium">Month</th>
|
||||
<th className="pb-2 text-right font-medium">Documents</th>
|
||||
<th className="pb-2 text-right font-medium">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{months.map((m) => (
|
||||
<tr key={m.month} className="border-t border-line">
|
||||
<td className="py-1.5 text-ink">
|
||||
{monthLabel(m.month)}
|
||||
{m.month === thisMonth && (
|
||||
<span className="ml-2 text-[11px] text-muted">this month</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="tnum py-1.5 text-right text-ink">{num(m.documents)}</td>
|
||||
<td className="sb-display tnum py-1.5 text-right font-semibold text-ink">
|
||||
{fine(m.cost)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* ── Split + cost totals ──────────────────────────────────────────── */}
|
||||
<div className={`grid gap-3 ${demography ? '' : 'lg:grid-cols-[minmax(0,1fr)_minmax(0,1.25fr)]'}`}>
|
||||
{!demography && (
|
||||
<Panel title="Field split (all documents)">
|
||||
{t.graded_fields === 0 ? (
|
||||
<p className="text-[13px] text-muted">No graded fields yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<Donut
|
||||
slices={slices}
|
||||
centerValue={num(t.graded_fields)}
|
||||
centerLabel="Fields"
|
||||
/>
|
||||
<dl className="sb-legend">
|
||||
{slices.filter((s) => s.value > 0).map((s) => (
|
||||
<div key={s.key}>
|
||||
<span className="sb-dot" style={{ backgroundColor: s.color }} />
|
||||
<dt className="min-w-0 truncate">{s.label}</dt>
|
||||
<dd className="sb-n shrink-0">{num(s.value)}</dd>
|
||||
<dd className="sb-p shrink-0">
|
||||
{Math.round((s.value / t.graded_fields) * 100)}%
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3.5 max-w-xl text-[11.5px] leading-relaxed text-muted">
|
||||
Clean means the print was legible, not that the value is right — the
|
||||
score rates the model's own reading. It measures how much work the
|
||||
pipeline removed, never accuracy.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel title="Cost totals (all documents)">
|
||||
{t.measured_documents === 0 ? (
|
||||
<>
|
||||
<p className="sb-display text-[16px] font-semibold text-ink">
|
||||
No cost recorded for these {num(t.documents)} documents
|
||||
</p>
|
||||
<p className="mt-1.5 max-w-xl text-[13px] leading-relaxed text-muted">
|
||||
They were extracted before the pipeline began storing token
|
||||
usage, so there is no figure to report and none is estimated
|
||||
here. The next extraction records its own cost.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{demography ? (
|
||||
<MoneyTile label="Total" value={fine(t.total_cost)} tone="var(--accent-2)" />
|
||||
) : (
|
||||
<div className="grid gap-2.5 sm:grid-cols-3">
|
||||
<MoneyTile label="Reading the page" value={fine(t.ocr_cost)} tone="var(--accent-1)" />
|
||||
<MoneyTile label="Mapping to fields" value={fine(t.json_cost)} tone="var(--verify)" />
|
||||
<MoneyTile label="Total" value={fine(t.total_cost)} tone="var(--accent-2)" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A deleted document was still paid for, so it stays in every
|
||||
total above; this is where the two are told apart. */}
|
||||
{t.deleted_documents > 0 && (
|
||||
<div className="mt-2.5 grid gap-2.5 sm:grid-cols-2">
|
||||
<MoneyTile
|
||||
label={`Active · ${num(t.documents - t.deleted_documents)} documents`}
|
||||
value={fine(t.total_cost - t.deleted_cost)}
|
||||
tone="var(--clean)"
|
||||
/>
|
||||
<MoneyTile
|
||||
label={`Deleted · ${num(t.deleted_documents)} documents`}
|
||||
value={fine(t.deleted_cost)}
|
||||
tone="var(--muted)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two calls per page — a transcription and a mapping — and they
|
||||
do not cost the same, so the split says which one to attack if
|
||||
this ever has to be cheaper. */}
|
||||
{!demography && (
|
||||
<span className="sb-meter mt-3.5 flex" aria-hidden="true">
|
||||
<span style={{ flexGrow: Math.max(t.ocr_cost, 1e-9), backgroundColor: 'var(--accent-1)' }} />
|
||||
<span style={{ flexGrow: Math.max(t.json_cost, 1e-9), backgroundColor: 'var(--verify)' }} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<p className="tnum mt-[7px] text-[11.5px] text-muted">
|
||||
{num(t.input_tokens + t.output_tokens)} tokens across{' '}
|
||||
{num(t.measured_documents)}{' '}
|
||||
{t.measured_documents === 1 ? 'document' : 'documents'}
|
||||
{t.thinking_tokens > 0 && ` · ${num(t.thinking_tokens)} thinking`}
|
||||
{unpriced > 0 && ` · ${num(unpriced)} older not included`}
|
||||
</p>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-3 gap-2.5">
|
||||
<Unit label="per page" value={unitPages ? fine(t.total_cost / unitPages) : '—'} />
|
||||
<Unit label="per patient" value={unitPatients ? fine(t.total_cost / unitPatients) : '—'} />
|
||||
<Unit label="per 1,000 patients"
|
||||
value={unitPatients ? usd((t.total_cost / unitPatients) * 1000) : '—'} />
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* ── The comparison, on the reader's own numbers ───────────────────── */}
|
||||
<Panel
|
||||
title="Against doing it by hand"
|
||||
note="Your assumptions, not a measurement"
|
||||
dashed
|
||||
>
|
||||
<div className="flex flex-wrap items-end gap-x-5 gap-y-3">
|
||||
<RateInput
|
||||
label="Minutes to key one patient by hand"
|
||||
value={rates.minutesPerPatient}
|
||||
step={0.5}
|
||||
onChange={(v) => setRate('minutesPerPatient', v)}
|
||||
/>
|
||||
{!demography && (
|
||||
<RateInput
|
||||
label="Seconds to check one flagged field"
|
||||
value={rates.secondsPerField}
|
||||
step={1}
|
||||
onChange={(v) => setRate('secondsPerField', v)}
|
||||
/>
|
||||
)}
|
||||
<RateInput
|
||||
label="Reviewer cost per hour"
|
||||
value={rates.hourlyRate}
|
||||
step={1}
|
||||
prefix="$"
|
||||
onChange={(v) => setRate('hourlyRate', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{model.patients === 0 ? (
|
||||
<p className="mt-4 text-[13px] text-muted">
|
||||
Nothing to compare yet — this needs at least one document with a
|
||||
recorded patient count.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-4 grid gap-3 border-t border-line-soft pt-3.5 sm:grid-cols-3">
|
||||
<Side
|
||||
heading="By hand"
|
||||
total={usd(model.manualCost)}
|
||||
lines={[
|
||||
`${num(model.patients)} patients × ${rates.minutesPerPatient} min`,
|
||||
`${hours(model.manualHours)} of keying`,
|
||||
]}
|
||||
/>
|
||||
<Side
|
||||
heading="With the pipeline"
|
||||
total={usd(model.pipelineCost)}
|
||||
lines={[
|
||||
`${fine(model.apiCost)} of model calls`,
|
||||
...(demography
|
||||
? []
|
||||
: [`${hours(model.reviewHours)} checking ${num(model.humanFields)} fields`]),
|
||||
]}
|
||||
/>
|
||||
<Side
|
||||
heading="Difference"
|
||||
total={usd(model.saved)}
|
||||
lines={[
|
||||
model.pipelineCost > 0
|
||||
? `${(model.manualCost / model.pipelineCost).toFixed(1)}× cheaper`
|
||||
: '—',
|
||||
`on ${num(priced.length)} priced ${priced.length === 1 ? 'document' : 'documents'}`,
|
||||
]}
|
||||
emphasis={model.saved >= 0}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] leading-relaxed text-muted">
|
||||
{demography
|
||||
? 'The pipeline column is model calls only: this board does not count reviewer time. Only the model-call figure is measured; the keying time comes from the rates you entered.'
|
||||
: 'The pipeline column charges for reviewer time as well as model calls, because a flagged field still costs somebody a minute. Only the model-call figure is measured; every duration above comes from the rates you entered.'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* ── Per document ─────────────────────────────────────────────────── */}
|
||||
<section className="flex flex-col gap-2.5">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
||||
<Pill active={filter === 'all'} onClick={() => setFilter('all')}>
|
||||
All <Count n={t.documents} active={filter === 'all'} />
|
||||
</Pill>
|
||||
<Pill active={filter === 'priced'} onClick={() => setFilter('priced')}>
|
||||
Priced <Count n={t.measured_documents} active={filter === 'priced'} />
|
||||
</Pill>
|
||||
<Pill active={filter === 'unmeasured'} onClick={() => setFilter('unmeasured')}>
|
||||
Not measured{' '}
|
||||
<Count n={unpriced - t.failed_documents} active={filter === 'unmeasured'} />
|
||||
</Pill>
|
||||
<Pill active={filter === 'failed'} onClick={() => setFilter('failed')}>
|
||||
Failed <Count n={t.failed_documents} active={filter === 'failed'} />
|
||||
</Pill>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Sort active={sort === 'recent'} onClick={() => setSort('recent')}>Newest</Sort>
|
||||
<Sort active={sort === 'cost'} onClick={() => setSort('cost')}>Costliest</Sort>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<p className="sb-empty !py-10">No documents in this filter.</p>
|
||||
) : (
|
||||
shown.map((r) => (
|
||||
<DocLine
|
||||
key={r.job_id}
|
||||
run={r}
|
||||
onOpen={onOpenRun && (() => onOpenRun(r.job_id))}
|
||||
showFields={!demography}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One document, as a line in the ledger.
|
||||
*
|
||||
* Reads left to right the way the question is asked: which file, how big, how
|
||||
* far through review, what it cost.
|
||||
*/
|
||||
function DocLine({
|
||||
run,
|
||||
onOpen,
|
||||
showFields,
|
||||
}: {
|
||||
run: RoiRun
|
||||
onOpen?: () => void
|
||||
/** False on the demography board, which carries no field figures. */
|
||||
showFields: boolean
|
||||
}) {
|
||||
const openable = !!onOpen && run.status === 'done' && !run.deleted
|
||||
const failed = run.status === 'failed'
|
||||
const running = run.status === 'running'
|
||||
const perPatient =
|
||||
run.total_cost != null && run.patients ? run.total_cost / run.patients : null
|
||||
|
||||
const pct = run.human_fields
|
||||
? Math.round((run.verified_fields / run.human_fields) * 100)
|
||||
: run.graded_fields ? 100 : 0
|
||||
|
||||
// The left rule states the document's condition at a glance down the column.
|
||||
// 4px, matching the band edge the document list already uses — the same
|
||||
// language, not a second one invented here.
|
||||
const rule = run.deleted
|
||||
? BAND_VAR.blank
|
||||
: failed
|
||||
? BAND_VAR.red
|
||||
: run.bands.red ? BAND_VAR.red
|
||||
: run.bands.amber ? BAND_VAR.amber
|
||||
: run.graded_fields ? BAND_VAR.green
|
||||
: BAND_VAR.blank
|
||||
|
||||
const extractPct = run.pages ? Math.round((run.pages_done / run.pages) * 100) : 0
|
||||
|
||||
return (
|
||||
<article
|
||||
onClick={openable ? onOpen : undefined}
|
||||
className={`sb-card group relative flex flex-wrap items-center gap-x-4 gap-y-2 overflow-hidden !pl-5 !pr-3.5 !py-3 transition-colors duration-200 ${
|
||||
openable ? 'cursor-pointer hover:border-muted' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="absolute inset-y-0 left-0 w-1" style={{ backgroundColor: rule }} />
|
||||
{running && <ExtractionFire pct={extractPct ?? 0} />}
|
||||
|
||||
<div className="relative min-w-0 flex-1 basis-64">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="sb-doc-name min-w-0 truncate">{run.document}</h4>
|
||||
{isToday(run.created_at) && (
|
||||
<span
|
||||
className="shrink-0 rounded-full px-1.5 py-px text-[10.5px] font-semibold uppercase tracking-[0.1em]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--accent-1) 12%, transparent)',
|
||||
color: 'var(--accent-1)',
|
||||
}}
|
||||
>
|
||||
Today
|
||||
</span>
|
||||
)}
|
||||
{run.deleted && (
|
||||
<span
|
||||
className="shrink-0 rounded-full px-1.5 py-px text-[10.5px] font-semibold uppercase tracking-[0.1em]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--muted) 16%, transparent)',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
title={`Deleted${run.deleted_by ? ` by ${run.deleted_by}` : ''}${
|
||||
run.deleted_at ? ` on ${new Date(run.deleted_at).toLocaleString()}` : ''
|
||||
} — its cost still counts`}
|
||||
>
|
||||
Deleted{run.deleted_by ? ` · ${run.deleted_by}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[11.5px] text-muted">
|
||||
{run.template ?? '—'} · {run.model.replace('gemini-', '')}
|
||||
{run.batch_name && ` · ${run.batch_name}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="tnum relative shrink-0 text-[11.5px] text-muted">
|
||||
{run.patients != null ? `${num(run.patients)} patients` : `${num(run.graded_fields)} fields`}
|
||||
{run.pages > 0 && ` · ${run.pages} ${run.pages === 1 ? 'page' : 'pages'}`}
|
||||
</p>
|
||||
|
||||
{/* Review progress, not extraction progress: how much of what a human was
|
||||
handed has actually been signed off.
|
||||
A document with nothing flagged has no such fraction. It used to render
|
||||
as "100% · 0 · 0", which is three figures agreeing on nothing — the bar
|
||||
was full because the queue was empty, not because anyone did the work.
|
||||
It says so in words instead. */}
|
||||
{!showFields ? (
|
||||
<div className="relative min-w-[140px] flex-1 basis-40" />
|
||||
) : run.human_fields > 0 ? (
|
||||
<>
|
||||
<div className="relative flex min-w-[140px] flex-1 basis-40 items-center gap-2.5">
|
||||
<span className="sb-meter min-w-0 flex-1">
|
||||
<span
|
||||
className="block h-full w-full origin-left rounded-full transition-transform duration-500"
|
||||
style={{
|
||||
transform: `scaleX(${pct / 100})`,
|
||||
backgroundColor: pct === 100 ? BAND_VAR.green : 'var(--accent-2)',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="tnum w-9 shrink-0 text-right text-[11.5px] text-muted">
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative flex shrink-0 items-center gap-1.5">
|
||||
<Chip
|
||||
tone={BAND_VAR.green}
|
||||
icon={<IconCheck size={12} />}
|
||||
n={run.verified_fields}
|
||||
title="Fields signed off"
|
||||
/>
|
||||
<Chip
|
||||
tone="var(--accent-2)"
|
||||
icon={<IconDot size={7} />}
|
||||
n={run.human_fields - run.verified_fields}
|
||||
title="Fields still waiting on a person"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="relative flex min-w-[140px] flex-1 basis-40 items-center">
|
||||
<span className="inline-flex items-center gap-1.5 text-[11.5px] font-medium text-clean">
|
||||
{run.graded_fields > 0 ? (
|
||||
<>
|
||||
<IconCheck size={13} /> Nothing flagged
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted">No fields extracted</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-28 shrink-0 text-right">
|
||||
{failed ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11.5px] font-medium text-review">
|
||||
<IconAlert size={13} /> Failed
|
||||
</span>
|
||||
) : run.total_cost != null ? (
|
||||
<span className="sb-display tnum text-[13.5px] font-bold text-ink">
|
||||
{fine(run.total_cost)}
|
||||
{perPatient != null && (
|
||||
<span className="ml-1 block font-plex text-[11.5px] font-normal text-muted">
|
||||
{fine(perPatient)}/patient
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
// Not a zero and not a bare dash: the reader has to know the number is
|
||||
// absent rather than small.
|
||||
<span
|
||||
className="text-[11.5px] text-muted"
|
||||
title="Extracted before cost was recorded"
|
||||
>
|
||||
not measured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`relative shrink-0 transition-transform ${
|
||||
openable
|
||||
? 'text-muted group-hover:translate-x-0.5 group-hover:text-ink-2'
|
||||
: 'text-transparent'
|
||||
}`}
|
||||
>
|
||||
<IconChevronRight size={16} />
|
||||
</span>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A headline figure with its own icon.
|
||||
*
|
||||
* `lead` marks the one tile that is the board's subject. Exactly one gets it —
|
||||
* a second would mean neither is the answer.
|
||||
*/
|
||||
function StatTile({
|
||||
icon, tone, value, label, lead, onClick, expanded,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
tone: string
|
||||
value: string
|
||||
label: string
|
||||
lead?: boolean
|
||||
/** Makes the tile a toggle button, e.g. to open the month list. */
|
||||
onClick?: () => void
|
||||
expanded?: boolean
|
||||
}) {
|
||||
const Tag = onClick ? 'button' : 'div'
|
||||
return (
|
||||
<Tag
|
||||
className={`sb-tile ${onClick ? 'w-full cursor-pointer text-left hover:border-ink-2' : ''}`}
|
||||
{...(onClick ? { type: 'button' as const, onClick, 'aria-expanded': expanded } : {})}
|
||||
>
|
||||
<span
|
||||
className="sb-chip"
|
||||
style={{
|
||||
color: tone,
|
||||
backgroundColor: `color-mix(in srgb, ${tone} 18%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className={`sb-tile-num ${lead ? 'sb-spend' : ''}`}>{value}</div>
|
||||
<div className="sb-tile-cap truncate">{label}</div>
|
||||
</div>
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
function MoneyTile({ label, value, tone }: { label: string; value: string; tone: string }) {
|
||||
return (
|
||||
<div className="rounded-[10px] border border-line bg-surface-2 p-3">
|
||||
<b className="sb-display tnum block text-[18px] font-bold leading-tight" style={{ color: tone }}>
|
||||
{value}
|
||||
</b>
|
||||
<span className="mt-1 flex items-center gap-1.5 text-[11.5px] text-muted">
|
||||
<span className="sb-dot" style={{ backgroundColor: tone }} />
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Panel({
|
||||
title, note, dashed, children,
|
||||
}: {
|
||||
title: string
|
||||
note?: string
|
||||
dashed?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={`sb-panel ${dashed ? 'border-dashed bg-surface-2' : ''}`}
|
||||
>
|
||||
<div className="mb-3.5 flex flex-wrap items-baseline gap-x-2.5">
|
||||
<h3 className="sb-panel-title !mb-0">{title}</h3>
|
||||
{note && <span className="sb-eyebrow !mb-0">{note}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Unit({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-[11px] text-muted">{label}</dt>
|
||||
<dd className="sb-display tnum text-[15px] font-semibold text-ink">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Side({
|
||||
heading, total, lines, emphasis,
|
||||
}: {
|
||||
heading: string
|
||||
total: string
|
||||
lines: string[]
|
||||
emphasis?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-[11px] text-muted">{heading}</h4>
|
||||
<p
|
||||
className="sb-display tnum mt-0.5 text-[20px] font-bold leading-none tracking-tight"
|
||||
style={{ color: emphasis ? 'var(--accent-1)' : 'var(--ink)' }}
|
||||
>
|
||||
{total}
|
||||
</p>
|
||||
<ul className="mt-1.5 space-y-0.5">
|
||||
{lines.map((l) => (
|
||||
<li key={l} className="tnum text-[11.5px] text-muted">
|
||||
{l}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({
|
||||
tone, icon, n, title,
|
||||
}: {
|
||||
tone: string
|
||||
icon: React.ReactNode
|
||||
n: number
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
className="tnum inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-semibold"
|
||||
style={{
|
||||
backgroundColor: `color-mix(in srgb, ${tone} 18%, transparent)`,
|
||||
color: tone,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{num(n)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Pill({
|
||||
active, onClick, children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={`sb-btn sb-btn-sm !rounded-full ${active ? 'sb-btn-primary' : 'sb-btn-quiet'}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Count({ n, active }: { n: number; active: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className="tnum rounded-full px-1.5 text-[11px]"
|
||||
style={
|
||||
// A translucent veil over the brand gradient lands too close to the
|
||||
// white label beside it. A solid surface chip carrying the accent reads
|
||||
// as a separate object and keeps its contrast.
|
||||
active
|
||||
? { backgroundColor: 'var(--surface)', color: 'var(--accent-1)' }
|
||||
: { backgroundColor: 'var(--surface)', color: 'var(--muted)' }
|
||||
}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Sort({
|
||||
active, onClick, children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={`min-h-[24px] rounded-md border px-2.5 py-1 text-[11.5px] font-medium transition-colors ${
|
||||
active
|
||||
? 'border-line bg-surface-2 text-ink'
|
||||
: 'border-transparent text-muted hover:text-ink-2'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function RateInput({
|
||||
label, value, step, prefix, onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
step: number
|
||||
prefix?: string
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-[11px] text-muted">{label}</span>
|
||||
<span className="sb-input mt-1 flex items-center !py-0 !px-2.5 focus-within:border-accent-1">
|
||||
{prefix && <span className="text-[13px] text-muted">{prefix}</span>}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={step}
|
||||
value={value}
|
||||
// A cleared box parses as NaN and would take every figure on the
|
||||
// board with it, so it reads as zero until something is typed.
|
||||
onChange={(e) => onChange(Number(e.target.value) || 0)}
|
||||
className="tnum w-16 bg-transparent py-2 text-[13px] font-semibold text-ink outline-none"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../api'
|
||||
import { IconDownload } from './icons'
|
||||
import type { Run } from '../types'
|
||||
|
||||
// Each model carries its OWN free-tier daily allowance, so when one is spent —
|
||||
// or overloaded — switching is the way through rather than waiting for a reset.
|
||||
const MODELS = ['gemini-3.5-flash', 'gemini-2.5-flash'] as const
|
||||
|
||||
/** One compact strip: status, page progress, band counts, and the approve gate. */
|
||||
export function RunHeader({
|
||||
jobId,
|
||||
onApproved,
|
||||
}: {
|
||||
jobId: string
|
||||
/** Approving is the end of the human's job on this document, so the app
|
||||
* follows through to the automation board rather than leaving the reviewer
|
||||
* on a screen they are finished with. */
|
||||
onApproved?: () => void
|
||||
}) {
|
||||
const { data: run } = useQuery({
|
||||
queryKey: ['run', jobId],
|
||||
queryFn: () => api.getRun(jobId),
|
||||
// Poll only while the answer can still change. A finished run polled every
|
||||
// second is a request that can never return anything new.
|
||||
refetchInterval: (q) => {
|
||||
const s = q.state.data?.status
|
||||
return s === 'done' || s === 'failed' ? false : 1500
|
||||
},
|
||||
})
|
||||
|
||||
const qc = useQueryClient()
|
||||
const approve = useMutation({
|
||||
// Approving with fields still unverified used to prompt "approve anyway?",
|
||||
// then fail: the gate is enforced server-side and returned 409, so OK did
|
||||
// nothing and the refusal was easy to miss in the row. The prompt now
|
||||
// states exactly what OK does, and OK does it — the remaining fields are
|
||||
// signed off as extracted first (audited separately as `bulk_verified`),
|
||||
// which satisfies the gate rather than pretending to bypass it.
|
||||
mutationFn: async () => {
|
||||
if ((run?.review?.remaining ?? 0) > 0) await api.verifyRemaining(jobId)
|
||||
return api.approve(jobId)
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['run', jobId] })
|
||||
qc.invalidateQueries({ queryKey: ['batches'] })
|
||||
// Held briefly so the "Approved ✓" state is actually seen before the
|
||||
// view changes underneath it — an instant jump reads as the click having
|
||||
// done something unrelated.
|
||||
setTimeout(() => onApproved?.(), 700)
|
||||
},
|
||||
})
|
||||
const retry = useMutation({
|
||||
mutationFn: (model?: string) => api.retryRun(jobId, model),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['run', jobId] })
|
||||
qc.invalidateQueries({ queryKey: ['runs'] })
|
||||
},
|
||||
})
|
||||
|
||||
if (!run) return null
|
||||
const left = run.review?.remaining ?? 0
|
||||
// A spent daily allowance is per-model, so the same model will just fail
|
||||
// again. Offer the switch right where the failure is shown.
|
||||
const quotaSpent = /quota/i.test(run.error ?? '')
|
||||
const other = MODELS.find((m) => m !== run.model) ?? MODELS[0]
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-line bg-surface px-4 py-2">
|
||||
<StatusPill run={run} />
|
||||
|
||||
{run.status === 'running' && (
|
||||
<span className="tnum text-[12px] text-muted">
|
||||
page {run.pages_done} of {run.pages_total || '?'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{run.summary && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Chip band="green" n={run.summary.green} label="clean" />
|
||||
<Chip band="amber" n={run.summary.amber} label="sample" />
|
||||
<Chip band="red" n={run.summary.red} label="review" />
|
||||
<Chip band="blank" n={run.summary.blank_critical} label="missing code" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.error && (
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-review" title={run.error}>
|
||||
{run.error}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Reads the same SPB_Roi + SPB_Extraction rows the review pane does —
|
||||
nothing re-extracts, no API call happens. Gated on 'done' rather than
|
||||
shown always: costs and every field are null until the run finishes,
|
||||
so a report requested earlier would just be empty. */}
|
||||
{run.status === 'done' && (
|
||||
<a
|
||||
href={`/api/runs/${jobId}/report.xlsx`}
|
||||
download
|
||||
title="Download a cost + extracted-values spreadsheet for this run"
|
||||
className="sb-btn sb-btn-quiet sb-btn-sm shrink-0"
|
||||
>
|
||||
<IconDownload size={13} />
|
||||
Report
|
||||
</a>
|
||||
)}
|
||||
|
||||
{run.status === 'failed' && (
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => retry.mutate(undefined)}
|
||||
disabled={retry.isPending}
|
||||
// A 503 spike or a per-minute window is weather, not a bad file. The
|
||||
// uploaded PDF is still on the server, so retrying costs no upload.
|
||||
title={`Run the same file again on ${run.model} — no re-upload needed`}
|
||||
// min-h-[24px]: text-xs at this line-height renders at 17px tall,
|
||||
// under the WCAG 2.5.8 24x24 minimum target size — the padding
|
||||
// alone didn't clear it.
|
||||
className="sb-btn sb-btn-quiet sb-btn-sm min-h-[24px]"
|
||||
>
|
||||
{retry.isPending ? 'Retrying…' : 'Retry'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => retry.mutate(other)}
|
||||
disabled={retry.isPending}
|
||||
title={`Retry on ${other}, which has its own daily allowance`}
|
||||
className={`sb-btn sb-btn-sm min-h-[24px] ${
|
||||
quotaSpent ? 'sb-btn-primary' : 'sb-btn-quiet'
|
||||
}`}
|
||||
>
|
||||
Retry on {other.replace('gemini-', '')}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{run.review && run.review.total > 0 && (
|
||||
<div className="ml-auto flex items-center gap-2.5">
|
||||
{/* The bar alone, no "N left" readout beside it. The number was
|
||||
redundant with the review pane's own "9/230 fields" and turned
|
||||
into noise ("222 left") on a document nobody had started. The
|
||||
proportion is what a glance actually needs. */}
|
||||
<div className="sb-meter w-32">
|
||||
<div
|
||||
className="h-full w-full origin-left rounded-full transition-transform duration-500 ease-out"
|
||||
style={{
|
||||
transform: `scaleX(${run.review.verified / run.review.total})`,
|
||||
background: 'var(--brand-grad)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ApproveButton
|
||||
onApprove={() => approve.mutate()}
|
||||
pending={approve.isPending}
|
||||
approved={approve.isSuccess}
|
||||
remaining={left}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.review?.total === 0 && (
|
||||
<div className="ml-auto flex items-center gap-2.5">
|
||||
<span className="sb-pill sb-pill-ok">Nothing flagged</span>
|
||||
<ApproveButton
|
||||
onApprove={() => approve.mutate()}
|
||||
pending={approve.isPending}
|
||||
approved={approve.isSuccess}
|
||||
remaining={0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full-width so a refused approve cannot hide at the end of a crowded
|
||||
row — the previous inline span was the only signal that the click had
|
||||
failed, and it was routinely missed. */}
|
||||
{approve.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="w-full rounded-card border border-review/40 bg-review/10 px-3 py-1.5 text-[12px] font-medium text-review"
|
||||
>
|
||||
Not approved — {(approve.error as Error).message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Approving releases a charge, so it asks first rather than firing on a single
|
||||
* click. The button is never disabled: hiding the gate behind a dead control
|
||||
* left a reviewer with a greyed-out button and no statement of what was
|
||||
* blocking it. Now the confirm says exactly how many fields are still
|
||||
* unverified, and the server still refuses (409) if they try anyway — the gate
|
||||
* stays where it is enforced, not where it is merely displayed.
|
||||
*/
|
||||
function ApproveButton({
|
||||
onApprove,
|
||||
pending,
|
||||
approved,
|
||||
remaining,
|
||||
}: {
|
||||
onApprove: () => void
|
||||
pending: boolean
|
||||
approved: boolean
|
||||
remaining: number
|
||||
}) {
|
||||
if (approved) {
|
||||
return (
|
||||
<span className="sb-pulse sb-pill sb-pill-ok inline-flex items-center gap-1.5">
|
||||
<span aria-hidden="true">✓</span> Approved
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
// States what OK actually does. The previous wording ("approve
|
||||
// anyway?") promised something the server refuses, so OK appeared to
|
||||
// do nothing at all.
|
||||
const ok = window.confirm(
|
||||
remaining > 0
|
||||
? `${remaining} field(s) are still unverified.\n\n` +
|
||||
`OK will sign them off as extracted, then approve this document ` +
|
||||
`for posting. That is recorded as a bulk sign-off.\n\n` +
|
||||
`Cancel to go back and check them individually.`
|
||||
: 'Approve this document for posting?',
|
||||
)
|
||||
if (ok) onApprove()
|
||||
}}
|
||||
className="sb-btn sb-btn-primary sb-btn-sm"
|
||||
>
|
||||
{pending ? 'Approving…' : 'Approve'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Fixed, not accent-driven: these are STATE semantics (queued/in-progress/done/
|
||||
// failed), read the same way a CI pipeline's status colors are read. Tying them
|
||||
// to the switchable accent would make "failed" turn the same red as "confirm"
|
||||
// whenever someone picks the red accent — a real confusion, not a style choice.
|
||||
function StatusPill({ run }: { run: Run }) {
|
||||
const tone = {
|
||||
// Uploaded but not yet handed to the extractor — the batch's Run button
|
||||
// has not been pressed. Deliberately the quietest of the five.
|
||||
pending: 'sb-pill-mute opacity-70',
|
||||
queued: 'sb-pill-mute',
|
||||
running: 'sb-pill-warn',
|
||||
done: 'sb-pill-ok',
|
||||
failed: 'sb-pill-bad',
|
||||
}[run.status]
|
||||
return <span className={`sb-pill ${tone}`}>{run.status}</span>
|
||||
}
|
||||
|
||||
function Chip({
|
||||
band,
|
||||
n,
|
||||
label,
|
||||
}: {
|
||||
band: 'green' | 'amber' | 'red' | 'blank'
|
||||
n: number
|
||||
label: string
|
||||
}) {
|
||||
// "blank" is not a grade — nothing was read, so there is no clarity to score.
|
||||
// It takes the neutral pill rather than one of the three grading colours.
|
||||
const tone = {
|
||||
green: 'sb-pill-ok',
|
||||
amber: 'sb-pill-warn',
|
||||
red: 'sb-pill-bad',
|
||||
blank: 'sb-pill-mute',
|
||||
}[band]
|
||||
return (
|
||||
<span className={`sb-pill ${tone}`}>
|
||||
<b className="tnum">{n}</b> {label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
import { useState } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { BatchStage, BoardTab } from '../types'
|
||||
import type { Project } from '../project'
|
||||
import { PROJECT_LABEL } from '../project'
|
||||
import { IconSpend, IconPage, IconLoop, IconIdCard, IconPatients } from './icons'
|
||||
|
||||
/**
|
||||
* The app-wide section rail, at a fixed 236px.
|
||||
*
|
||||
* It used to collapse to a 56px icon strip and expand to an overlay on hover.
|
||||
* That is gone with the reskin: the rail is now always labelled, which is what
|
||||
* the approved mockup shows and what removes the "hover to find out what these
|
||||
* icons are" step from every navigation.
|
||||
*
|
||||
* Two projects, one rail: ECW's boards (templates 1-6) and Tebra's (template 7
|
||||
* / Example Client), each under its own group header. See project.ts for how a
|
||||
* batch or run is assigned to one or the other.
|
||||
*
|
||||
* Selection and drag-and-drop-to-move-stage behaviour are unchanged — only the
|
||||
* shape of the control moved.
|
||||
*/
|
||||
export function Sidebar({
|
||||
tab,
|
||||
onTab,
|
||||
counts,
|
||||
onDropBatch,
|
||||
onDropRun,
|
||||
isAdmin,
|
||||
projects,
|
||||
}: {
|
||||
tab: BoardTab
|
||||
onTab: (t: BoardTab) => void
|
||||
/** Per project, per stage — each section shows its own queue depth. */
|
||||
counts: Record<Project, Record<BatchStage, number>>
|
||||
/** A batch card dragged onto a section moves to that stage. */
|
||||
onDropBatch: (batchId: string, stage: BatchStage) => void
|
||||
/** A single document dragged onto Automation is gathered into its own batch. */
|
||||
onDropRun?: (jobId: string) => void
|
||||
/** Gates each section's ROI item, and the single account-level People
|
||||
* item below them all — both are admin-and-above. */
|
||||
isAdmin: boolean
|
||||
/** Which sections render at all. A scoped admin sees one; everyone else sees
|
||||
* both. Independent of `isAdmin`: an operator sees both sections' boards
|
||||
* with ROI/People hidden inside each, while a scoped admin has a whole
|
||||
* section absent regardless of role. */
|
||||
projects: Project[]
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
className="sb-rail z-10 w-[236px] shrink-0 overflow-y-auto"
|
||||
>
|
||||
{projects.includes('ecw') && (
|
||||
<>
|
||||
<SectionHead project="ecw" first={projects[0] === 'ecw'} />
|
||||
{/* First, and deliberately not a drop target: ROI is a report over the
|
||||
whole pipeline, not a place a batch can be put. Passing no
|
||||
`dropStage` is what makes dropping onto it impossible, rather than
|
||||
a handler that quietly does nothing. */}
|
||||
{isAdmin && (
|
||||
<Item
|
||||
tab="roi"
|
||||
icon={IconSpend}
|
||||
label="ROI Dashboard"
|
||||
hint="What the pipeline has cost, and what that bought"
|
||||
active={tab === 'roi'}
|
||||
onSelect={onTab}
|
||||
/>
|
||||
)}
|
||||
<Item
|
||||
tab="extraction"
|
||||
dropStage="extraction"
|
||||
dropTab="extraction"
|
||||
icon={IconPage}
|
||||
label="Charge Extraction"
|
||||
hint="Drop PDFs, run the extractor, clear the flagged fields"
|
||||
active={tab === 'extraction'}
|
||||
n={counts.ecw.extraction}
|
||||
onSelect={onTab}
|
||||
onDropBatch={onDropBatch}
|
||||
onDropRun={onDropRun}
|
||||
/>
|
||||
{/* Demography's own spend, apart from the charges ROI above (ticket
|
||||
05). Admin only, the same rule as the other ROI items. */}
|
||||
{isAdmin && (
|
||||
<Item
|
||||
tab="demography-roi"
|
||||
icon={IconSpend}
|
||||
label="Demography ROI"
|
||||
hint="What demography extraction has cost, and what that bought"
|
||||
active={tab === 'demography-roi'}
|
||||
onSelect={onTab}
|
||||
/>
|
||||
)}
|
||||
<Item
|
||||
tab="demography"
|
||||
icon={IconIdCard}
|
||||
label="Demography Extraction"
|
||||
hint="OCR-only extraction for facesheets and admission records"
|
||||
active={tab === 'demography'}
|
||||
onSelect={onTab}
|
||||
/>
|
||||
<Item
|
||||
tab="automation"
|
||||
dropStage="automation"
|
||||
dropTab="automation"
|
||||
icon={IconLoop}
|
||||
label="Charge Automation"
|
||||
hint="Batches that finished extraction and are ready to post"
|
||||
active={tab === 'automation'}
|
||||
n={counts.ecw.automation}
|
||||
onSelect={onTab}
|
||||
onDropBatch={onDropBatch}
|
||||
onDropRun={onDropRun}
|
||||
/>
|
||||
{/* Not a drop target, same reasoning as Demography Extraction above:
|
||||
a demography run is not a batch, so BATCH_DRAG_TYPE's payload
|
||||
(a batch_id) has nowhere to go here. */}
|
||||
<Item
|
||||
tab="demography-automation"
|
||||
icon={IconIdCard}
|
||||
label="Demography Automation"
|
||||
hint="Demography runs that finished and are ready to post"
|
||||
active={tab === 'demography-automation'}
|
||||
onSelect={onTab}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{projects.includes('tebra') && (
|
||||
<>
|
||||
<SectionHead project="tebra" first={projects[0] === 'tebra'} />
|
||||
{isAdmin && (
|
||||
<Item
|
||||
tab="tebra-roi"
|
||||
icon={IconSpend}
|
||||
label="ROI Dashboard"
|
||||
hint="What the Example Client pipeline has cost, and what that bought"
|
||||
active={tab === 'tebra-roi'}
|
||||
onSelect={onTab}
|
||||
/>
|
||||
)}
|
||||
<Item
|
||||
tab="tebra-extraction"
|
||||
dropStage="extraction"
|
||||
dropTab="tebra-extraction"
|
||||
icon={IconPage}
|
||||
label="Example Client Charge Extraction"
|
||||
hint="Example Client tickets: extract and clear the flagged fields"
|
||||
active={tab === 'tebra-extraction'}
|
||||
n={counts.tebra.extraction}
|
||||
onSelect={onTab}
|
||||
onDropBatch={onDropBatch}
|
||||
onDropRun={onDropRun}
|
||||
/>
|
||||
<Item
|
||||
tab="tebra-automation"
|
||||
dropStage="automation"
|
||||
dropTab="tebra-automation"
|
||||
icon={IconLoop}
|
||||
label="Example Client Charge Automation"
|
||||
hint="Example Client batches that finished extraction and are ready to post"
|
||||
active={tab === 'tebra-automation'}
|
||||
n={counts.tebra.automation}
|
||||
onSelect={onTab}
|
||||
onDropBatch={onDropBatch}
|
||||
onDropRun={onDropRun}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* One People entry for the whole app, below every project section
|
||||
rather than inside one — access is an account-level thing, not a
|
||||
board, and a copy under each project only ever asked "which of these
|
||||
two identical links did I mean". The screen itself is merged for
|
||||
anyone who can see both projects (see UsersPanel); a single-project
|
||||
admin lands on their own project's screen, which is why the tab id
|
||||
follows `projects` rather than being fixed — 'people' maps to ECW,
|
||||
and a Tebra-only admin must not be sent to a board they cannot see
|
||||
(App.tsx bounces them straight back off it).
|
||||
|
||||
Not a drop target: this is settings. Admins need it to grant access;
|
||||
operators have nothing to do here and the endpoint would refuse them
|
||||
anyway. */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<hr className="sb-rail-divider" />
|
||||
<Item
|
||||
tab={projects.includes('ecw') ? 'people' : 'tebra-people'}
|
||||
icon={IconPatients}
|
||||
label="User Access"
|
||||
hint="Who can sign in, and what each of them may do"
|
||||
active={tab === 'people' || tab === 'tebra-people'}
|
||||
onSelect={onTab}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
/** One project's name above its own nav list, with the bar glyph the mockup
|
||||
* uses for a group header. The divider is skipped on the first section —
|
||||
* there is nothing yet to divide it from. */
|
||||
function SectionHead({ project, first }: { project: Project; first: boolean }) {
|
||||
return (
|
||||
<>
|
||||
{!first && <hr className="sb-rail-divider" />}
|
||||
<div className="sb-rail-group">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||
<path d="M5 20V11M12 20V5M19 20V14" />
|
||||
</svg>
|
||||
<span className="whitespace-pre-line">{PROJECT_LABEL[project]}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const BATCH_DRAG_TYPE = 'application/x-superbill-batch'
|
||||
const RUN_DRAG_TYPE = 'application/x-superbill-run'
|
||||
|
||||
function Item({
|
||||
tab,
|
||||
dropStage,
|
||||
dropTab,
|
||||
icon: IconEl,
|
||||
label,
|
||||
hint,
|
||||
active,
|
||||
n,
|
||||
onSelect,
|
||||
onDropBatch,
|
||||
onDropRun,
|
||||
}: {
|
||||
tab: BoardTab
|
||||
/** The stage a card dropped here should move to. Absent on sections that
|
||||
* are a view rather than a place, which is what makes them reject a drop. */
|
||||
dropStage?: BatchStage
|
||||
/** The board to navigate to after a drop. Two projects can share a stage
|
||||
* name (both have an "extraction" and an "automation"), so the stage alone
|
||||
* no longer identifies which board a card landed on — this does. Defaults
|
||||
* to `tab`, which was the drop target anyway on every item that has one. */
|
||||
dropTab?: BoardTab
|
||||
icon: (p: { size?: number; className?: string }) => ReactElement
|
||||
label: string
|
||||
hint: string
|
||||
active: boolean
|
||||
/** Omitted on sections that count nothing — a report has no queue depth. */
|
||||
n?: number
|
||||
onSelect: (t: BoardTab) => void
|
||||
onDropBatch?: (batchId: string, stage: BatchStage) => void
|
||||
onDropRun?: (jobId: string) => void
|
||||
}) {
|
||||
const [over, setOver] = useState(false)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(tab)}
|
||||
title={hint}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
// A section is also a drop target, so a batch — or a single document —
|
||||
// can be thrown at "Automation" without first switching to it and
|
||||
// losing sight of the card being dragged.
|
||||
onDragOver={(e) => {
|
||||
if (!dropStage) return
|
||||
const t = e.dataTransfer.types
|
||||
if (t.includes(BATCH_DRAG_TYPE) || (onDropRun && t.includes(RUN_DRAG_TYPE))) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setOver(true)
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setOver(false)}
|
||||
onDrop={(e) => {
|
||||
if (!dropStage) return
|
||||
setOver(false)
|
||||
const batchId = e.dataTransfer.getData(BATCH_DRAG_TYPE)
|
||||
const runId = e.dataTransfer.getData(RUN_DRAG_TYPE)
|
||||
if (!batchId && !runId) return
|
||||
e.preventDefault()
|
||||
if (batchId && onDropBatch) onDropBatch(batchId, dropStage)
|
||||
else if (runId && onDropRun) onDropRun(runId)
|
||||
// Navigate to the board that was dropped ON, not to the stage name —
|
||||
// see the `dropTab` prop doc above.
|
||||
onSelect(dropTab ?? tab)
|
||||
}}
|
||||
className={`sb-nav-item ${over ? 'bg-surface-2 text-ink-2' : ''}`}
|
||||
>
|
||||
<IconEl size={16} className="shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
{n !== undefined && (
|
||||
// Red only when the number is work somebody has to pick up. A badge
|
||||
// that is red at zero teaches people to stop reading it.
|
||||
<span className={`sb-count ${n > 0 ? 'sb-count-hot' : ''}`}>{n}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const KEY = 'superbill.split'
|
||||
|
||||
/**
|
||||
* Draggable two-pane split, PDF on the left and values on the right.
|
||||
*
|
||||
* The ratio is remembered: how much page a reviewer needs beside the values
|
||||
* depends on their screen and their document, and re-dragging it every session
|
||||
* would be a small tax paid hundreds of times.
|
||||
*
|
||||
* The drag listeners live on window, not the handle, so the pointer can outrun
|
||||
* the 6px handle mid-drag without dropping it.
|
||||
*
|
||||
* Below `md`, side-by-side stops being usable at all — squeezing both panes
|
||||
* into a 375px screen leaves the PDF at ~165px wide, unreadable. Below that
|
||||
* breakpoint this becomes two full-width tabs instead of a shrinking split;
|
||||
* both panes stay mounted (toggling `hidden`/`flex`, not unmounting) so
|
||||
* switching tabs never resets the PDF viewer's scroll or zoom.
|
||||
*/
|
||||
export function SplitPane({
|
||||
left,
|
||||
right,
|
||||
min = 25,
|
||||
max = 75,
|
||||
ratio,
|
||||
}: {
|
||||
left: React.ReactNode
|
||||
right: React.ReactNode
|
||||
min?: number
|
||||
max?: number
|
||||
/**
|
||||
* Force a split for as long as it is set, without overwriting the reviewer's
|
||||
* remembered one — fullscreen widens the page and docks the values to the
|
||||
* right, and leaving it must restore the ratio they chose, not this one.
|
||||
*/
|
||||
ratio?: number
|
||||
}) {
|
||||
const [saved, setSaved] = useState(() => {
|
||||
const stored = Number(localStorage.getItem(KEY))
|
||||
// The scan is the thing being read; the values are short. The page gets
|
||||
// the larger share by default.
|
||||
return stored >= min && stored <= max ? stored : 60
|
||||
})
|
||||
const pct = ratio ?? saved
|
||||
const setPct = setSaved
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [mobileTab, setMobileTab] = useState<'left' | 'right'>('left')
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(KEY, String(saved))
|
||||
}, [saved])
|
||||
|
||||
const onMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
const rect = box.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const next = ((e.clientX - rect.left) / rect.width) * 100
|
||||
setPct(Math.min(max, Math.max(min, next)))
|
||||
},
|
||||
[min, max],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return
|
||||
const stop = () => setDragging(false)
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', stop)
|
||||
// The PDF pane has its own drag-to-pan handler; without this it would
|
||||
// interpret the divider drag as a pan and text would select across both
|
||||
// panes on the way.
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'col-resize'
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', stop)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
}, [dragging, onMove])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Tab bar: mobile only. The drag handle is the equivalent affordance on
|
||||
desktop, so this disappears at md instead of coexisting with it. */}
|
||||
<div className="flex shrink-0 border-b border-line-soft bg-rail md:hidden">
|
||||
<button
|
||||
onClick={() => setMobileTab('left')}
|
||||
aria-current={mobileTab === 'left'}
|
||||
className="flex-1 border-b-2 py-2.5 font-sora text-[13px] font-semibold transition-colors"
|
||||
style={{
|
||||
borderColor: mobileTab === 'left' ? 'var(--accent-1)' : 'transparent',
|
||||
color: mobileTab === 'left' ? 'var(--ink)' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
Source page
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMobileTab('right')}
|
||||
aria-current={mobileTab === 'right'}
|
||||
className="flex-1 border-b-2 py-2.5 font-sora text-[13px] font-semibold transition-colors"
|
||||
style={{
|
||||
borderColor: mobileTab === 'right' ? 'var(--accent-1)' : 'transparent',
|
||||
color: mobileTab === 'right' ? 'var(--ink)' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
Fields to review
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={box} className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<div
|
||||
style={{ '--split-w': `${pct}%` } as React.CSSProperties}
|
||||
className={`min-h-0 min-w-0 flex-1 border-b border-line md:w-[var(--split-w)] md:flex-none md:border-b-0 md:border-r md:border-line ${
|
||||
mobileTab === 'left' ? 'flex' : 'hidden'
|
||||
} md:flex`}
|
||||
>
|
||||
{left}
|
||||
</div>
|
||||
|
||||
<div
|
||||
onMouseDown={() => setDragging(true)}
|
||||
onDoubleClick={() => setPct(52)}
|
||||
title="Drag to resize — double-click to reset"
|
||||
className={`hidden w-1.5 shrink-0 cursor-col-resize transition-colors md:block ${
|
||||
dragging ? 'bg-accent-1' : 'bg-line hover:bg-muted'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Pointer events off during the drag, so neither pane steals the move. */}
|
||||
<div
|
||||
style={{
|
||||
'--split-w': `${100 - pct}%`,
|
||||
pointerEvents: dragging ? 'none' : undefined,
|
||||
} as React.CSSProperties}
|
||||
className={`min-h-0 min-w-0 flex-1 md:w-[var(--split-w)] md:flex-none ${
|
||||
mobileTab === 'right' ? 'flex' : 'hidden'
|
||||
} md:flex`}
|
||||
>
|
||||
{right}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
import { BAND_VAR } from '../bands'
|
||||
import type { Run } from '../types'
|
||||
|
||||
/**
|
||||
* The batch in one line: how much runs unattended, how much needs a person.
|
||||
*
|
||||
* This is the number the whole pipeline exists to produce. Reading it off a list
|
||||
* of cards means counting, so it is stated once at the top — and stated as
|
||||
* documents, not fields, because the decision it supports ("what do I have to
|
||||
* look at today") is made per document.
|
||||
*/
|
||||
export function StatsBar({ runs }: { runs: Run[] }) {
|
||||
const done = runs.filter((r) => r.status === 'done')
|
||||
const clean = done.filter((r) => flagged(r) === 0)
|
||||
const needs = done.filter((r) => flagged(r) > 0)
|
||||
const busy = runs.filter((r) => r.status === 'queued' || r.status === 'running')
|
||||
const failed = runs.filter((r) => r.status === 'failed')
|
||||
const fields = done.reduce((n, r) => n + flagged(r), 0)
|
||||
|
||||
if (runs.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="sb-runbar">
|
||||
<Stat n={clean.length} label="run unattended" tone="accent" />
|
||||
<Stat n={needs.length} label="need a reviewer" tone="amber" />
|
||||
{fields > 0 && (
|
||||
<Stat n={fields} label="fields flagged" tone="slate" />
|
||||
)}
|
||||
{busy.length > 0 && <Stat n={busy.length} label="extracting" tone="blue" />}
|
||||
{failed.length > 0 && <Stat n={failed.length} label="failed" tone="red" />}
|
||||
|
||||
<BandLegend />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function flagged(run: Run): number {
|
||||
if (run.review) return run.review.total
|
||||
const s = run.summary
|
||||
return s ? s.amber + s.red + s.unscored + s.blank_critical : 0
|
||||
}
|
||||
|
||||
function Stat({
|
||||
n,
|
||||
label,
|
||||
tone,
|
||||
}: {
|
||||
n: number
|
||||
label: string
|
||||
// "accent" ties to the brand ramp (this stat means "went straight through,"
|
||||
// the same semantic as the confirm/approve action elsewhere); the rest are
|
||||
// theme tokens for the state they report.
|
||||
tone: 'accent' | 'amber' | 'slate' | 'blue' | 'red'
|
||||
}) {
|
||||
// `slate` stays on the runbar's own ink — a stat with no state to report
|
||||
// should not borrow a colour that means one.
|
||||
const colour = {
|
||||
accent: 'var(--accent-1)',
|
||||
amber: 'var(--verify)',
|
||||
slate: '',
|
||||
blue: 'var(--accent-2)',
|
||||
red: 'var(--review)',
|
||||
}[tone]
|
||||
return (
|
||||
<span>
|
||||
<b style={colour ? { color: colour } : undefined}>{n}</b> {label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the colours mean, on the page rather than in a document nobody opens.
|
||||
* The caveat is part of the legend on purpose: the bands rate how legibly the
|
||||
* source was printed, and a reviewer who reads them as "verified" will trust a
|
||||
* confident misread.
|
||||
*/
|
||||
export function BandLegend() {
|
||||
return (
|
||||
<div className="sb-keys">
|
||||
<Swatch band="green" label="≥0.95 clean" />
|
||||
<Swatch band="amber" label="0.90–0.95 sample" />
|
||||
<Swatch band="red" label="<0.90 review" />
|
||||
<span
|
||||
className="cursor-help text-muted"
|
||||
title="The score rates how legibly the source was printed, not whether the value is correct. A cleanly printed value can still be misread."
|
||||
>
|
||||
print clarity, not correctness ⓘ
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Swatch({ band, label }: { band: 'green' | 'amber' | 'red'; label: string }) {
|
||||
return (
|
||||
<span className="flex items-center text-ink-2">
|
||||
<i className="sb-swatch" style={{ backgroundColor: BAND_VAR[band] }} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
const GAP = 8
|
||||
|
||||
type Rect = { top: number; bottom: number; left: number; width: number }
|
||||
type Placed = { top: number; left: number }
|
||||
|
||||
/**
|
||||
* A tooltip that is actually part of the design.
|
||||
*
|
||||
* Replaces `title=""`, which cannot be styled, cannot be themed, appears after a
|
||||
* delay the page does not control, and renders in the OS's own font at the
|
||||
* cursor rather than against the thing it describes.
|
||||
*
|
||||
* Positioning is deliberately in two stages, and the order matters:
|
||||
*
|
||||
* 1. The anchor's rect is captured in the open handler, and the panel is drawn
|
||||
* immediately from it using a transform (`translate(-50%, -100%)`) — the
|
||||
* browser centres and lifts it with no measurement at all, so it is
|
||||
* correctly placed on the very first paint.
|
||||
* 2. A layout effect then measures the rendered panel and only refines: flip
|
||||
* below when there is no room above, clamp into the viewport at the edges.
|
||||
*
|
||||
* An earlier version skipped straight to stage 2 — render at 0,0 hidden, measure,
|
||||
* then position. When that measurement did not land the tooltip stayed invisible
|
||||
* in the corner, which is the worst failure available: the information silently
|
||||
* disappears. Now a failed refinement costs at most some clipping at a screen
|
||||
* edge, and the tooltip is always visible.
|
||||
*
|
||||
* Because the pane behind it scrolls, the captured rect can go stale — so any
|
||||
* scroll closes the tooltip rather than leaving it pointing at nothing.
|
||||
*
|
||||
* It is `aria-hidden` on purpose: the same fact is in the trigger's own
|
||||
* accessible name (see the field input's `aria-label`), so a screen reader loses
|
||||
* nothing when this never appears. Opens on hover AND on keyboard focus.
|
||||
*/
|
||||
export function Tooltip({
|
||||
label,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
/** Applied to the wrapper, which stands in for the element it replaces. */
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const anchor = useRef<HTMLSpanElement>(null)
|
||||
const panel = useRef<HTMLDivElement>(null)
|
||||
const [rect, setRect] = useState<Rect | null>(null)
|
||||
const [placed, setPlaced] = useState<Placed | null>(null)
|
||||
|
||||
const open = () => {
|
||||
const r = anchor.current?.getBoundingClientRect()
|
||||
if (r) setRect({ top: r.top, bottom: r.bottom, left: r.left, width: r.width })
|
||||
}
|
||||
const close = () => {
|
||||
setRect(null)
|
||||
setPlaced(null)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!rect) return
|
||||
const p = panel.current?.getBoundingClientRect()
|
||||
if (!p) return
|
||||
const flip = rect.top - p.height - GAP < GAP
|
||||
const top = flip ? rect.bottom + GAP : rect.top - p.height - GAP
|
||||
const wanted = rect.left + rect.width / 2 - p.width / 2
|
||||
const left = Math.max(GAP, Math.min(wanted, window.innerWidth - p.width - GAP))
|
||||
// Only take over from the transform when the transform would actually be
|
||||
// wrong; otherwise leave the first-paint placement exactly as it is.
|
||||
const drifted = Math.abs(left - wanted) > 0.5 || flip
|
||||
setPlaced(drifted ? { top, left } : null)
|
||||
}, [rect, label])
|
||||
|
||||
useEffect(() => {
|
||||
if (!rect) return
|
||||
// `true` for the capture phase: the scroll happens inside the review pane,
|
||||
// and scroll events on an inner element do not bubble to window.
|
||||
window.addEventListener('scroll', close, true)
|
||||
window.addEventListener('resize', close)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', close, true)
|
||||
window.removeEventListener('resize', close)
|
||||
}
|
||||
}, [rect])
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={anchor}
|
||||
className={className}
|
||||
// focus/blur do not bubble natively, but React synthesises these from
|
||||
// focusin/focusout, which do — so focusing the inner input opens it.
|
||||
onMouseEnter={open}
|
||||
onMouseLeave={close}
|
||||
onFocus={open}
|
||||
onBlur={close}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
{rect &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panel}
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none fixed z-50 w-max max-w-[18rem] rounded-lg px-2.5 py-1.5 font-plex text-[11px] font-medium leading-snug shadow-lg"
|
||||
style={
|
||||
placed
|
||||
? { top: placed.top, left: placed.left, backgroundColor: 'var(--ink)', color: 'var(--surface)' }
|
||||
: {
|
||||
top: rect.top - GAP,
|
||||
left: rect.left + rect.width / 2,
|
||||
// Centres and lifts with no measurement — this is what makes
|
||||
// the first paint correct rather than hidden.
|
||||
transform: 'translate(-50%, -100%)',
|
||||
// Inverted from the surface it floats over: a panel sharing
|
||||
// its host's background reads as part of the page instead of
|
||||
// above it. Both directions clear AAA against their own text.
|
||||
backgroundColor: 'var(--ink)',
|
||||
color: 'var(--surface)',
|
||||
}
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
/**
|
||||
* The icon set.
|
||||
*
|
||||
* Drawn here rather than pulled from a package, and drawn rather than typed:
|
||||
* `✓`, `✗` and `●` are punctuation borrowed as pictures — they carry a font's
|
||||
* metrics, not a stroke weight, so they sit at different optical sizes from each
|
||||
* other and cannot be aligned with anything. A screen reader also reads them
|
||||
* aloud as the characters they are.
|
||||
*
|
||||
* One grid (24), one stroke (1.75), round caps and joins throughout, so any two
|
||||
* of these placed side by side read as the same hand. `currentColor` everywhere,
|
||||
* so an icon inherits whatever the surface already decided about contrast.
|
||||
*/
|
||||
|
||||
function Icon({
|
||||
children,
|
||||
size = 18,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
size?: number
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
// Decorative by default: every icon in this app sits beside its own
|
||||
// label. An icon that ever stands alone gets a real name at the call
|
||||
// site instead of a title buried in here.
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
type P = { size?: number; className?: string }
|
||||
|
||||
/** Stacked sheets — a count of documents. */
|
||||
export const IconStack = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M12 3 3 7.5l9 4.5 9-4.5L12 3Z" />
|
||||
<path d="M3 12.5 12 17l9-4.5" />
|
||||
<path d="M3 17.5 12 22l9-4.5" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/** A file leaving the app for the reviewer's own disk. */
|
||||
export const IconDownload = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M12 3v12" />
|
||||
<path d="m7.5 10.5 4.5 4.5 4.5-4.5" />
|
||||
<path d="M4.5 19.5h15" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/** A settled thing. */
|
||||
export const IconCheck = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m8.5 12.5 2.5 2.5 4.5-5.5" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/** Work still in flight. */
|
||||
export const IconClock = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7.5V12l3 2" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/** Something absent or wrong — never used for "red band", which is data. */
|
||||
export const IconAlert = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 8v4.5" />
|
||||
<path d="M12 16h.01" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/** A single scanned page. */
|
||||
export const IconPage = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z" />
|
||||
<path d="M14 3v5h5" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/** People, not rows: this counts patients. */
|
||||
export const IconPatients = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M16 20v-1.5a3.5 3.5 0 0 0-3.5-3.5h-4A3.5 3.5 0 0 0 5 18.5V20" />
|
||||
<circle cx="10.5" cy="8" r="3.5" />
|
||||
<path d="M19 20v-1.5a3.5 3.5 0 0 0-2.5-3.35" />
|
||||
<path d="M15 4.6a3.5 3.5 0 0 1 0 6.65" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/** Money spent. A coin edge-on, not a currency glyph. */
|
||||
export const IconSpend = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<ellipse cx="12" cy="6.5" rx="8" ry="3.5" />
|
||||
<path d="M4 6.5v11c0 1.93 3.58 3.5 8 3.5s8-1.57 8-3.5v-11" />
|
||||
<path d="M4 12c0 1.93 3.58 3.5 8 3.5s8-1.57 8-3.5" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/** Two curved arrows chasing each other — a stage in a repeating flow. */
|
||||
export const IconLoop = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M4.5 12a7.5 7.5 0 0 1 12.5-5.6l2 1.9" />
|
||||
<path d="M17.5 5.5v3.3h-3.3" />
|
||||
<path d="M19.5 12a7.5 7.5 0 0 1-12.5 5.6l-2-1.9" />
|
||||
<path d="M6.5 18.5v-3.3h3.3" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/** A wallet card with a portrait square — a person's own identifying record. */
|
||||
export const IconIdCard = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<rect x="3" y="5.5" width="18" height="13" rx="2" />
|
||||
<circle cx="9" cy="11.5" r="2" />
|
||||
<path d="M6.5 15.5c0-1.4 1.1-2.2 2.5-2.2s2.5.8 2.5 2.2" />
|
||||
<path d="M14.5 10h4" />
|
||||
<path d="M14.5 13h4" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
export const IconChevronRight = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/**
|
||||
* The legend and chip marker. A drawn disc rather than `●`, so it lands on the
|
||||
* text baseline the same way at every size and takes its colour from the same
|
||||
* `currentColor` chain as everything else.
|
||||
*/
|
||||
export const IconDot = ({ size = 8, className }: P) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 8 8"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="4" cy="4" r="4" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<!-- EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. -->
|
||||
<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,91 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
import { 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,347 @@
|
||||
/* EXAMPLE ONLY: an extract from the Superbill Review prototype, kept as reference for porting. ECW/eClinicalWorks, Tebra, 'Example Client', SPB_* tables and patient/ticket wording are that project's examples. Don't copy them into a new project. */
|
||||
import 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 / Example Client). 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) — Example Client'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
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Confidence bands: the single source of truth for the cut points.
|
||||
|
||||
Copy this file into the backend. Everything that needs a band (the review queue,
|
||||
an exported sheet, the approve gate) imports from here, so they never disagree
|
||||
about what counts as red.
|
||||
|
||||
GREEN >= 0.95 clean print, straight through
|
||||
AMBER 0.90-0.95 slightly unclear, check it
|
||||
RED < 0.90 handwriting or a messy scan
|
||||
BLANK a mandatory field with no value
|
||||
UNSCORED no word scores came back for the value
|
||||
|
||||
The approve gate: every field that isn't green must be verified by a person before the
|
||||
run can be approved (needs_review). Amber is not sampled; it is checked like red.
|
||||
|
||||
Calibration (from the prototype these rules came from): crisp print scored 0.90 and
|
||||
up, handwriting clustered in 0.80-0.89, and 0.70-0.79 was empty. 0.90 sits in that
|
||||
gap. 0.95 splits off the slightly unclear 0.90-0.95 reads from clean print.
|
||||
|
||||
What a band claims: the score is the model's own legibility rating. Green means the
|
||||
text was read cleanly, NOT that the value is correct; a confident misread scores
|
||||
green. Red is the useful direction: it reliably surfaces handwriting.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Optional
|
||||
|
||||
GREEN_THRESHOLD = 0.95
|
||||
CONFIDENCE_THRESHOLD = 0.90
|
||||
BANDS = ("green", "amber", "red", "blank", "unscored")
|
||||
|
||||
|
||||
def band_of(score: float) -> str:
|
||||
if score >= GREEN_THRESHOLD:
|
||||
return "green"
|
||||
return "amber" if score >= CONFIDENCE_THRESHOLD else "red"
|
||||
|
||||
|
||||
def field_band(word_scores: Optional[Iterable[float]], value: Optional[str], mandatory: bool = False) -> str:
|
||||
"""A field's band. It takes its WORST word score, so one shaky word can't hide in
|
||||
a clean-looking field. A blank mandatory field is flagged whatever its score."""
|
||||
if mandatory and not (value or "").strip():
|
||||
return "blank"
|
||||
scores = list(word_scores or [])
|
||||
return band_of(min(scores)) if scores else "unscored"
|
||||
|
||||
|
||||
def needs_review(band: str) -> bool:
|
||||
"""Every band but green must be verified by a person before the run can be approved."""
|
||||
return band != "green"
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
AI RPA dashboard (React + Vite + Tailwind). Everything on the no-AI page, plus
|
||||
confidence bands, the review split pane and the approve gate.
|
||||
Fill every {{...}} placeholder in the CONFIG block; they mean the same as on the
|
||||
no-AI page. Theme = the body / body.dark token blocks in src/index.css only.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{APP_NAME}}</title>
|
||||
<script>
|
||||
// ===== CONFIG (fill per project) =====
|
||||
const APP_NAME = "{{APP_NAME}}";
|
||||
const APP_SUBTITLE = "{{APP_SUBTITLE}}";
|
||||
const LOGO_SRC = "{{LOGO_SRC}}";
|
||||
const STEPS = {{STEPS_JSON}}; // e.g. ["Initializing","Extract","Review","Completed"]
|
||||
const REPORT_COLUMNS = {{REPORT_COLUMNS_JSON}}; // must include {"key":"status",...}
|
||||
const API_BASE = "{{API_BASE}}"; // e.g. "/api"
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2030
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "rpa-ai-dashboard",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Pricing table and cost per call for the cost page. The backend imports it.
|
||||
|
||||
Rates are USD per million tokens. Thinking tokens bill at the output rate. A model
|
||||
with "long_threshold" switches both rates to its *_long rates when a call's input
|
||||
tokens go above that threshold.
|
||||
|
||||
Replace the table with the rates of the provider and models the project really uses,
|
||||
copied from the provider's pricing page, and update PRICING_SOURCE and PRICING_AS_OF.
|
||||
Never guess a rate: a model missing here is an error, never a cost of zero.
|
||||
"""
|
||||
|
||||
PRICING_SOURCE = "https://ai.google.dev/gemini-api/docs/pricing"
|
||||
PRICING_AS_OF = "2026-08-11"
|
||||
|
||||
PRICING = {
|
||||
"gemini-2.5-pro": {"input": 1.25, "output": 10.00,
|
||||
"long_threshold": 200_000, "input_long": 2.50, "output_long": 15.00},
|
||||
"gemini-2.5-flash": {"input": 0.30, "output": 2.50},
|
||||
"gemini-2.5-flash-lite": {"input": 0.10, "output": 0.40},
|
||||
}
|
||||
|
||||
|
||||
def cost_for_call(model, input_tokens, output_tokens, thinking_tokens=0):
|
||||
"""USD for one call. Raises ValueError, naming the known models, for a model not in PRICING."""
|
||||
if model not in PRICING:
|
||||
raise ValueError(f"No pricing for model '{model}'. Add it to PRICING from {PRICING_SOURCE}. "
|
||||
f"Known models: {', '.join(PRICING)}")
|
||||
p = PRICING[model]
|
||||
in_rate, out_rate = p["input"], p["output"]
|
||||
if "long_threshold" in p and input_tokens > p["long_threshold"]:
|
||||
in_rate, out_rate = p["input_long"], p["output_long"]
|
||||
return (input_tokens * in_rate + (output_tokens + thinking_tokens) * out_rate) / 1_000_000
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Sample-data backend for the AI review dashboard: runs, review rows and the approve gate.
|
||||
|
||||
npm run build && python server_stub.py # page + API on http://127.0.0.1:8000
|
||||
python server_stub.py & npm run dev # or Vite on :5173, proxying /api here
|
||||
|
||||
It stands in for the project's backend until that exists. A real backend serves the
|
||||
same endpoints (templates/contract.md) with the same rules: bands from bands.py, and
|
||||
approval refused while any flagged field is unverified. The page never decides that.
|
||||
Standard library only.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from html import escape
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from bands import BANDS, field_band, needs_review
|
||||
from pricing import PRICING_AS_OF, PRICING_SOURCE, cost_for_call
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
API = "/api" # same as API_BASE in index.html
|
||||
REASON = {"green": None, "amber": "Slightly unclear, verify it", "red": "Unclear print, check every word",
|
||||
"blank": "Required field is empty", "unscored": "Not found in the transcription"}
|
||||
# (field, value, word scores, mandatory). Swap in the fields the project's extraction returns.
|
||||
SAMPLE_FIELDS = [
|
||||
("Invoice number", "INV-1001", [1.0], True),
|
||||
("Invoice date", "2026-10-01", [0.99, 0.97], True),
|
||||
("Vendor", "Contoso Supplies", [0.98, 0.92], True),
|
||||
("PO number", "PO-4471", [0.86], False),
|
||||
("Due date", "", [], True),
|
||||
("Total", "2,450.00", [0.99], True),
|
||||
("Tax", "196.00", [], False),
|
||||
]
|
||||
RUNS, ROWS = {}, {}
|
||||
ROI_RUNS = [] # [{job_id, document, created_at, model, input_tokens, output_tokens, thinking_tokens}]
|
||||
_next_row = [1]
|
||||
|
||||
|
||||
def put_run(job_id, document, fields, status="done", pages_done=1, pages_total=1, model="sample-model"):
|
||||
"""Add or replace a run. fields: [(field, value, word scores, mandatory)]."""
|
||||
RUNS[job_id] = {"job_id": job_id, "document": document, "status": status, "model": model,
|
||||
"pages_done": pages_done, "pages_total": pages_total, "approved": False, "error": None}
|
||||
ROWS[job_id] = []
|
||||
for field, value, words, mandatory in fields:
|
||||
band = field_band(words, value, mandatory)
|
||||
ROWS[job_id].append({"row_id": _next_row[0], "field": field, "value": value, "band": band,
|
||||
"score": min(words) if words else None, "reason": REASON[band],
|
||||
"verified": False, "corrected_value": None})
|
||||
_next_row[0] += 1
|
||||
return RUNS[job_id]
|
||||
|
||||
|
||||
def reset():
|
||||
RUNS.clear()
|
||||
ROWS.clear()
|
||||
put_run("job-1002", "invoice-1002.pdf", SAMPLE_FIELDS, status="running", pages_done=1, pages_total=3)
|
||||
put_run("job-1001", "invoice-1001.pdf", SAMPLE_FIELDS, pages_total=2, pages_done=2)
|
||||
ROI_RUNS.clear()
|
||||
now = time.time()
|
||||
for i, (model, tokens) in enumerate([("gemini-2.5-flash", (21_000, 1_800, 900)),
|
||||
("gemini-2.5-flash", (18_500, 1_600, 0)),
|
||||
("gemini-2.5-pro", (240_000, 3_000, 2_500)),
|
||||
("gemini-2.5-flash-lite", (22_000, 1_700, 0)),
|
||||
("gemini-2.5-flash", (None, None, None))]): # usage not recorded
|
||||
ROI_RUNS.append({"job_id": f"job-{900 + i}", "document": f"invoice-{900 + i}.pdf",
|
||||
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now - i * 12 * 864e2)),
|
||||
"model": model, **dict(zip(("input_tokens", "output_tokens", "thinking_tokens"), tokens))})
|
||||
|
||||
|
||||
reset() # sample data from import on: the desktop bridge imports this module without a server
|
||||
|
||||
|
||||
def run_view(job_id):
|
||||
run, rows = RUNS[job_id], ROWS[job_id]
|
||||
done = run["status"] == "done"
|
||||
return {**run, "summary": {b: sum(r["band"] == b for r in rows) for b in BANDS} if done else None}
|
||||
|
||||
|
||||
def unverified(job_id):
|
||||
return [r for r in ROWS[job_id] if needs_review(r["band"]) and not r["verified"]]
|
||||
|
||||
|
||||
# ---- routes: (method, path regex, handler(match groups..., body) -> (status, payload)) ----
|
||||
def list_runs(body):
|
||||
return 200, {"runs": [run_view(j) for j in RUNS]}
|
||||
|
||||
|
||||
def get_run(job_id, body):
|
||||
return 200, run_view(job_id)
|
||||
|
||||
|
||||
def review(job_id, body):
|
||||
return 200, {"rows": ROWS[job_id] if RUNS[job_id]["status"] == "done" else []}
|
||||
|
||||
|
||||
def patch_row(row_id, body):
|
||||
row = next(r for rows in ROWS.values() for r in rows if r["row_id"] == int(row_id))
|
||||
row.update({k: body[k] for k in ("corrected_value", "verified") if k in body})
|
||||
return 200, row
|
||||
|
||||
|
||||
def verify_remaining(job_id, body):
|
||||
left = unverified(job_id)
|
||||
for r in left:
|
||||
r["verified"] = True
|
||||
return 200, {"verified": len(left)}
|
||||
|
||||
|
||||
def approve(job_id, body):
|
||||
left = unverified(job_id)
|
||||
if RUNS[job_id]["status"] != "done":
|
||||
return 409, {"detail": "The run hasn't finished extracting."}
|
||||
if left: # the approve gate
|
||||
return 409, {"detail": f"{len(left)} flagged field(s) still need verifying."}
|
||||
RUNS[job_id]["approved"] = True
|
||||
return 200, {"ok": True}
|
||||
|
||||
|
||||
def roi(body):
|
||||
"""Cost per run. total_cost is null when the run recorded no token usage: unpriced, never zero.
|
||||
An unknown model is an error (500 naming the known models), never a cost of zero."""
|
||||
try:
|
||||
runs = [{**r, "total_cost": None if r["input_tokens"] is None else
|
||||
cost_for_call(r["model"], r["input_tokens"], r["output_tokens"], r["thinking_tokens"] or 0)}
|
||||
for r in ROI_RUNS]
|
||||
except ValueError as e:
|
||||
return 500, {"detail": str(e)}
|
||||
return 200, {"runs": runs, "pricing": {"source": PRICING_SOURCE, "as_of": PRICING_AS_OF}}
|
||||
|
||||
|
||||
def document(job_id, body):
|
||||
"""A stand-in for the source document. A real backend returns the PDF or page image."""
|
||||
lines = "".join(f'<text x="48" y="{150 + i * 46}" font-size="20">{escape(f)}: {escape(v)}</text>'
|
||||
for i, (f, v, *_) in enumerate(SAMPLE_FIELDS))
|
||||
svg = (f'<svg xmlns="http://www.w3.org/2000/svg" width="640" height="820" font-family="Georgia,serif">'
|
||||
f'<rect width="100%" height="100%" fill="#fff"/><text x="48" y="80" font-size="28">'
|
||||
f'{escape(RUNS[job_id]["document"])} (sample)</text>{lines}</svg>')
|
||||
return 200, svg
|
||||
|
||||
|
||||
ROUTES = [
|
||||
("GET", r"/runs", list_runs),
|
||||
("GET", r"/runs/([\w-]+)", get_run),
|
||||
("GET", r"/runs/([\w-]+)/review", review),
|
||||
("GET", r"/runs/([\w-]+)/document", document),
|
||||
("PATCH", r"/review/(\d+)", patch_row),
|
||||
("POST", r"/runs/([\w-]+)/verify-remaining", verify_remaining),
|
||||
("POST", r"/runs/([\w-]+)/approve", approve),
|
||||
("GET", r"/roi", roi),
|
||||
("POST", r"/set-dark", lambda body: (200, {})),
|
||||
]
|
||||
|
||||
|
||||
def dispatch(method, path, body):
|
||||
"""One API request -> (status, payload). The web handler and the desktop bridge both call this."""
|
||||
for m, pattern, fn in ROUTES:
|
||||
match = re.fullmatch(pattern, path)
|
||||
if m == method and match:
|
||||
try:
|
||||
return fn(*match.groups(), body)
|
||||
except (KeyError, StopIteration):
|
||||
return 404, {"detail": "Not found"}
|
||||
return 404, {"detail": f"No route for {method} {path}"}
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(HERE / "dist"), **kwargs)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
self.api("GET") if self.path.startswith(API + "/") else super().do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
self.api("POST")
|
||||
|
||||
def do_PATCH(self):
|
||||
self.api("PATCH")
|
||||
|
||||
def api(self, method):
|
||||
path = urlparse(self.path).path[len(API):]
|
||||
size = int(self.headers.get("Content-Length") or 0)
|
||||
body = json.loads(self.rfile.read(size) or b"{}") if size else {}
|
||||
status, payload = dispatch(method, path, body)
|
||||
svg = isinstance(payload, str)
|
||||
data = payload.encode() if svg else json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "image/svg+xml" if svg else "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
|
||||
def make_server(host="127.0.0.1", port=8000):
|
||||
reset()
|
||||
return ThreadingHTTPServer((host, port), Handler)
|
||||
|
||||
|
||||
def simulate_extraction():
|
||||
"""Demo only: the running sample run reads one page every 3 seconds, then finishes."""
|
||||
while True:
|
||||
time.sleep(3)
|
||||
for run in RUNS.values():
|
||||
if run["status"] == "running":
|
||||
run["pages_done"] += 1
|
||||
if run["pages_done"] >= run["pages_total"]:
|
||||
run["status"] = "done"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = make_server()
|
||||
threading.Thread(target=simulate_extraction, daemon=True).start()
|
||||
print("Sample backend on http://127.0.0.1:8000 (Ctrl+C to stop)")
|
||||
server.serve_forever()
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react'
|
||||
import { act, clearRun, toggleDark, useStore } from './store'
|
||||
import { Overview } from './components/Overview'
|
||||
import { RunPanel } from './components/RunPanel'
|
||||
import { Report } from './components/Report'
|
||||
import { Review } from './components/Review'
|
||||
import { Cost } from './components/Cost'
|
||||
|
||||
const TABS = {
|
||||
dashboard: ['Dashboard', 'Overview of every batch', <><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" /></>],
|
||||
run: ['Run', 'Live progress and log', <polygon points="6 4 20 12 6 20 6 4" />],
|
||||
review: ['Review', 'Check extracted fields against the document', <><path d="M4 4h10l6 6v10H4z" /><path d="m8 14 3 3 5-6" /></>],
|
||||
report: ['Report', 'Result of every processed item', <><path d="M4 4h16v16H4z" /><path d="M8 9h8M8 13h8M8 17h5" /></>],
|
||||
cost: ['Cost', 'Tokens, spend and the case against doing it by hand', <><path d="M12 2v20" /><path d="M17 6H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></>],
|
||||
// OPTIONAL: admin/roles tab goes here (its nav entry; the screen is below, in <section>)
|
||||
} as const
|
||||
type Tab = keyof typeof TABS
|
||||
|
||||
const DOT = { ready: 'bg-ok', done: 'bg-ok', running: 'bg-warn animate-pulse', error: 'bg-bad' }
|
||||
|
||||
export function App() {
|
||||
const { status, user, dark } = useStore()
|
||||
const [tab, setTab] = useState<Tab>('dashboard')
|
||||
const [unsaved, setUnsaved] = useState(0) // review edits not saved yet
|
||||
|
||||
const show = (t: Tab) => {
|
||||
if (t === tab) return
|
||||
if (unsaved && !window.confirm(`${unsaved} unsaved change(s) on this document. Discard them and leave?`)) return
|
||||
setUnsaved(0)
|
||||
setTab(t)
|
||||
if (t === 'dashboard') act('load_dashboard')
|
||||
if (t === 'report') act('load_latest_report')
|
||||
}
|
||||
const run = () => {
|
||||
show('run')
|
||||
clearRun()
|
||||
act('start_automation')
|
||||
}
|
||||
|
||||
// OPTIONAL: login page goes here (until signed in, return the sign-in screen instead of the shell)
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<aside className="flex w-[230px] min-w-[230px] flex-col border-r border-line bg-glass max-[820px]:w-[66px] max-[820px]:min-w-[66px]" style={{ backdropFilter: 'var(--blur)' }}>
|
||||
<div className="flex items-center gap-3 border-b border-line px-5 py-5">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-line bg-glass-strong">
|
||||
{LOGO_SRC && <img src={LOGO_SRC} alt="" className="h-7 w-7 object-contain" onError={(e) => e.currentTarget.remove()} />}
|
||||
</div>
|
||||
<div className="max-[820px]:hidden">
|
||||
<div className="font-head text-[15px] font-bold">{APP_NAME}</div>
|
||||
<div className="mt-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted">{APP_SUBTITLE}</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 px-3 py-4">
|
||||
{(Object.keys(TABS) as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
title={TABS[t][0]}
|
||||
onClick={() => show(t)}
|
||||
aria-current={tab === t ? 'page' : undefined}
|
||||
className={`mb-1 flex w-full cursor-pointer items-center gap-3 rounded-[10px] px-3 py-2.5 text-left text-[13.5px] font-semibold ${tab === t ? 'text-white shadow-md' : 'text-muted hover:bg-glass-strong hover:text-ink'}`}
|
||||
style={tab === t ? { background: 'var(--nav-active-bg)' } : undefined}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-[18px] w-[18px] shrink-0 fill-none stroke-current stroke-2">{TABS[t][2]}</svg>
|
||||
<span className="max-[820px]:hidden">{TABS[t][0]}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex items-center gap-2.5 border-t border-line p-3.5">
|
||||
<div className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-full text-xs font-bold text-white" style={{ background: 'var(--nav-active-bg)' }}>
|
||||
{(user.username || 'U')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="max-[820px]:hidden">
|
||||
<div className="text-[13px] font-bold">{user.username}</div>
|
||||
<div className="text-[10.5px] text-muted">{user.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
<header className="flex items-center gap-3.5 border-b border-line px-7 py-5 shadow-[0_6px_18px_rgba(20,50,70,.10)]"
|
||||
style={{ background: 'var(--header-bg)', color: 'var(--header-ink)', backdropFilter: 'var(--blur)' }}>
|
||||
<div>
|
||||
<div className="font-head text-[21px] font-extrabold">{TABS[tab][0]}</div>
|
||||
<div className="mt-0.5 text-[12.5px]" style={{ color: 'var(--header-muted)' }}>{TABS[tab][1]}</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<button className="btn btn-primary" onClick={run} disabled={status.state === 'running'}>▶ Run</button>
|
||||
<button className="h-[38px] w-[38px] cursor-pointer rounded-[11px] border border-line bg-glass-strong text-base text-ink"
|
||||
title="Dark mode" aria-label="Dark mode" aria-pressed={dark} onClick={toggleDark}>
|
||||
{dark ? '☀' : '🌙'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 rounded-full border border-line bg-glass-strong px-3 py-1.5 text-xs font-bold text-ink">
|
||||
<span className={`h-2 w-2 rounded-full ${DOT[status.state] ?? 'bg-muted'}`} />
|
||||
<span>{status.msg || status.state}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-y-auto px-7 py-6 max-[820px]:px-4">
|
||||
{tab === 'dashboard' && <Overview />}
|
||||
{tab === 'run' && <RunPanel />}
|
||||
{tab === 'review' && <Review onUnsaved={setUnsaved} />}
|
||||
{tab === 'report' && <Report />}
|
||||
{tab === 'cost' && <Cost />}
|
||||
{/* OPTIONAL: admin/roles screen goes here (admins only) */}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/** The AI contract additions (see contract.md): runs, review rows, the approve gate. */
|
||||
export type Band = 'green' | 'amber' | 'red' | 'blank' | 'unscored'
|
||||
export type RunStatus = 'pending' | 'queued' | 'running' | 'done' | 'failed'
|
||||
export type Run = {
|
||||
job_id: string
|
||||
document: string
|
||||
status: RunStatus
|
||||
model: string
|
||||
pages_done: number
|
||||
pages_total: number
|
||||
summary: Record<Band, number> | null // null until the run is done
|
||||
approved: boolean
|
||||
error: string | null
|
||||
}
|
||||
export type ReviewRow = {
|
||||
row_id: number
|
||||
field: string
|
||||
value: string | null
|
||||
band: Band
|
||||
score: number | null
|
||||
reason: string | null
|
||||
verified: boolean
|
||||
corrected_value: string | null
|
||||
}
|
||||
|
||||
/** One run on the cost page. total_cost is null when the run recorded no token usage (unpriced). */
|
||||
export type RoiRun = {
|
||||
job_id: string
|
||||
document: string
|
||||
created_at: string // ISO, UTC
|
||||
model: string
|
||||
input_tokens: number | null
|
||||
output_tokens: number | null
|
||||
thinking_tokens: number | null
|
||||
total_cost: number | null // USD
|
||||
}
|
||||
export type Pricing = { source: string; as_of: string }
|
||||
|
||||
export const BUSY: RunStatus[] = ['queued', 'running'] // polled every 1.5s while in one of these
|
||||
|
||||
/** Legend order, label, and the colour token. Band colours are semantic: never on buttons or headings. */
|
||||
export const BANDS: { band: Band; label: string; colour: string }[] = [
|
||||
{ band: 'green', label: 'Clean ≥0.95', colour: 'var(--clean)' },
|
||||
{ band: 'amber', label: 'Check 0.90–0.95', colour: 'var(--verify)' },
|
||||
{ band: 'red', label: 'Review <0.90', colour: 'var(--review)' },
|
||||
{ band: 'blank', label: 'Missing (required)', colour: 'var(--absent)' },
|
||||
{ band: 'unscored', label: 'Unscored', colour: 'var(--absent)' },
|
||||
]
|
||||
export const CAVEAT = 'Colour reflects print clarity, not correctness. A cleanly printed value can still be misread.'
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(API_BASE + path, { ...init, headers: { 'Content-Type': 'application/json' } })
|
||||
if (!res.ok) {
|
||||
// The server says why it refused (the approve gate answers 409 with the count).
|
||||
const body = await res.json().catch(() => null)
|
||||
throw new Error(body?.detail ?? `${res.status} ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const ai = {
|
||||
runs: () => req<{ runs: Run[] }>('/runs'),
|
||||
run: (id: string) => req<Run>(`/runs/${id}`),
|
||||
rows: (id: string) => req<{ rows: ReviewRow[] }>(`/runs/${id}/review`),
|
||||
patchRow: (rowId: number, body: { corrected_value?: string | null; verified: boolean }) =>
|
||||
req<ReviewRow>(`/review/${rowId}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
verifyRemaining: (id: string) => req<{ verified: number }>(`/runs/${id}/verify-remaining`, { method: 'POST' }),
|
||||
approve: (id: string) => req<{ ok: boolean }>(`/runs/${id}/approve`, { method: 'POST' }),
|
||||
roi: () => req<{ runs: RoiRun[]; pricing: Pricing }>('/roi'),
|
||||
documentUrl: (id: string) => `${API_BASE}/runs/${id}/document`,
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ai, type Pricing, type RoiRun } from '../ai'
|
||||
|
||||
const money = (n: number) => `$${n.toFixed(n > 0 && n < 0.01 ? 4 : 2)}`
|
||||
const num = (n: number | null) => (n == null ? '—' : n.toLocaleString('en-US'))
|
||||
const monthOf = (iso: string) => iso.slice(0, 7) // 'YYYY-MM', UTC
|
||||
const monthLabel = (ym: string) =>
|
||||
new Date(`${ym}-01T00:00:00Z`).toLocaleString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' })
|
||||
|
||||
/**
|
||||
* The cost page. Two kinds of number, kept apart:
|
||||
* measured: tokens and cost per run, priced by the backend's pricing table;
|
||||
* assumed: minutes per document and an hourly rate, typed by the reader and labelled so.
|
||||
* A run with no recorded token usage is "not priced", never $0, and the coverage pill says how many.
|
||||
*/
|
||||
export function Cost() {
|
||||
const [runs, setRuns] = useState<RoiRun[]>([])
|
||||
const [pricing, setPricing] = useState<Pricing>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [minutes, setMinutes] = useState(5) // assumption: minutes to key one document by hand
|
||||
const [rate, setRate] = useState(20) // assumption: USD per hour of that person's time
|
||||
|
||||
useEffect(() => {
|
||||
ai.roi().then((d) => { setRuns(d.runs); setPricing(d.pricing) }).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
const priced = runs.filter((r) => r.total_cost != null)
|
||||
const sum = (rs: RoiRun[]) => rs.reduce((n, r) => n + (r.total_cost ?? 0), 0)
|
||||
const thisMonth = new Date().toISOString().slice(0, 7)
|
||||
const months = [...new Set(runs.map((r) => monthOf(r.created_at)))].sort().reverse()
|
||||
.map((m) => {
|
||||
const rs = runs.filter((r) => monthOf(r.created_at) === m)
|
||||
return { m, docs: rs.length, priced: rs.filter((r) => r.total_cost != null).length, cost: sum(rs) }
|
||||
})
|
||||
const tokens = (k: 'input_tokens' | 'output_tokens' | 'thinking_tokens') => priced.reduce((n, r) => n + (r[k] ?? 0), 0)
|
||||
const byHand = (priced.length * minutes / 60) * rate
|
||||
|
||||
if (error) return <div className="card text-bad">Can't load costs from {API_BASE}/roi: {error}</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<span className={`pill ${priced.length === runs.length ? 'bg-ok' : 'bg-warn'}`}>
|
||||
{priced.length} of {runs.length} documents priced
|
||||
</span>
|
||||
{pricing && (
|
||||
<span className="text-[11.5px] text-muted">
|
||||
Rates from <a className="underline" href={pricing.source} target="_blank" rel="noreferrer">{pricing.source}</a>, as of {pricing.as_of}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-[repeat(auto-fit,minmax(170px,1fr))] gap-4">
|
||||
<Tile name="Total spend" value={money(sum(priced))} note={`${priced.length} priced runs`} />
|
||||
<Tile name="This month" value={money(sum(runs.filter((r) => monthOf(r.created_at) === thisMonth)))} note={monthLabel(thisMonth)} />
|
||||
<Tile name="Tokens" value={num(tokens('input_tokens') + tokens('output_tokens') + tokens('thinking_tokens'))}
|
||||
note={`in ${num(tokens('input_tokens'))} · out ${num(tokens('output_tokens'))} · thinking ${num(tokens('thinking_tokens'))}`} />
|
||||
<Tile name="Per document" value={priced.length ? money(sum(priced) / priced.length) : '—'} note="average, priced runs" />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Month by month</div>
|
||||
<div className="overflow-auto">
|
||||
<table aria-label="Spend by month" className="w-full border-collapse whitespace-nowrap text-xs">
|
||||
<thead><tr><Th>Month</Th><Th>Documents priced</Th><Th right>Spend</Th></tr></thead>
|
||||
<tbody>
|
||||
{months.map((x) => (
|
||||
<tr key={x.m} className="border-t border-line">
|
||||
<Td>{monthLabel(x.m)}{x.m === thisMonth && <span className="ml-2 text-muted">this month</span>}</Td>
|
||||
<Td>{x.priced} of {x.docs}</Td>
|
||||
<Td right>{x.priced ? money(x.cost) : '—'}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="card" aria-labelledby="by-hand">
|
||||
<div id="by-hand" className="card-title">Against doing it by hand</div>
|
||||
<p className="mb-3 text-xs text-muted">These are your assumptions, not a measurement. Change them to match the team.</p>
|
||||
<div className="mb-3 flex flex-wrap gap-4 text-xs">
|
||||
<label className="flex items-center gap-2">Minutes per document
|
||||
<input className="input w-20" type="number" min="0" value={minutes} onChange={(e) => setMinutes(+e.target.value)} />
|
||||
</label>
|
||||
<label className="flex items-center gap-2">Hourly rate
|
||||
<input className="input w-20" type="number" min="0" value={rate} onChange={(e) => setRate(+e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[13px]">
|
||||
By hand, {priced.length} documents would take {num(Math.round(priced.length * minutes / 6) / 10)} h, about <b>{money(byHand)}</b>.
|
||||
The AI spent <b>{money(sum(priced))}</b> on them.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* OPTIONAL: model picker (per batch) goes here */}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Cost per run</div>
|
||||
<div className="max-h-[50vh] overflow-auto">
|
||||
<table aria-label="Cost per run" className="w-full border-collapse whitespace-nowrap text-xs">
|
||||
<thead>
|
||||
<tr><Th>Document</Th><Th>Date</Th><Th>Model</Th><Th right>Input</Th><Th right>Output</Th><Th right>Thinking</Th><Th right>Cost</Th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r) => (
|
||||
<tr key={r.job_id} className="border-t border-line">
|
||||
<Td>{r.document}</Td><Td>{r.created_at.slice(0, 10)}</Td><Td>{r.model}</Td>
|
||||
<Td right>{num(r.input_tokens)}</Td><Td right>{num(r.output_tokens)}</Td><Td right>{num(r.thinking_tokens)}</Td>
|
||||
<Td right>{r.total_cost == null ? <span className="text-muted">not priced</span> : money(r.total_cost)}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Tile({ name, value, note }: { name: string; value: string; note: string }) {
|
||||
return (
|
||||
<div role="group" aria-label={name} className="card mb-0">
|
||||
<div className="text-[11px] font-bold uppercase tracking-wide text-muted">{name}</div>
|
||||
<div className="mt-1 font-head text-[26px] font-extrabold">{value}</div>
|
||||
<div className="mt-1 text-[11px] text-muted">{note}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const Th = ({ children, right }: { children: React.ReactNode; right?: boolean }) =>
|
||||
<th className={`px-3 py-2 font-bold ${right ? 'text-right' : 'text-left'}`}>{children}</th>
|
||||
const Td = ({ children, right }: { children: React.ReactNode; right?: boolean }) =>
|
||||
<td className={`px-3 py-2 ${right ? 'text-right' : ''}`}>{children}</td>
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useStore } from '../store'
|
||||
|
||||
// batch state -> [label, colour token, pill colour]
|
||||
export const STATES: Record<string, [string, string, string]> = {
|
||||
completed: ['Completed', 'var(--ok)', 'bg-ok'],
|
||||
inprogress: ['In progress', 'var(--warn)', 'bg-warn'],
|
||||
notcompleted: ['Not completed', 'var(--bad)', 'bg-bad'],
|
||||
cleared: ['Cleared', 'var(--muted)', 'bg-muted'],
|
||||
}
|
||||
|
||||
function Kpi({ label, n, colour, live }: { label: string; n: number; colour: string; live?: boolean }) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="flex items-center gap-3 rounded-2xl border border-line bg-glass-strong px-4 py-4 shadow-sm">
|
||||
<div className={`h-[42px] w-[42px] shrink-0 rounded-xl ${live ? 'animate-pulse' : ''}`} style={{ background: colour }} />
|
||||
<div>
|
||||
<div className="font-head text-[26px] leading-tight font-extrabold tabular-nums">{n}</div>
|
||||
<div className="mt-0.5 text-[11.5px] font-semibold text-muted">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
const { batches } = useStore()
|
||||
const count = Object.fromEntries(Object.keys(STATES).map((k) => [k, batches.filter((b) => b.state === k).length]))
|
||||
const total = Object.values(count).reduce((a, x) => a + x, 0)
|
||||
let acc = 0
|
||||
const stops = Object.keys(STATES).flatMap((k) => {
|
||||
if (!count[k]) return []
|
||||
const from = (acc / total) * 100
|
||||
acc += count[k]
|
||||
return [`${STATES[k][1]} ${from}% ${(acc / total) * 100}%`]
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 grid grid-cols-4 gap-3.5 max-[820px]:grid-cols-2">
|
||||
<Kpi label="Total batches" n={batches.length} colour="var(--accent)" />
|
||||
<Kpi label="Completed" n={count.completed} colour="var(--ok)" />
|
||||
<Kpi label="In progress" n={count.inprogress} colour="var(--warn)" live={count.inprogress > 0} />
|
||||
<Kpi label="Not completed" n={count.notcompleted} colour="var(--bad)" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3.5 max-[820px]:grid-cols-1">
|
||||
<div className="card">
|
||||
<div className="card-title">Batch status split</div>
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div role="img" className="relative h-[150px] w-[150px] rounded-full"
|
||||
aria-label={'Batch status: ' + (total ? Object.keys(STATES).map((k) => `${STATES[k][0]} ${count[k]}`).join(', ') : 'no batches yet')}
|
||||
style={{ background: total ? `conic-gradient(${stops.join(',')})` : 'conic-gradient(var(--border) 0 100%)' }}>
|
||||
<div className="absolute inset-[22px] flex flex-col items-center justify-center rounded-full" style={{ background: 'var(--bg-bottom)' }}>
|
||||
<div className="font-head text-[26px] font-extrabold">{batches.length}</div>
|
||||
<div className="text-[10px] font-bold uppercase text-muted">batches</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul aria-label="Batch status">
|
||||
{Object.keys(STATES).map((k) => (
|
||||
<li key={k} className="mb-2 flex items-center gap-2 text-[12.5px] font-semibold">
|
||||
<span className="h-2.5 w-2.5 rounded-[3px]" style={{ background: STATES[k][1] }} />
|
||||
{STATES[k][0]} · {count[k]}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">Recent batches</div>
|
||||
{batches.length === 0 && <div className="py-10 text-center text-[13px] text-muted">No batches yet</div>}
|
||||
{batches.slice(0, 6).map((b) => {
|
||||
const s = STATES[b.state] ?? [b.state, '', 'bg-warn']
|
||||
return (
|
||||
<div key={b.batch_id} className="mb-2 flex items-center gap-2 text-[12.5px] font-semibold">
|
||||
#{b.batch_id} · {b.date}
|
||||
<span className={`pill ml-auto ${s[2]}`}>{s[0]}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react'
|
||||
import { useStore, type ReportRow } from '../store'
|
||||
|
||||
const PILL: Record<string, string> = { success: 'bg-ok', attention: 'bg-warn', failed: 'bg-bad' }
|
||||
|
||||
// ponytail: a CSV that Excel opens. Have the backend send a real .xlsx if the sheet needs formatting.
|
||||
function exportCsv(rows: ReportRow[]) {
|
||||
const cell = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"`
|
||||
const csv = [REPORT_COLUMNS.map((c) => cell(c.label)), ...rows.map((r) => REPORT_COLUMNS.map((c) => cell(r[c.key])))]
|
||||
.map((line) => line.join(',')).join('\r\n')
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(new Blob(['' + csv], { type: 'text/csv' }))
|
||||
a.download = 'report.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
}
|
||||
|
||||
export function Report() {
|
||||
const { reportRows } = useStore()
|
||||
const [q, setQ] = useState('')
|
||||
const rows = reportRows.filter((r) => !q || Object.values(r).join(' ').toLowerCase().includes(q.toLowerCase()))
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="mb-3.5 flex flex-wrap items-center gap-2.5">
|
||||
<input type="search" aria-label="Search report" placeholder="Search…" className="input" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<button className="btn btn-primary ml-auto" onClick={() => exportCsv(rows)}>⬇ Export to Excel</button>
|
||||
</div>
|
||||
<div className="max-h-[60vh] overflow-auto rounded-2xl border border-line bg-glass">
|
||||
<table className="w-full border-collapse whitespace-nowrap text-xs">
|
||||
<thead>
|
||||
<tr>{REPORT_COLUMNS.map((c) => <th key={c.key} className="sticky top-0 bg-glass-strong px-3.5 py-3 text-left font-bold">{c.label}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && <tr><td colSpan={REPORT_COLUMNS.length} className="p-10 text-center text-[13px] text-muted">No rows yet</td></tr>}
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
{REPORT_COLUMNS.map((c) => (
|
||||
<td key={c.key} className="border-b border-line px-3.5 py-2.5">
|
||||
{c.key === 'status' ? <span className={`pill ${PILL[r.bucket ?? ''] ?? PILL.attention}`}>{r.status}</span> : String(r[c.key] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ai, BANDS, BUSY, CAVEAT, type Band, type ReviewRow, type Run } from '../ai'
|
||||
|
||||
const FILTERS: { label: string; band: Band | 'all' }[] = [
|
||||
{ label: 'All', band: 'all' }, { label: 'Red', band: 'red' }, { label: 'Amber', band: 'amber' },
|
||||
{ label: 'Missing', band: 'blank' }, { label: 'Unscored', band: 'unscored' },
|
||||
]
|
||||
const colourOf = (b: Band) => BANDS.find((x) => x.band === b)!.colour
|
||||
const settled = (r: ReviewRow) => r.corrected_value ?? r.value ?? ''
|
||||
|
||||
/**
|
||||
* The review split pane: the source document beside its extracted fields.
|
||||
* Typing makes a draft; nothing is written until Save. Verify signs off one field
|
||||
* as it reads. Approve asks the server, which refuses while flagged fields are unverified.
|
||||
*/
|
||||
export function Review({ onUnsaved }: { onUnsaved: (n: number) => void }) {
|
||||
const [runs, setRuns] = useState<Run[]>()
|
||||
const [jobId, setJobId] = useState<string>()
|
||||
const [run, setRun] = useState<Run>()
|
||||
const [rows, setRows] = useState<ReviewRow[]>([])
|
||||
const [drafts, setDrafts] = useState<Record<number, string>>({})
|
||||
const [filter, setFilter] = useState<Band | 'all'>('all')
|
||||
const [refusal, setRefusal] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const dirty = rows.filter((r) => r.row_id in drafts && drafts[r.row_id] !== settled(r))
|
||||
useEffect(() => onUnsaved(dirty.length), [dirty.length, onUnsaved])
|
||||
useEffect(() => {
|
||||
if (!dirty.length) return
|
||||
const warn = (e: BeforeUnloadEvent) => e.preventDefault()
|
||||
window.addEventListener('beforeunload', warn)
|
||||
return () => window.removeEventListener('beforeunload', warn)
|
||||
}, [dirty.length])
|
||||
|
||||
useEffect(() => {
|
||||
ai.runs().then((d) => { setRuns(d.runs); setJobId(d.runs[0]?.job_id) })
|
||||
.catch((e) => setError(`Can't load runs from ${API_BASE}: ${e.message}. Is the backend (or server_stub.py) running?`))
|
||||
}, [])
|
||||
|
||||
// Load the chosen run, and poll it every 1.5s while it is still extracting.
|
||||
useEffect(() => {
|
||||
if (!jobId) return
|
||||
let alive = true
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
setRun(undefined); setRows([]); setDrafts({}); setRefusal(undefined)
|
||||
const tick = async () => {
|
||||
try {
|
||||
const r = await ai.run(jobId)
|
||||
if (!alive) return
|
||||
setRun(r)
|
||||
if (BUSY.includes(r.status)) timer = setTimeout(tick, 1500)
|
||||
else { const d = await ai.rows(jobId); if (alive) setRows(d.rows) }
|
||||
} catch (e) { if (alive) setError((e as Error).message) }
|
||||
}
|
||||
tick()
|
||||
return () => { alive = false; clearTimeout(timer) }
|
||||
}, [jobId])
|
||||
|
||||
const reload = async () => {
|
||||
if (!jobId) return
|
||||
const [r, d] = await Promise.all([ai.run(jobId), ai.rows(jobId)])
|
||||
setRun(r); setRows(d.rows)
|
||||
}
|
||||
const pick = (id: string) => {
|
||||
if (dirty.length && !window.confirm(`${dirty.length} unsaved change(s) on this document. Discard them and leave?`)) return
|
||||
setJobId(id)
|
||||
}
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await Promise.all(dirty.map((r) => {
|
||||
const text = drafts[r.row_id].trim()
|
||||
// Same as extracted (or emptied) means "the extracted value was right", not an edit.
|
||||
return ai.patchRow(r.row_id, { corrected_value: text === '' || text === (r.value ?? '') ? null : text, verified: true })
|
||||
}))
|
||||
setDrafts({})
|
||||
await reload()
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
const verify = async (r: ReviewRow) => { await ai.patchRow(r.row_id, { verified: !r.verified }); await reload() }
|
||||
const verifyRemaining = async () => { if (jobId) { await ai.verifyRemaining(jobId); await reload() } }
|
||||
const approve = async () => {
|
||||
if (!jobId) return
|
||||
setRefusal(undefined)
|
||||
try { await ai.approve(jobId); await reload() } catch (e) { setRefusal((e as Error).message) }
|
||||
}
|
||||
|
||||
if (error) return <div className="card text-[13px] text-bad">{error}</div>
|
||||
if (!runs) return <div className="card text-[13px] text-muted">Loading runs…</div>
|
||||
if (!runs.length) return <div className="card text-[13px] text-muted">No documents extracted yet.</div>
|
||||
|
||||
const shown = filter === 'all' ? rows : rows.filter((r) => r.band === filter)
|
||||
const left = rows.filter((r) => r.band !== 'green' && !r.verified).length
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[560px] flex-1 flex-col gap-3">
|
||||
<div className="card mb-0 flex flex-wrap items-center gap-3 py-3">
|
||||
<label className="flex items-center gap-2 text-[12.5px] font-semibold">
|
||||
Document
|
||||
<select aria-label="Document" className="input" value={jobId} onChange={(e) => pick(e.target.value)}>
|
||||
{runs.map((r) => <option key={r.job_id} value={r.job_id}>{r.document}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{run && <span className={`pill ${run.status === 'done' ? 'bg-ok' : run.status === 'failed' ? 'bg-bad' : 'bg-warn'}`}>{run.status}</span>}
|
||||
{run && BUSY.includes(run.status) && <span className="text-[12.5px] text-muted tabular-nums">page {run.pages_done} of {run.pages_total || '?'}</span>}
|
||||
{run?.error && <span className="text-[12px] text-bad">{run.error}</span>}
|
||||
{/* OPTIONAL: retry on another model (failed runs) goes here */}
|
||||
{run?.status === 'done' && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="text-[12px] text-muted">{left} to verify</span>
|
||||
<button className="btn btn-sm" onClick={verifyRemaining} disabled={!left || run.approved}>Verify remaining</button>
|
||||
{run.approved
|
||||
? <span className="pill bg-ok">✓ Approved</span>
|
||||
: <button className="btn btn-primary btn-sm" onClick={approve} disabled={dirty.length > 0}
|
||||
title={dirty.length ? 'Save or discard your edits first' : undefined}>Approve</button>}
|
||||
</div>
|
||||
)}
|
||||
{refusal && <p role="alert" className="w-full rounded-lg bg-bad-bg px-3 py-1.5 text-[12px] font-semibold text-bad">Not approved: {refusal}</p>}
|
||||
</div>
|
||||
|
||||
<div className="card mb-0 flex flex-wrap items-center gap-x-5 gap-y-2 py-3">
|
||||
<ul aria-label="Confidence bands" className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{BANDS.map((b) => (
|
||||
<li key={b.band} className="flex items-center gap-1.5 text-[12px] font-semibold">
|
||||
<i className="h-2.5 w-2.5 rounded-[3px]" style={{ background: b.colour }} />
|
||||
{b.label} · {run?.summary?.[b.band] ?? 0}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span className="text-[11.5px] text-muted">{CAVEAT}</span>
|
||||
<div role="group" aria-label="Filter by band" className="ml-auto flex gap-1">
|
||||
{FILTERS.map((f) => (
|
||||
<button key={f.band} aria-pressed={filter === f.band} onClick={() => setFilter(f.band)}
|
||||
className={`btn btn-sm ${filter === f.band ? 'btn-primary' : ''}`}>{f.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-3 max-[820px]:flex-col">
|
||||
<iframe title="Source document" src={jobId && ai.documentUrl(jobId)}
|
||||
className="min-h-[420px] flex-1 rounded-2xl border border-line bg-white" />
|
||||
<div className="card mb-0 flex min-h-0 flex-1 flex-col overflow-hidden p-0">
|
||||
{run && run.status !== 'done' && <p className="p-5 text-[13px] text-muted">Fields appear when extraction finishes.</p>}
|
||||
<ul aria-label="Extracted fields" className="flex-1 overflow-y-auto p-4">
|
||||
{shown.map((r) => {
|
||||
const draft = drafts[r.row_id] ?? settled(r)
|
||||
const isDirty = draft !== settled(r)
|
||||
return (
|
||||
<li key={r.row_id} className={`mb-2 grid grid-cols-[minmax(80px,130px)_1fr_auto_auto] items-center gap-2 ${r.verified && !isDirty ? 'opacity-70' : ''}`}>
|
||||
<span className="truncate text-[12px] font-semibold text-muted" title={r.field}>{r.field}</span>
|
||||
<input aria-label={r.field} value={draft} placeholder="empty" title={r.reason ?? undefined}
|
||||
onChange={(e) => setDrafts((d) => ({ ...d, [r.row_id]: e.target.value }))}
|
||||
onKeyDown={(e) => e.key === 'Escape' && setDrafts((d) => ({ ...d, [r.row_id]: settled(r) }))}
|
||||
className={`input min-w-0 border-l-4 ${isDirty ? 'outline outline-warn' : ''}`} style={{ borderLeftColor: colourOf(r.band) }} />
|
||||
<span className="w-9 text-right text-[11px] text-muted tabular-nums" title="Legibility score: how clearly it was read, not whether it is right">
|
||||
{r.score?.toFixed(2) ?? '—'}
|
||||
</span>
|
||||
<button className="btn btn-sm w-[86px]" aria-label={`Verify ${r.field}`} aria-pressed={r.verified}
|
||||
disabled={isDirty} title={isDirty ? 'Save or discard the edit first' : undefined} onClick={() => verify(r)}>
|
||||
{r.verified ? '✓ Verified' : 'Verify'}
|
||||
</button>
|
||||
{(r.reason || r.corrected_value != null) && (
|
||||
<span className="col-span-4 -mt-1 text-[11px] text-muted">
|
||||
{r.reason}{r.reason && r.corrected_value != null && ' · '}
|
||||
{r.corrected_value != null && <>extracted <s>{r.value || 'empty'}</s></>}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{dirty.length > 0 && (
|
||||
<div className="flex items-center gap-2.5 border-t border-line px-4 py-2.5">
|
||||
<span className="text-[12px] font-semibold text-warn">{dirty.length} unsaved change{dirty.length === 1 ? '' : 's'}</span>
|
||||
<button className="btn btn-sm ml-auto" onClick={() => setDrafts({})} disabled={saving}>Discard</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useStore } from '../store'
|
||||
|
||||
const LOG_COLOUR = { info: 'text-[#7fc4f0]', success: 'text-[#5fe0a0]', warn: 'text-[#f5c563]', error: 'text-[#f68c82]' }
|
||||
|
||||
export function RunPanel() {
|
||||
const { step, log } = useStore()
|
||||
const active = !step ? -1 : step.pct >= 100 ? STEPS.length : STEPS.indexOf(step.label)
|
||||
const logBox = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => { logBox.current?.scrollTo(0, logBox.current.scrollHeight) }, [log.length])
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">Run automation</div>
|
||||
<div className="mb-2 flex gap-3 text-[13px] font-bold">
|
||||
<span>{step?.label ?? 'Waiting to start'}</span>
|
||||
<span className="ml-auto text-accent">{step?.pct ?? 0}%</span>
|
||||
</div>
|
||||
<div className="mb-4 h-2 overflow-hidden rounded-lg bg-glass-strong">
|
||||
<div className="h-full transition-[width] duration-500" style={{ width: `${step?.pct ?? 0}%`, background: 'var(--progress-bg)' }} />
|
||||
</div>
|
||||
<ol aria-label="Steps">
|
||||
{STEPS.map((s, i) => {
|
||||
const c = i < active ? 'done' : i === active ? 'active' : 'pending'
|
||||
return (
|
||||
<li key={s} aria-current={c === 'active' ? 'step' : undefined}
|
||||
className={`mb-1.5 flex items-center gap-3 rounded-[10px] px-3 py-2.5 text-[13px] font-semibold ${c === 'done' ? 'bg-ok-bg text-ok' : c === 'active' ? 'bg-warn-bg text-warn' : 'opacity-50'}`}>
|
||||
<span className={`flex h-[22px] w-[22px] items-center justify-center rounded-full text-[11px] ${c === 'done' ? 'bg-ok text-white' : c === 'active' ? 'bg-warn text-white' : 'bg-glass-strong'}`}>
|
||||
{c === 'done' ? '✓' : i + 1}
|
||||
</span>
|
||||
{s}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
{/* OPTIONAL: checkpoint tracker goes here */}
|
||||
<div ref={logBox} role="log" aria-label="Run log"
|
||||
className="mt-3 max-h-[220px] overflow-y-auto rounded-xl bg-accent-dark px-3.5 py-3 font-mono text-[13px] leading-[1.85] text-[#9fb6c4]">
|
||||
{log.map((l, i) => <div key={i} className={LOG_COLOUR[l.type] ?? LOG_COLOUR.info}>{l.msg}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Globals from the CONFIG block in index.html.
|
||||
declare const APP_NAME: string
|
||||
declare const APP_SUBTITLE: string
|
||||
declare const LOGO_SRC: string
|
||||
declare const STEPS: string[]
|
||||
declare const REPORT_COLUMNS: { key: string; label: string }[]
|
||||
declare const API_BASE: string
|
||||
|
||||
interface Window {
|
||||
/** Contract entry point: one {event, data} envelope as JSON (desktop hosts, tests). */
|
||||
__pyMsg: (json: string) => void
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
@import 'tailwindcss';
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* ===== THEME TOKENS (swap these for a preset/custom palette) =====
|
||||
The same two blocks as the no-AI page, so every preset in
|
||||
rpa-dashboard-no-ai/templates/themes.md applies here unchanged. */
|
||||
body{
|
||||
--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; --font-display:var(--font);
|
||||
--header-bg:var(--glass-strong); --header-ink:var(--ink); --header-muted:var(--muted);
|
||||
--primary-bg:linear-gradient(180deg,var(--accent-2),var(--accent));
|
||||
--nav-active-bg:linear-gradient(135deg,var(--accent),var(--accent-dark));
|
||||
--progress-bg:var(--ok); --blur:blur(14px);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/* ===== BAND COLOURS =====
|
||||
Semantic only: swatches, the rule down a field, legend dots. Never on a button
|
||||
or a heading. They say how clearly a value was printed, not whether it is right.
|
||||
The same in every preset and both modes. */
|
||||
body{ --clean:#51a68f; --verify:#e39b34; --review:#de5243; --absent:#6b6580; }
|
||||
|
||||
@theme inline {
|
||||
--color-glass: var(--glass);
|
||||
--color-glass-strong: var(--glass-strong);
|
||||
--color-line: var(--border);
|
||||
--color-ink: var(--ink);
|
||||
--color-muted: var(--muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-dark: var(--accent-dark);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-accent-soft-2: var(--accent-soft-2);
|
||||
--color-ok: var(--ok);
|
||||
--color-ok-bg: var(--ok-bg);
|
||||
--color-warn: var(--warn);
|
||||
--color-warn-bg: var(--warn-bg);
|
||||
--color-bad: var(--bad);
|
||||
--color-bad-bg: var(--bad-bg);
|
||||
--color-clean: var(--clean);
|
||||
--color-verify: var(--verify);
|
||||
--color-review: var(--review);
|
||||
--color-absent: var(--absent);
|
||||
--font-body: var(--font);
|
||||
--font-head: var(--font-display);
|
||||
}
|
||||
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
color: var(--ink);
|
||||
background: linear-gradient(180deg, var(--bg-top) 0%, var(--bg-mid) 45%, var(--bg-bottom) 100%);
|
||||
}
|
||||
|
||||
/* Shared pieces, kept as classes because every screen uses them. */
|
||||
@layer components {
|
||||
.card { @apply mb-4 rounded-2xl border border-line bg-glass p-5 shadow-[0_10px_30px_rgba(20,50,70,.08)]; backdrop-filter: var(--blur); }
|
||||
.card-title { @apply mb-3.5 font-head text-[15px] font-bold; }
|
||||
.btn { @apply cursor-pointer rounded-[11px] border border-accent-soft-2 px-4 py-2 font-head text-[12.5px] font-semibold text-ink disabled:cursor-not-allowed disabled:opacity-45; background: linear-gradient(180deg, var(--accent-soft), var(--accent-soft-2)); }
|
||||
.btn-primary { @apply border-accent text-white; background: var(--primary-bg); }
|
||||
.btn-sm { @apply px-2.5 py-1 text-[11.5px]; }
|
||||
.pill { @apply inline-block rounded-full px-3 py-1 text-[11px] font-bold text-white; }
|
||||
.input { @apply rounded-[10px] border border-line bg-glass-strong px-2.5 py-2 text-[12.5px] text-ink; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import { App } from './App'
|
||||
import { startTransport } from './store'
|
||||
|
||||
startTransport()
|
||||
createRoot(document.getElementById('root')!).render(<App />)
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
/**
|
||||
* The no-AI contract, as on the no-AI page: envelopes in, actions out.
|
||||
* Events reach handle() from window.__pyMsg, from polling, or from the sample data.
|
||||
* The AI review screen talks to its own endpoints (see ai.ts).
|
||||
*/
|
||||
export const SAMPLE_DATA = true // no bot yet: the page answers its own data actions. Set false once wired.
|
||||
|
||||
export type LogType = 'info' | 'success' | 'warn' | 'error'
|
||||
export type Batch = { batch_id: string | number; date: string; state: string; items_total: number; items_done: number }
|
||||
export type ReportRow = Record<string, unknown> & { status?: string; bucket?: string }
|
||||
export type State = {
|
||||
status: { state: 'ready' | 'running' | 'done' | 'error'; msg?: string }
|
||||
step: { label: string; pct: number } | null
|
||||
log: { msg: string; type: LogType }[]
|
||||
batches: Batch[]
|
||||
reportRows: ReportRow[]
|
||||
user: { username: string; role: string }
|
||||
dark: boolean
|
||||
}
|
||||
|
||||
const DARK_KEY = 'rpa-dashboard.dark'
|
||||
let state: State = {
|
||||
status: { state: 'ready', msg: 'System ready' },
|
||||
step: null,
|
||||
log: [],
|
||||
batches: [],
|
||||
reportRows: [],
|
||||
user: { username: 'User', role: 'Operator' },
|
||||
dark: false,
|
||||
}
|
||||
const subs = new Set<() => void>()
|
||||
const set = (patch: Partial<State>) => {
|
||||
state = { ...state, ...patch }
|
||||
subs.forEach((f) => f())
|
||||
}
|
||||
export const useStore = () =>
|
||||
useSyncExternalStore((cb) => (subs.add(cb), () => void subs.delete(cb)), () => state)
|
||||
|
||||
// ===== EVENT DISPATCH (see contract.md) =====
|
||||
export function handle(event: string, d: any) { // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
switch (event) {
|
||||
case 'status': set({ status: d }); break
|
||||
case 'step': set({ step: d }); break
|
||||
case 'log': set({ log: [...state.log, d] }); break
|
||||
case 'dashboard_data': set({ batches: d.batches ?? [] }); break
|
||||
case 'report_data': set({ reportRows: d.rows ?? [] }); break
|
||||
case 'set_user': set({ user: d }); break
|
||||
case 'apply_dark': setDark(!!d.on); break
|
||||
}
|
||||
}
|
||||
window.__pyMsg = (json) => {
|
||||
const m = JSON.parse(json)
|
||||
handle(m.event, m.data)
|
||||
}
|
||||
|
||||
/** Flip the theme now (not on the next render), so the page never shows a half-switched theme. */
|
||||
export function setDark(on: boolean) {
|
||||
document.body.classList.toggle('dark', on)
|
||||
try { localStorage.setItem(DARK_KEY, on ? '1' : '') } catch { /* private window */ }
|
||||
set({ dark: on })
|
||||
}
|
||||
export function toggleDark() {
|
||||
const on = !state.dark
|
||||
setDark(on)
|
||||
act('set_dark', on)
|
||||
}
|
||||
export function clearRun() {
|
||||
set({ log: [], step: STEPS.length ? { label: STEPS[0], pct: 0 } : null })
|
||||
}
|
||||
|
||||
// ===== TRANSPORT: sample data, else HTTP polling =====
|
||||
/** Page -> bot action: POST {API_BASE}/<slot-with-dashes>, body = the args. */
|
||||
export function act(slot: string, ...args: unknown[]) {
|
||||
if (SAMPLE_DATA && slot in SAMPLE) return SAMPLE[slot]()
|
||||
fetch(`${API_BASE}/${slot.replace(/_/g, '-')}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(args),
|
||||
})
|
||||
.then(() => SAMPLE_DATA || poll()) // the answer arrives as events: fetch them now
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
async function poll() {
|
||||
clearTimeout(timer)
|
||||
try {
|
||||
handle('dashboard_data', await (await fetch(`${API_BASE}/dashboard`)).json())
|
||||
const s = await (await fetch(`${API_BASE}/run/status`)).json()
|
||||
for (const e of s.events ?? []) handle(e.event, e.data)
|
||||
timer = setTimeout(poll, s.running ? 2000 : 10000) // poll fast only while busy
|
||||
} catch {
|
||||
timer = setTimeout(poll, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
export function startTransport() {
|
||||
try { setDark(!!localStorage.getItem(DARK_KEY)) } catch { /* private window */ }
|
||||
if (SAMPLE_DATA) act('load_dashboard')
|
||||
else poll()
|
||||
}
|
||||
|
||||
// ===== SAMPLE DATA: delete this block and set SAMPLE_DATA=false once the bot is wired =====
|
||||
const SAMPLE: Record<string, () => void> = {
|
||||
load_dashboard() {
|
||||
const day = (n: number) => new Date(Date.now() - n * 864e5).toISOString().slice(0, 10)
|
||||
handle('dashboard_data', { batches: [
|
||||
{ batch_id: 1006, date: day(0), state: 'inprogress', items_total: 40, items_done: 18 },
|
||||
{ batch_id: 1005, date: day(1), state: 'completed', items_total: 35, items_done: 35 },
|
||||
{ batch_id: 1004, date: day(2), state: 'completed', items_total: 52, items_done: 52 },
|
||||
{ batch_id: 1003, date: day(3), state: 'notcompleted', items_total: 20, items_done: 7 },
|
||||
{ batch_id: 1002, date: day(4), state: 'completed', items_total: 28, items_done: 28 },
|
||||
{ batch_id: 1001, date: day(6), state: 'cleared', items_total: 12, items_done: 0 },
|
||||
] })
|
||||
},
|
||||
load_latest_report() {
|
||||
const rows = [['Processed successfully', 'success'], ['Posted', 'success'], ['Needs review', 'attention'], ['Error', 'failed']]
|
||||
.map(([status, bucket], i) => ({
|
||||
...Object.fromEntries(REPORT_COLUMNS.map((c) => [c.key, `${c.label} ${i + 1}`])), status, bucket,
|
||||
}))
|
||||
handle('report_data', { rows })
|
||||
},
|
||||
start_automation() {
|
||||
let i = 0
|
||||
handle('status', { state: 'running', msg: 'Running (sample)' })
|
||||
const t = setInterval(() => {
|
||||
if (i < STEPS.length) {
|
||||
handle('step', { label: STEPS[i], pct: Math.round(((i + 1) / STEPS.length) * 100) })
|
||||
handle('log', { msg: `Sample step: ${STEPS[i++]}`, type: 'info' })
|
||||
} else {
|
||||
clearInterval(t)
|
||||
handle('log', { msg: 'Sample run finished.', type: 'success' })
|
||||
handle('status', { state: 'done', msg: 'Completed' })
|
||||
}
|
||||
}, 400)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: './', // relative asset paths, so the built page works under any URL prefix
|
||||
// `npm run dev`: the backend (or server_stub.py) answers /api on port 8000.
|
||||
server: { proxy: { '/api': 'http://127.0.0.1:8000' } },
|
||||
})
|
||||
Reference in new issue
Block a user