import { useEffect, useMemo, useState } from 'preact/hooks'; import { errText, formatCents, j } from '../api'; import { DayChart } from './DayChart'; 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 } interface PerDrinkByDay { day: string; drinks: PerDrink[] } interface ByHour { hour: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number } interface ByHourOfDay { hour_of_day: number; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number } interface StatsData { totals: Totals[]; per_drink: PerDrink[]; by_day: ByDay[]; per_drink_by_day: PerDrinkByDay[]; by_hour: ByHour[]; by_hour_of_day: ByHourOfDay[]; } function fmtDay(day: string): string { const [y, m, d] = day.split('-'); return `${d}.${m}.${y}`; } // Shorter, no-year form for chart axis labels — the table above already // carries the full date, and a year suffix on every tick just crowds a // narrow axis for what's realistically a single-event date range anyway. function fmtDayShort(day: string): string { const [, m, d] = day.split('-'); return `${d}.${m}.`; } // `hour` is a "YYYY-MM-DD HH:00" bucket (see localHourBucket() server-side) // — render as "30.07. 14h" for the continuous timeline chart. function fmtHour(hour: string): string { const [day, time] = hour.split(' '); const [, m, d] = (day ?? '').split('-'); const h = (time ?? '').split(':')[0]; return `${d}.${m}. ${h}h`; } // Auth here isn't a full -style flow — GET /api/stats itself is the // source of truth for whether a login is needed (STATS_PUBLIC can make it // open with no session at all), so we just try the fetch and fall back to // the same admin login form on a 401, rather than pre-checking /admin/api/me // like Admin.tsx does. export function Stats() { const [data, setData] = useState(null); const [needsLogin, setNeedsLogin] = useState(false); const [err, setErr] = useState(null); // Separate from "did /api/stats succeed" — STATS_PUBLIC can make that // true with no session at all, but "Statistik zurücksetzen" deletes // every transaction and must stay gated on an actual admin login // regardless of STATS_PUBLIC (the server enforces this too; this is just // to not show a button that always 401s for a public/anonymous viewer). const [isAdmin, setIsAdmin] = useState(false); function reload() { setErr(null); fetch('/admin/api/me').then(j).then(r => setIsAdmin(!!r.authed)).catch(() => setIsAdmin(false)); fetch('/api/stats') .then(async res => { if (res.status === 401) { setNeedsLogin(true); return; } if (!res.ok) throw new Error(await errText(res)); setNeedsLogin(false); setData(await res.json()); }) .catch(e => setErr(String(e))); } useEffect(reload, []); if (needsLogin) return ; if (err) return

{err}

; if (!data) return

Lade Statistik…

; return ; } function Login({ onAuthed }: { onAuthed: () => void }) { const [pw, setPw] = useState(''); const [err, setErr] = useState(null); async function submit(e: Event) { e.preventDefault(); setErr(null); try { await fetch('/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }), }).then(j); onAuthed(); } catch (e: any) { setErr(String(e)); } } return (
); } function Dashboard({ data, isAdmin, onReset }: { data: StatsData; isAdmin: boolean; onReset: () => void }) { async function reset() { if (!confirm('Wirklich die gesamte Statistik (alle Transaktionen) unwiderruflich löschen?')) return; const res = await fetch('/admin/api/stats/reset', { method: 'POST' }); if (!res.ok) { alert(`Fehler: ${await errText(res)}`); return; } onReset(); } // by_day/per_drink_by_day come pre-sorted from the server (by_day newest // first for the table below, per_drink_by_day oldest first for the // chart's left-to-right reading order) — no re-sort needed here. // // Each of these produces the full {labels, series} shape DayChart wants, // memoized as one object (#55) — building `series`/`labels` as fresh // array/object literals inline in JSX on every render defeats DayChart's // effect (keyed on those props by reference, not deep-equality), so // Dashboard re-rendering for any unrelated reason (e.g. the isAdmin // check resolving after data already loaded) would destroy/recreate // every uPlot instance for no data-related reason. const revenueChart = useMemo(() => { const days = [...data.by_day].reverse(); return { labels: days.map(d => fmtDayShort(d.day)), series: [{ name: 'Umsatz (€)', values: days.map(d => d.paid_cents / 100) }] }; }, [data.by_day]); const txChart = useMemo(() => { const days = [...data.by_day].reverse(); return { labels: days.map(d => fmtDayShort(d.day)), series: [{ name: 'Transaktionen', values: days.map(d => d.tx_count) }] }; }, [data.by_day]); // #45's two hourly readings — see the by_hour/by_hour_of_day comment on // computeStats() server-side for why they're separate. Revenue only // here (not also a tx-count variant like the per-day charts above) to // keep the page from growing a chart per metric per granularity; ask if // you want tx-count broken out hourly too. const hourlyRevenueChart = useMemo( () => ({ labels: data.by_hour.map(h => fmtHour(h.hour)), series: [{ name: 'Umsatz (€)', values: data.by_hour.map(h => h.paid_cents / 100) }], }), [data.by_hour] ); const hourOfDayRevenueChart = useMemo( () => ({ labels: data.by_hour_of_day.map(h => `${String(h.hour_of_day).padStart(2, '0')}h`), series: [{ name: 'Umsatz (€)', values: data.by_hour_of_day.map(h => h.paid_cents / 100) }], }), [data.by_hour_of_day] ); // Top 5 drinks by all-time volume — a per-drink-per-day line chart with // every drink on it gets unreadable past a handful of series, and the // long tail is rarely what "how's it developing" is actually asking // about. const topDrinkIds = useMemo( () => data.per_drink.slice(0, 5).map(d => d.drink_id), [data.per_drink] ); const drinkTrend = useMemo(() => { const names = new Map(data.per_drink.map(d => [d.drink_id, d.drink_name])); const labels = data.per_drink_by_day.map(d => fmtDayShort(d.day)); const series = topDrinkIds.map(id => ({ name: names.get(id) ?? `#${id}`, values: data.per_drink_by_day.map(day => day.drinks.find(x => x.drink_id === id)?.sold_qty ?? 0), })); return { labels, series }; }, [data.per_drink_by_day, topDrinkIds]); return (

Statistik

{isAdmin && } ← Backoffice

Umsatz pro Tag

{revenueChart.labels.length === 0 ?

Noch keine Verkäufe

: }

Transaktionen pro Tag

{txChart.labels.length === 0 ?

Noch keine Verkäufe

: }

Umsatz nach Stunde

{hourlyRevenueChart.labels.length === 0 ?

Noch keine Verkäufe

: }

Umsatz nach Tageszeit

{hourOfDayRevenueChart.series[0]!.values.every(v => v === 0) ?

Noch keine Verkäufe

: }

Top-Getränke im Verlauf

{drinkTrend.series.length === 0 ?

Noch keine Verkäufe

: }

Umsatz nach Tagen

{data.by_day.length === 0 && } {data.by_day.map(d => ( ))}
TagTransaktionenBezahltCrew-TransaktionenPfand zurück
Noch keine Verkäufe
{fmtDay(d.day)} {d.tx_count} {formatCents(d.paid_cents)} {d.crew_count} {d.pfand_returns}

Umsatz pro Bar

{data.totals.map(t => ( ))}
BarTransaktionenBezahltCrew-TransaktionenPfand zurück
{t.bar_name} {t.tx_count} {formatCents(t.paid_cents)} {t.crew_count} {t.pfand_returns}

Getränke (gesamt)

{data.per_drink.map(d => ( ))}
GetränkVerkauft
{d.drink_name} {d.sold_qty ?? 0}
); }