AI web starter: bands, review split pane, approve gate, server stub (checkpoint)

React + Vite + Tailwind starter with the no-AI shell, band legend, review
split pane with drafts and verify, and a server stub that refuses approval
while flagged fields are unverified. Band self-check and AI page check.
Both page checks share one Chromium via tests/conftest.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 15:06:44 +05:30
1 parent 3eb3fdf00e
commit d4d3f83fb1
24 files changed
+3429 -73

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
import { useState } from 'react'
import { act, clearRun, toggleDark, useStore } from './store'
import { Overview } from './components/Overview'
import { RunPanel } from './components/RunPanel'
import { Report } from './components/Report'
import { Review } from './components/Review'
const TABS = {
dashboard: ['Dashboard', 'Overview of every batch', <><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></>],
run: ['Run', 'Live progress and log', <polygon points="6 4 20 12 6 20 6 4" />],
review: ['Review', 'Check extracted fields against the document', <><path d="M4 4h10l6 6v10H4z" /><path d="m8 14 3 3 5-6" /></>],
report: ['Report', 'Result of every processed item', <><path d="M4 4h16v16H4z" /><path d="M8 9h8M8 13h8M8 17h5" /></>],
// OPTIONAL: admin/roles tab goes here
} as const
type Tab = keyof typeof TABS
const DOT = { ready: 'bg-ok', done: 'bg-ok', running: 'bg-warn animate-pulse', error: 'bg-bad' }
export function App() {
const { status, user, dark } = useStore()
const [tab, setTab] = useState<Tab>('dashboard')
const [unsaved, setUnsaved] = useState(0) // review edits not saved yet
const show = (t: Tab) => {
if (t === tab) return
if (unsaved && !window.confirm(`${unsaved} unsaved change(s) on this document. Discard them and leave?`)) return
setUnsaved(0)
setTab(t)
if (t === 'dashboard') act('load_dashboard')
if (t === 'report') act('load_latest_report')
}
const run = () => {
show('run')
clearRun()
act('start_automation')
}
return (
<div className="flex h-screen">
<aside className="flex w-[230px] min-w-[230px] flex-col border-r border-line bg-glass max-[820px]:w-[66px] max-[820px]:min-w-[66px]" style={{ backdropFilter: 'var(--blur)' }}>
<div className="flex items-center gap-3 border-b border-line px-5 py-5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-line bg-glass-strong">
{LOGO_SRC && <img src={LOGO_SRC} alt="" className="h-7 w-7 object-contain" onError={(e) => e.currentTarget.remove()} />}
</div>
<div className="max-[820px]:hidden">
<div className="font-head text-[15px] font-bold">{APP_NAME}</div>
<div className="mt-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted">{APP_SUBTITLE}</div>
</div>
</div>
<nav className="flex-1 px-3 py-4">
{(Object.keys(TABS) as Tab[]).map((t) => (
<button
key={t}
title={TABS[t][0]}
onClick={() => show(t)}
aria-current={tab === t ? 'page' : undefined}
className={`mb-1 flex w-full cursor-pointer items-center gap-3 rounded-[10px] px-3 py-2.5 text-left text-[13.5px] font-semibold ${tab === t ? 'text-white shadow-md' : 'text-muted hover:bg-glass-strong hover:text-ink'}`}
style={tab === t ? { background: 'var(--nav-active-bg)' } : undefined}
>
<svg viewBox="0 0 24 24" className="h-[18px] w-[18px] shrink-0 fill-none stroke-current stroke-2">{TABS[t][2]}</svg>
<span className="max-[820px]:hidden">{TABS[t][0]}</span>
</button>
))}
</nav>
<div className="flex items-center gap-2.5 border-t border-line p-3.5">
<div className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-full text-xs font-bold text-white" style={{ background: 'var(--nav-active-bg)' }}>
{(user.username || 'U')[0].toUpperCase()}
</div>
<div className="max-[820px]:hidden">
<div className="text-[13px] font-bold">{user.username}</div>
<div className="text-[10.5px] text-muted">{user.role}</div>
</div>
</div>
</aside>
<main className="flex flex-1 flex-col overflow-hidden">
<header className="flex items-center gap-3.5 border-b border-line px-7 py-5 shadow-[0_6px_18px_rgba(20,50,70,.10)]"
style={{ background: 'var(--header-bg)', color: 'var(--header-ink)', backdropFilter: 'var(--blur)' }}>
<div>
<div className="font-head text-[21px] font-extrabold">{TABS[tab][0]}</div>
<div className="mt-0.5 text-[12.5px]" style={{ color: 'var(--header-muted)' }}>{TABS[tab][1]}</div>
</div>
<div className="ml-auto flex items-center gap-3">
<button className="btn btn-primary" onClick={run} disabled={status.state === 'running'}>▶ Run</button>
<button className="h-[38px] w-[38px] cursor-pointer rounded-[11px] border border-line bg-glass-strong text-base text-ink"
title="Dark mode" aria-label="Dark mode" aria-pressed={dark} onClick={toggleDark}>
{dark ? '☀' : '🌙'}
</button>
<div className="flex items-center gap-2 rounded-full border border-line bg-glass-strong px-3 py-1.5 text-xs font-bold text-ink">
<span className={`h-2 w-2 rounded-full ${DOT[status.state] ?? 'bg-muted'}`} />
<span>{status.msg || status.state}</span>
</div>
</div>
</header>
<section className="flex min-h-0 flex-1 flex-col overflow-y-auto px-7 py-6 max-[820px]:px-4">
{tab === 'dashboard' && <Overview />}
{tab === 'run' && <RunPanel />}
{tab === 'review' && <Review onUnsaved={setUnsaved} />}
{tab === 'report' && <Report />}
</section>
</main>
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
/** The AI contract additions (see contract.md): runs, review rows, the approve gate. */
export type Band = 'green' | 'amber' | 'red' | 'blank' | 'unscored'
export type RunStatus = 'pending' | 'queued' | 'running' | 'done' | 'failed'
export type Run = {
job_id: string
document: string
status: RunStatus
model: string
pages_done: number
pages_total: number
summary: Record<Band, number> | null // null until the run is done
approved: boolean
error: string | null
}
export type ReviewRow = {
row_id: number
field: string
value: string | null
band: Band
score: number | null
reason: string | null
verified: boolean
corrected_value: string | null
}
export const BUSY: RunStatus[] = ['queued', 'running'] // polled every 1.5s while in one of these
/** Legend order, label, and the colour token. Band colours are semantic: never on buttons or headings. */
export const BANDS: { band: Band; label: string; colour: string }[] = [
{ band: 'green', label: 'Clean ≥0.95', colour: 'var(--clean)' },
{ band: 'amber', label: 'Check 0.90–0.95', colour: 'var(--verify)' },
{ band: 'red', label: 'Review <0.90', colour: 'var(--review)' },
{ band: 'blank', label: 'Missing (required)', colour: 'var(--absent)' },
{ band: 'unscored', label: 'Unscored', colour: 'var(--absent)' },
]
export const CAVEAT = 'Colour reflects print clarity, not correctness. A cleanly printed value can still be misread.'
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(API_BASE + path, { ...init, headers: { 'Content-Type': 'application/json' } })
if (!res.ok) {
// The server says why it refused (the approve gate answers 409 with the count).
const body = await res.json().catch(() => null)
throw new Error(body?.detail ?? `${res.status} ${res.statusText}`)
}
return res.json()
}
export const ai = {
runs: () => req<{ runs: Run[] }>('/runs'),
run: (id: string) => req<Run>(`/runs/${id}`),
rows: (id: string) => req<{ rows: ReviewRow[] }>(`/runs/${id}/review`),
patchRow: (rowId: number, body: { corrected_value?: string | null; verified: boolean }) =>
req<ReviewRow>(`/review/${rowId}`, { method: 'PATCH', body: JSON.stringify(body) }),
verifyRemaining: (id: string) => req<{ verified: number }>(`/runs/${id}/verify-remaining`, { method: 'POST' }),
approve: (id: string) => req<{ ok: boolean }>(`/runs/${id}/approve`, { method: 'POST' }),
documentUrl: (id: string) => `${API_BASE}/runs/${id}/document`,
}
@@ -0,0 +1,81 @@
import { useStore } from '../store'
// batch state -> [label, colour token, pill colour]
export const STATES: Record<string, [string, string, string]> = {
completed: ['Completed', 'var(--ok)', 'bg-ok'],
inprogress: ['In progress', 'var(--warn)', 'bg-warn'],
notcompleted: ['Not completed', 'var(--bad)', 'bg-bad'],
cleared: ['Cleared', 'var(--muted)', 'bg-muted'],
}
function Kpi({ label, n, colour, live }: { label: string; n: number; colour: string; live?: boolean }) {
return (
<div role="group" aria-label={label} className="flex items-center gap-3 rounded-2xl border border-line bg-glass-strong px-4 py-4 shadow-sm">
<div className={`h-[42px] w-[42px] shrink-0 rounded-xl ${live ? 'animate-pulse' : ''}`} style={{ background: colour }} />
<div>
<div className="font-head text-[26px] leading-tight font-extrabold tabular-nums">{n}</div>
<div className="mt-0.5 text-[11.5px] font-semibold text-muted">{label}</div>
</div>
</div>
)
}
export function Overview() {
const { batches } = useStore()
const count = Object.fromEntries(Object.keys(STATES).map((k) => [k, batches.filter((b) => b.state === k).length]))
const total = Object.values(count).reduce((a, x) => a + x, 0)
let acc = 0
const stops = Object.keys(STATES).flatMap((k) => {
if (!count[k]) return []
const from = (acc / total) * 100
acc += count[k]
return [`${STATES[k][1]} ${from}% ${(acc / total) * 100}%`]
})
return (
<div>
<div className="mb-4 grid grid-cols-4 gap-3.5 max-[820px]:grid-cols-2">
<Kpi label="Total batches" n={batches.length} colour="var(--accent)" />
<Kpi label="Completed" n={count.completed} colour="var(--ok)" />
<Kpi label="In progress" n={count.inprogress} colour="var(--warn)" live={count.inprogress > 0} />
<Kpi label="Not completed" n={count.notcompleted} colour="var(--bad)" />
</div>
<div className="grid grid-cols-2 gap-3.5 max-[820px]:grid-cols-1">
<div className="card">
<div className="card-title">Batch status split</div>
<div className="flex flex-wrap items-center gap-6">
<div role="img" className="relative h-[150px] w-[150px] rounded-full"
aria-label={'Batch status: ' + (total ? Object.keys(STATES).map((k) => `${STATES[k][0]} ${count[k]}`).join(', ') : 'no batches yet')}
style={{ background: total ? `conic-gradient(${stops.join(',')})` : 'conic-gradient(var(--border) 0 100%)' }}>
<div className="absolute inset-[22px] flex flex-col items-center justify-center rounded-full" style={{ background: 'var(--bg-bottom)' }}>
<div className="font-head text-[26px] font-extrabold">{batches.length}</div>
<div className="text-[10px] font-bold uppercase text-muted">batches</div>
</div>
</div>
<ul aria-label="Batch status">
{Object.keys(STATES).map((k) => (
<li key={k} className="mb-2 flex items-center gap-2 text-[12.5px] font-semibold">
<span className="h-2.5 w-2.5 rounded-[3px]" style={{ background: STATES[k][1] }} />
{STATES[k][0]} · {count[k]}
</li>
))}
</ul>
</div>
</div>
<div className="card">
<div className="card-title">Recent batches</div>
{batches.length === 0 && <div className="py-10 text-center text-[13px] text-muted">No batches yet</div>}
{batches.slice(0, 6).map((b) => {
const s = STATES[b.state] ?? [b.state, '', 'bg-warn']
return (
<div key={b.batch_id} className="mb-2 flex items-center gap-2 text-[12.5px] font-semibold">
#{b.batch_id} · {b.date}
<span className={`pill ml-auto ${s[2]}`}>{s[0]}</span>
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -0,0 +1,50 @@
import { useState } from 'react'
import { useStore, type ReportRow } from '../store'
const PILL: Record<string, string> = { success: 'bg-ok', attention: 'bg-warn', failed: 'bg-bad' }
// ponytail: a CSV that Excel opens. Have the backend send a real .xlsx if the sheet needs formatting.
function exportCsv(rows: ReportRow[]) {
const cell = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"`
const csv = [REPORT_COLUMNS.map((c) => cell(c.label)), ...rows.map((r) => REPORT_COLUMNS.map((c) => cell(r[c.key])))]
.map((line) => line.join(',')).join('\r\n')
const a = document.createElement('a')
a.href = URL.createObjectURL(new Blob(['' + csv], { type: 'text/csv' }))
a.download = 'report.csv'
a.click()
URL.revokeObjectURL(a.href)
}
export function Report() {
const { reportRows } = useStore()
const [q, setQ] = useState('')
const rows = reportRows.filter((r) => !q || Object.values(r).join(' ').toLowerCase().includes(q.toLowerCase()))
return (
<div className="card">
<div className="mb-3.5 flex flex-wrap items-center gap-2.5">
<input type="search" aria-label="Search report" placeholder="Search…" className="input" value={q} onChange={(e) => setQ(e.target.value)} />
<button className="btn btn-primary ml-auto" onClick={() => exportCsv(rows)}>⬇ Export to Excel</button>
</div>
<div className="max-h-[60vh] overflow-auto rounded-2xl border border-line bg-glass">
<table className="w-full border-collapse whitespace-nowrap text-xs">
<thead>
<tr>{REPORT_COLUMNS.map((c) => <th key={c.key} className="sticky top-0 bg-glass-strong px-3.5 py-3 text-left font-bold">{c.label}</th>)}</tr>
</thead>
<tbody>
{rows.length === 0 && <tr><td colSpan={REPORT_COLUMNS.length} className="p-10 text-center text-[13px] text-muted">No rows yet</td></tr>}
{rows.map((r, i) => (
<tr key={i}>
{REPORT_COLUMNS.map((c) => (
<td key={c.key} className="border-b border-line px-3.5 py-2.5">
{c.key === 'status' ? <span className={`pill ${PILL[r.bucket ?? ''] ?? PILL.attention}`}>{r.status}</span> : String(r[c.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,183 @@
import { useEffect, useState } from 'react'
import { ai, BANDS, BUSY, CAVEAT, type Band, type ReviewRow, type Run } from '../ai'
const FILTERS: { label: string; band: Band | 'all' }[] = [
{ label: 'All', band: 'all' }, { label: 'Red', band: 'red' }, { label: 'Amber', band: 'amber' },
{ label: 'Missing', band: 'blank' }, { label: 'Unscored', band: 'unscored' },
]
const colourOf = (b: Band) => BANDS.find((x) => x.band === b)!.colour
const settled = (r: ReviewRow) => r.corrected_value ?? r.value ?? ''
/**
* The review split pane: the source document beside its extracted fields.
* Typing makes a draft; nothing is written until Save. Verify signs off one field
* as it reads. Approve asks the server, which refuses while flagged fields are unverified.
*/
export function Review({ onUnsaved }: { onUnsaved: (n: number) => void }) {
const [runs, setRuns] = useState<Run[]>()
const [jobId, setJobId] = useState<string>()
const [run, setRun] = useState<Run>()
const [rows, setRows] = useState<ReviewRow[]>([])
const [drafts, setDrafts] = useState<Record<number, string>>({})
const [filter, setFilter] = useState<Band | 'all'>('all')
const [refusal, setRefusal] = useState<string>()
const [error, setError] = useState<string>()
const [saving, setSaving] = useState(false)
const dirty = rows.filter((r) => r.row_id in drafts && drafts[r.row_id] !== settled(r))
useEffect(() => onUnsaved(dirty.length), [dirty.length, onUnsaved])
useEffect(() => {
if (!dirty.length) return
const warn = (e: BeforeUnloadEvent) => e.preventDefault()
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [dirty.length])
useEffect(() => {
ai.runs().then((d) => { setRuns(d.runs); setJobId(d.runs[0]?.job_id) })
.catch((e) => setError(`Can't load runs from ${API_BASE}: ${e.message}. Is the backend (or server_stub.py) running?`))
}, [])
// Load the chosen run, and poll it every 1.5s while it is still extracting.
useEffect(() => {
if (!jobId) return
let alive = true
let timer: ReturnType<typeof setTimeout>
setRun(undefined); setRows([]); setDrafts({}); setRefusal(undefined)
const tick = async () => {
try {
const r = await ai.run(jobId)
if (!alive) return
setRun(r)
if (BUSY.includes(r.status)) timer = setTimeout(tick, 1500)
else { const d = await ai.rows(jobId); if (alive) setRows(d.rows) }
} catch (e) { if (alive) setError((e as Error).message) }
}
tick()
return () => { alive = false; clearTimeout(timer) }
}, [jobId])
const reload = async () => {
if (!jobId) return
const [r, d] = await Promise.all([ai.run(jobId), ai.rows(jobId)])
setRun(r); setRows(d.rows)
}
const pick = (id: string) => {
if (dirty.length && !window.confirm(`${dirty.length} unsaved change(s) on this document. Discard them and leave?`)) return
setJobId(id)
}
const save = async () => {
setSaving(true)
try {
await Promise.all(dirty.map((r) => {
const text = drafts[r.row_id].trim()
// Same as extracted (or emptied) means "the extracted value was right", not an edit.
return ai.patchRow(r.row_id, { corrected_value: text === '' || text === (r.value ?? '') ? null : text, verified: true })
}))
setDrafts({})
await reload()
} finally { setSaving(false) }
}
const verify = async (r: ReviewRow) => { await ai.patchRow(r.row_id, { verified: !r.verified }); await reload() }
const verifyRemaining = async () => { if (jobId) { await ai.verifyRemaining(jobId); await reload() } }
const approve = async () => {
if (!jobId) return
setRefusal(undefined)
try { await ai.approve(jobId); await reload() } catch (e) { setRefusal((e as Error).message) }
}
if (error) return <div className="card text-[13px] text-bad">{error}</div>
if (!runs) return <div className="card text-[13px] text-muted">Loading runs…</div>
if (!runs.length) return <div className="card text-[13px] text-muted">No documents extracted yet.</div>
const shown = filter === 'all' ? rows : rows.filter((r) => r.band === filter)
const left = rows.filter((r) => r.band !== 'green' && !r.verified).length
return (
<div className="flex min-h-[560px] flex-1 flex-col gap-3">
<div className="card mb-0 flex flex-wrap items-center gap-3 py-3">
<label className="flex items-center gap-2 text-[12.5px] font-semibold">
Document
<select aria-label="Document" className="input" value={jobId} onChange={(e) => pick(e.target.value)}>
{runs.map((r) => <option key={r.job_id} value={r.job_id}>{r.document}</option>)}
</select>
</label>
{run && <span className={`pill ${run.status === 'done' ? 'bg-ok' : run.status === 'failed' ? 'bg-bad' : 'bg-warn'}`}>{run.status}</span>}
{run && BUSY.includes(run.status) && <span className="text-[12.5px] text-muted tabular-nums">page {run.pages_done} of {run.pages_total || '?'}</span>}
{run?.error && <span className="text-[12px] text-bad">{run.error}</span>}
{run?.status === 'done' && (
<div className="ml-auto flex items-center gap-2">
<span className="text-[12px] text-muted">{left} to verify</span>
<button className="btn btn-sm" onClick={verifyRemaining} disabled={!left || run.approved}>Verify remaining</button>
{run.approved
? <span className="pill bg-ok">✓ Approved</span>
: <button className="btn btn-primary btn-sm" onClick={approve} disabled={dirty.length > 0}
title={dirty.length ? 'Save or discard your edits first' : undefined}>Approve</button>}
</div>
)}
{refusal && <p role="alert" className="w-full rounded-lg bg-bad-bg px-3 py-1.5 text-[12px] font-semibold text-bad">Not approved: {refusal}</p>}
</div>
<div className="card mb-0 flex flex-wrap items-center gap-x-5 gap-y-2 py-3">
<ul aria-label="Confidence bands" className="flex flex-wrap gap-x-4 gap-y-1">
{BANDS.map((b) => (
<li key={b.band} className="flex items-center gap-1.5 text-[12px] font-semibold">
<i className="h-2.5 w-2.5 rounded-[3px]" style={{ background: b.colour }} />
{b.label} · {run?.summary?.[b.band] ?? 0}
</li>
))}
</ul>
<span className="text-[11.5px] text-muted">{CAVEAT}</span>
<div role="group" aria-label="Filter by band" className="ml-auto flex gap-1">
{FILTERS.map((f) => (
<button key={f.band} aria-pressed={filter === f.band} onClick={() => setFilter(f.band)}
className={`btn btn-sm ${filter === f.band ? 'btn-primary' : ''}`}>{f.label}</button>
))}
</div>
</div>
<div className="flex min-h-0 flex-1 gap-3 max-[820px]:flex-col">
<iframe title="Source document" src={jobId && ai.documentUrl(jobId)}
className="min-h-[420px] flex-1 rounded-2xl border border-line bg-white" />
<div className="card mb-0 flex min-h-0 flex-1 flex-col overflow-hidden p-0">
{run && run.status !== 'done' && <p className="p-5 text-[13px] text-muted">Fields appear when extraction finishes.</p>}
<ul aria-label="Extracted fields" className="flex-1 overflow-y-auto p-4">
{shown.map((r) => {
const draft = drafts[r.row_id] ?? settled(r)
const isDirty = draft !== settled(r)
return (
<li key={r.row_id} className={`mb-2 grid grid-cols-[minmax(80px,130px)_1fr_auto_auto] items-center gap-2 ${r.verified && !isDirty ? 'opacity-70' : ''}`}>
<span className="truncate text-[12px] font-semibold text-muted" title={r.field}>{r.field}</span>
<input aria-label={r.field} value={draft} placeholder="empty" title={r.reason ?? undefined}
onChange={(e) => setDrafts((d) => ({ ...d, [r.row_id]: e.target.value }))}
onKeyDown={(e) => e.key === 'Escape' && setDrafts((d) => ({ ...d, [r.row_id]: settled(r) }))}
className={`input min-w-0 border-l-4 ${isDirty ? 'outline outline-warn' : ''}`} style={{ borderLeftColor: colourOf(r.band) }} />
<span className="w-9 text-right text-[11px] text-muted tabular-nums" title="Legibility score: how clearly it was read, not whether it is right">
{r.score?.toFixed(2) ?? '—'}
</span>
<button className="btn btn-sm w-[86px]" aria-label={`Verify ${r.field}`} aria-pressed={r.verified}
disabled={isDirty} title={isDirty ? 'Save or discard the edit first' : undefined} onClick={() => verify(r)}>
{r.verified ? '✓ Verified' : 'Verify'}
</button>
{(r.reason || r.corrected_value != null) && (
<span className="col-span-4 -mt-1 text-[11px] text-muted">
{r.reason}{r.reason && r.corrected_value != null && ' · '}
{r.corrected_value != null && <>extracted <s>{r.value || 'empty'}</s></>}
</span>
)}
</li>
)
})}
</ul>
{dirty.length > 0 && (
<div className="flex items-center gap-2.5 border-t border-line px-4 py-2.5">
<span className="text-[12px] font-semibold text-warn">{dirty.length} unsaved change{dirty.length === 1 ? '' : 's'}</span>
<button className="btn btn-sm ml-auto" onClick={() => setDrafts({})} disabled={saving}>Discard</button>
<button className="btn btn-primary btn-sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,43 @@
import { useEffect, useRef } from 'react'
import { useStore } from '../store'
const LOG_COLOUR = { info: 'text-[#7fc4f0]', success: 'text-[#5fe0a0]', warn: 'text-[#f5c563]', error: 'text-[#f68c82]' }
export function RunPanel() {
const { step, log } = useStore()
const active = !step ? -1 : step.pct >= 100 ? STEPS.length : STEPS.indexOf(step.label)
const logBox = useRef<HTMLDivElement>(null)
useEffect(() => { logBox.current?.scrollTo(0, logBox.current.scrollHeight) }, [log.length])
return (
<div className="card">
<div className="card-title">Run automation</div>
<div className="mb-2 flex gap-3 text-[13px] font-bold">
<span>{step?.label ?? 'Waiting to start'}</span>
<span className="ml-auto text-accent">{step?.pct ?? 0}%</span>
</div>
<div className="mb-4 h-2 overflow-hidden rounded-lg bg-glass-strong">
<div className="h-full transition-[width] duration-500" style={{ width: `${step?.pct ?? 0}%`, background: 'var(--progress-bg)' }} />
</div>
<ol aria-label="Steps">
{STEPS.map((s, i) => {
const c = i < active ? 'done' : i === active ? 'active' : 'pending'
return (
<li key={s} aria-current={c === 'active' ? 'step' : undefined}
className={`mb-1.5 flex items-center gap-3 rounded-[10px] px-3 py-2.5 text-[13px] font-semibold ${c === 'done' ? 'bg-ok-bg text-ok' : c === 'active' ? 'bg-warn-bg text-warn' : 'opacity-50'}`}>
<span className={`flex h-[22px] w-[22px] items-center justify-center rounded-full text-[11px] ${c === 'done' ? 'bg-ok text-white' : c === 'active' ? 'bg-warn text-white' : 'bg-glass-strong'}`}>
{c === 'done' ? '✓' : i + 1}
</span>
{s}
</li>
)
})}
</ol>
{/* OPTIONAL: checkpoint tracker goes here */}
<div ref={logBox} role="log" aria-label="Run log"
className="mt-3 max-h-[220px] overflow-y-auto rounded-xl bg-accent-dark px-3.5 py-3 font-mono text-[13px] leading-[1.85] text-[#9fb6c4]">
{log.map((l, i) => <div key={i} className={LOG_COLOUR[l.type] ?? LOG_COLOUR.info}>{l.msg}</div>)}
</div>
</div>
)
}
+12
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 />)
+139
View File
@@ -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)
},
}