|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { definePage } from '@objectstack/spec/ui'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Invoice Console — a `kind:'react'` business scenario (ADR-0081). |
| 7 | + * |
| 8 | + * Accounts-receivable management: a KPI strip aggregating invoices by status |
| 9 | + * (useAdapter), a status segmented-filter driving a real `<ListView>`, a real |
| 10 | + * `<ObjectForm>` for create + edit, and a "Mark Paid" quick action on the |
| 11 | + * selected invoice. Demonstrates aggregation KPIs, segmented filtering, full |
| 12 | + * CRUD, and a one-click status transition in one screen. |
| 13 | + */ |
| 14 | +export const InvoiceConsolePage = definePage({ |
| 15 | + name: 'showcase_invoice_console', |
| 16 | + label: 'Invoice Console (React)', |
| 17 | + type: 'home', |
| 18 | + kind: 'react', |
| 19 | + source: ` |
| 20 | +function Page() { |
| 21 | + const adapter = useAdapter(); |
| 22 | + const [status, setStatus] = React.useState('all'); |
| 23 | + const [sel, setSel] = React.useState(null); |
| 24 | + const [mode, setMode] = React.useState('edit'); |
| 25 | + const [reload, setReload] = React.useState(0); |
| 26 | + const [kpi, setKpi] = React.useState({ count: 0, draft: 0, sent: 0, paid: 0 }); |
| 27 | +
|
| 28 | + React.useEffect(() => { |
| 29 | + let alive = true; |
| 30 | + (async () => { |
| 31 | + if (!adapter) return; |
| 32 | + const res = await adapter.find('showcase_invoice', { top: 500 }); |
| 33 | + const rows = Array.isArray(res) ? res : (res && res.records) || []; |
| 34 | + if (alive) setKpi({ |
| 35 | + count: rows.length, |
| 36 | + draft: rows.filter((r) => r.status === 'draft').length, |
| 37 | + sent: rows.filter((r) => r.status === 'sent').length, |
| 38 | + paid: rows.filter((r) => r.status === 'paid').length, |
| 39 | + }); |
| 40 | + })(); |
| 41 | + return () => { alive = false; }; |
| 42 | + }, [adapter, reload]); |
| 43 | +
|
| 44 | + const afterWrite = () => { setSel(null); setMode('edit'); setReload((k) => k + 1); }; |
| 45 | + const markPaid = async () => { if (!adapter || !sel) return; await adapter.update('showcase_invoice', sel.id, { status: 'paid' }); afterWrite(); }; |
| 46 | + const openNew = () => { setSel(null); setMode('create'); }; |
| 47 | +
|
| 48 | + const FILTERS = [['all', 'All'], ['draft', 'Draft'], ['sent', 'Sent'], ['paid', 'Paid'], ['void', 'Void']]; |
| 49 | + const filters = status === 'all' ? undefined : ['status', '=', status]; |
| 50 | + const editing = mode === 'create' || sel; |
| 51 | + const Kpi = ({ label, value, accent }) => ( |
| 52 | + <div className="rounded-xl border border-slate-200 bg-white p-4"> |
| 53 | + <div className="text-xs font-medium uppercase tracking-wide text-slate-400">{label}</div> |
| 54 | + <div className={'mt-1 text-3xl font-bold ' + (accent || 'text-slate-900')}>{value}</div> |
| 55 | + </div> |
| 56 | + ); |
| 57 | +
|
| 58 | + return ( |
| 59 | + <div className="mx-auto max-w-6xl space-y-5 p-8"> |
| 60 | + <header className="flex items-center justify-between"> |
| 61 | + <div> |
| 62 | + <h1 className="text-2xl font-bold tracking-tight text-slate-900">Invoice Console</h1> |
| 63 | + <p className="mt-1 text-sm text-slate-500">Accounts receivable over <code>showcase_invoice</code> — aggregate, filter, edit, and collect.</p> |
| 64 | + </div> |
| 65 | + <button onClick={openNew} className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500">+ New invoice</button> |
| 66 | + </header> |
| 67 | +
|
| 68 | + <div className="grid grid-cols-4 gap-4"> |
| 69 | + <Kpi label="Total" value={kpi.count} /> |
| 70 | + <Kpi label="Draft" value={kpi.draft} accent="text-slate-500" /> |
| 71 | + <Kpi label="Sent" value={kpi.sent} accent="text-blue-600" /> |
| 72 | + <Kpi label="Paid" value={kpi.paid} accent="text-emerald-600" /> |
| 73 | + </div> |
| 74 | +
|
| 75 | + <div className="flex flex-wrap gap-2"> |
| 76 | + {FILTERS.map(([k, label]) => ( |
| 77 | + <button key={k} onClick={() => setStatus(k)} |
| 78 | + className={'rounded-full px-3.5 py-1 text-sm font-semibold ' + (status === k ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200')}>{label}</button> |
| 79 | + ))} |
| 80 | + </div> |
| 81 | +
|
| 82 | + <div className="grid grid-cols-5 gap-6"> |
| 83 | + <section className="col-span-3 rounded-xl border border-slate-200 bg-white p-2"> |
| 84 | + <ListView key={status + ':' + reload} objectName="showcase_invoice" |
| 85 | + fields={['name', 'account', 'status', 'total']} filters={filters} |
| 86 | + navigation={{ mode: 'none' }} onRowClick={(r) => { setSel(r); setMode('edit'); }} /> |
| 87 | + </section> |
| 88 | + <section className="col-span-2 space-y-3 rounded-xl border border-slate-200 bg-white p-5"> |
| 89 | + {editing ? ( |
| 90 | + <React.Fragment> |
| 91 | + <ObjectForm key={(mode === 'create' ? 'new' : sel && sel.id) + ':' + reload} |
| 92 | + objectName="showcase_invoice" mode={mode} |
| 93 | + recordId={mode === 'edit' && sel ? sel.id : undefined} |
| 94 | + onSuccess={afterWrite} onCancel={() => setSel(null)} /> |
| 95 | + {mode === 'edit' && sel && sel.status !== 'paid' ? ( |
| 96 | + <button onClick={markPaid} className="w-full rounded-lg border border-emerald-600 px-4 py-2 text-sm font-semibold text-emerald-700 hover:bg-emerald-50">✓ Mark Paid</button> |
| 97 | + ) : null} |
| 98 | + </React.Fragment> |
| 99 | + ) : ( |
| 100 | + <div className="flex h-full min-h-[240px] flex-col items-center justify-center text-center text-slate-400"> |
| 101 | + <div className="text-4xl">🧾</div> |
| 102 | + <p className="mt-2 text-sm">Select an invoice, or create one.</p> |
| 103 | + </div> |
| 104 | + )} |
| 105 | + </section> |
| 106 | + </div> |
| 107 | + </div> |
| 108 | + ); |
| 109 | +}`, |
| 110 | +}); |
0 commit comments