Eval results for tickets 04-07; grader ignores bot-run skips; resolve tickets 01-07

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Sonnet 5.5 committed 2026-10-16 16:56:16 +05:30
1 parent 258bfbb500
commit 312742cfef
17 files changed
+2546 -19

No files matched your search

@@ -0,0 +1,421 @@
<!DOCTYPE html>
<!--
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.html).
One file: works inside PyQt6 QWebEngineView (QWebChannel bridge) OR served by any web
backend (falls back to polling GET /api/dashboard and /run/status).
Theme = the body / body.dark token blocks only (see themes.md).
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>RPA Dashboard</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Sora:wght@600;700;800&display=swap">
<style>
/* ===== THEME TOKENS: Superbill Ember (see themes.md) ===== */
body{
--bg-top:#f4f3f7; --bg-mid:#f4f3f7; --bg-bottom:#f4f3f7;
--glass:#ffffff; --glass-strong:#f7f6fa; --border:#e2dfe9;
--ink:#18151f; --muted:#66607a; --accent-dark:#18151f;
--accent:#ff3049; --accent-2:#f26b21; --accent-soft:#f7f6fa; --accent-soft-2:#e2dfe9;
--ok:#1f9d57; --ok-bg:#e6f6ec; --warn:#b7791f; --warn-bg:#fbf1dd; --bad:#c0392b; --bad-bg:#fdecea;
--font:'IBM Plex Sans','Segoe UI',-apple-system,sans-serif; --font-display:'Sora','Segoe UI',sans-serif;
--header-bg:linear-gradient(100deg,#ff3049,#f26b21); --header-ink:#ffffff; --header-muted:rgba(255,255,255,.85);
--primary-bg:linear-gradient(115deg,#ff3049,#f26b21);
--nav-active-bg:linear-gradient(100deg,#ff3049,#f26b21);
--progress-bg:linear-gradient(90deg,#ff3049,#f26b21); --blur:none;
}
body.dark{
--bg-top:#0a0912; --bg-mid:#0a0912; --bg-bottom:#0a0912;
--glass:#151220; --glass-strong:#1b1727; --border:#2a2438;
--ink:#f3f1f8; --muted:#8d87a0; --accent-dark:#100e1a;
--accent-soft:#1b1727; --accent-soft-2:#2a2438;
--ok:#2fbf72; --ok-bg:rgba(31,157,87,.16); --warn:#d9a13c; --warn-bg:rgba(183,121,31,.16);
--bad:#e05a4b; --bad-bg:rgba(192,57,43,.16);
}
/* ===== BASE ===== */
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{font-family:var(--font);color:var(--ink);
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
.app{display:flex;height:100vh}
.hidden{display:none!important}
/* sidebar */
.sidebar{width:230px;min-width:230px;background:var(--glass);border-right:1px solid var(--border);backdrop-filter:var(--blur);display:flex;flex-direction:column}
.logo-area{padding:22px 20px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--border)}
.logo-box{width:40px;height:40px;border-radius:12px;background:var(--glass-strong);display:flex;align-items:center;justify-content:center;border:1px solid var(--border)}
.logo-box img{width:28px;height:28px;object-fit:contain}
.logo-title{font-size:15px;font-weight:700}
.logo-sub{font-size:10px;color:var(--muted);margin-top:2px;letter-spacing:.5px;text-transform:uppercase;font-weight:600}
.nav{padding:16px 12px;flex:1}
.nav-item{border:0;background:none;width:100%;font:inherit;text-align:left;display:flex;align-items:center;gap:11px;padding:11px 13px;border-radius:10px;cursor:pointer;margin-bottom:3px;color:var(--muted);font-size:13.5px;font-weight:600;transition:all .15s;user-select:none}
.nav-item svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2}
.nav-item:hover{background:var(--glass-strong);color:var(--ink)}
.nav-item.active{background:var(--nav-active-bg);color:#fff;box-shadow:0 4px 12px rgba(20,50,70,.22)}
.sidebar-foot{padding:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px}
.avatar{width:34px;height:34px;border-radius:50%;background:var(--nav-active-bg);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff}
.u-name{font-size:13px;font-weight:700}.u-role{font-size:10.5px;color:var(--muted)}
/* main + topbar */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{background:var(--header-bg);color:var(--header-ink);border-bottom:1px solid var(--border);backdrop-filter:var(--blur);padding:20px 28px;display:flex;align-items:center;gap:14px;box-shadow:0 6px 18px rgba(20,50,70,.10)}
.tb-title{font-size:21px;font-weight:800}.tb-sub{font-size:12.5px;color:var(--header-muted);margin-top:3px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.indicator{color:var(--ink);display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border);font-size:12px;font-weight:700}
.dot{width:8px;height:8px;border-radius:50%}
.dot.ready,.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)}
.dot.running{background:var(--warn);box-shadow:0 0 7px var(--warn);animation:blink 1.2s infinite}
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
@keyframes blink{50%{opacity:.4}}
.mode-btn{color:var(--ink);width:38px;height:38px;border-radius:11px;border:1px solid var(--border);background:var(--glass-strong);cursor:pointer;font-size:16px}
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px}
/* cards, buttons, pills */
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px}
.card{background:var(--glass);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:16px;backdrop-filter:var(--blur);box-shadow:0 10px 30px rgba(20,50,70,.08)}
.c-title{font-size:15px;font-weight:700;margin-bottom:14px}
.btn{padding:9px 15px;border-radius:11px;border:1px solid var(--accent-soft-2);background:linear-gradient(180deg,var(--accent-soft),var(--accent-soft-2));color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s}
.btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(20,50,70,.2)}
.btn.primary{background:var(--primary-bg);color:#fff;border-color:var(--accent)}
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none}
.pill{display:inline-block;padding:4px 11px;border-radius:20px;font-size:11px;font-weight:700;color:#fff}
.pill.success{background:var(--ok)}.pill.attention{background:var(--warn)}.pill.failed{background:var(--bad)}.pill.cleared{background:var(--muted)}
ul,ol{list-style:none}
.logo-title,.tb-title,.c-title,.kpi-val,.dh-num,.btn{font-family:var(--font-display)}
/* KPI */
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}
.kpi{display:flex;align-items:center;gap:13px;padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border);box-shadow:0 4px 14px rgba(20,50,70,.10)}
.kpi-ic{width:42px;height:42px;border-radius:12px;position:relative}
.kpi.total .kpi-ic{background:var(--accent)}
.kpi.done .kpi-ic{background:var(--ok)}.kpi.prog .kpi-ic{background:var(--warn)}.kpi.notdone .kpi-ic{background:var(--bad)}
.kpi.prog.live .kpi-ic::after{content:'';position:absolute;inset:-3px;border-radius:14px;border:2px solid var(--warn);animation:kpulse 1.6s ease-out infinite}
@keyframes kpulse{0%{transform:scale(.9);opacity:.6}100%{transform:scale(1.25);opacity:0}}
.kpi-val{font-size:26px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums}
.kpi-lbl{font-size:11.5px;color:var(--muted);font-weight:600;margin-top:2px}
/* donut */
.dash-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.donut-wrap{display:flex;align-items:center;gap:26px;flex-wrap:wrap}
.donut{width:150px;height:150px;border-radius:50%;position:relative;background:conic-gradient(var(--border) 0 100%)}
.donut-hole{position:absolute;inset:22px;border-radius:50%;background:var(--bg-bottom);display:flex;flex-direction:column;align-items:center;justify-content:center}
.dh-num{font-size:26px;font-weight:800}.dh-lbl{font-size:10px;color:var(--muted);font-weight:700;text-transform:uppercase}
.dl-row{display:flex;align-items:center;gap:9px;font-size:12.5px;font-weight:600;margin-bottom:9px}
.dl-dot{width:10px;height:10px;border-radius:3px}
/* run panel */
.prog-head{display:flex;gap:12px;margin-bottom:8px;font-size:13px;font-weight:700}
.prog-head .pct{margin-left:auto;color:var(--accent)}
.prog-bg{height:8px;background:var(--glass-strong);border-radius:8px;overflow:hidden;margin-bottom:18px}
.prog-fill{height:100%;width:0;background:var(--progress-bg);transition:width .5s}
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;margin-bottom:6px;font-size:13px;font-weight:600}
.step.done{background:var(--ok-bg);color:var(--ok)}.step.active{background:var(--warn-bg);color:var(--warn)}.step.pending{opacity:.5}
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--glass-strong)}
.step.done .step-dot{background:var(--ok);color:#fff}.step.active .step-dot{background:var(--warn);color:#fff}
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:220px;overflow-y:auto;font-family:Consolas,'Courier New',monospace;font-size:13px;line-height:1.85;color:#9fb6c4}
.log .info{color:#7fc4f0}.log .success{color:#5fe0a0}.log .warn{color:#f5c563}.log .error{color:#f68c82}
/* report table */
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.toolbar input{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.tbl-wrap{border:1px solid var(--border);border-radius:14px;overflow:auto;max-height:60vh;background:var(--glass)}
table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
td{padding:10px 14px;border-bottom:1px solid var(--border)}
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
.cp-track{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px;margin-bottom:16px}
.cp-box{display:flex;flex-direction:column;gap:4px;padding:10px 11px;border-radius:11px;border:1.5px solid var(--border);background:var(--glass-strong);color:var(--muted);font-size:11.5px;font-weight:700}
.cp-name{color:var(--ink);font-size:12.5px}
.cp-box.active{border-color:var(--warn);background:var(--warn-bg);color:var(--warn)}
.cp-box.done{border-color:var(--ok);background:var(--ok-bg);color:var(--ok)}
.cp-box.stuck{border-color:var(--bad);background:var(--bad-bg);color:var(--bad)}
body:not([data-role="admin" i]) .adm-only{display:none!important}
.toolbar select{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.toolbar select option{background:var(--bg-bottom);color:var(--ink)}
.pill.adm-role{background:var(--muted);text-transform:capitalize}.pill.adm-role.admin{background:var(--accent)}
.adm-note{font-size:12.5px;color:var(--muted)}
.login-screen{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;padding:16px;
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
.login-card{width:min(360px,100%);display:flex;flex-direction:column;gap:12px;margin:0}
.login-card .c-title{font-size:19px;margin:0}
.login-sub{font-size:12.5px;color:var(--muted);margin-top:-8px}
.login-card label{display:flex;flex-direction:column;gap:5px;font-size:12px;font-weight:700;color:var(--muted)}
.login-card input{padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:13px}
.login-err{color:var(--bad);font-size:12.5px;font-weight:600;min-height:1.2em}
.login-out svg{width:17px;height:17px;stroke:currentColor;fill:none;stroke-width:2;vertical-align:middle}
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="logo-area">
<div class="logo-box"><img src="" alt="" onerror="this.remove()"></div>
<div><div class="logo-title">RPA Dashboard</div><div class="logo-sub">Bot status and runs</div></div>
</div>
<nav class="nav">
<button class="nav-item active" data-tab="dashboard" title="Dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></button>
<button class="nav-item" data-tab="run" title="Run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></button>
<button class="nav-item" data-tab="report" title="Report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></button>
<button class="nav-item adm-only" id="adm-nav" data-tab="admin" title="Admin"><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>Admin</span></button>
</nav>
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
</aside>
<main class="main">
<header class="topbar">
<div><div class="tb-title" id="tb-title">Dashboard</div><div class="tb-sub" id="tb-sub">Overview of every batch</div></div>
<div class="tb-right">
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button>
<button class="mode-btn login-out" title="Sign out" aria-label="Sign out" onclick="loginShow(true); api('logout')"><svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></button>
<button class="mode-btn" id="mode-btn" title="Dark mode" aria-label="Dark mode" aria-pressed="false" onclick="toggleDark()">🌙</button>
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
</div>
</header>
<section class="content">
<!-- DASHBOARD -->
<div id="tab-dashboard">
<div class="kpi-row">
<div class="kpi total" role="group" aria-label="Total batches"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-total">0</div><div class="kpi-lbl">Total batches</div></div></div>
<div class="kpi done" role="group" aria-label="Completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-done">0</div><div class="kpi-lbl">Completed</div></div></div>
<div class="kpi prog" id="kpi-prog" role="group" aria-label="In progress"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-prog">0</div><div class="kpi-lbl">In progress</div></div></div>
<div class="kpi notdone" role="group" aria-label="Not completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-fail">0</div><div class="kpi-lbl">Not completed</div></div></div>
</div>
<div class="dash-grid">
<div class="card"><div class="c-title">Batch status split</div>
<div class="donut-wrap"><div class="donut" id="donut" role="img" aria-label="Batch status: no batches yet"><div class="donut-hole"><div class="dh-num" id="dh-num">0</div><div class="dh-lbl">batches</div></div></div><ul id="legend" aria-label="Batch status"></ul></div>
</div>
<div class="card"><div class="c-title">Recent batches</div><div id="recent" class="empty">No batches yet</div></div>
</div>
</div>
<!-- RUN -->
<div id="tab-run" class="hidden">
<div class="card"><div class="c-title">Run automation</div>
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
<ol id="steps" aria-label="Steps"></ol>
<div class="section-label">Checkpoints</div>
<ol class="cp-track" id="cp-track" aria-label="Checkpoints"></ol>
<div class="log" id="log" role="log" aria-label="Run log"></div>
</div>
</div>
<!-- REPORT -->
<div id="tab-report" class="hidden">
<div class="card">
<div class="toolbar"><input id="r-search" type="search" aria-label="Search report" placeholder="Search…" oninput="renderReport()"><span style="margin-left:auto"></span><button class="btn primary" onclick="api('download_report')">⬇ Download Excel</button></div>
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
</div>
</div>
<!-- ADMIN: admins only. Hiding it is not security: the host re-checks the role on every admin action. -->
<div id="tab-admin" class="hidden adm-only">
<div class="card"><div class="c-title">Assign access</div>
<form class="toolbar" aria-label="Assign access" onsubmit="admAssign(event)">
<input id="adm-user" aria-label="Username or employee ID" placeholder="Username or employee ID" required>
<select id="adm-role" aria-label="Role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
<button class="btn primary">Assign</button>
<span class="adm-note" id="adm-note" role="status"></span>
</form>
</div>
<div class="card"><div class="c-title">Users with access</div>
<div class="tbl-wrap"><table aria-label="Users with access"><thead><tr><th>User</th><th>Employee ID</th><th>Role</th><th></th></tr></thead><tbody id="adm-rows" onclick="admRemove(event)"></tbody></table></div>
</div>
</div>
</section>
</main>
</div>
<!-- LOGIN: UI only. The host checks the password with the project's own auth (see sections.md). -->
<div class="login-screen" id="login-screen">
<form class="card login-card" aria-label="Sign in" onsubmit="loginSubmit(event)">
<div class="c-title">RPA Dashboard</div>
<p class="login-sub">Sign in to continue</p>
<label>Username<input id="login-user" autocomplete="username" required></label>
<label>Password<input id="login-pass" type="password" autocomplete="current-password" required></label>
<div class="login-err" id="login-err" role="alert"></div>
<button class="btn primary">Sign in</button>
</form>
</div>
<script>
// ===== CONFIG (fill per project) =====
const STEPS = ["Initializing","Login","Process items","Completed"];
const REPORT_COLUMNS = [{"key":"item","label":"Item"},{"key":"batch_id","label":"Batch"},{"key":"status","label":"Status"}];
const API_BASE = "/api"; // web mode
const SAMPLE_DATA = true; // no bot yet: the page answers its own data actions. Set false once wired.
const TABS = {dashboard:["Dashboard","Overview of every batch"],run:["Run","Live progress and log"],report:["Report","Result of every processed item"]};
// ===== TRANSPORT: sample data, else Qt bridge, else HTTP polling =====
let bridge = null, reportRows = [];
function api(slot, ...args){
if(SAMPLE_DATA && SAMPLE[slot]) return SAMPLE[slot](...args);
if(bridge) return bridge[slot](...args);
return fetch(`${API_BASE}/${slot.replace(/_/g,'-')}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(args)}).catch(()=>{});
}
window.__pyMsg = (json)=>{ const m=JSON.parse(json); handle(m.event,m.data); };
function startTransport(){
if(window.qt && window.qt.webChannelTransport){
new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; api('load_dashboard'); });
}else if(SAMPLE_DATA){
api('load_dashboard');
}else{
const poll = async()=>{
try{
const d = await (await fetch(`${API_BASE}/dashboard`)).json(); handle('dashboard_data',d);
const s = await (await fetch(`${API_BASE}/run/status`)).json();
(s.events||[]).forEach(e=>handle(e.event,e.data));
setTimeout(poll, s.running ? 2000 : 10000); // poll fast only while busy
}catch(e){ setTimeout(poll, 10000); }
};
poll();
}
}
// ===== SAMPLE DATA: delete this block and set SAMPLE_DATA=false once the bot is wired =====
const SAMPLE = {
load_dashboard(){ const day=n=>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(){
handle('report_data',{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}))}); },
start_automation(){ let i=0; handle('status',{state:'running',msg:'Running (sample)'});
const t=setInterval(()=>{
handle('cpstate', i<STEPS.length ? {kind:'active',cp:Math.ceil((i+1)*CHECKPOINTS.length/STEPS.length)} : {kind:'done',cp:CHECKPOINTS.length});
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); },
adm_users:[{username:'Sample admin',emp_id:'E-1001',role:'admin'},{username:'Sample operator',emp_id:'E-1002',role:'operator'}],
load_admin(){ handle('admin_data',{rows:this.adm_users}); },
assign_role(who,role){ this.remove_role(who); this.adm_users.push({username:who,emp_id:'',role}); this.load_admin(); },
remove_role(who){ this.adm_users=this.adm_users.filter(u=>u.username!==who&&u.emp_id!==who); this.load_admin(); },
login(username){ handle('login_result',{ok:true,user:{username,emp_id:'',role:document.body.dataset.role||'operator'}}); }, // stub: anyone, same role
logout(){},
};
// ===== EVENT DISPATCH (see contract.md) =====
function handle(event, d){
switch(event){
case 'status': setStatus(d.state, d.msg); break;
case 'step': setStep(d.label, d.pct); break;
case 'log': addLog(d.msg, d.type); break;
case 'dashboard_data': renderDashboard(d.batches||[]); break;
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
case 'set_user': setUser(d); break;
case 'apply_dark': setDark(!!d.on); break;
case 'cpstate': cpState(d.kind, d.cp); break;
case 'admin_data': admRender(d); break;
case 'login_result': loginResult(d); break;
}
}
// ===== UI =====
document.querySelectorAll('.nav-item').forEach(n=>n.onclick=()=>showTab(n.dataset.tab));
function showTab(t){
document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab===t));
Object.keys(TABS).forEach(k=>document.getElementById('tab-'+k).classList.toggle('hidden',k!==t));
tbTitle.textContent=TABS[t][0]; tbSub.textContent=TABS[t][1];
if(t==='dashboard') api('load_dashboard');
if(t==='report') api('load_latest_report');
}
const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub');
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function setDark(on){ document.body.classList.toggle('dark',on); $('mode-btn').setAttribute('aria-pressed',on); $('mode-btn').textContent=on?'☀':'🌙'; }
function toggleDark(){ const on=!document.body.classList.contains('dark'); setDark(on); api('set_dark',on); }
function setUser(u){ document.body.dataset.role=u.role||''; $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
function renderSteps(active=-1){
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending';
return `<li class="step ${c}"${c==='active'?' aria-current="step"':''}><span class="step-dot">${c==='done'?'✓':i+1}</span>${esc(s)}</li>`}).join('');
}
function setStep(label,pct){ $('p-label').textContent=label; $('p-pct').textContent=pct+'%'; $('p-fill').style.width=pct+'%';
renderSteps(pct>=100?STEPS.length:STEPS.indexOf(label)); }
function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('beforeend',`<div class="${type}">${esc(msg)}</div>`); l.scrollTop=l.scrollHeight; }
function countUp(el,to){ const from=+el.textContent||0,t0=performance.now();
const f=t=>{const p=Math.min((t-t0)/500,1); el.textContent=Math.round(from+(to-from)*p); if(p<1)requestAnimationFrame(f)}; requestAnimationFrame(f); }
// batch state -> [label, colour token, pill class]
const STATES={completed:['Completed','var(--ok)','success'],inprogress:['In progress','var(--warn)','attention'],
notcompleted:['Not completed','var(--bad)','failed'],cleared:['Cleared','var(--muted)','cleared']};
function renderDashboard(b){
const c={}; Object.keys(STATES).forEach(k=>c[k]=b.filter(x=>x.state===k).length);
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted);
$('kpi-prog').classList.toggle('live',c.inprogress>0); $('dh-num').textContent=b.length;
const tot=Object.values(c).reduce((a,x)=>a+x,0); let acc=0; const stops=[];
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/tot*100; acc+=c[k]; stops.push(`${STATES[k][1]} ${a}% ${acc/tot*100}%`); });
$('donut').style.background = tot?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
$('donut').setAttribute('aria-label','Batch status: '+Object.keys(c).map(k=>`${STATES[k][0]} ${c[k]}`).join(', '));
$('legend').innerHTML=Object.keys(c).map(k=>`<li class="dl-row"><span class="dl-dot" style="background:${STATES[k][1]}"></span>${STATES[k][0]} · ${c[k]}</li>`).join('');
$('recent').className = b.length?'':'empty';
$('recent').innerHTML = b.length? b.slice(0,6).map(x=>{const s=STATES[x.state]||[esc(x.state),'','attention'];
return `<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${s[2]}" style="margin-left:auto">${s[0]}</span></div>`}).join('') : 'No batches yet';
}
function renderReport(){
const q=$('r-search').value.toLowerCase();
$('r-head').innerHTML='<tr>'+REPORT_COLUMNS.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
const rows=reportRows.filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q));
$('r-body').innerHTML = rows.length? rows.map(r=>'<tr>'+REPORT_COLUMNS.map(c=>c.key==='status'
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
}
// Checkpoint tracker: numbered boxes driven by cpstate {kind, cp}. cp counts from 1.
const CHECKPOINTS = ["Open the portal","Load the list","Process items","Finish up"];
const CP_LABEL = {pending:'Pending', active:'In progress', done:'Done', stuck:'Stuck'};
let cpKinds = CHECKPOINTS.map(()=>'pending');
function cpState(kind, n){
if(!CP_LABEL[kind] || !(n>=1 && n<=CHECKPOINTS.length)) return;
if(kind==='active') cpKinds = cpKinds.map((_,i)=>i<n-1?'done':'pending'); // a new active box resets the rest
cpKinds[n-1] = kind;
$('cp-track').innerHTML = CHECKPOINTS.map((c,i)=>`<li class="cp-box ${cpKinds[i]}"${cpKinds[i]==='active'?' aria-current="step"':''}>`
+`<span>${i+1}</span><span class="cp-name">${esc(c)}</span><span>${CP_LABEL[cpKinds[i]]}</span></li>`).join('');
}
cpState('pending', 1);
// Admin screen: shown only while set_user says role "admin" (CSS on body[data-role]).
TABS.admin = ['Admin','Who can use this dashboard'];
$('adm-nav').addEventListener('click', ()=>api('load_admin'));
function admRender(d){
$('adm-note').textContent = d.msg||'';
const rows = d.rows||[];
$('adm-rows').innerHTML = rows.length ? rows.map(u=>`<tr><td>${esc(u.username)}</td><td>${esc(u.emp_id)}</td>`
+`<td><span class="pill adm-role ${/^admin$/i.test(u.role)?'admin':''}">${esc(u.role)}</span></td>`
+`<td><button class="btn" data-user="${esc(u.emp_id||u.username)}">Remove</button></td></tr>`).join('')
: '<tr><td class="empty" colspan="4">No one has access yet</td></tr>';
}
function admAssign(e){ e.preventDefault(); api('assign_role', $('adm-user').value.trim(), $('adm-role').value); $('adm-user').value=''; }
function admRemove(e){ const who=e.target.dataset.user; if(who) api('remove_role', who); }
if(SAMPLE_DATA) setUser({username:'Sample admin', emp_id:'E-1001', role:'admin'}); // demo: show the admin screen
// Login page (UI only): the host checks the password with the project's own auth and answers login_result.
function loginShow(on){
$('login-screen').classList.toggle('hidden', !on);
document.querySelector('.app').inert = on;
if(on){ $('login-err').textContent=''; $('login-user').focus(); }
}
function loginSubmit(e){
e.preventDefault(); $('login-err').textContent='';
const u=$('login-user').value.trim(), p=$('login-pass').value;
if(SAMPLE_DATA || bridge) return api('login', u, p);
fetch(`${API_BASE}/login`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})})
.then(r=>r.json()).then(d=>handle('login_result',d))
.catch(()=>handle('login_result',{ok:false,msg:'Could not reach the server.'}));
}
function loginResult(d){
$('login-pass').value='';
if(!d.ok){ $('login-err').textContent = d.msg||'Sign-in failed.'; return; }
if(d.user) setUser(d.user);
loginShow(false); showTab('dashboard');
}
loginShow(true);
renderSteps();
startTransport();
</script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"fixture": "a-empty-sections",
"passed": true,
"checks": {
"frontend reported correctly": true,
"asks /colou?r/": true,
"asks /desktop|web/": true,
"asks /section/": true,
"never asks /\\bkeep\\b|restyle/": true,
"exactly one page built": true,
"no placeholders left": true,
"checkpoints built": true,
"admin built": true,
"login built": true,
"page check passes": true,
"no section checks skipped": true
},
"page_check": [
"43 passed, 3 skipped in 34.31s"
],
"files": [
"README.md",
"dashboard\\dashboard.html"
],
"reply": "Dashboard ready. Sample data la ok-aa run aagudhu, ippo bot connect pannala.\n\n| Item | Details |\n|---|---|\n| File | `dashboard/dashboard.html` (single file) |\n| Theme | Superbill Ember, light + dark |\n| Stack | Web, `API_BASE = \"/api\"`. Backend ippo illa, so `SAMPLE_DATA = true` |\n| Sections | Shell, KPI cards, donut, Run panel, Report + Excel button, **Checkpoint tracker, Admin/roles, Login** |\n| Login | **Stub** dhaan: password check illa, empty illadha username kudutha yaarum ulla poga mudiyum. Unga project la auth illa, adhaan |\n\n**Check pannadhu (headless Playwright screenshots):**\n- Login, Dashboard (6 batches, 4 KPI), Run (4/4 steps, 4/4 checkpoints, log), Report (4/4 rows, pills), Admin (2 users), dark mode, 800px width: ellam render aagudhu.\n- `{{` placeholder ethuvum baakki illa. Script errors um illa.\n\n**Bot wire panna:** backend `GET /api/dashboard`, `GET /api/run/status` (`{running, events[]}`), `POST /api/<action>` serve pannanum, apram `/api/login` um venum. Bot anuppa vendiya events: `status`, `step`, `log`, `cpstate`, `admin_data`, `login_result`.\n\n**Also noticed (fix pannala):** `file://` la open panna console la rendu harmless errors varum. Onnu: `qwebchannel.js` load aagala, adhu Qt ku mattum dhaan venum. Rendu: dark toggle `/api/set-dark` POST panna try pannum, adhu fail aagi silent-aa catch aagidum. Backend la serve panna rendum varaadhu.\n\nFRONTEND: none (detect_frontend.py: \"No existing frontend found.\")\nQUESTIONS: Which colours: Tebra Sky, Superbill Ember, or Custom (one brand hex)? | Desktop (PyQt6) or web? | Which optional sections: Checkpoint tracker, Admin/roles screen, Login page (any or none)?"
}
+14 -8
View File
@@ -2,14 +2,14 @@
<!--
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.html).
One file: works inside PyQt6 QWebEngineView (QWebChannel bridge) OR served by any web
backend (falls back to polling GET /dashboard and /run/status).
Theme = the body / body.dark token blocks only (see themes.md). Custom brand #7a3cff.
backend (falls back to polling GET <API_BASE>/dashboard and /run/status).
Placeholders filled. Theme = the body / body.dark token blocks only (see themes.md).
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>RPA Dashboard</title>
<title>RPA Bot</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
/* ===== THEME TOKENS (swap these for a preset/custom palette, see themes.md) ===== */
@@ -125,7 +125,7 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
<aside class="sidebar">
<div class="logo-area">
<div class="logo-box"><img src="" alt="" onerror="this.remove()"></div>
<div><div class="logo-title">RPA Dashboard</div><div class="logo-sub">Bot status &amp; reports</div></div>
<div><div class="logo-title">RPA Bot</div><div class="logo-sub">Automation dashboard</div></div>
</div>
<nav class="nav">
<button class="nav-item active" data-tab="dashboard" title="Dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></button>
@@ -179,15 +179,17 @@ td{padding:10px 14px;border-bottom:1px solid var(--border)}
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
</div>
</div>
</section>
</main>
</div>
<script>
// ===== CONFIG (fill per project) =====
const STEPS = ["Initializing","Login","Process items","Completed"];
const REPORT_COLUMNS = [{"key":"item","label":"Item"},{"key":"batch","label":"Batch"},{"key":"status","label":"Status"}];
const API_BASE = ""; // web mode only, e.g. "/api"
const STEPS = ["Initializing","Login","Process items","Completed"]; // e.g. ["Initializing","Login","Process items","Completed"]
const REPORT_COLUMNS = [{"key":"item","label":"Item"},{"key":"date","label":"Date"},{"key":"status","label":"Status"}]; // e.g. [{key:"item",label:"Item"},{key:"status",label:"Status"}]
const API_BASE = ""; // web mode only, e.g. "/api"
const SAMPLE_DATA = true; // no bot yet: the page answers its own data actions. Set false once wired.
const TABS = {dashboard:["Dashboard","Overview of every batch"],run:["Run","Live progress and log"],report:["Report","Result of every processed item"]};
@@ -263,7 +265,7 @@ const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub'
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function setDark(on){ document.body.classList.toggle('dark',on); $('mode-btn').setAttribute('aria-pressed',on); $('mode-btn').textContent=on?'☀':'🌙'; }
function toggleDark(){ const on=!document.body.classList.contains('dark'); setDark(on); api('set_dark',on); }
function setUser(u){ $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setUser(u){ document.body.dataset.role=u.role||''; $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
@@ -301,6 +303,10 @@ function renderReport(){
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
}
renderSteps();
startTransport();
</script>
+6 -2
View File
@@ -10,15 +10,19 @@
"exactly one page built": true,
"no placeholders left": true,
"custom palette applied": true,
"checkpoints left no trace": true,
"admin left no trace": true,
"login left no trace": true,
"page check passes": true
},
"page_check": [
"22 passed in 17.68s"
"28 passed, 18 skipped in 21.91s"
],
"files": [
"README.md",
"dashboard\\bot_runner.py",
"dashboard\\bridge.py",
"dashboard\\dashboard.html"
],
"reply": "Dashboard ready ah iruku. Sample data la run aagudhu, headless browser la check panniten.\n\n| Item | Details |\n|---|---|\n| Files | `dashboard/dashboard.html`, `dashboard/bridge.py` (PyQt6 desktop host) |\n| Theme | Custom `#7a3cff`. Contrast check 10/10 PASS, colour-ah change panna thevai illa. Dark mode la button colour konjam light ah `#8248ff` nu irukum. Background innum template oda sky-blue gradient dhaan, purple accents mattum dhaan maarinadhu. |\n| Sections | Core mattum: sidebar, top bar, dark toggle, status dot, KPI cards, donut, run panel, report table. Optional edhuvum add pannala. |\n| Data | `SAMPLE_DATA = true`. Project la bot illa. |\n\n**Check results (Playwright, browser la open panni):**\n- KPI: 6 total / 3 completed / 1 in progress / 1 not completed. Donut legend la 4 rows.\n- Run: 4/4 steps done, 5 log lines, status \"Completed\".\n- Report: 4/4 rows la status pill varudhu.\n- Light, dark, 800px width moonum sariya varudhu. Script errors 0, `{{` placeholders 0.\n- **UNVERIFIED:** `bridge.py` la irukura PyQt window-ah naan open pannala. Adhu real ah run aagudhanu check pannala.\n\n**Bot wire pannumbodhu:**\n- Bot indha events anuppanum: `status`, `step`, `log`, `dashboard_data`, `report_data`.\n- `bridge.py` la `BOT_CMD` (ippo `bot` nu placeholder), `STEP_MARKERS`, `FRIENDLY_RULES` set pannunga.\n- Apram `SAMPLE_DATA = false` panni, SAMPLE block-ah delete pannunga.\n\n**Also noticed:** steps (`Initializing \u2192 Login \u2192 Process items \u2192 Completed`) and report columns (Item/Batch/Status) placeholder dhaan, ungaloda real bot stages vandha maathanum.\n\nFRONTEND: none\nQUESTIONS: Which colours: Tebra Sky, Superbill Ember, or Custom (one brand hex)? | Desktop or web? | Which optional sections: checkpoint tracker, admin/roles screen, login page, or none?"
"reply": "Dashboard ready, sample data-la odudhu. Project-la frontend-um illa, bot-um illa.\n\n**Files** (`dashboard/` folder):\n| File | Enna |\n|---|---|\n| `dashboard/dashboard.html` | Page: custom #7a3cff theme, optional sections ellam remove pannitten |\n| `dashboard/bridge.py` | PyQt6 desktop window |\n| `dashboard/bot_runner.py` | Bot wiring. `BOT_MODULE = bot` placeholder; unga bot vandhadhum idha set pannunga |\n\n**Contrast** (ellam pass): light accent 5.36:1, dark accent 4.86:1, dark accent text 3.03:1 (3:1 venum, edge-la dhaan pass aagudhu).\n\n**Browser-la test pannen** (headless Playwright, screenshots paathen): light/dark theme rendering, KPI 6 batches, donut legend 4, Run click pannumbodhu button disabled-a irundhu 100%-la thirumba enable aachu, Report 4 rows/4 pills, 800px-la horizontal scroll illa, console errors illa.\n\n**Test pannala:** `python dashboard/bridge.py` PyQt window open pannala (session non-interactive). Adhanala window close panni reopen pannumbodhu dark-mode save aagudha nu check pannala. Neenga oru dhadava run panni paarunga.\n\n**Bot vandha apram:** bot ivvalavu events anuppanum: `status`, `step`, `log`, `dashboard_data`, `report_data` (details `contract.md`-la irukku). Adhoda `SAMPLE_DATA = false` set pannanum.\n\nFRONTEND: none\nQUESTIONS: Colours \u2014 Tebra Sky, Superbill Ember, or Custom (one brand hex)? | Desktop or web? | Which optional sections \u2014 Checkpoint tracker, Admin/roles screen, Login page (any or none)?"
}
@@ -0,0 +1,28 @@
<!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>Invoice Extraction</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Sora:wght@600;700;800&display=swap">
<script>
// ===== CONFIG (fill per project) =====
const APP_NAME = "Invoice Extraction";
const APP_SUBTITLE = "AI invoice field review";
const LOGO_SRC = "";
const STEPS = ["Initializing","Extract","Review","Completed"];
const REPORT_COLUMNS = [{"key":"document","label":"Document"},{"key":"invoice_number","label":"Invoice number"},{"key":"vendor","label":"Vendor"},{"key":"total","label":"Total"},{"key":"status","label":"Status"}];
const API_BASE = "/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,568 @@
<!DOCTYPE html>
<!--
Generic RPA dashboard (no AI). Derived from Tebra ERA (reference/tebra_dashboard.html).
One file: works inside PyQt6 QWebEngineView (QWebChannel bridge) OR served by any web
backend (falls back to polling GET {API_BASE}/dashboard and /run/status).
Theme = the body / body.dark token blocks only (Tebra Sky, see themes.md).
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Invoice Intake</title>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
/* ===== THEME TOKENS (swap these for a preset/custom palette, see themes.md) ===== */
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);
}
/* ===== BASE ===== */
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{font-family:var(--font);color:var(--ink);
background:linear-gradient(180deg,var(--bg-top) 0%,var(--bg-mid) 45%,var(--bg-bottom) 100%)}
.app{display:flex;height:100vh}
.hidden{display:none!important}
/* sidebar */
.sidebar{width:230px;min-width:230px;background:var(--glass);border-right:1px solid var(--border);backdrop-filter:var(--blur);display:flex;flex-direction:column}
.logo-area{padding:22px 20px;display:flex;align-items:center;gap:11px;border-bottom:1px solid var(--border)}
.logo-box{width:40px;height:40px;border-radius:12px;background:var(--glass-strong);display:flex;align-items:center;justify-content:center;border:1px solid var(--border)}
.logo-box img{width:28px;height:28px;object-fit:contain}
.logo-title{font-size:15px;font-weight:700}
.logo-sub{font-size:10px;color:var(--muted);margin-top:2px;letter-spacing:.5px;text-transform:uppercase;font-weight:600}
.nav{padding:16px 12px;flex:1}
.nav-item{border:0;background:none;width:100%;font:inherit;text-align:left;display:flex;align-items:center;gap:11px;padding:11px 13px;border-radius:10px;cursor:pointer;margin-bottom:3px;color:var(--muted);font-size:13.5px;font-weight:600;transition:all .15s;user-select:none}
.nav-item svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2}
.nav-item:hover{background:var(--glass-strong);color:var(--ink)}
.nav-item.active{background:var(--nav-active-bg);color:#fff;box-shadow:0 4px 12px rgba(20,50,70,.22)}
.sidebar-foot{padding:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px}
.avatar{width:34px;height:34px;border-radius:50%;background:var(--nav-active-bg);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff}
.u-name{font-size:13px;font-weight:700}.u-role{font-size:10.5px;color:var(--muted)}
/* main + topbar */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{background:var(--header-bg);color:var(--header-ink);border-bottom:1px solid var(--border);backdrop-filter:var(--blur);padding:20px 28px;display:flex;align-items:center;gap:14px;box-shadow:0 6px 18px rgba(20,50,70,.10)}
.tb-title{font-size:21px;font-weight:800}.tb-sub{font-size:12.5px;color:var(--header-muted);margin-top:3px}
.tb-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.indicator{color:var(--ink);display:flex;align-items:center;gap:7px;background:var(--glass-strong);padding:7px 13px;border-radius:20px;border:1px solid var(--border);font-size:12px;font-weight:700}
.dot{width:8px;height:8px;border-radius:50%}
.dot.ready,.dot.done{background:var(--ok);box-shadow:0 0 7px var(--ok)}
.dot.running{background:var(--warn);box-shadow:0 0 7px var(--warn);animation:blink 1.2s infinite}
.dot.error{background:var(--bad);box-shadow:0 0 7px var(--bad)}
@keyframes blink{50%{opacity:.4}}
.mode-btn{color:var(--ink);width:38px;height:38px;border-radius:11px;border:1px solid var(--border);background:var(--glass-strong);cursor:pointer;font-size:16px}
.content{flex:1;overflow-y:auto;padding:26px 28px;border-top-left-radius:30px}
/* cards, buttons, pills */
.section-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);font-weight:700;margin-bottom:14px}
.card{background:var(--glass);border:1px solid var(--border);border-radius:16px;padding:22px;margin-bottom:16px;backdrop-filter:var(--blur);box-shadow:0 10px 30px rgba(20,50,70,.08)}
.c-title{font-size:15px;font-weight:700;margin-bottom:14px}
.btn{padding:9px 15px;border-radius:11px;border:1px solid var(--accent-soft-2);background:linear-gradient(180deg,var(--accent-soft),var(--accent-soft-2));color:var(--ink);font-size:12.5px;font-weight:600;cursor:pointer;transition:all .18s}
.btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(20,50,70,.2)}
.btn.primary{background:var(--primary-bg);color:#fff;border-color:var(--accent)}
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none;box-shadow:none}
.pill{display:inline-block;padding:4px 11px;border-radius:20px;font-size:11px;font-weight:700;color:#fff}
.pill.success{background:var(--ok)}.pill.attention{background:var(--warn)}.pill.failed{background:var(--bad)}.pill.cleared{background:var(--muted)}
ul,ol{list-style:none}
.logo-title,.tb-title,.c-title,.kpi-val,.dh-num,.btn{font-family:var(--font-display)}
/* KPI */
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}
.kpi{display:flex;align-items:center;gap:13px;padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border);box-shadow:0 4px 14px rgba(20,50,70,.10)}
.kpi-ic{width:42px;height:42px;border-radius:12px;position:relative}
.kpi.total .kpi-ic{background:var(--accent)}
.kpi.done .kpi-ic{background:var(--ok)}.kpi.prog .kpi-ic{background:var(--warn)}.kpi.notdone .kpi-ic{background:var(--bad)}
.kpi.prog.live .kpi-ic::after{content:'';position:absolute;inset:-3px;border-radius:14px;border:2px solid var(--warn);animation:kpulse 1.6s ease-out infinite}
@keyframes kpulse{0%{transform:scale(.9);opacity:.6}100%{transform:scale(1.25);opacity:0}}
.kpi-val{font-size:26px;font-weight:800;line-height:1.1;font-variant-numeric:tabular-nums}
.kpi-lbl{font-size:11.5px;color:var(--muted);font-weight:600;margin-top:2px}
/* donut */
.dash-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.donut-wrap{display:flex;align-items:center;gap:26px;flex-wrap:wrap}
.donut{width:150px;height:150px;border-radius:50%;position:relative;background:conic-gradient(var(--border) 0 100%)}
.donut-hole{position:absolute;inset:22px;border-radius:50%;background:var(--bg-bottom);display:flex;flex-direction:column;align-items:center;justify-content:center}
.dh-num{font-size:26px;font-weight:800}.dh-lbl{font-size:10px;color:var(--muted);font-weight:700;text-transform:uppercase}
.dl-row{display:flex;align-items:center;gap:9px;font-size:12.5px;font-weight:600;margin-bottom:9px}
.dl-dot{width:10px;height:10px;border-radius:3px}
/* run panel */
.prog-head{display:flex;gap:12px;margin-bottom:8px;font-size:13px;font-weight:700}
.prog-head .pct{margin-left:auto;color:var(--accent)}
.prog-bg{height:8px;background:var(--glass-strong);border-radius:8px;overflow:hidden;margin-bottom:18px}
.prog-fill{height:100%;width:0;background:var(--progress-bg);transition:width .5s}
.step{display:flex;align-items:center;gap:12px;padding:10px 13px;border-radius:10px;margin-bottom:6px;font-size:13px;font-weight:600}
.step.done{background:var(--ok-bg);color:var(--ok)}.step.active{background:var(--warn-bg);color:var(--warn)}.step.pending{opacity:.5}
.step-dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;background:var(--glass-strong)}
.step.done .step-dot{background:var(--ok);color:#fff}.step.active .step-dot{background:var(--warn);color:#fff}
.log{background:var(--accent-dark);border-radius:12px;padding:12px 14px;max-height:220px;overflow-y:auto;font-family:Consolas,'Courier New',monospace;font-size:13px;line-height:1.85;color:#9fb6c4}
.log .info{color:#7fc4f0}.log .success{color:#5fe0a0}.log .warn{color:#f5c563}.log .error{color:#f68c82}
/* report table */
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.toolbar input{padding:8px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.tbl-wrap{border:1px solid var(--border);border-radius:14px;overflow:auto;max-height:60vh;background:var(--glass)}
table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap}
th{background:var(--glass-strong);text-align:left;padding:11px 14px;position:sticky;top:0;font-weight:700}
td{padding:10px 14px;border-bottom:1px solid var(--border)}
.empty{text-align:center;padding:40px;color:var(--muted);font-size:13px}
@media (max-width:820px){.sidebar{width:66px;min-width:66px}.logo-title,.logo-sub,.nav-item span,.u-name,.u-role{display:none}.kpi-row{grid-template-columns:1fr 1fr}.dash-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="logo-area">
<div class="logo-box"><img src="" alt="" onerror="this.remove()"></div>
<div><div class="logo-title">Invoice Intake</div><div class="logo-sub">Scanned invoice extraction</div></div>
</div>
<nav class="nav">
<button class="nav-item active" data-tab="dashboard" title="Dashboard"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span>Dashboard</span></button>
<button class="nav-item" data-tab="run" title="Run"><svg viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Run</span></button>
<button class="nav-item" data-tab="report" title="Report"><svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg><span>Report</span></button>
</nav>
<div class="sidebar-foot"><div class="avatar" id="avatar">U</div><div><div class="u-name" id="u-name">User</div><div class="u-role" id="u-role">Operator</div></div></div>
</aside>
<main class="main">
<header class="topbar">
<div><div class="tb-title" id="tb-title">Dashboard</div><div class="tb-sub" id="tb-sub">Overview of every batch</div></div>
<div class="tb-right">
<button class="btn primary" id="run-btn" onclick="startRun()">▶ Run</button>
<button class="mode-btn" id="mode-btn" title="Dark mode" aria-label="Dark mode" aria-pressed="false" onclick="toggleDark()">🌙</button>
<div class="indicator"><span class="dot ready" id="st-dot"></span><span id="st-text">System ready</span></div>
</div>
</header>
<section class="content">
<!-- DASHBOARD -->
<div id="tab-dashboard">
<div class="kpi-row">
<div class="kpi total" role="group" aria-label="Total batches"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-total">0</div><div class="kpi-lbl">Total batches</div></div></div>
<div class="kpi done" role="group" aria-label="Completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-done">0</div><div class="kpi-lbl">Completed</div></div></div>
<div class="kpi prog" id="kpi-prog" role="group" aria-label="In progress"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-prog">0</div><div class="kpi-lbl">In progress</div></div></div>
<div class="kpi notdone" role="group" aria-label="Not completed"><div class="kpi-ic"></div><div><div class="kpi-val" id="k-fail">0</div><div class="kpi-lbl">Not completed</div></div></div>
</div>
<div class="dash-grid">
<div class="card"><div class="c-title">Batch status split</div>
<div class="donut-wrap"><div class="donut" id="donut" role="img" aria-label="Batch status: no batches yet"><div class="donut-hole"><div class="dh-num" id="dh-num">0</div><div class="dh-lbl">batches</div></div></div><ul id="legend" aria-label="Batch status"></ul></div>
</div>
<div class="card"><div class="c-title">Recent batches</div><div id="recent" class="empty">No batches yet</div></div>
</div>
</div>
<!-- RUN -->
<div id="tab-run" class="hidden">
<div class="card"><div class="c-title">Run automation</div>
<div class="prog-head"><span id="p-label">Waiting to start</span><span id="p-time"></span><span class="pct" id="p-pct">0%</span></div>
<div class="prog-bg"><div class="prog-fill" id="p-fill"></div></div>
<ol id="steps" aria-label="Steps"></ol>
<div class="log" id="log" role="log" aria-label="Run log"></div>
</div>
</div>
<!-- REPORT -->
<div id="tab-report" class="hidden">
<div class="card">
<div class="toolbar"><input id="r-search" type="search" aria-label="Search report" placeholder="Search…" oninput="renderReport()"><span style="margin-left:auto"></span><button class="btn primary" onclick="api('download_report')">⬇ Download Excel</button></div>
<div class="tbl-wrap"><table><thead id="r-head"></thead><tbody id="r-body"></tbody></table></div>
</div>
</div>
</section>
</main>
</div>
<script>
// ===== CONFIG (fill per project) =====
const STEPS = ["Initializing","Extract","Review","Completed"]; // bot.py's [step N] stages
const REPORT_COLUMNS = [{key:"document",label:"Invoice"},{key:"fields",label:"Fields extracted"},{key:"status",label:"Status"}];
const API_BASE = ""; // desktop: the Qt bridge, no web server
const SAMPLE_DATA = false; // wired to bot.py through bot_runner.py
const TABS = {dashboard:["Dashboard","Overview of every batch"],run:["Run","Live progress and log"],report:["Report","Result of every processed item"]};
// ===== TRANSPORT: sample data, else Qt bridge, else HTTP polling =====
let bridge = null, reportRows = [];
function api(slot, ...args){
if(SAMPLE_DATA && SAMPLE[slot]) return SAMPLE[slot](...args);
if(bridge) return bridge[slot](...args);
return fetch(`${API_BASE}/${slot.replace(/_/g,'-')}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(args)}).catch(()=>{});
}
window.__pyMsg = (json)=>{ const m=JSON.parse(json); handle(m.event,m.data); };
function startTransport(){
if(window.qt && window.qt.webChannelTransport){
new QWebChannel(qt.webChannelTransport, ch=>{ bridge=ch.objects.pyBridge; api('load_dashboard'); });
}else if(SAMPLE_DATA){
api('load_dashboard');
}else{
const poll = async()=>{
try{
const d = await (await fetch(`${API_BASE}/dashboard`)).json(); handle('dashboard_data',d);
const s = await (await fetch(`${API_BASE}/run/status`)).json();
(s.events||[]).forEach(e=>handle(e.event,e.data));
setTimeout(poll, s.running ? 2000 : 10000); // poll fast only while busy
}catch(e){ setTimeout(poll, 10000); }
};
poll();
}
}
// ===== EVENT DISPATCH (see contract.md) =====
function handle(event, d){
switch(event){
case 'status': setStatus(d.state, d.msg); break;
case 'step': setStep(d.label, d.pct); break;
case 'log': addLog(d.msg, d.type); break;
case 'dashboard_data': renderDashboard(d.batches||[]); break;
case 'report_data': reportRows = d.rows||[]; renderReport(); break;
case 'set_user': setUser(d); break;
case 'apply_dark': setDark(!!d.on); break;
}
}
// ===== UI =====
document.querySelectorAll('.nav-item').forEach(n=>n.onclick=()=>showTab(n.dataset.tab));
function showTab(t){
document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab===t));
Object.keys(TABS).forEach(k=>document.getElementById('tab-'+k).classList.toggle('hidden',k!==t));
tbTitle.textContent=TABS[t][0]; tbSub.textContent=TABS[t][1];
if(t==='dashboard') api('load_dashboard');
if(t==='report') api('load_latest_report');
}
const $=id=>document.getElementById(id), tbTitle=$('tb-title'), tbSub=$('tb-sub');
function esc(s){return String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function setDark(on){ document.body.classList.toggle('dark',on); $('mode-btn').setAttribute('aria-pressed',on); $('mode-btn').textContent=on?'☀':'🌙'; }
function toggleDark(){ const on=!document.body.classList.contains('dark'); setDark(on); api('set_dark',on); }
function setUser(u){ document.body.dataset.role=u.role||''; $('u-name').textContent=u.username; $('u-role').textContent=u.role; $('avatar').textContent=(u.username||'U')[0].toUpperCase(); }
function setStatus(state,msg){ $('st-dot').className='dot '+state; $('st-text').textContent=msg||state; $('run-btn').disabled=state==='running'; }
function startRun(){ showTab('run'); $('log').innerHTML=''; setStep(STEPS[0],0); api('start_automation'); }
function renderSteps(active=-1){
$('steps').innerHTML=STEPS.map((s,i)=>{const c=i<active?'done':i===active?'active':'pending';
return `<li class="step ${c}"${c==='active'?' aria-current="step"':''}><span class="step-dot">${c==='done'?'✓':i+1}</span>${esc(s)}</li>`}).join('');
}
function setStep(label,pct){ $('p-label').textContent=label; $('p-pct').textContent=pct+'%'; $('p-fill').style.width=pct+'%';
renderSteps(pct>=100?STEPS.length:STEPS.indexOf(label)); }
function addLog(msg,type='info'){ const l=$('log'); l.insertAdjacentHTML('beforeend',`<div class="${type}">${esc(msg)}</div>`); l.scrollTop=l.scrollHeight; }
function countUp(el,to){ const from=+el.textContent||0,t0=performance.now();
const f=t=>{const p=Math.min((t-t0)/500,1); el.textContent=Math.round(from+(to-from)*p); if(p<1)requestAnimationFrame(f)}; requestAnimationFrame(f); }
// batch state -> [label, colour token, pill class]
const STATES={completed:['Completed','var(--ok)','success'],inprogress:['In progress','var(--warn)','attention'],
notcompleted:['Not completed','var(--bad)','failed'],cleared:['Cleared','var(--muted)','cleared']};
function renderDashboard(b){
const c={}; Object.keys(STATES).forEach(k=>c[k]=b.filter(x=>x.state===k).length);
countUp($('k-total'),b.length); countUp($('k-done'),c.completed); countUp($('k-prog'),c.inprogress); countUp($('k-fail'),c.notcompleted);
$('kpi-prog').classList.toggle('live',c.inprogress>0); $('dh-num').textContent=b.length;
const tot=Object.values(c).reduce((a,x)=>a+x,0); let acc=0; const stops=[];
Object.keys(c).forEach(k=>{ if(!c[k])return; const a=acc/tot*100; acc+=c[k]; stops.push(`${STATES[k][1]} ${a}% ${acc/tot*100}%`); });
$('donut').style.background = tot?`conic-gradient(${stops.join(',')})`:'conic-gradient(var(--border) 0 100%)';
$('donut').setAttribute('aria-label','Batch status: '+Object.keys(c).map(k=>`${STATES[k][0]} ${c[k]}`).join(', '));
$('legend').innerHTML=Object.keys(c).map(k=>`<li class="dl-row"><span class="dl-dot" style="background:${STATES[k][1]}"></span>${STATES[k][0]} · ${c[k]}</li>`).join('');
$('recent').className = b.length?'':'empty';
$('recent').innerHTML = b.length? b.slice(0,6).map(x=>{const s=STATES[x.state]||[esc(x.state),'','attention'];
return `<div class="dl-row">#${esc(x.batch_id)} · ${esc(x.date)} <span class="pill ${s[2]}" style="margin-left:auto">${s[0]}</span></div>`}).join('') : 'No batches yet';
}
function renderReport(){
const q=$('r-search').value.toLowerCase();
$('r-head').innerHTML='<tr>'+REPORT_COLUMNS.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
const rows=reportRows.filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q));
$('r-body').innerHTML = rows.length? rows.map(r=>'<tr>'+REPORT_COLUMNS.map(c=>c.key==='status'
?`<td><span class="pill ${esc(r.bucket||'attention')}">${esc(r.status)}</span></td>`:`<td>${esc(r[c.key])}</td>`).join('')+'</tr>').join('')
: `<tr><td class="empty" colspan="${REPORT_COLUMNS.length}">No rows yet</td></tr>`;
}
renderSteps();
startTransport();
</script>
<!-- ===== AI SECTIONS (desktop) =====
Paste this whole block into the no-AI dashboard.html, just before </body>. It adds a Review
tab: band legend, band filters, the source document beside its extracted fields, drafts,
Verify, and Approve. Plain HTML, the page's own theme tokens, no build step.
In PyQt, every request goes over the Qt bridge as one slot call, ai_request(id, method, path,
body), and comes back as an `ai_response` envelope (see ../contract.md, "Desktop"). With no
Qt (a plain-HTML web project), it fetches {API_BASE}{path} from the backend instead, the
web endpoints in ../contract.md. The approve gate is the backend's: this page only asks,
and shows the refusal.
-->
<style>
/* BAND COLOURS: semantic only (swatches, the rule down a field). The same in every preset and both modes. */
body{--clean:#51a68f;--verify:#e39b34;--review:#de5243;--absent:#6b6580}
.rv-bar{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px;padding:14px 18px}
.rv-bar label{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:600}
.rv-bar select{padding:7px 10px;border:1px solid var(--border);border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.rv-muted{font-size:12px;color:var(--muted)}
.rv-right{margin-left:auto;display:flex;align-items:center;gap:8px}
.rv-alert{width:100%;border-radius:10px;padding:7px 12px;background:var(--bad-bg);color:var(--bad);font-size:12px;font-weight:700}
.rv-legend{display:flex;flex-wrap:wrap;gap:4px 16px}
.rv-legend li{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600}
.rv-sw{width:10px;height:10px;border-radius:3px;display:inline-block}
.rv-filters{margin-left:auto;display:flex;gap:4px}
.rv-filters .btn,.rv-bar .btn,.rv-fields .btn,.rv-save .btn{padding:6px 11px;font-size:12px}
.rv-split{display:flex;gap:14px;min-height:460px}
.rv-doc{flex:1;min-height:420px;border:1px solid var(--border);border-radius:16px;background:#fff}
.rv-pane{flex:1;display:flex;flex-direction:column;padding:0;overflow:hidden;margin-bottom:0}
.rv-fields{flex:1;overflow-y:auto;padding:16px}
.rv-fields li{display:grid;grid-template-columns:minmax(80px,130px) 1fr auto auto;align-items:center;gap:8px;margin-bottom:9px}
.rv-fields li.done{opacity:.7}
.rv-name{font-size:12px;font-weight:600;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rv-fields input{min-width:0;padding:7px 10px;border:1px solid var(--border);border-left-width:4px;border-radius:10px;background:var(--glass-strong);color:var(--ink);font:inherit;font-size:12.5px}
.rv-fields input.dirty{outline:2px solid var(--warn)}
.rv-score{width:36px;text-align:right;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
.rv-note{grid-column:1/-1;margin-top:-4px;font-size:11px;color:var(--muted)}
.rv-fields .btn{width:92px}
.rv-save{display:flex;align-items:center;gap:10px;border-top:1px solid var(--border);padding:10px 16px}
.rv-save .rv-unsaved{font-size:12px;font-weight:700;color:var(--warn)}
.co-tile{padding:16px 18px;border-radius:16px;background:var(--glass-strong);border:1px solid var(--border)}
.co-tile .kpi-val{margin-top:4px}
.co-r{text-align:right}
@media (max-width:820px){.rv-split{flex-direction:column}.rv-filters{margin-left:0}}
</style>
<template id="ai-review-tab">
<div id="tab-review" class="hidden">
<div class="card rv-bar" id="rv-head">
<label>Document <select id="rv-doc-pick" aria-label="Document"></select></label>
<span id="rv-state"></span>
<div class="rv-right" id="rv-actions"></div>
<p class="rv-alert hidden" id="rv-alert" role="alert"></p>
</div>
<div class="card rv-bar">
<ul class="rv-legend" id="rv-legend" aria-label="Confidence bands"></ul>
<span class="rv-muted">Colour reflects print clarity, not correctness. A cleanly printed value can still be misread.</span>
<div class="rv-filters" id="rv-filters" role="group" aria-label="Filter by band"></div>
</div>
<div class="rv-split">
<iframe class="rv-doc" id="rv-doc" title="Source document"></iframe>
<div class="card rv-pane">
<p class="rv-muted hidden" id="rv-wait" style="padding:18px">Fields appear when extraction finishes.</p>
<ul class="rv-fields" id="rv-fields" aria-label="Extracted fields"></ul>
<div class="rv-save hidden" id="rv-save"></div>
</div>
</div>
</div>
</template>
<template id="ai-cost-tab">
<div id="tab-cost" class="hidden">
<div class="toolbar"><span class="pill" id="co-cover"></span><span class="rv-muted" id="co-source"></span></div>
<div class="kpi-row" id="co-tiles"></div>
<div class="card"><div class="c-title">Month by month</div>
<div class="tbl-wrap"><table aria-label="Spend by month"><thead><tr><th>Month</th><th>Documents priced</th><th class="co-r">Spend</th></tr></thead><tbody id="co-months"></tbody></table></div>
</div>
<section class="card" aria-labelledby="co-by-hand">
<div class="c-title" id="co-by-hand">Against doing it by hand</div>
<p class="rv-muted" style="margin-bottom:12px">These are your assumptions, not a measurement. Change them to match the team.</p>
<div class="toolbar">
<label class="rv-muted">Minutes per document <input id="co-min" type="number" min="0" value="5" style="width:80px"></label>
<label class="rv-muted">Hourly rate <input id="co-rate" type="number" min="0" value="20" style="width:80px"></label>
</div>
<p id="co-hand" style="font-size:13px"></p>
</section>
<div class="card"><div class="c-title">Cost per run</div>
<div class="tbl-wrap"><table aria-label="Cost per run"><thead><tr><th>Document</th><th>Date</th><th>Model</th><th class="co-r">Input</th><th class="co-r">Output</th><th class="co-r">Thinking</th><th class="co-r">Cost</th></tr></thead><tbody id="co-runs"></tbody></table></div>
</div>
</div>
</template>
<script>
(()=>{
// Legend order, label and colour token. Cut points come from bands.py on the Python side.
const BANDS=[['green','Clean ≥0.95','var(--clean)'],['amber','Check 0.90–0.95','var(--verify)'],
['red','Review <0.90','var(--review)'],['blank','Missing (required)','var(--absent)'],['unscored','Unscored','var(--absent)']];
const FILTERS=[['All','all'],['Red','red'],['Amber','amber'],['Missing','blank'],['Unscored','unscored']];
const BUSY=['queued','running']; // polled every 1.5s while in one of these
const colourOf=b=>BANDS.find(x=>x[0]===b)[2];
const settled=r=>r.corrected_value??r.value??'';
// ----- the tabs: nav buttons after Report, sections in the content area -----
TABS.review=['Review','Check extracted fields, then approve'];
TABS.cost=['Cost','What the AI cost, run by run'];
document.querySelector('.nav-item[data-tab="report"]').insertAdjacentHTML('afterend',
'<button class="nav-item" data-tab="review" title="Review"><svg viewBox="0 0 24 24"><path d="M9 11l3 3 8-8"/><path d="M20 12v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h11"/></svg><span>Review</span></button>'
+'<button class="nav-item" data-tab="cost" title="Cost"><svg viewBox="0 0 24 24"><path d="M12 2v20M17 6H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg><span>Cost</span></button>');
['review','cost'].forEach(t=>{ document.querySelector(`.nav-item[data-tab="${t}"]`).onclick=()=>showTab(t);
document.querySelector('.content').append($(`ai-${t}-tab`).content.cloneNode(true)); });
const baseShow=showTab;
showTab=t=>{
const onReview=!$('tab-review').classList.contains('hidden');
if(onReview && t!=='review' && !leaveOk()) return;
baseShow(t);
if(t==='review') loadRuns();
if(t==='cost') loadRoi();
};
window.addEventListener('beforeunload',e=>{ if(dirty().length) e.preventDefault(); });
// ----- transport: in PyQt, one bridge slot out, one `ai_response` envelope back;
// with no Qt (a plain web page), fetch {API_BASE}{path} from the backend -----
const pending={}; let seq=0;
async function aiFetch(method,path,body){
const r=await fetch(API_BASE+path,{method,headers:{'Content-Type':'application/json'},
body:method==='GET'?undefined:JSON.stringify(body||{})});
const b=await r.json().catch(()=>({}));
if(!r.ok) throw new Error(b.detail||`Error ${r.status}`);
return b;
}
function ai(method,path,body){
if(!window.qt) return aiFetch(method,path,body);
if(!bridge) return Promise.reject(new Error('The Qt bridge is not ready yet. Try again.'));
return new Promise((ok,fail)=>{ const id=++seq; pending[id]=[ok,fail];
bridge.ai_request(id,method,path,JSON.stringify(body||{})); });
}
const baseHandle=handle;
handle=(event,d)=>{
if(event!=='ai_response') return baseHandle(event,d);
const p=pending[d.id]; delete pending[d.id];
if(p) d.status<300 ? p[0](d.body) : p[1](new Error((d.body&&d.body.detail)||`Error ${d.status}`));
};
// ----- state -----
let runs=[], jobId=null, run=null, rows=[], drafts={}, filter='all', refusal=null, timer=null;
const dirty=()=>rows.filter(r=>r.row_id in drafts && drafts[r.row_id]!==settled(r));
const leaveOk=()=>!dirty().length || confirm(`${dirty().length} unsaved change(s) on this document. Discard them and leave?`);
const fail=e=>{ refusal=e.message; renderHead(); };
async function loadRuns(){
try{ runs=(await ai('GET','/runs')).runs; }catch(e){ return fail(e); }
$('rv-doc-pick').innerHTML=runs.map(r=>`<option value="${esc(r.job_id)}">${esc(r.document)}</option>`).join('');
if(!runs.length){ refusal=null; $('rv-state').innerHTML='<span class="rv-muted">No documents extracted yet.</span>'; return; }
openRun(runs.some(r=>r.job_id===jobId) ? jobId : runs[0].job_id);
}
$('rv-doc-pick').onchange=e=>{ if(leaveOk()) openRun(e.target.value); else e.target.value=jobId; };
function openRun(id){
clearTimeout(timer);
jobId=id; run=null; rows=[]; drafts={}; refusal=null;
$('rv-doc-pick').value=id;
if(!window.qt) $('rv-doc').src=`${API_BASE}/runs/${encodeURIComponent(id)}/document`; // web: the URL itself
else ai('GET',`/runs/${id}/document`).then(doc=>{ if(id===jobId) $('rv-doc').src=
String(doc).startsWith('<') ? 'data:image/svg+xml;charset=utf-8,'+encodeURIComponent(doc) : doc; }).catch(()=>{});
render(); tick(id);
}
async function tick(id){ // load the run, and poll it while it is still extracting
try{
const r=await ai('GET',`/runs/${id}`); if(id!==jobId) return;
run=r;
if(BUSY.includes(r.status)) timer=setTimeout(()=>tick(id),1500);
else{ const d=await ai('GET',`/runs/${id}/review`); if(id!==jobId) return; rows=d.rows; }
render();
}catch(e){ if(id===jobId) fail(e); }
}
async function reload(){
const id=jobId, [r,d]=await Promise.all([ai('GET',`/runs/${id}`),ai('GET',`/runs/${id}/review`)]);
if(id===jobId){ run=r; rows=d.rows; render(); }
}
// ----- actions -----
async function act(fn){ refusal=null; try{ await fn(); }catch(e){ refusal=e.message; } await reload().catch(fail); }
const save=()=>act(()=>Promise.all(dirty().map(r=>{ const text=drafts[r.row_id].trim();
// Same as extracted (or emptied) means "the extracted value was right", not an edit.
return ai('PATCH',`/review/${r.row_id}`,{corrected_value: text===''||text===(r.value??'') ? null : text, verified:true});
})).then(()=>{ drafts={}; }));
const verify=r=>act(()=>ai('PATCH',`/review/${r.row_id}`,{verified:!r.verified}));
const verifyRemaining=()=>act(()=>ai('POST',`/runs/${jobId}/verify-remaining`));
const approve=()=>act(()=>ai('POST',`/runs/${jobId}/approve`).catch(e=>{ throw new Error('Not approved: '+e.message); }));
// ----- render -----
function render(){ renderHead(); renderLegend(); renderFields(); renderSave(); }
function renderHead(){
const busy=run && BUSY.includes(run.status), pill=!run?'':run.status==='done'?'success':run.status==='failed'?'failed':'attention';
$('rv-state').innerHTML = run ? `<span class="pill ${pill}">${esc(run.status)}</span>`
+ (busy ? ` <span class="rv-muted">page ${esc(run.pages_done)} of ${esc(run.pages_total||'?')}</span>` : '')
+ (run.error ? ` <span class="rv-muted" style="color:var(--bad)">${esc(run.error)}</span>` : '') : '';
const left=rows.filter(r=>r.band!=='green' && !r.verified).length, n=dirty().length, acts=$('rv-actions');
acts.innerHTML = run && run.status==='done' ? `<span class="rv-muted">${left} to verify</span>
<button class="btn" id="rv-vr"${!left||run.approved?' disabled':''}>Verify remaining</button>`
+ (run.approved ? '<span class="pill success">✓ Approved</span>'
: `<button class="btn primary" id="rv-ap"${n?' disabled title="Save or discard your edits first"':''}>Approve</button>`) : '';
if($('rv-vr')) $('rv-vr').onclick=verifyRemaining;
if($('rv-ap')) $('rv-ap').onclick=approve;
$('rv-alert').textContent=refusal||''; $('rv-alert').classList.toggle('hidden',!refusal);
}
function renderLegend(){
const s=(run&&run.summary)||{};
$('rv-legend').innerHTML=BANDS.map(([b,label,c])=>`<li><i class="rv-sw" style="background:${c}"></i>${label} · ${s[b]||0}</li>`).join('');
$('rv-filters').innerHTML=FILTERS.map(([label,b])=>`<button class="btn${filter===b?' primary':''}" aria-pressed="${filter===b}" data-band="${b}">${label}</button>`).join('');
$('rv-filters').querySelectorAll('button').forEach(btn=>btn.onclick=()=>{ filter=btn.dataset.band; renderLegend(); renderFields(); });
}
function renderFields(){
$('rv-wait').classList.toggle('hidden',!run||run.status==='done');
const shown=filter==='all'?rows:rows.filter(r=>r.band===filter);
$('rv-fields').innerHTML=shown.map(r=>{ const draft=drafts[r.row_id]??settled(r), isDirty=draft!==settled(r);
const note=[r.reason, r.corrected_value!=null ? `extracted <s>${esc(r.value||'empty')}</s>` : ''].filter(Boolean);
return `<li data-row="${r.row_id}" class="${r.verified&&!isDirty?'done':''}">
<span class="rv-name" title="${esc(r.field)}">${esc(r.field)}</span>
<input aria-label="${esc(r.field)}" value="${esc(draft)}" placeholder="empty" title="${esc(r.reason||'')}" class="${isDirty?'dirty':''}" style="border-left-color:${colourOf(r.band)}">
<span class="rv-score" title="Legibility score: how clearly it was read, not whether it is right">${r.score==null?'—':Number(r.score).toFixed(2)}</span>
<button class="btn" aria-label="Verify ${esc(r.field)}" aria-pressed="${!!r.verified}"${isDirty?' disabled':''}>${r.verified?'✓ Verified':'Verify'}</button>
${note.length?`<span class="rv-note">${note.map(x=>x===r.reason?esc(x):x).join(' · ')}</span>`:''}</li>`; }).join('');
$('rv-fields').querySelectorAll('li').forEach(li=>{ const r=rows.find(x=>x.row_id===+li.dataset.row), box=li.querySelector('input');
box.oninput=()=>{ drafts[r.row_id]=box.value; const d=box.value!==settled(r); // a draft: nothing is sent until Save
box.classList.toggle('dirty',d); li.querySelector('button').disabled=d; renderSave(); renderHead(); };
box.onkeydown=e=>{ if(e.key==='Escape'){ box.value=settled(r); box.oninput(); } };
li.querySelector('button').onclick=()=>verify(r); });
}
function renderSave(){
const n=dirty().length;
$('rv-save').classList.toggle('hidden',!n);
$('rv-save').innerHTML = n ? `<span class="rv-unsaved">${n} unsaved change${n===1?'':'s'}</span>
<button class="btn" id="rv-discard" style="margin-left:auto">Discard</button><button class="btn primary" id="rv-save-btn">Save</button>` : '';
if(n){ $('rv-discard').onclick=()=>{ drafts={}; render(); }; $('rv-save-btn').onclick=save; }
}
// ----- cost: measured (tokens and cost per run, priced in Python) kept apart from assumed (typed here) -----
// A run with no recorded token usage is "not priced", never $0, and the coverage pill says how many.
let roi=[];
const money=n=>`$${n.toFixed(n>0&&n<0.01?4:2)}`, num=n=>n==null?'—':n.toLocaleString('en-US');
const monthOf=iso=>iso.slice(0,7); // 'YYYY-MM', UTC
const monthLabel=ym=>new Date(`${ym}-01T00:00:00Z`).toLocaleString('en-US',{month:'short',year:'numeric',timeZone:'UTC'});
const sum=rs=>rs.reduce((n,r)=>n+(r.total_cost??0),0);
const tileHtml=(name,value,note)=>`<div class="co-tile" role="group" aria-label="${name}"><div class="kpi-lbl">${name}</div><div class="kpi-val">${value}</div><div class="kpi-lbl">${esc(note)}</div></div>`;
async function loadRoi(){
let d; try{ d=await ai('GET','/roi'); }catch(e){ $('co-source').textContent=`Can't load costs: ${e.message}`; return; }
roi=d.runs; const p=d.pricing||{};
$('co-source').innerHTML=`Rates from ${esc(p.source)}, as of ${esc(p.as_of)}`;
renderCost();
}
function renderCost(){
const priced=roi.filter(r=>r.total_cost!=null), thisMonth=new Date().toISOString().slice(0,7);
const tok=k=>priced.reduce((n,r)=>n+(r[k]??0),0);
$('co-cover').textContent=`${priced.length} of ${roi.length} documents priced`;
$('co-cover').className='pill '+(priced.length===roi.length?'success':'attention');
$('co-tiles').innerHTML=tileHtml('Total spend',money(sum(priced)),`${priced.length} priced runs`)
+tileHtml('This month',money(sum(roi.filter(r=>monthOf(r.created_at)===thisMonth))),monthLabel(thisMonth))
+tileHtml('Tokens',num(tok('input_tokens')+tok('output_tokens')+tok('thinking_tokens')),`in ${num(tok('input_tokens'))} · out ${num(tok('output_tokens'))} · thinking ${num(tok('thinking_tokens'))}`)
+tileHtml('Per document',priced.length?money(sum(priced)/priced.length):'—','average, priced runs');
$('co-months').innerHTML=[...new Set(roi.map(r=>monthOf(r.created_at)))].sort().reverse().map(m=>{
const rs=roi.filter(r=>monthOf(r.created_at)===m), n=rs.filter(r=>r.total_cost!=null).length;
return `<tr><td>${monthLabel(m)}${m===thisMonth?' <span class="rv-muted">this month</span>':''}</td><td>${n} of ${rs.length}</td><td class="co-r">${n?money(sum(rs)):'—'}</td></tr>`; }).join('');
$('co-runs').innerHTML=roi.map(r=>`<tr><td>${esc(r.document)}</td><td>${esc(r.created_at.slice(0,10))}</td><td>${esc(r.model)}</td>
<td class="co-r">${num(r.input_tokens)}</td><td class="co-r">${num(r.output_tokens)}</td><td class="co-r">${num(r.thinking_tokens)}</td>
<td class="co-r">${r.total_cost==null?'<span class="rv-muted">not priced</span>':money(r.total_cost)}</td></tr>`).join('');
renderByHand();
}
function renderByHand(){
const n=roi.filter(r=>r.total_cost!=null).length, min=+$('co-min').value||0, rate=+$('co-rate').value||0;
$('co-hand').innerHTML=`By hand, ${n} documents would take ${num(Math.round(n*min/6)/10)} h, about <b>${money(n*min/60*rate)}</b>. The AI spent <b>${money(sum(roi))}</b> on them.`;
}
$('co-min').oninput=$('co-rate').oninput=renderByHand;
})();
</script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
{
"fixture": "e-pyqt-ai",
"passed": true,
"checks": {
"frontend reported correctly": true,
"asks /colou?r/": true,
"asks /section/": true,
"never asks /\\bkeep\\b|restyle/": true,
"never asks /desktop|web/": true,
"exactly one page built": true,
"AI desktop path: sections, ai_bridge and review backend built": true,
"no placeholders left in ai_bridge.py": true,
"no placeholders left": true,
"page wired to the bot (sample data off)": true,
"bot_runner.py built": true,
"no placeholders left in the host": true,
"bot dry run ends done": true,
"real steps reach the page": true,
"bot error line reaches the log": true,
"page check passes": true
},
"page_check": [
"99 passed, 23 skipped in 61.26s (0:01:01)"
],
"files": [
"README.md",
"bot.py",
"dashboard\\ai_bridge.py",
"dashboard\\bands.py",
"dashboard\\bot_raw.log",
"dashboard\\bot_runner.py",
"dashboard\\bridge.py",
"dashboard\\dashboard.html",
"dashboard\\pricing.py",
"dashboard\\server_stub.py",
"extractor.py",
"inbox\\invoice-2201.pdf",
"inbox\\invoice-2202.pdf",
"requirements.txt"
],
"reply": "Dashboard ready \u2014 **desktop (PyQt6)** path, because `requirements.txt` already has PyQt6. Theme Tebra Sky, no optional sections. Start it with `python dashboard/ai_bridge.py`.\n\n| Check (headless Qt run of the real window) | Result |\n|---|---|\n| Bot dry run (`python dashboard/bot_runner.py`) | 4 steps in order, 10\u219230\u219290\u2192**100**, both `ERROR:` lines shown |\n| Review: band legend | 3 clean \u00b7 1 check \u00b7 1 review \u00b7 1 missing (6/6 fields) |\n| Approve gate | Refused: \"3 flagged field(s) still need verifying.\" \u2192 after Verify remaining \u2192 **\u2713 Approved** |\n| Draft edit | Shows \"1 unsaved change\" until Save; Discard clears it |\n| Running invoice | \"page 1 of 3\" |\n| Cost page | \"4 of 5 documents priced\", one run \"not priced\", by-hand figure goes $6.67 \u2192 $13.33 when minutes change |\n| Dark mode / 800px width / `{{` left / console errors | ok / ok / 0 / **0** |\n\n- **Files:** everything is in `dashboard/`: `dashboard.html`, `bridge.py`, `bot_runner.py`, `ai_bridge.py`, `server_stub.py`, `bands.py`, `pricing.py`.\n- **Wiring:** the Run tab is wired to the real `bot.py`. The Review and Cost tabs still show **sample data** from `server_stub.py`, using your 6 real fields and `gemini-2.5-flash`. To make them real, the project needs to save its extraction results and expose `dispatch()` over that store.\n- **UNVERIFIED:** the Gemini rate ($0.30 in / $2.50 out per 1M tokens) comes from the skill's bundled table (as of 2026-08-11). I wasn't allowed to fetch the pricing page, so please check it.\n- **Not tested:** I didn't click Run inside the window. The bot was only dry-run through `bot_runner.py`.\n\n**Also noticed (not fixed):**\n- `extractor.py` doesn't keep `usage_metadata`, so real runs will show up as \"not priced\".\n- With no API key set, both invoices fail, but the bot still exits 0, so the run shows \"Completed\".\n- The Dashboard KPIs and Report tab are empty, because `bridge.py`'s `load_dashboard` and `load_latest_report` are still TODO.\n\nFRONTEND: none (detect_frontend.py: \"No existing frontend found.\"; PyQt6 in requirements, so desktop path)\nQUESTIONS: Which colours: Tebra Sky, Superbill Ember, or Custom (one brand hex)? | Which optional sections to add: checkpoint tracker, admin/roles screen, login page, model picker, retry on another model (any, or none)?"
}
+13
View File
@@ -0,0 +1,13 @@
{"event": "status", "data": {"state": "running", "msg": "Running\u2026"}}
{"event": "step", "data": {"label": "Initializing", "pct": 10}}
{"event": "log", "data": {"msg": "Starting the invoice bot.", "type": "info"}}
{"event": "log", "data": {"msg": "Checked the inbox for invoices.", "type": "info"}}
{"event": "step", "data": {"label": "Extract", "pct": 30}}
{"event": "log", "data": {"msg": "Reading invoice fields with the AI model.", "type": "info"}}
{"event": "log", "data": {"msg": "ERROR: extraction failed for invoice-2201.pdf: ValueError", "type": "error"}}
{"event": "log", "data": {"msg": "ERROR: extraction failed for invoice-2202.pdf: ValueError", "type": "error"}}
{"event": "step", "data": {"label": "Review", "pct": 90}}
{"event": "log", "data": {"msg": "Queueing invoices for review.", "type": "info"}}
{"event": "step", "data": {"label": "Completed", "pct": 100}}
{"event": "log", "data": {"msg": "All invoices processed.", "type": "success"}}
{"event": "status", "data": {"state": "done", "msg": "Completed"}}
+3 -2
View File
@@ -225,12 +225,13 @@ def main(fixture):
if cfg.get("ai_desktop"):
env["PAGE_CHECK_AI_DESKTOP_DIR"] = str(pages[0].parent)
check_dirs.append(ROOT / "tests" / "page_check_ai_desktop")
pc = subprocess.run([sys.executable, "-m", "pytest", *map(str, check_dirs), "-q"],
pc = subprocess.run([sys.executable, "-m", "pytest", *map(str, check_dirs), "-q", "-rs"],
env=env, capture_output=True, text=True)
checks["page check passes"] = pc.returncode == 0
page_check = pc.stdout.strip().splitlines()[-1:] if pc.stdout else [pc.stderr[-500:]]
built_without = "page built without" in pc.stdout # a chosen section whose cases skipped
if cfg.get("sections"): # every chosen section's page check ran
checks["no section checks skipped"] = "skipped" not in page_check[0]
checks["no section checks skipped"] = not built_without
else:
page_check = ["no page"]