import { useEffect, useRef, useState } from 'preact/hooks'; import { formatCents } from '../api'; async function j(res: Response): Promise { if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res.json(); } // Best-effort error message from a failed response ({ error } JSON or status text). async function errText(res: Response): Promise { try { return (await res.json()).error ?? res.statusText; } catch { return res.statusText; } } interface Drink { id: number; name: string; price_cents: number; archived: number } 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); useEffect(() => { fetch('/admin/api/me').then(j).then(r => setAuthed(r.authed)).catch(() => setAuthed(false)); }, []); if (authed === null) return

Lade…

; if (!authed) return setAuthed(true)} />; return setAuthed(false)} />; } 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({ onLogout }: { onLogout: () => void }) { return (

wutzcalc Backoffice

); } 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); } 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

{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

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

CSV-Export

); } function Drinks() { const [list, setList] = useState([]); const [name, setName] = useState(''); const [price, setPrice] = useState(''); function reload() { fetch('/admin/api/drinks').then(j).then(setList); } useEffect(reload, []); async function add() { const cents = Math.round(parseFloat(price) * 100); if (!name || !Number.isFinite(cents)) return; await fetch('/admin/api/drinks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, price_cents: cents }), }); setName(''); setPrice(''); reload(); } async function patch(id: number, body: Partial) { await fetch(`/admin/api/drinks/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); 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 errText(res)}`); return; } reload(); } return ( <>

Getränke

{list.map(d => ( ))}
NamePreisStatus
patch(d.id, { name: e.currentTarget.value })} /> patch(d.id, { price_cents: Math.round(parseFloat(e.currentTarget.value) * 100) })} /> {d.archived ? 'archiviert' : 'aktiv'}
setName(e.currentTarget.value)} /> setPrice(e.currentTarget.value)} />
); } function BarDrinkEditor({ bar, allDrinks, onChange, }: { bar: BarRow; allDrinks: Drink[]; onChange: (ids: number[]) => void; }) { const byId = new Map(allDrinks.map(d => [d.id, d])); const selected = bar.drink_ids.filter(id => { const d = byId.get(id); return d && !d.archived; }); const available = allDrinks.filter(d => !d.archived && !selected.includes(d.id)); const dragFrom = useRef(null); const [dragOver, setDragOver] = useState(null); function remove(idx: number) { onChange(selected.filter((_, i) => i !== idx)); } function add(id: number) { onChange([...selected, id]); } function reorder(from: number, to: number) { if (from === to || from < 0 || to < 0 || from >= selected.length || to >= selected.length) return; const next = selected.slice(); const [moved] = next.splice(from, 1); next.splice(to, 0, moved!); onChange(next); } return ( <>
Aktive Getränke (zum Sortieren ziehen)
    {selected.length === 0 &&
  • Keine
  • } {selected.map((id, idx) => { const d = byId.get(id); if (!d) return null; return (
  • { dragFrom.current = idx; e.dataTransfer.effectAllowed = 'move'; }} onDragOver={(e: any) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (dragOver !== idx) setDragOver(idx); }} onDragLeave={() => { if (dragOver === idx) setDragOver(null); }} onDrop={(e: any) => { e.preventDefault(); const from = dragFrom.current; dragFrom.current = null; setDragOver(null); if (from != null) reorder(from, idx); }} onDragEnd={() => { dragFrom.current = null; setDragOver(null); }} > {idx + 1}. {d.name}
  • ); })}
{available.length > 0 && ( <>
Hinzufügen
{available.map(d => ( ))}
)} ); } function Bars() { const [bars, setBars] = useState([]); const [drinks, setDrinks] = useState([]); const [newName, setNewName] = useState(''); function reload() { fetch('/admin/api/bars').then(j).then(setBars); fetch('/admin/api/drinks').then(j).then(setDrinks); } useEffect(reload, []); async function patch(id: number, body: Partial) { const res = await fetch(`/admin/api/bars/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) alert(`Fehler: ${await errText(res)}`); 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 errText(res)}`); return; } reload(); } async function addBar() { const name = newName.trim(); if (!name) return; const res = await fetch('/admin/api/bars', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, pfand_cents: 200 }), }); if (!res.ok) { alert(`Fehler: ${await errText(res)}`); return; } setNewName(''); reload(); } return ( <>

Bars

{bars.map(b => (
patch(b.id, { drink_ids: ids } as any)} />
))}
setNewName(e.currentTarget.value)} />
); }