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:
VasanthandClaude Sonnet 5.5 committed 2026-10-16 17:04:38 +05:30
1 parent 312742cfef
commit d95e5d452d
84 files changed
+97 -31

No files matched your search

@@ -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>
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>
)
}
+12
View File
@@ -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' } },
})