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
parent cf33e6ea04
commit e0898bea22
34 changed files with 5446 additions and 0 deletions

13
client/admin.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>wutzcalc — Backoffice</title>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/admin/main.tsx"></script>
</body>
</html>

13
client/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>wutzcalc</title>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/tablet/main.tsx"></script>
</body>
</html>

23
client/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "client",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@wutzcalc/shared": "workspace:*",
"preact": "^10.22.0"
},
"devDependencies": {
"@preact/preset-vite": "^2.8.2",
"@vitejs/plugin-legacy": "^5.4.0",
"terser": "^5.31.0",
"typescript": "^5.4.0",
"vite": "^5.2.0"
}
}

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>
))}
</>
);
}

View file

@ -0,0 +1,4 @@
import { render } from 'preact';
import { Admin } from './Admin';
render(<Admin />, document.getElementById('app')!);

39
client/src/api.ts Normal file
View file

@ -0,0 +1,39 @@
import type {
Bar,
BarConfig,
CreateTransactionRequest,
CreateTransactionResponse,
} from '@wutzcalc/shared';
async function json<T>(res: Response): Promise<T> {
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json() as Promise<T>;
}
export const api = {
bars: () => fetch('/api/bars').then(json<Bar[]>),
config: (barId: number) => fetch(`/api/config?bar=${barId}`).then(json<BarConfig>),
createTransaction: (body: CreateTransactionRequest) =>
fetch('/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(json<CreateTransactionResponse>),
};
export function formatCents(cents: number): string {
const sign = cents < 0 ? '-' : '';
const abs = Math.abs(cents);
return `${sign}${(abs / 100).toFixed(2)}`;
}
export function uuid(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return (crypto as any).randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

157
client/src/styles.css Normal file
View file

@ -0,0 +1,157 @@
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #111;
color: #eee;
overscroll-behavior: none;
-webkit-user-select: none;
user-select: none;
}
button {
font: inherit;
color: inherit;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
padding: 12px 16px;
cursor: pointer;
}
button:active { background: #3a3a3a; }
button:disabled { opacity: 0.4; }
/* ---------- Tablet UI (portrait) ---------- */
.tablet {
display: flex;
flex-direction: column;
height: 100%;
max-width: 100vw;
}
.bar-picker {
display: flex;
flex-direction: column;
gap: 16px;
justify-content: center;
align-items: center;
height: 100%;
padding: 24px;
}
.bar-picker h1 { margin: 0 0 16px; }
.bar-picker button {
width: 280px;
height: 96px;
font-size: 28px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
background: #1a1a1a;
border-bottom: 1px solid #333;
flex: 0 0 auto;
}
.topbar .bar-name { font-size: 20px; font-weight: bold; }
.topbar .change { font-size: 14px; padding: 8px 12px; }
/* Square-ish drink buttons, sized to fit a portrait tablet width.
3 columns on a typical portrait iPad (~768px) gives ~240px-wide tiles. */
.grid {
flex: 1 1 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 8px;
overflow-y: auto;
align-content: start;
}
.grid > .drink { aspect-ratio: 1 / 1; }
@media (max-width: 480px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
.drink {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 8px;
font-size: 18px;
}
.drink .name { font-weight: bold; line-height: 1.2; }
.drink .price { font-size: 14px; opacity: 0.7; margin-top: 6px; }
.drink.pfand-return {
background: #5a2a2a;
border-color: #7a3a3a;
}
.cart {
flex: 0 0 auto;
background: #1a1a1a;
border-top: 1px solid #333;
padding: 8px 16px;
display: flex;
flex-direction: column;
max-height: 38vh;
}
.cart-items {
flex: 1 1 auto;
overflow-y: auto;
margin-bottom: 6px;
min-height: 60px;
}
.cart-line {
display: flex;
justify-content: space-between;
padding: 3px 0;
font-size: 16px;
}
.cart-line.return { color: #ff8a8a; }
.cart-line.muted { opacity: 0.5; }
.cart-total {
font-size: 26px;
font-weight: bold;
text-align: right;
padding: 4px 0 8px;
}
.cart-total.negative { color: #ff8a8a; }
.actions {
display: flex;
gap: 8px;
}
.actions button {
flex: 1;
height: 64px;
font-size: 18px;
}
.actions .cancel { background: #5a2a2a; border-color: #7a3a3a; }
.actions .confirm { background: #2a5a2a; border-color: #3a7a3a; }
.actions .crew { background: #2a3a5a; border-color: #3a4a7a; }
/* ---------- Admin ---------- */
.admin {
max-width: 960px;
margin: 0 auto;
padding: 16px;
}
.admin h1, .admin h2 { margin-top: 24px; }
.admin table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
.admin th, .admin td { padding: 6px 8px; border-bottom: 1px solid #333; text-align: left; }
.admin input, .admin select {
background: #1a1a1a; color: #eee; border: 1px solid #444; border-radius: 4px; padding: 6px;
}
.admin .row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
.admin .muted { opacity: 0.6; }
.admin .login { max-width: 320px; margin: 80px auto; display: flex; flex-direction: column; gap: 12px; }

45
client/src/tablet/App.tsx Normal file
View file

@ -0,0 +1,45 @@
import { useEffect, useState } from 'preact/hooks';
import type { Bar, BarConfig } from '@wutzcalc/shared';
import { api } from '../api';
import { BarPicker } from './BarPicker';
import { Sale } from './Sale';
const STORAGE_KEY = 'wutz.bar_id';
export function App() {
const [bars, setBars] = useState<Bar[] | null>(null);
const [barId, setBarId] = useState<number | null>(() => {
const v = localStorage.getItem(STORAGE_KEY);
return v ? Number(v) : null;
});
const [config, setConfig] = useState<BarConfig | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (barId == null) {
api.bars().then(setBars).catch(e => setError(String(e)));
}
}, [barId]);
useEffect(() => {
if (barId == null) return;
setConfig(null);
api.config(barId).then(setConfig).catch(e => setError(String(e)));
}, [barId]);
function pick(id: number) {
localStorage.setItem(STORAGE_KEY, String(id));
setBarId(id);
}
function changeBar() {
localStorage.removeItem(STORAGE_KEY);
setBarId(null);
setConfig(null);
}
if (error) return <div class="bar-picker"><h1>Fehler</h1><p>{error}</p><button onClick={() => location.reload()}>Neu laden</button></div>;
if (barId == null) return <BarPicker bars={bars} onPick={pick} />;
if (!config) return <div class="bar-picker"><p>Lade</p></div>;
return <Sale config={config} onChangeBar={changeBar} />;
}

View file

@ -0,0 +1,23 @@
import type { Bar } from '@wutzcalc/shared';
interface Props {
bars: Bar[] | null;
onPick: (id: number) => void;
}
export function BarPicker({ bars, onPick }: Props) {
return (
<div class="bar-picker">
<h1>Bar auswählen</h1>
{bars == null ? (
<p>Lade</p>
) : (
bars.map(b => (
<button key={b.id} onClick={() => onPick(b.id)}>
{b.name}
</button>
))
)}
</div>
);
}

134
client/src/tablet/Sale.tsx Normal file
View file

@ -0,0 +1,134 @@
import { useMemo, useState } from 'preact/hooks';
import type { BarConfig, CartItem } from '@wutzcalc/shared';
import { api, formatCents, uuid } from '../api';
interface Props {
config: BarConfig;
onChangeBar: () => void;
}
type Line = { drink_id: number; qty: number };
export function Sale({ config, onChangeBar }: Props) {
const { bar, drinks } = config;
const [lines, setLines] = useState<Line[]>([]);
const [pfandReturns, setPfandReturns] = useState(0);
const [submitting, setSubmitting] = useState(false);
const drinkById = useMemo(() => {
const m = new Map<number, typeof drinks[number]>();
drinks.forEach(d => m.set(d.id, d));
return m;
}, [drinks]);
const total = useMemo(() => {
const drinksSum = lines.reduce((sum, l) => {
const d = drinkById.get(l.drink_id);
if (!d) return sum;
return sum + (d.price_cents + bar.pfand_cents) * l.qty;
}, 0);
return drinksSum - bar.pfand_cents * pfandReturns;
}, [lines, drinkById, bar.pfand_cents, pfandReturns]);
function addDrink(drinkId: number) {
setLines(prev => {
const i = prev.findIndex(l => l.drink_id === drinkId);
if (i >= 0) {
const copy = prev.slice();
copy[i] = { ...copy[i]!, qty: copy[i]!.qty + 1 };
return copy;
}
return [...prev, { drink_id: drinkId, qty: 1 }];
});
}
function addPfandReturn() {
setPfandReturns(n => n + 1);
}
function clear() {
setLines([]);
setPfandReturns(0);
}
const isEmpty = lines.length === 0 && pfandReturns === 0;
async function confirm(crew: boolean) {
if (isEmpty || submitting) return;
setSubmitting(true);
const items: CartItem[] = lines.map(l => ({ drink_id: l.drink_id, qty: l.qty }));
try {
await api.createTransaction({
client_uuid: uuid(),
bar_id: bar.id,
crew,
items,
pfand_returns: pfandReturns,
});
clear();
} catch (e) {
alert(`Fehler beim Speichern: ${e}`);
} finally {
setSubmitting(false);
}
}
return (
<div class="tablet">
<div class="topbar">
<div class="bar-name">{bar.name}</div>
<button class="change" onClick={onChangeBar}>Bar wechseln</button>
</div>
<div class="grid">
{drinks.map(d => (
<button key={d.id} class="drink" onClick={() => addDrink(d.id)}>
<div class="name">{d.name}</div>
<div class="price">{formatCents(d.price_cents + bar.pfand_cents)}</div>
</button>
))}
<button class="drink pfand-return" onClick={addPfandReturn}>
<div class="name">Pfand zurück</div>
<div class="price">{formatCents(bar.pfand_cents)}</div>
</button>
</div>
<div class="cart">
<div class="cart-items">
{isEmpty && <div class="cart-line muted">Keine Einträge</div>}
{lines.map((l, i) => {
const d = drinkById.get(l.drink_id);
if (!d) return null;
const unit = d.price_cents + bar.pfand_cents;
return (
<div key={i} class="cart-line">
<span>{l.qty}× {d.name}</span>
<span>{formatCents(unit * l.qty)}</span>
</div>
);
})}
{pfandReturns > 0 && (
<div class="cart-line return">
<span>{pfandReturns}× Pfand zurück</span>
<span>{formatCents(-bar.pfand_cents * pfandReturns)}</span>
</div>
)}
</div>
<div class={`cart-total ${total < 0 ? 'negative' : ''}`}>
{formatCents(total)}
</div>
<div class="actions">
<button class="cancel" onClick={clear} disabled={isEmpty || submitting}>
Abbrechen
</button>
<button class="crew" onClick={() => confirm(true)} disabled={isEmpty || submitting}>
Crew
</button>
<button class="confirm" onClick={() => confirm(false)} disabled={isEmpty || submitting}>
Bestätigen
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,4 @@
import { render } from 'preact';
import { App } from './App';
render(<App />, document.getElementById('app')!);

11
client/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"noEmit": true
},
"include": ["src/**/*"]
}

32
client/vite.config.ts Normal file
View file

@ -0,0 +1,32 @@
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
import legacy from '@vitejs/plugin-legacy';
import { resolve } from 'node:path';
export default defineConfig({
plugins: [
preact(),
legacy({
targets: ['ios >= 12', 'safari >= 12'],
modernPolyfills: true,
renderLegacyChunks: true,
}),
],
build: {
target: 'es2017',
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
admin: resolve(__dirname, 'admin.html'),
},
},
},
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3000',
'/admin': 'http://localhost:3000',
'/healthz': 'http://localhost:3000',
},
},
});