import { useEffect, useRef, useState } from 'preact/hooks'; import type { Bar, Drink } from '@wutzcalc/shared'; 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. Prefers RFC 7807 // (application/problem+json) `detail`/`title` — what the admin routes send // — falls back to the older ad hoc `{ error }` shape (still used by a few // not-yet-converted endpoints), then plain status text. async function errText(res: Response): Promise { try { const body = await res.json(); return body.detail ?? body.title ?? body.error ?? res.statusText; } catch { return res.statusText; } } // `Drink`/`Bar` come from @wutzcalc/shared — the tablet code already did // this, but Admin.tsx used to re-declare its own near-identical copies, // which had already drifted (its local Drink.archived was typed `number` // while the shared one was `boolean`, even though both describe the exact // 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[] } 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 }) { // Drinks lives here, not inside , so (specifically // BarDrinkEditor's "available to add" list) sees a newly added/edited // drink immediately. Each component used to fetch its own independent // copy of the drinks list, so adding a drink updated only 's // copy — kept rendering its stale pre-add snapshot until a full // page reload re-mounted everything. const [drinks, setDrinks] = useState([]); function reloadDrinks() { fetch('/admin/api/drinks').then(j).then(setDrinks).catch(e => alert(`Fehler beim Laden: ${e}`)); } useEffect(reloadDrinks, []); return (

wutzcalc Backoffice

); } function Exports() { return ( <>

CSV-Export

); } function Drinks({ drinks: list, reload }: { drinks: Drink[]; reload: () => void }) { const [name, setName] = useState(''); const [price, setPrice] = useState(''); const [adding, setAdding] = useState(false); async function add() { const cents = Math.round(parseFloat(price) * 100); if (!name || !Number.isFinite(cents) || adding) return; setAdding(true); try { const res = await fetch('/admin/api/drinks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, price_cents: cents }), }); if (!res.ok) { alert(`Fehler: ${await errText(res)}`); return; } setName(''); setPrice(''); reload(); } finally { setAdding(false); } } async function patch(id: number, body: Partial) { const res = await fetch(`/admin/api/drinks/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { alert(`Fehler: ${await errText(res)}`); return; } 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])); // `onChange` fires a fire-and-forget PATCH (see Bars.patch) that only // resolves into a fresh `bar` prop once its `reload()` completes. Deriving // `selected` straight from `bar.drink_ids` meant a second click before that // round-trip landed re-derived its reorder/add/remove from the same stale // array the first click started from — whichever PATCH the server applied // last won, silently discarding the rest. `pendingIds` tracks our own // in-flight optimistic state so back-to-back clicks chain off each other // instead of the lagging prop; it's cleared whenever the server sends back // a fresh `bar.drink_ids` (our own round-trip landing, or an edit from // elsewhere), deferring to that as the new source of truth. const [pendingIds, setPendingIds] = useState(null); useEffect(() => { setPendingIds(null); }, [bar.drink_ids]); const selected = (pendingIds ?? 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 commit(next: number[]) { setPendingIds(next); onChange(next); } function remove(idx: number) { commit(selected.filter((_, i) => i !== idx)); } function add(id: number) { commit([...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!); commit(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} {/* Drag-and-drop above doesn't fire on touch-only input (no mouse) and has no keyboard equivalent — these buttons are the fallback that works regardless of input method. */}
  • ); })}
{available.length > 0 && ( <>
Hinzufügen
{available.map(d => ( ))}
)} ); } function Bars({ drinks }: { drinks: Drink[] }) { const [bars, setBars] = useState([]); const [newName, setNewName] = useState(''); const [addingBar, setAddingBar] = useState(false); function reload() { fetch('/admin/api/bars').then(j).then(setBars).catch(e => alert(`Fehler beim Laden: ${e}`)); } 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 || addingBar) return; setAddingBar(true); try { 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(); } finally { setAddingBar(false); } } return ( <>

Bars

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