dedupe j()/errText(), stale-fetch guard, empty bar-picker state Six small, mostly-unrelated items from the #47 review round (#57): 1. 'Statistik zurücksetzen' now checks res.ok and shows the server's error instead of silently re-rendering the un-reset data on a 401/500 with no indication anything went wrong. 2. DayChart now tracks its container width via ResizeObserver and calls uPlot's setSize() on change — previously read once at plot-creation time, so a resize/orientation-change (more visible on the phone/ tablet path into /stats) left the chart the wrong width. 3. .qty-btn (cart +/- buttons) grown from 28x28px to 44x44px, the conventional minimum touch target — was the one conspicuously small interactive control in the money path, at a fast-moving bar counter. 4. Tablet's bar-config fetch (App.tsx) now guards against an out-of-order resolution if barId changes again while a previous api.config() call is still in flight. Currently unreachable (nothing triggers a second barId change mid-fetch today) but closes the gap before the next 'add a cancel button to the loading screen' change could make it live. 5. j()/errText() were duplicated byte-for-byte between Admin.tsx and Stats.tsx (Admin.tsx's own comment noted this exact duplication had already caused drift once before) — centralized in api.ts, both files now import instead of redeclaring. 6. Tablet's bar picker now shows an explicit empty-state message when zero bars exist (fresh install, or every bar deleted), previously indistinguishable from 'still loading'. Closes #57. Verified: tsc --noEmit and vite build both clean.
262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
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 <Admin/>-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<StatsData | null>(null);
|
|
const [needsLogin, setNeedsLogin] = useState(false);
|
|
const [err, setErr] = useState<string | null>(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 <Login onAuthed={reload} />;
|
|
if (err) return <div class="admin"><p style="color:var(--danger)">{err}</p></div>;
|
|
if (!data) return <div class="admin"><p>Lade Statistik…</p></div>;
|
|
return <Dashboard data={data} isAdmin={isAdmin} onReset={reload} />;
|
|
}
|
|
|
|
function Login({ onAuthed }: { onAuthed: () => void }) {
|
|
const [pw, setPw] = useState('');
|
|
const [err, setErr] = useState<string | null>(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 (
|
|
<form class="admin" onSubmit={submit}>
|
|
<div class="login">
|
|
<h1>Statistik</h1>
|
|
<input
|
|
type="password"
|
|
placeholder="Passwort"
|
|
value={pw}
|
|
onInput={(e: any) => setPw(e.currentTarget.value)}
|
|
/>
|
|
{err && <div style="color:var(--danger)">{err}</div>}
|
|
<button type="submit">Anmelden</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div class="admin">
|
|
<div class="row" style="justify-content: space-between">
|
|
<h1 style="margin:0">Statistik</h1>
|
|
<div class="row" style="gap: 8px">
|
|
{isAdmin && <button class="danger" onClick={reset}>Statistik zurücksetzen</button>}
|
|
<a href="/admin">← Backoffice</a>
|
|
</div>
|
|
</div>
|
|
|
|
<h2>Umsatz pro Tag</h2>
|
|
{revenueChart.labels.length === 0
|
|
? <p class="muted">Noch keine Verkäufe</p>
|
|
: <DayChart labels={revenueChart.labels} series={revenueChart.series} />}
|
|
|
|
<h2>Transaktionen pro Tag</h2>
|
|
{txChart.labels.length === 0
|
|
? <p class="muted">Noch keine Verkäufe</p>
|
|
: <DayChart labels={txChart.labels} series={txChart.series} />}
|
|
|
|
<h2>Umsatz nach Stunde</h2>
|
|
{hourlyRevenueChart.labels.length === 0
|
|
? <p class="muted">Noch keine Verkäufe</p>
|
|
: <DayChart labels={hourlyRevenueChart.labels} series={hourlyRevenueChart.series} />}
|
|
|
|
<h2>Umsatz nach Tageszeit</h2>
|
|
{hourOfDayRevenueChart.series[0]!.values.every(v => v === 0)
|
|
? <p class="muted">Noch keine Verkäufe</p>
|
|
: <DayChart labels={hourOfDayRevenueChart.labels} series={hourOfDayRevenueChart.series} />}
|
|
|
|
<h2>Top-Getränke im Verlauf</h2>
|
|
{drinkTrend.series.length === 0
|
|
? <p class="muted">Noch keine Verkäufe</p>
|
|
: <DayChart labels={drinkTrend.labels} series={drinkTrend.series} />}
|
|
|
|
<h2>Umsatz nach Tagen</h2>
|
|
<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 (gesamt)</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>
|
|
</div>
|
|
);
|
|
}
|