diff --git a/client/src/admin/Admin.tsx b/client/src/admin/Admin.tsx index 77be805..7a7a38b 100644 --- a/client/src/admin/Admin.tsx +++ b/client/src/admin/Admin.tsx @@ -10,6 +10,7 @@ interface Drink { id: number; name: string; price_cents: number; archived: numbe interface BarRow { id: number; name: string; pfand_cents: number; 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(null); @@ -76,12 +77,44 @@ 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[] } | null>(null); - useEffect(() => { fetch('/admin/api/stats').then(j).then(setData); }, []); + const [data, setData] = useState<{ totals: Totals[]; per_drink: PerDrink[]; by_day: ByDay[] } | null>(null); + function reload() { fetch('/admin/api/stats').then(j).then(setData); } + 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

Lade Statistik…

; return ( <> +
+

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

@@ -153,6 +186,13 @@ function Drinks() { reload(); } + async function del(d: Drink) { + if (!confirm(`Getränk „${d.name}“ wirklich löschen?`)) return; + const res = await fetch(`/admin/api/drinks/${d.id}`, { method: 'DELETE' }); + if (!res.ok) { alert(`Fehler: ${(await res.json().catch(() => null))?.error ?? await res.text()}`); return; } + reload(); + } + return ( <>

Getränke

@@ -174,9 +214,12 @@ function Drinks() { ))} @@ -298,6 +341,13 @@ function Bars() { reload(); } + async function delBar(b: BarRow) { + if (!confirm(`Tresen „${b.name}“ wirklich löschen?`)) return; + const res = await fetch(`/admin/api/bars/${b.id}`, { method: 'DELETE' }); + if (!res.ok) { alert(`Fehler: ${(await res.json().catch(() => null))?.error ?? await res.text()}`); return; } + reload(); + } + async function addBar() { const name = newName.trim(); if (!name) return; @@ -338,6 +388,7 @@ function Bars() { } /> + ('/admin/api/drinks/:id', async (req, reply) => { + if (!requireAuth(req, reply)) return; + const id = Number(req.params.id); + const sold = db + .prepare('SELECT 1 FROM transaction_items WHERE drink_id = ? LIMIT 1') + .get(id); + if (sold) { + return reply + .code(409) + .send({ error: 'Getränk wurde bereits verkauft — bitte archivieren statt löschen.' }); + } + db.transaction(() => { + db.prepare('DELETE FROM bar_drinks WHERE drink_id = ?').run(id); + db.prepare('DELETE FROM drinks WHERE id = ?').run(id); + })(); + return { ok: true }; + }); + // ----- Bars ----- app.get('/admin/api/bars', async (req, reply) => { if (!requireAuth(req, reply)) return; @@ -145,6 +164,24 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { } ); + app.delete<{ Params: { id: string } }>('/admin/api/bars/:id', async (req, reply) => { + if (!requireAuth(req, reply)) return; + const id = Number(req.params.id); + const used = db + .prepare('SELECT 1 FROM transactions WHERE bar_id = ? LIMIT 1') + .get(id); + if (used) { + return reply + .code(409) + .send({ error: 'Tresen hat Transaktionen — Löschen würde die Statistik verfälschen.' }); + } + db.transaction(() => { + db.prepare('DELETE FROM bar_drinks WHERE bar_id = ?').run(id); + db.prepare('DELETE FROM bars WHERE id = ?').run(id); + })(); + return { ok: true }; + }); + // ----- Stats ----- app.get('/admin/api/stats', async (req, reply) => { if (!requireAuth(req, reply)) return; @@ -174,7 +211,36 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { ) .all(); - return { totals, per_drink: perDrink }; + // Per business day (sales night runs past midnight — see time.ts). + const txRows = db + .prepare('SELECT created_at, total_cents, crew, pfand_returns FROM transactions') + .all() as Array<{ created_at: string; total_cents: number; crew: number; pfand_returns: number }>; + + const dayMap = new Map(); + for (const r of txRows) { + const day = businessDay(parseDbTime(r.created_at)); + let agg = dayMap.get(day); + if (!agg) { + agg = { day, tx_count: 0, paid_cents: 0, crew_count: 0, pfand_returns: 0 }; + dayMap.set(day, agg); + } + agg.tx_count += 1; + if (r.crew) agg.crew_count += 1; + else agg.paid_cents += r.total_cents; + agg.pfand_returns += r.pfand_returns; + } + const byDay = [...dayMap.values()].sort((a, b) => b.day.localeCompare(a.day)); + + return { totals, per_drink: perDrink, by_day: byDay }; + }); + + app.post('/admin/api/stats/reset', async (req, reply) => { + if (!requireAuth(req, reply)) return; + db.transaction(() => { + db.prepare('DELETE FROM transaction_items').run(); + db.prepare('DELETE FROM transactions').run(); + })(); + return { ok: true }; }); // ----- CSV export ----- @@ -211,6 +277,11 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { .all() as any[]; } + // created_at is stored as UTC — export it as local wall-clock time. + for (const r of rows) { + if (r.created_at) r.created_at = formatLocal(parseDbTime(r.created_at)); + } + const csv = [header.join(',')] .concat(rows.map(r => header.map(h => csvCell(r[h])).join(','))) .join('\n'); diff --git a/server/src/routes/public.ts b/server/src/routes/public.ts index e7db2a1..d607017 100644 --- a/server/src/routes/public.ts +++ b/server/src/routes/public.ts @@ -108,8 +108,8 @@ export function registerPublicRoutes(app: FastifyInstance, db: DB) { const clientIp = req.ip ?? null; const insertTx = db.prepare( - `INSERT INTO transactions (bar_id, total_cents, crew, pfand_returns, client_ip, client_uuid) - VALUES (?, ?, ?, ?, ?, ?)` + `INSERT INTO transactions (bar_id, created_at, total_cents, crew, pfand_returns, client_ip, client_uuid) + VALUES (?, ?, ?, ?, ?, ?, ?)` ); const insertItem = db.prepare( `INSERT INTO transaction_items @@ -117,8 +117,9 @@ export function registerPublicRoutes(app: FastifyInstance, db: DB) { VALUES (?, ?, ?, ?, ?, 0)` ); + const createdAt = new Date().toISOString(); const txId = db.transaction(() => { - const info = insertTx.run(bar_id, paidTotal, crew ? 1 : 0, pfand_returns, clientIp, client_uuid); + const info = insertTx.run(bar_id, createdAt, paidTotal, crew ? 1 : 0, pfand_returns, clientIp, client_uuid); const id = Number(info.lastInsertRowid); for (const p of priced) { insertItem.run(id, p.drink_id, p.qty, p.unit_price_cents, p.pfand_cents_per_unit); diff --git a/server/src/time.ts b/server/src/time.ts new file mode 100644 index 0000000..e70445b --- /dev/null +++ b/server/src/time.ts @@ -0,0 +1,78 @@ +// Time handling for the sales day. +// +// Timestamps are stored in the DB as UTC ISO strings (`new Date().toISOString()`). +// For display and for grouping into business days we convert to a fixed local +// timezone (default Europe/Berlin) so the numbers match the bar's wall clock +// regardless of where the server runs. + +export const TZ = process.env.WUTZ_TZ ?? 'Europe/Berlin'; + +// A sale at e.g. 03:00 still belongs to the previous night's business day. +// Anything before this local hour counts towards the day before. +export const BUSINESS_DAY_CUTOFF_HOUR = Number(process.env.WUTZ_DAY_CUTOFF_HOUR ?? 5); + +/** + * Parse a value stored in `created_at`. New rows are UTC ISO strings (with `Z`), + * but legacy rows written by SQLite's CURRENT_TIMESTAMP look like + * `YYYY-MM-DD HH:MM:SS` and are also UTC — normalise both to a Date. + */ +export function parseDbTime(s: string): Date { + if (/[zZ]|[+-]\d{2}:\d{2}$/.test(s)) return new Date(s); + return new Date(s.replace(' ', 'T') + 'Z'); +} + +interface LocalParts { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +} + +function localParts(d: Date, tz: string = TZ): LocalParts { + const fmt = new Intl.DateTimeFormat('en-CA', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + const parts: Record = {}; + for (const p of fmt.formatToParts(d)) { + if (p.type !== 'literal') parts[p.type] = p.value; + } + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + hour: Number(parts.hour === '24' ? '0' : parts.hour), + minute: Number(parts.minute), + second: Number(parts.second), + }; +} + +/** Local wall-clock time as `YYYY-MM-DD HH:MM:SS` for CSV / display. */ +export function formatLocal(d: Date, tz: string = TZ): string { + const p = localParts(d, tz); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${p.year}-${pad(p.month)}-${pad(p.day)} ${pad(p.hour)}:${pad(p.minute)}:${pad(p.second)}`; +} + +/** + * The business day a timestamp belongs to, as `YYYY-MM-DD`. + * Hours before BUSINESS_DAY_CUTOFF_HOUR are attributed to the previous day. + */ +export function businessDay(d: Date, tz: string = TZ): string { + const p = localParts(d, tz); + // Build a date from the local Y-M-D and roll back a day when before cutoff. + const date = new Date(Date.UTC(p.year, p.month - 1, p.day)); + if (p.hour < BUSINESS_DAY_CUTOFF_HOUR) { + date.setUTCDate(date.getUTCDate() - 1); + } + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`; +}
BarTransaktionenBezahltCrew-TransaktionenPfand zurück
{d.archived ? 'archiviert' : 'aktiv'} - +
+ + +