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.
414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|