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>
106 lines
5.5 KiB
TypeScript
106 lines
5.5 KiB
TypeScript
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>
|
|
)
|
|
}
|