scaffold festival drink tracker (pnpm workspace, Fastify + SQLite, Preact tablet UI, admin)
This commit is contained in:
parent
cf33e6ea04
commit
e0898bea22
34 changed files with 5446 additions and 0 deletions
45
client/src/tablet/App.tsx
Normal file
45
client/src/tablet/App.tsx
Normal 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} />;
|
||||
}
|
||||
23
client/src/tablet/BarPicker.tsx
Normal file
23
client/src/tablet/BarPicker.tsx
Normal 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
134
client/src/tablet/Sale.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
client/src/tablet/main.tsx
Normal file
4
client/src/tablet/main.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { render } from 'preact';
|
||||
import { App } from './App';
|
||||
|
||||
render(<App />, document.getElementById('app')!);
|
||||
Loading…
Add table
Add a link
Reference in a new issue