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
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
packages:
|
||||
- server
|
||||
- client
|
||||
- shared
|
||||
42
server/migrations/001_init.sql
Normal file
42
server/migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
CREATE TABLE bars (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
pfand_cents INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE drinks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
price_cents INTEGER NOT NULL,
|
||||
archived INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE bar_drinks (
|
||||
bar_id INTEGER NOT NULL REFERENCES bars(id) ON DELETE CASCADE,
|
||||
drink_id INTEGER NOT NULL REFERENCES drinks(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (bar_id, drink_id)
|
||||
);
|
||||
|
||||
CREATE TABLE transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bar_id INTEGER NOT NULL REFERENCES bars(id),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
total_cents INTEGER NOT NULL,
|
||||
crew INTEGER NOT NULL DEFAULT 0,
|
||||
client_ip TEXT,
|
||||
client_uuid TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
drink_id INTEGER NOT NULL REFERENCES drinks(id),
|
||||
qty INTEGER NOT NULL,
|
||||
unit_price_cents INTEGER NOT NULL,
|
||||
pfand_cents_per_unit INTEGER NOT NULL,
|
||||
is_return INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tx_bar_created ON transactions(bar_id, created_at);
|
||||
CREATE INDEX idx_txitem_tx ON transaction_items(transaction_id);
|
||||
23
server/migrations/002_seed.sql
Normal file
23
server/migrations/002_seed.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
INSERT INTO bars (name, pfand_cents) VALUES
|
||||
('Hauptbar', 200),
|
||||
('Cocktailbar', 200),
|
||||
('Außenbar', 200);
|
||||
|
||||
INSERT INTO drinks (name, price_cents) VALUES
|
||||
('Bier 0.5l', 400),
|
||||
('Bier 0.3l', 300),
|
||||
('Radler', 400),
|
||||
('Wasser', 200),
|
||||
('Cola', 300),
|
||||
('Aperol Spritz', 700),
|
||||
('Gin Tonic', 800),
|
||||
('Schnaps', 250);
|
||||
|
||||
INSERT INTO bar_drinks (bar_id, drink_id, sort_order)
|
||||
SELECT 1, id, id FROM drinks WHERE name IN ('Bier 0.5l', 'Bier 0.3l', 'Radler', 'Wasser', 'Cola', 'Schnaps');
|
||||
|
||||
INSERT INTO bar_drinks (bar_id, drink_id, sort_order)
|
||||
SELECT 2, id, id FROM drinks WHERE name IN ('Aperol Spritz', 'Gin Tonic', 'Wasser', 'Cola');
|
||||
|
||||
INSERT INTO bar_drinks (bar_id, drink_id, sort_order)
|
||||
SELECT 3, id, id FROM drinks WHERE name IN ('Bier 0.5l', 'Wasser', 'Cola', 'Schnaps');
|
||||
1
server/migrations/003_pfand_returns.sql
Normal file
1
server/migrations/003_pfand_returns.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE transactions ADD COLUMN pfand_returns INTEGER NOT NULL DEFAULT 0;
|
||||
25
server/package.json
Normal file
25
server/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^9.3.1",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@wutzcalc/shared": "workspace:*",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"fastify": "^4.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/node": "^20.12.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
45
server/src/db.ts
Normal file
45
server/src/db.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export type DB = Database.Database;
|
||||
|
||||
export function openDb(path: string): DB {
|
||||
const db = new Database(path);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
function runMigrations(db: DB) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
const applied = new Set(
|
||||
db.prepare('SELECT name FROM _migrations').all().map((r: any) => r.name)
|
||||
);
|
||||
const migrationsDir = join(__dirname, '..', 'migrations');
|
||||
const files = readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
||||
const insert = db.prepare('INSERT INTO _migrations (name) VALUES (?)');
|
||||
for (const f of files) {
|
||||
if (applied.has(f)) continue;
|
||||
const sql = readFileSync(join(migrationsDir, f), 'utf8');
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
db.exec(sql);
|
||||
insert.run(f);
|
||||
db.exec('COMMIT');
|
||||
console.log(`migration applied: ${f}`);
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
58
server/src/index.ts
Normal file
58
server/src/index.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import Fastify from 'fastify';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import { openDb } from './db.js';
|
||||
import { registerPublicRoutes } from './routes/public.js';
|
||||
import { registerAdminRoutes } from './routes/admin.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3000);
|
||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||
const DB_PATH = process.env.DB_PATH ?? join(process.cwd(), 'wutz.db');
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const db = openDb(DB_PATH);
|
||||
|
||||
registerPublicRoutes(app, db);
|
||||
registerAdminRoutes(app, db);
|
||||
|
||||
app.get('/healthz', async () => ({ ok: true }));
|
||||
|
||||
// Static client (built by Vite into client/dist)
|
||||
const clientDist = join(__dirname, '..', '..', 'client', 'dist');
|
||||
if (existsSync(clientDist)) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: clientDist,
|
||||
prefix: '/',
|
||||
wildcard: false,
|
||||
});
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.url.startsWith('/api') || req.url.startsWith('/admin/api')) {
|
||||
reply.code(404).send({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
if (req.url.startsWith('/admin')) {
|
||||
reply.sendFile('admin.html');
|
||||
return;
|
||||
}
|
||||
reply.sendFile('index.html');
|
||||
});
|
||||
} else {
|
||||
app.log.warn(`client dist not found at ${clientDist} — run \`pnpm --filter client build\``);
|
||||
}
|
||||
|
||||
try {
|
||||
await app.listen({ port: PORT, host: HOST });
|
||||
app.log.info(`listening on http://${HOST}:${PORT}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
196
server/src/routes/admin.ts
Normal file
196
server/src/routes/admin.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { DB } from '../db.js';
|
||||
|
||||
const SESSION_COOKIE = 'wutz_admin';
|
||||
|
||||
function isAuthed(req: FastifyRequest): boolean {
|
||||
const expected = process.env.ADMIN_PASSWORD;
|
||||
if (!expected) return false;
|
||||
return req.cookies?.[SESSION_COOKIE] === expected;
|
||||
}
|
||||
|
||||
function requireAuth(req: FastifyRequest, reply: FastifyReply): boolean {
|
||||
if (isAuthed(req)) return true;
|
||||
reply.code(401).send({ error: 'unauthorized' });
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
||||
app.post<{ Body: { password?: string } }>('/admin/login', async (req, reply) => {
|
||||
const expected = process.env.ADMIN_PASSWORD;
|
||||
if (!expected) return reply.code(500).send({ error: 'ADMIN_PASSWORD not set' });
|
||||
if (req.body?.password !== expected) return reply.code(401).send({ error: 'wrong password' });
|
||||
reply.setCookie(SESSION_COOKIE, expected, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/admin/logout', async (_req, reply) => {
|
||||
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/admin/api/me', async (req) => ({ authed: isAuthed(req) }));
|
||||
|
||||
// ----- Drinks -----
|
||||
app.get('/admin/api/drinks', async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
return db
|
||||
.prepare('SELECT id, name, price_cents, archived FROM drinks ORDER BY archived, id')
|
||||
.all();
|
||||
});
|
||||
|
||||
app.post<{ Body: { name: string; price_cents: number } }>(
|
||||
'/admin/api/drinks',
|
||||
async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
const { name, price_cents } = req.body ?? ({} as any);
|
||||
if (!name || !Number.isInteger(price_cents))
|
||||
return reply.code(400).send({ error: 'invalid' });
|
||||
const info = db
|
||||
.prepare('INSERT INTO drinks (name, price_cents) VALUES (?, ?)')
|
||||
.run(name, price_cents);
|
||||
return { id: Number(info.lastInsertRowid) };
|
||||
}
|
||||
);
|
||||
|
||||
app.patch<{ Params: { id: string }; Body: { name?: string; price_cents?: number; archived?: boolean } }>(
|
||||
'/admin/api/drinks/:id',
|
||||
async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
const id = Number(req.params.id);
|
||||
const { name, price_cents, archived } = req.body ?? {};
|
||||
const sets: string[] = [];
|
||||
const args: any[] = [];
|
||||
if (name !== undefined) { sets.push('name = ?'); args.push(name); }
|
||||
if (price_cents !== undefined) { sets.push('price_cents = ?'); args.push(price_cents); }
|
||||
if (archived !== undefined) { sets.push('archived = ?'); args.push(archived ? 1 : 0); }
|
||||
if (sets.length === 0) return { ok: true };
|
||||
args.push(id);
|
||||
db.prepare(`UPDATE drinks SET ${sets.join(', ')} WHERE id = ?`).run(...args);
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
// ----- Bars -----
|
||||
app.get('/admin/api/bars', async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
const bars = db.prepare('SELECT id, name, pfand_cents FROM bars ORDER BY id').all() as any[];
|
||||
const drinkRows = db
|
||||
.prepare('SELECT bar_id, drink_id, sort_order FROM bar_drinks ORDER BY sort_order, drink_id')
|
||||
.all() as any[];
|
||||
return bars.map(b => ({
|
||||
...b,
|
||||
drink_ids: drinkRows.filter(d => d.bar_id === b.id).map(d => d.drink_id),
|
||||
}));
|
||||
});
|
||||
|
||||
app.patch<{ Params: { id: string }; Body: { pfand_cents?: number; drink_ids?: number[] } }>(
|
||||
'/admin/api/bars/:id',
|
||||
async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
const id = Number(req.params.id);
|
||||
const { pfand_cents, drink_ids } = req.body ?? {};
|
||||
db.transaction(() => {
|
||||
if (pfand_cents !== undefined) {
|
||||
db.prepare('UPDATE bars SET pfand_cents = ? WHERE id = ?').run(pfand_cents, id);
|
||||
}
|
||||
if (Array.isArray(drink_ids)) {
|
||||
db.prepare('DELETE FROM bar_drinks WHERE bar_id = ?').run(id);
|
||||
const ins = db.prepare(
|
||||
'INSERT INTO bar_drinks (bar_id, drink_id, sort_order) VALUES (?, ?, ?)'
|
||||
);
|
||||
drink_ids.forEach((did, idx) => ins.run(id, did, idx));
|
||||
}
|
||||
})();
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
// ----- Stats -----
|
||||
app.get('/admin/api/stats', async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
|
||||
const totals = db
|
||||
.prepare(
|
||||
`SELECT b.id AS bar_id, b.name AS bar_name,
|
||||
COUNT(*) AS tx_count,
|
||||
COALESCE(SUM(CASE WHEN crew = 0 THEN total_cents ELSE 0 END), 0) AS paid_cents,
|
||||
COALESCE(SUM(CASE WHEN crew = 1 THEN 1 ELSE 0 END), 0) AS crew_count,
|
||||
COALESCE(SUM(pfand_returns), 0) AS pfand_returns
|
||||
FROM transactions t
|
||||
JOIN bars b ON b.id = t.bar_id
|
||||
GROUP BY b.id, b.name
|
||||
ORDER BY b.id`
|
||||
)
|
||||
.all();
|
||||
|
||||
const perDrink = db
|
||||
.prepare(
|
||||
`SELECT d.id AS drink_id, d.name AS drink_name,
|
||||
COALESCE(SUM(ti.qty), 0) AS sold_qty
|
||||
FROM transaction_items ti
|
||||
JOIN drinks d ON d.id = ti.drink_id
|
||||
GROUP BY d.id, d.name
|
||||
ORDER BY sold_qty DESC, d.id`
|
||||
)
|
||||
.all();
|
||||
|
||||
return { totals, per_drink: perDrink };
|
||||
});
|
||||
|
||||
// ----- CSV export -----
|
||||
app.get<{ Querystring: { what?: string } }>('/admin/api/export.csv', async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
const what = req.query.what === 'items' ? 'items' : 'transactions';
|
||||
|
||||
let rows: any[];
|
||||
let header: string[];
|
||||
if (what === 'transactions') {
|
||||
header = ['id', 'bar_id', 'bar_name', 'created_at', 'total_cents', 'crew', 'pfand_returns', 'client_ip', 'client_uuid'];
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT t.id, t.bar_id, b.name AS bar_name, t.created_at, t.total_cents,
|
||||
t.crew, t.pfand_returns, t.client_ip, t.client_uuid
|
||||
FROM transactions t JOIN bars b ON b.id = t.bar_id
|
||||
ORDER BY t.id`
|
||||
)
|
||||
.all() as any[];
|
||||
} else {
|
||||
header = [
|
||||
'transaction_id', 'bar_id', 'created_at', 'drink_id', 'drink_name',
|
||||
'qty', 'unit_price_cents', 'pfand_cents_per_unit',
|
||||
];
|
||||
rows = db
|
||||
.prepare(
|
||||
`SELECT ti.transaction_id, t.bar_id, t.created_at, ti.drink_id, d.name AS drink_name,
|
||||
ti.qty, ti.unit_price_cents, ti.pfand_cents_per_unit
|
||||
FROM transaction_items ti
|
||||
JOIN transactions t ON t.id = ti.transaction_id
|
||||
JOIN drinks d ON d.id = ti.drink_id
|
||||
ORDER BY ti.transaction_id, ti.id`
|
||||
)
|
||||
.all() as any[];
|
||||
}
|
||||
|
||||
const csv = [header.join(',')]
|
||||
.concat(rows.map(r => header.map(h => csvCell(r[h])).join(',')))
|
||||
.join('\n');
|
||||
|
||||
reply
|
||||
.header('Content-Type', 'text/csv; charset=utf-8')
|
||||
.header('Content-Disposition', `attachment; filename="${what}.csv"`)
|
||||
.send(csv);
|
||||
});
|
||||
}
|
||||
|
||||
function csvCell(v: unknown): string {
|
||||
if (v === null || v === undefined) return '';
|
||||
const s = String(v);
|
||||
if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
||||
return s;
|
||||
}
|
||||
132
server/src/routes/public.ts
Normal file
132
server/src/routes/public.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import type { DB } from '../db.js';
|
||||
import type {
|
||||
Bar,
|
||||
BarConfig,
|
||||
CreateTransactionRequest,
|
||||
CreateTransactionResponse,
|
||||
Drink,
|
||||
} from '@wutzcalc/shared';
|
||||
|
||||
export function registerPublicRoutes(app: FastifyInstance, db: DB) {
|
||||
app.get('/api/bars', async () => {
|
||||
const rows = db.prepare('SELECT id, name, pfand_cents FROM bars ORDER BY id').all() as Bar[];
|
||||
return rows;
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { bar: string } }>('/api/config', async (req, reply) => {
|
||||
const barId = Number(req.query.bar);
|
||||
if (!Number.isInteger(barId)) return reply.code(400).send({ error: 'bar required' });
|
||||
|
||||
const bar = db
|
||||
.prepare('SELECT id, name, pfand_cents FROM bars WHERE id = ?')
|
||||
.get(barId) as Bar | undefined;
|
||||
if (!bar) return reply.code(404).send({ error: 'bar not found' });
|
||||
|
||||
const drinks = db
|
||||
.prepare(
|
||||
`SELECT d.id, d.name, d.price_cents, d.archived
|
||||
FROM drinks d
|
||||
JOIN bar_drinks bd ON bd.drink_id = d.id
|
||||
WHERE bd.bar_id = ? AND d.archived = 0
|
||||
ORDER BY bd.sort_order, d.id`
|
||||
)
|
||||
.all(barId) as Drink[];
|
||||
|
||||
const config: BarConfig = { bar, drinks };
|
||||
return config;
|
||||
});
|
||||
|
||||
app.post<{ Body: CreateTransactionRequest }>('/api/transactions', async (req, reply) => {
|
||||
const body = req.body ?? ({} as CreateTransactionRequest);
|
||||
const { client_uuid, bar_id, crew, items } = body;
|
||||
const pfand_returns = Math.max(0, Math.floor(body.pfand_returns ?? 0));
|
||||
|
||||
if (!client_uuid || typeof client_uuid !== 'string') {
|
||||
return reply.code(400).send({ error: 'client_uuid required' });
|
||||
}
|
||||
if (!Number.isInteger(bar_id)) {
|
||||
return reply.code(400).send({ error: 'bar_id required' });
|
||||
}
|
||||
if (!Array.isArray(items)) {
|
||||
return reply.code(400).send({ error: 'items required' });
|
||||
}
|
||||
if (items.length === 0 && pfand_returns === 0) {
|
||||
return reply.code(400).send({ error: 'empty transaction' });
|
||||
}
|
||||
|
||||
const existing = db
|
||||
.prepare('SELECT id, total_cents FROM transactions WHERE client_uuid = ?')
|
||||
.get(client_uuid) as { id: number; total_cents: number } | undefined;
|
||||
if (existing) {
|
||||
const res: CreateTransactionResponse = { id: existing.id, total_cents: existing.total_cents };
|
||||
return res;
|
||||
}
|
||||
|
||||
const bar = db
|
||||
.prepare('SELECT id, pfand_cents FROM bars WHERE id = ?')
|
||||
.get(bar_id) as { id: number; pfand_cents: number } | undefined;
|
||||
if (!bar) return reply.code(404).send({ error: 'bar not found' });
|
||||
|
||||
const drinkStmt = db.prepare(
|
||||
`SELECT d.id, d.price_cents
|
||||
FROM drinks d
|
||||
JOIN bar_drinks bd ON bd.drink_id = d.id
|
||||
WHERE bd.bar_id = ? AND d.id = ? AND d.archived = 0`
|
||||
);
|
||||
|
||||
let total = 0;
|
||||
const priced: Array<{
|
||||
drink_id: number;
|
||||
qty: number;
|
||||
unit_price_cents: number;
|
||||
pfand_cents_per_unit: number;
|
||||
}> = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (!Number.isInteger(item.drink_id) || !Number.isInteger(item.qty) || item.qty <= 0) {
|
||||
return reply.code(400).send({ error: 'invalid item' });
|
||||
}
|
||||
const drink = drinkStmt.get(bar_id, item.drink_id) as
|
||||
| { id: number; price_cents: number }
|
||||
| undefined;
|
||||
if (!drink) return reply.code(400).send({ error: `drink ${item.drink_id} not at bar` });
|
||||
|
||||
const lineUnit = drink.price_cents + bar.pfand_cents;
|
||||
total += lineUnit * item.qty;
|
||||
priced.push({
|
||||
drink_id: drink.id,
|
||||
qty: item.qty,
|
||||
unit_price_cents: drink.price_cents,
|
||||
pfand_cents_per_unit: bar.pfand_cents,
|
||||
});
|
||||
}
|
||||
|
||||
total -= bar.pfand_cents * pfand_returns;
|
||||
|
||||
const paidTotal = crew ? 0 : total;
|
||||
const clientIp = req.ip ?? null;
|
||||
|
||||
const insertTx = db.prepare(
|
||||
`INSERT INTO transactions (bar_id, total_cents, crew, pfand_returns, client_ip, client_uuid)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const insertItem = db.prepare(
|
||||
`INSERT INTO transaction_items
|
||||
(transaction_id, drink_id, qty, unit_price_cents, pfand_cents_per_unit, is_return)
|
||||
VALUES (?, ?, ?, ?, ?, 0)`
|
||||
);
|
||||
|
||||
const txId = db.transaction(() => {
|
||||
const info = insertTx.run(bar_id, paidTotal, crew ? 1 : 0, pfand_returns, clientIp, client_uuid);
|
||||
const id = Number(info.lastInsertRowid);
|
||||
for (const p of priced) {
|
||||
insertItem.run(id, p.drink_id, p.qty, p.unit_price_cents, p.pfand_cents_per_unit);
|
||||
}
|
||||
return id;
|
||||
})();
|
||||
|
||||
const res: CreateTransactionResponse = { id: txId, total_cents: paidTotal };
|
||||
return res;
|
||||
});
|
||||
}
|
||||
11
server/tsconfig.json
Normal file
11
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
17
shared/package.json
Normal file
17
shared/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "@wutzcalc/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
55
shared/src/index.ts
Normal file
55
shared/src/index.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
export interface Bar {
|
||||
id: number;
|
||||
name: string;
|
||||
pfand_cents: number;
|
||||
}
|
||||
|
||||
export interface Drink {
|
||||
id: number;
|
||||
name: string;
|
||||
price_cents: number;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export interface BarConfig {
|
||||
bar: Bar;
|
||||
drinks: Drink[];
|
||||
}
|
||||
|
||||
export interface CartItem {
|
||||
drink_id: number;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface CreateTransactionRequest {
|
||||
client_uuid: string;
|
||||
bar_id: number;
|
||||
crew: boolean;
|
||||
items: CartItem[];
|
||||
pfand_returns: number;
|
||||
}
|
||||
|
||||
export interface CreateTransactionResponse {
|
||||
id: number;
|
||||
total_cents: number;
|
||||
}
|
||||
|
||||
export interface TransactionItemRecord {
|
||||
drink_id: number;
|
||||
drink_name: string;
|
||||
qty: number;
|
||||
unit_price_cents: number;
|
||||
pfand_cents_per_unit: number;
|
||||
}
|
||||
|
||||
export interface TransactionRecord {
|
||||
id: number;
|
||||
bar_id: number;
|
||||
bar_name: string;
|
||||
created_at: string;
|
||||
total_cents: number;
|
||||
crew: boolean;
|
||||
pfand_returns: number;
|
||||
client_ip: string | null;
|
||||
items: TransactionItemRecord[];
|
||||
}
|
||||
7
shared/tsconfig.json
Normal file
7
shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
14
tsconfig.base.json
Normal file
14
tsconfig.base.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue