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

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')!);