wutzcalc/client/src/admin/Admin.tsx
müde cca3077b7a review fixes: light-mode contrast, tz robustness, deploy copy
- 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
2026-06-14 22:55:29 +02:00

419 lines
13 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 { 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>
</>
);
}