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
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.direnv/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
138
PLAN.md
Normal file
138
PLAN.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# wutzcalc — Implementation Plan
|
||||
|
||||
Festival drink-sale tracker. 3 bars, ~10k attendees over 1 week, old iPads (iOS 12),
|
||||
local network only, central linux server.
|
||||
|
||||
## Decisions (locked for v1)
|
||||
|
||||
- **Stack:** Preact + Vite (client), Node + Fastify (server), SQLite (better-sqlite3).
|
||||
- **Deployment:** single Node process serves API + static client + opens one `.db` file.
|
||||
- **Sync model:** online-only. Confirm requires server reachable on LAN.
|
||||
- **Auth:** none on tablets in v1 (trusted LAN); `/admin` gated by a shared password env var.
|
||||
- **Tablet identity:** server records source IP per transaction (`client_ip`) to attribute transactions to a tablet. Assumes static DHCP leases or stable IPs on the festival LAN.
|
||||
- **Drink catalog:** shared across bars; each bar picks which drinks it serves.
|
||||
- **Pfand:** single global pfand value per bar; added on top of drink price.
|
||||
- **Pfandrückgabe:** dedicated minus/return buttons per drink; endbetrag may go negative.
|
||||
- **Crew/freebies:** a separate "Bestätigen (Crew)" confirm button — logs transaction with `crew=true`, paid amount = 0, items still recorded.
|
||||
- **Undo:** only before confirming, fully local (edit the current cart). No undo of confirmed transactions in v1.
|
||||
- **iOS 12 target:** Vite build with `legacy` plugin → ES2017 + polyfills. No CSS features past Safari 12.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ HTTP/JSON ┌────────────────────────┐
|
||||
│ Tablet (PWA) │ ───────────────► │ Node + Fastify │
|
||||
│ Preact SPA │ │ /api/... │
|
||||
│ per-bar UI │ │ /admin (pw-gated) │
|
||||
└──────────────┘ │ static client assets │
|
||||
│ ───────────────────── │
|
||||
│ better-sqlite3 ── wutz.db │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
- Single port, single binary-ish (`node server.js`). Systemd unit for autostart.
|
||||
- SQLite WAL mode for concurrent reads from dashboard while writes happen.
|
||||
|
||||
## Data model (SQLite)
|
||||
|
||||
```
|
||||
bars(id, name, pfand_cents)
|
||||
drinks(id, name, price_cents, archived)
|
||||
bar_drinks(bar_id, drink_id, sort_order) -- which drinks each bar sells
|
||||
transactions(
|
||||
id, bar_id, created_at, total_cents,
|
||||
crew INTEGER NOT NULL DEFAULT 0,
|
||||
client_ip TEXT, -- which tablet (per-bar device id)
|
||||
client_uuid TEXT UNIQUE -- idempotency from tablet
|
||||
)
|
||||
transaction_items(
|
||||
id, transaction_id, drink_id, qty, unit_price_cents, pfand_cents_per_unit,
|
||||
is_return INTEGER NOT NULL DEFAULT 0 -- pfandrückgabe line
|
||||
)
|
||||
settings(key, value) -- admin password hash etc.
|
||||
```
|
||||
|
||||
- All money in integer cents.
|
||||
- `client_uuid` makes confirm idempotent against double-tap / retry.
|
||||
- Returns stored as separate line items with `is_return=1` and negative contribution to total.
|
||||
|
||||
## API surface
|
||||
|
||||
- `GET /api/config?bar=<id>` → bar + its drinks + pfand value.
|
||||
- `GET /api/bars` → list of bars (for tablet first-run picker).
|
||||
- `POST /api/transactions` → body: `{ client_uuid, bar_id, crew, items: [{drink_id, qty, is_return}] }`. Server recomputes prices from current catalog, captures source IP (`req.ip`, trust proxy off) as `client_ip`, stores, returns `{id, total_cents}`.
|
||||
- `GET /admin` → password form → session cookie.
|
||||
- `GET /admin/api/stats` → totals per bar, per drink, crew vs paid.
|
||||
- `GET /admin/api/export.csv?what=transactions|items` → CSV download.
|
||||
- `POST /admin/api/drinks` / `PATCH` / `DELETE` → catalog config.
|
||||
- `POST /admin/api/bars/:id` → pfand + drink selection.
|
||||
|
||||
Server is the source of truth for prices — tablet sends drink IDs + qty only.
|
||||
|
||||
## Client (tablet) UX
|
||||
|
||||
- **First run:** pick which bar this tablet is. Stored in `localStorage`.
|
||||
- **Main screen:** grid of large buttons, one per drink served at this bar.
|
||||
- Tap → adds 1 to cart.
|
||||
- Long-press / "−" sub-button → returns 1 pfand for that drink.
|
||||
- **Cart strip:** running total (price + pfand − returns), per-line qty.
|
||||
- **Actions:** `Abbrechen` (clear cart), `Bestätigen` (send, crew=false), `Bestätigen (Crew)` (send, crew=true, paid=0).
|
||||
- All cart edits are local until confirm — undo = remove from cart.
|
||||
- Tap targets ≥ 64px, no hover states, no small text.
|
||||
|
||||
## Backoffice (v1)
|
||||
|
||||
- `/admin` login (single password from env `ADMIN_PASSWORD`).
|
||||
- Pages:
|
||||
1. **Drinks**: CRUD table (name, price, archived).
|
||||
2. **Bars**: per bar — pfand value + checklist of drinks served + sort order.
|
||||
3. **Stats**: totals per bar, top drinks, crew freebie count/value.
|
||||
4. **Export**: CSV of transactions and transaction_items.
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
wutzcalc/
|
||||
├── server/
|
||||
│ ├── src/
|
||||
│ │ ├── index.ts # fastify bootstrap, static serving
|
||||
│ │ ├── db.ts # better-sqlite3 + migrations
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── public.ts # /api/* (tablets)
|
||||
│ │ │ └── admin.ts # /admin/*
|
||||
│ │ └── csv.ts
|
||||
│ └── migrations/
|
||||
├── client/ # Preact + Vite
|
||||
│ ├── src/
|
||||
│ │ ├── main.tsx
|
||||
│ │ ├── pages/{Bar,Setup}.tsx
|
||||
│ │ ├── components/{DrinkButton,Cart}.tsx
|
||||
│ │ └── api.ts
|
||||
│ └── vite.config.ts # @vitejs/plugin-legacy → iOS 12 target
|
||||
├── admin/ # could share Preact app or be plain HTML forms
|
||||
├── package.json # workspaces
|
||||
├── README.md
|
||||
├── NOTES.md
|
||||
├── TODO.md
|
||||
└── PLAN.md
|
||||
```
|
||||
|
||||
(Admin can be a second entry in the same Vite build, or server-rendered HTML — leaning toward second Vite entry for consistency.)
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Repo scaffold: pnpm workspaces, Fastify server skeleton, Vite client skeleton, shared types package.
|
||||
2. SQLite schema + migrations + seed (3 bars, a few drinks).
|
||||
3. `/api/config` + `/api/transactions` with idempotency on `client_uuid`.
|
||||
4. Tablet UI: bar picker → drink grid → cart → confirm/cancel/crew-confirm.
|
||||
5. Admin password gate + drinks/bars config UI.
|
||||
6. Stats endpoint + minimal dashboard.
|
||||
7. CSV export.
|
||||
8. Legacy build verification on an iOS 12 Safari (or BrowserStack equivalent).
|
||||
9. Systemd unit + README deploy instructions + backup cron note.
|
||||
|
||||
## Open risks
|
||||
|
||||
- iOS 12 Safari is the main constraint — need to verify Preact + legacy plugin output actually runs there early (after step 1, before building more).
|
||||
- Local network reliability: if the WiFi at the venue is flaky, online-only model bites. Tracked in `TODO.md` (offline-capable client).
|
||||
- SQLite single-writer is fine at ~10k attendees / week, but document WAL mode and a backup cron.
|
||||
34
README.md
Normal file
34
README.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# wutzcalc
|
||||
|
||||
Festival drink-sale tracker. See [PLAN.md](PLAN.md) for architecture, [NOTES.md](NOTES.md)
|
||||
for the original requirements, and [TODO.md](TODO.md) for deferred work.
|
||||
|
||||
## Dev
|
||||
|
||||
```sh
|
||||
nix develop # node 20, pnpm, sqlite, build deps
|
||||
pnpm install
|
||||
ADMIN_PASSWORD=changeme pnpm dev:server # http://localhost:3000
|
||||
pnpm dev:client # http://localhost:5173 (proxies /api, /admin)
|
||||
```
|
||||
|
||||
Open `http://localhost:5173/` for the tablet UI and
|
||||
`http://localhost:5173/admin.html` for the backoffice.
|
||||
|
||||
## Production build
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm build
|
||||
ADMIN_PASSWORD=... DB_PATH=/var/lib/wutzcalc/wutz.db node server/dist/index.js
|
||||
```
|
||||
|
||||
Single Node process serves the API, both client entries (`/` tablet,
|
||||
`/admin` backoffice), and writes to one SQLite file.
|
||||
|
||||
## Env vars
|
||||
|
||||
- `PORT` (default `3000`)
|
||||
- `HOST` (default `0.0.0.0`)
|
||||
- `DB_PATH` (default `./wutz.db`)
|
||||
- `ADMIN_PASSWORD` (**required** for backoffice login)
|
||||
19
TODO.md
Normal file
19
TODO.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# TODO
|
||||
|
||||
Longer-term items deferred from v1.
|
||||
|
||||
## Auth & access control
|
||||
- Add short PIN per bar/tablet on first launch (currently no client auth).
|
||||
- Harden `/admin` beyond a single shared password (per-user, audit log).
|
||||
- Decide whether bartender identity should be tracked per transaction.
|
||||
|
||||
## Features
|
||||
- Custom price entry (free-form numpad amount as a line item, and/or per-drink price override).
|
||||
- Per-bar price overrides on top of the shared drink catalog (v1 is shared catalog + per-bar selection only).
|
||||
- Crew identification (which crew member took the freebie) beyond a flat "by crew" flag.
|
||||
- Backoffice: live transaction feed, per-drink charts, void/edit confirmed transactions.
|
||||
- Offline-capable client with sync queue (v1 is online-only).
|
||||
|
||||
## Ops
|
||||
- Backup strategy for the SQLite file (cron + copy off-box).
|
||||
- Health check endpoint + simple status page on the tablet if server unreachable.
|
||||
13
client/admin.html
Normal file
13
client/admin.html
Normal 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
13
client/index.html
Normal 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
23
client/package.json
Normal 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
256
client/src/admin/Admin.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
4
client/src/admin/main.tsx
Normal file
4
client/src/admin/main.tsx
Normal 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
39
client/src/api.ts
Normal 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
157
client/src/styles.css
Normal 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
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')!);
|
||||
11
client/tsconfig.json
Normal file
11
client/tsconfig.json
Normal 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
32
client/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
36
flake.nix
Normal file
36
flake.nix
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
description = "wutzcalc — festival drink tracker dev shell";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f {
|
||||
inherit system;
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
});
|
||||
in {
|
||||
devShells = forAllSystems ({ pkgs, system }: {
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs_20
|
||||
pnpm
|
||||
sqlite
|
||||
sqlite-interactive
|
||||
python3
|
||||
pkg-config
|
||||
gcc
|
||||
gnumake
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "wutzcalc dev shell"
|
||||
echo " node $(node --version)"
|
||||
echo " pnpm $(pnpm --version)"
|
||||
echo " sqlite $(sqlite3 --version | cut -d' ' -f1)"
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
15
package.json
Normal file
15
package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "wutzcalc",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"scripts": {
|
||||
"dev": "pnpm -r --parallel dev",
|
||||
"dev:server": "pnpm --filter server dev",
|
||||
"dev:client": "pnpm --filter client dev",
|
||||
"build": "pnpm --filter client build && pnpm --filter server build",
|
||||
"start": "pnpm --filter server start",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
}
|
||||
}
|
||||
3811
pnpm-lock.yaml
generated
Normal file
3811
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue