AI web starter: bands, review split pane, approve gate, server stub (checkpoint)
React + Vite + Tailwind starter with the no-AI shell, band legend, review split pane with drafts and verify, and a server stub that refuses approval while flagged fields are unverified. Band self-check and AI page check. Both page checks share one Chromium via tests/conftest.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3eb3fdf00e
commit
d4d3f83fb1
24 files changed
+3429
-73
No files matched your search
@@ -1,64 +0,0 @@
|
||||
"""Confidence-band classification — single source of truth for the cut points.
|
||||
|
||||
The workbook colours and the review queue MUST agree about what counts as red.
|
||||
If each applied its own threshold they would drift the first time one is tuned,
|
||||
and a reviewer would see a red cell the queue never asked them to check.
|
||||
Everything that needs a band imports from here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cut points
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calibrated against the measured distribution over all 225 extracted rows of
|
||||
# the six sample documents:
|
||||
#
|
||||
# 1.00 69.8% crisp print
|
||||
# 0.90-0.99 18.2% crisp print
|
||||
# 0.80-0.89 11.0% almost entirely handwritten CPT and modifiers
|
||||
# 0.70-0.79 0.0% <- empty; the natural gap in this data
|
||||
# <0.70 1.1% handwritten modifiers
|
||||
#
|
||||
# 0.80 left only 1.1% of cells red so nearly everything went green and the
|
||||
# colour carried no information. 0.90 sits in the empty band and separates
|
||||
# the handwriting cluster from printed text.
|
||||
CONFIDENCE_THRESHOLD: float = 0.90
|
||||
|
||||
# Above CONFIDENCE_THRESHOLD the 0.90-0.99 band is still 18.2% of cells, and
|
||||
# routing treats it differently from a clean 1.00: reviewers sample it rather
|
||||
# than either trusting it outright or checking every one. A second cut at
|
||||
# 0.95 names that band so the sheet shows what the routing rule already assumes.
|
||||
#
|
||||
# GREEN >= 0.95 clean print, straight through
|
||||
# AMBER 0.90-0.95 slightly unclear, sampled
|
||||
# RED < 0.90 handwriting cluster, always reviewed
|
||||
GREEN_THRESHOLD: float = 0.95
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Band name constants
|
||||
# ---------------------------------------------------------------------------
|
||||
BAND_GREEN = "green"
|
||||
BAND_AMBER = "amber"
|
||||
BAND_RED = "red"
|
||||
|
||||
|
||||
def band_of(score: Optional[float]) -> Optional[str]:
|
||||
"""Return the confidence band name, or ``None`` when the value was never scored.
|
||||
|
||||
WHAT THE BAND CLAIMS: green means the source text this value came from was
|
||||
transcribed cleanly. It does NOT mean the value is correct. The score is
|
||||
the model's own legibility rating, so a confident misread comes back scored
|
||||
high. Verifying correctness needs a second, independent read. Red is the
|
||||
useful direction: it reliably surfaces handwriting.
|
||||
|
||||
Amber does not mean "probably fine". It means the reading was good enough to
|
||||
escape the handwriting cluster but not clean enough to trust unsampled, which
|
||||
is a routing decision, not a quality claim.
|
||||
"""
|
||||
if score is None:
|
||||
return None
|
||||
if score >= GREEN_THRESHOLD:
|
||||
return BAND_GREEN
|
||||
return BAND_AMBER if score >= CONFIDENCE_THRESHOLD else BAND_RED
|
||||
@@ -0,0 +1,47 @@
|
||||
"""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, sampled
|
||||
RED < 0.90 handwriting or a messy scan, always reviewed
|
||||
BLANK a mandatory field with no value, always flagged
|
||||
UNSCORED no word scores came back for the value
|
||||
|
||||
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 0.90-0.99 band that reviewers sample rather than trust.
|
||||
|
||||
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,187 @@
|
||||
"""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
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
API = "/api" # same as API_BASE in index.html
|
||||
REASON = {"green": None, "amber": "Slightly unclear, sample check", "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 = {}, {}
|
||||
_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)
|
||||
|
||||
|
||||
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 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),
|
||||
("POST", r"/set-dark", lambda body: (200, {})),
|
||||
]
|
||||
|
||||
|
||||
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 = 404, {"detail": f"No route for {method} {path}"}
|
||||
for m, pattern, fn in ROUTES:
|
||||
match = re.fullmatch(pattern, path)
|
||||
if m == method and match:
|
||||
try:
|
||||
status, payload = fn(*match.groups(), body)
|
||||
except (KeyError, StopIteration):
|
||||
status, payload = 404, {"detail": "Not found"}
|
||||
break
|
||||
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,105 @@
|
||||
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'
|
||||
|
||||
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" /></>],
|
||||
// OPTIONAL: admin/roles tab goes here
|
||||
} 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')
|
||||
}
|
||||
|
||||
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 />}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/** 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
|
||||
}
|
||||
|
||||
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' }),
|
||||
documentUrl: (id: string) => `${API_BASE}/runs/${id}/document`,
|
||||
}
|
||||
@@ -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,183 @@
|
||||
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>}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
+12
@@ -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