client: standalone /stats page with charts, off the admin dashboard

Closes #40.

New /stats route (client/stats.html + src/stats/), served by the same
catch-all pattern as /admin. Reuses the admin login (STATS_PUBLIC env var
on the server side decides whether it needs one at all).

Three uPlot charts (daily revenue, daily transaction count, top-5-drink
sold-qty trend) plus the same three tables Admin.tsx used to render inline
— those move here wholesale, Admin.tsx now just links to /stats instead of
fetching /admin/api/stats itself. The 'Statistik zurücksetzen' reset button
moves here too, gated on an actual admin session (checked separately from
whether /api/stats itself succeeded, since STATS_PUBLIC can make that true
for an anonymous viewer).

Chart lib is uPlot (~45kb) per mara's steer not to hand-roll this. Both
client and server build/typecheck clean; manually smoke-tested the auth
gate (401 unauthed, 200 after login) and the /stats route against a fresh
DB.
This commit is contained in:
iris 2026-07-30 10:25:54 +02:00 committed by mara
commit 1d99881e88
10 changed files with 332 additions and 75 deletions

View file

@ -27,9 +27,6 @@ async function errText(res: Response): Promise<string> {
// same wire field). BarRow/stats shapes below are admin-only — not part
// of the wire contract the tablet also consumes — so they stay local.
interface BarRow extends Bar { drink_ids: number[] }
interface Totals { bar_id: number; bar_name: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number }
interface PerDrink { drink_id: number; drink_name: string; sold_qty: number }
interface ByDay { day: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number }
export function Admin() {
const [authed, setAuthed] = useState<boolean | null>(null);
@ -100,7 +97,10 @@ function Dashboard({ onLogout }: { onLogout: () => void }) {
Abmelden
</button>
</div>
<Stats />
<div class="row" style="justify-content: space-between; align-items: center">
<h2 style="margin:0">Statistik</h2>
<a href="/stats">Statistik ansehen </a>
</div>
<Exports />
<Drinks drinks={drinks} reload={reloadDrinks} />
<Bars drinks={drinks} />
@ -108,76 +108,6 @@ function Dashboard({ onLogout }: { onLogout: () => void }) {
);
}
function fmtDay(day: string): string {
const [y, m, d] = day.split('-');
return `${d}.${m}.${y}`;
}
function Stats() {
const [data, setData] = useState<{ totals: Totals[]; per_drink: PerDrink[]; by_day: ByDay[] } | null>(null);
function reload() {
fetch('/admin/api/stats').then(j).then(setData).catch(e => alert(`Fehler beim Laden: ${e}`));
}
useEffect(reload, []);
async function reset() {
if (!confirm('Wirklich die gesamte Statistik (alle Transaktionen) unwiderruflich löschen?')) return;
await fetch('/admin/api/stats/reset', { method: 'POST' });
reload();
}
if (!data) return <p>Lade Statistik</p>;
return (
<>
<div class="row" style="justify-content: space-between">
<h2 style="margin:0">Umsatz nach Tagen</h2>
<button class="danger" onClick={reset}>Statistik zurücksetzen</button>
</div>
<table>
<thead><tr><th>Tag</th><th>Transaktionen</th><th>Bezahlt</th><th>Crew-Transaktionen</th><th>Pfand zurück</th></tr></thead>
<tbody>
{data.by_day.length === 0 && <tr><td colSpan={5} class="muted">Noch keine Verkäufe</td></tr>}
{data.by_day.map(d => (
<tr key={d.day}>
<td>{fmtDay(d.day)}</td>
<td>{d.tx_count}</td>
<td>{formatCents(d.paid_cents)}</td>
<td>{d.crew_count}</td>
<td>{d.pfand_returns}</td>
</tr>
))}
</tbody>
</table>
<h2>Umsatz pro Bar</h2>
<table>
<thead><tr><th>Bar</th><th>Transaktionen</th><th>Bezahlt</th><th>Crew-Transaktionen</th><th>Pfand zurück</th></tr></thead>
<tbody>
{data.totals.map(t => (
<tr key={t.bar_id}>
<td>{t.bar_name}</td>
<td>{t.tx_count}</td>
<td>{formatCents(t.paid_cents)}</td>
<td>{t.crew_count}</td>
<td>{t.pfand_returns}</td>
</tr>
))}
</tbody>
</table>
<h2>Getränke</h2>
<table>
<thead><tr><th>Getränk</th><th>Verkauft</th></tr></thead>
<tbody>
{data.per_drink.map(d => (
<tr key={d.drink_id}>
<td>{d.drink_name}</td>
<td>{d.sold_qty ?? 0}</td>
</tr>
))}
</tbody>
</table>
</>
);
}
function Exports() {
return (