scaffold festival drink tracker (pnpm workspace, Fastify + SQLite, Preact tablet UI, admin)

This commit is contained in:
müde 2026-05-19 18:12:01 +02:00
commit e0898bea22
34 changed files with 5446 additions and 0 deletions

256
client/src/admin/Admin.tsx Normal file
View file

@ -0,0 +1,256 @@
import { useEffect, 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();
}
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 }
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:#ff8a8a">{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 Stats() {
const [data, setData] = useState<{ totals: Totals[]; per_drink: PerDrink[] } | null>(null);
useEffect(() => { fetch('/admin/api/stats').then(j).then(setData); }, []);
if (!data) return <p>Lade Statistik</p>;
return (
<>
<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();
}
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>
<button onClick={() => patch(d.id, { archived: !d.archived } as any)}>
{d.archived ? 'aktivieren' : 'archivieren'}
</button>
</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 Bars() {
const [bars, setBars] = useState<BarRow[]>([]);
const [drinks, setDrinks] = useState<Drink[]>([]);
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>) {
await fetch(`/admin/api/bars/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
reload();
}
return (
<>
<h2>Bars</h2>
{bars.map(b => (
<div key={b.id} style="margin-bottom:16px; padding:8px; border:1px solid #333; border-radius:6px">
<div class="row">
<strong>{b.name}</strong>
<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>
</div>
<div class="row">
{drinks.filter(d => !d.archived).map(d => {
const checked = b.drink_ids.includes(d.id);
return (
<label key={d.id}>
<input
type="checkbox"
checked={checked}
onChange={() => {
const next = checked
? b.drink_ids.filter(x => x !== d.id)
: [...b.drink_ids, d.id];
patch(b.id, { drink_ids: next } as any);
}}
/>
{d.name}
</label>
);
})}
</div>
</div>
))}
</>
);
}