wutzcalc/client/src/admin/Admin.tsx
iris a589f88cd8 admin: fix drink-order reorder race under rapid clicks
BarDrinkEditor derived selected drink order straight from the bar prop,
which only refreshes once a fire-and-forget PATCH's reload() lands. A
second reorder/add/remove click before that round-trip completed
re-derived from the same stale array the first click started from, so
whichever PATCH the server applied last silently won and discarded the
rest.

Track our own in-flight edits in local pendingIds state so back-to-back
clicks chain off each other instead of the lagging prop; it resets to
null (defer to the prop) whenever a fresh bar.drink_ids comes back,
whether that's our own round-trip landing or an edit from elsewhere.
2026-07-31 01:12:20 +02:00

414 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'preact/hooks';
import type { Bar, Drink } from '@wutzcalc/shared';
import { formatCents } from '../api';
async function j<T = any>(res: Response): Promise<T> {
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<string> {
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<boolean | null>(null);
useEffect(() => {
fetch('/admin/api/me').then(j).then(r => setAuthed(r.authed)).catch(() => setAuthed(false));
}, []);
if (authed === null) return <div class="admin"><p>Lade</p></div>;
if (!authed) return <Login onAuthed={() => setAuthed(true)} />;
return <Dashboard onLogout={() => setAuthed(false)} />;
}
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>Backoffice</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({ onLogout }: { onLogout: () => void }) {
// Drinks lives here, not inside <Drinks>, so <Bars> (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 <Drinks>'s
// copy — <Bars> kept rendering its stale pre-add snapshot until a full
// page reload re-mounted everything.
const [drinks, setDrinks] = useState<Drink[]>([]);
function reloadDrinks() {
fetch('/admin/api/drinks').then(j).then(setDrinks).catch(e => alert(`Fehler beim Laden: ${e}`));
}
useEffect(reloadDrinks, []);
return (
<div class="admin">
<div class="row" style="justify-content: space-between">
<h1 style="margin:0">wutzcalc Backoffice</h1>
<button onClick={async () => { await fetch('/admin/logout', { method: 'POST' }); onLogout(); }}>
Abmelden
</button>
</div>
<div class="row" style="justify-content: space-between; align-items: center">
<h2 style="margin:0">Statistik</h2>
<a href="/stats">Statistik ansehen </a>
</div>
<Exports />
<Drinks drinks={drinks} reload={reloadDrinks} />
<Bars drinks={drinks} />
</div>
);
}
function Exports() {
return (
<>
<h2>CSV-Export</h2>
<div class="row">
<a href="/admin/api/export.csv?what=transactions"><button>Transaktionen</button></a>
<a href="/admin/api/export.csv?what=items"><button>Positionen</button></a>
</div>
</>
);
}
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<Drink>) {
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 (
<>
<h2>Getränke</h2>
<table>
<thead><tr><th>Name</th><th>Preis</th><th>Status</th><th></th></tr></thead>
<tbody>
{list.map(d => (
<tr key={d.id}>
<td>
<input value={d.name} onChange={(e: any) => patch(d.id, { name: e.currentTarget.value })} />
</td>
<td>
<input
type="number"
step="0.01"
value={(d.price_cents / 100).toFixed(2)}
onChange={(e: any) => patch(d.id, { price_cents: Math.round(parseFloat(e.currentTarget.value) * 100) })}
/>
</td>
<td class={d.archived ? 'muted' : ''}>{d.archived ? 'archiviert' : 'aktiv'}</td>
<td>
<div class="row" style="margin:0">
<button onClick={() => patch(d.id, { archived: !d.archived } as any)}>
{d.archived ? 'aktivieren' : 'archivieren'}
</button>
<button class="danger" onClick={() => del(d)}>löschen</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<div class="row">
<input placeholder="Name" value={name} onInput={(e: any) => setName(e.currentTarget.value)} />
<input placeholder="Preis €" type="number" step="0.01" value={price} onInput={(e: any) => setPrice(e.currentTarget.value)} />
<button onClick={add} disabled={adding}>Hinzufügen</button>
</div>
</>
);
}
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<number[] | null>(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<number | null>(null);
const [dragOver, setDragOver] = useState<number | null>(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 (
<>
<div style="margin-top:8px; font-size:14px; opacity:0.7">Aktive Getränke (zum Sortieren ziehen)</div>
<ul class="dnd-list">
{selected.length === 0 && <li class="muted">Keine</li>}
{selected.map((id, idx) => {
const d = byId.get(id);
if (!d) return null;
return (
<li
key={id}
class={`dnd-item ${dragOver === idx ? 'drop-target' : ''}`}
draggable
onDragStart={(e: any) => {
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); }}
>
<span class="grip" aria-hidden="true"></span>
<span class="dnd-name">{idx + 1}. {d.name}</span>
{/* 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. */}
<button onClick={() => reorder(idx, idx - 1)} disabled={idx === 0} aria-label="nach oben">
</button>
<button
onClick={() => reorder(idx, idx + 1)}
disabled={idx === selected.length - 1}
aria-label="nach unten"
>
</button>
<button onClick={() => remove(idx)}>×</button>
</li>
);
})}
</ul>
{available.length > 0 && (
<>
<div style="font-size:14px; opacity:0.7">Hinzufügen</div>
<div class="row">
{available.map(d => (
<button key={d.id} onClick={() => add(d.id)}>+ {d.name}</button>
))}
</div>
</>
)}
</>
);
}
function Bars({ drinks }: { drinks: Drink[] }) {
const [bars, setBars] = useState<BarRow[]>([]);
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<BarRow>) {
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 (
<>
<h2>Bars</h2>
{bars.map(b => (
<div key={b.id} style="margin-bottom:16px; padding:8px; border:1px solid var(--border); border-radius:6px">
<div class="row">
<label>
Name
<input
value={b.name}
onChange={(e: any) => patch(b.id, { name: e.currentTarget.value } as any)}
/>
</label>
<label>
Pfand
<input
type="number"
step="0.01"
value={(b.pfand_cents / 100).toFixed(2)}
onChange={(e: any) =>
patch(b.id, { pfand_cents: Math.round(parseFloat(e.currentTarget.value) * 100) } as any)
}
/>
</label>
<button class="danger" onClick={() => delBar(b)} style="margin-left:auto">Tresen löschen</button>
</div>
<BarDrinkEditor
bar={b}
allDrinks={drinks}
onChange={(ids) => patch(b.id, { drink_ids: ids } as any)}
/>
</div>
))}
<div class="row">
<input
placeholder="Neue Bar"
value={newName}
onInput={(e: any) => setNewName(e.currentTarget.value)}
/>
<button onClick={addBar} disabled={addingBar}>Bar hinzufügen</button>
</div>
</>
);
}