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

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