- admin: maroon buttons use .danger class (white text); replace hardcoded #333/#ff8a8a/drop-target colors with theme vars so light mode is legible - time.ts: force hourCycle h23 (no 24:00 artifact), drop dead 24-guard - Makefile/README: rsync with excludes instead of `cp -a .` so .git and the dev DB don't ship to /opt; add rsync to deps
419 lines
13 KiB
TypeScript
419 lines
13 KiB
TypeScript
import { useEffect, useRef, useState } from 'preact/hooks';
|
||
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 ({ error } JSON or status text).
|
||
async function errText(res: Response): Promise<string> {
|
||
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<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 }) {
|
||
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>
|
||
<Stats />
|
||
<Exports />
|
||
<Drinks />
|
||
<Bars />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 <p>Lade Statistik…</p>;
|
||
return (
|
||
<>
|
||
<div class="row" style="justify-content: space-between">
|
||
<h2 style="margin:0">Umsatz nach Tagen</h2>
|
||
<button class="danger" onClick={reset}>Statistik zurücksetzen</button>
|
||
</div>
|
||
<table>
|
||
<thead><tr><th>Tag</th><th>Transaktionen</th><th>Bezahlt</th><th>Crew-Transaktionen</th><th>Pfand zurück</th></tr></thead>
|
||
<tbody>
|
||
{data.by_day.length === 0 && <tr><td colSpan={5} class="muted">Noch keine Verkäufe</td></tr>}
|
||
{data.by_day.map(d => (
|
||
<tr key={d.day}>
|
||
<td>{fmtDay(d.day)}</td>
|
||
<td>{d.tx_count}</td>
|
||
<td>{formatCents(d.paid_cents)}</td>
|
||
<td>{d.crew_count}</td>
|
||
<td>{d.pfand_returns}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
<h2>Umsatz pro Bar</h2>
|
||
<table>
|
||
<thead><tr><th>Bar</th><th>Transaktionen</th><th>Bezahlt</th><th>Crew-Transaktionen</th><th>Pfand zurück</th></tr></thead>
|
||
<tbody>
|
||
{data.totals.map(t => (
|
||
<tr key={t.bar_id}>
|
||
<td>{t.bar_name}</td>
|
||
<td>{t.tx_count}</td>
|
||
<td>{formatCents(t.paid_cents)}</td>
|
||
<td>{t.crew_count}</td>
|
||
<td>{t.pfand_returns}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
<h2>Getränke</h2>
|
||
<table>
|
||
<thead><tr><th>Getränk</th><th>Verkauft</th></tr></thead>
|
||
<tbody>
|
||
{data.per_drink.map(d => (
|
||
<tr key={d.drink_id}>
|
||
<td>{d.drink_name}</td>
|
||
<td>{d.sold_qty ?? 0}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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() {
|
||
const [list, setList] = useState<Drink[]>([]);
|
||
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<Drink>) {
|
||
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 (
|
||
<>
|
||
<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}>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]));
|
||
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<number | null>(null);
|
||
const [dragOver, setDragOver] = useState<number | null>(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 (
|
||
<>
|
||
<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>
|
||
<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() {
|
||
const [bars, setBars] = useState<BarRow[]>([]);
|
||
const [drinks, setDrinks] = useState<Drink[]>([]);
|
||
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<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) 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 (
|
||
<>
|
||
<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}>Bar hinzufügen</button>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|