diff --git a/README.md b/README.md index c26aa6f..4029716 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,17 @@ sudo corepack enable # provides pnpm sudo apt install -y build-essential python3 ``` +### Fedora + +```sh +# Node 20 + pnpm (corepack ships with the nodejs package) +sudo dnf install -y nodejs +sudo corepack enable # provides pnpm + +# Only needed if a prebuilt better-sqlite3 binary is unavailable for your arch +sudo dnf install -y gcc-c++ make python3 +``` + ### Windows Install Node.js 20 LTS via the official MSI from (this @@ -70,9 +81,46 @@ 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. +## Run as a systemd service + +Template files live in [`deploy/`](deploy/): a unit ([`wutzcalc.service`](deploy/wutzcalc.service)) +and an environment file ([`wutzcalc.env.example`](deploy/wutzcalc.env.example)). +They assume the built app lives in `/opt/wutzcalc` and the database in +`/var/lib/wutzcalc` — adjust paths in the unit if yours differ. + +```sh +# 1. Dedicated system user (no login, no home) +sudo useradd --system --no-create-home --shell /usr/sbin/nologin wutzcalc + +# 2. Install the built app (run `pnpm install && pnpm build` first) +sudo mkdir -p /opt/wutzcalc +sudo cp -a . /opt/wutzcalc # or rsync/checkout into place +sudo chown -R root:root /opt/wutzcalc # app dir stays read-only to the service + +# 3. Config + secrets (chmod 600 — holds ADMIN_PASSWORD) +sudo mkdir -p /etc/wutzcalc +sudo install -m 600 deploy/wutzcalc.env.example /etc/wutzcalc/wutzcalc.env +sudoedit /etc/wutzcalc/wutzcalc.env # set ADMIN_PASSWORD + +# 4. Install and start the unit +sudo cp deploy/wutzcalc.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now wutzcalc + +# Logs / status +systemctl status wutzcalc +journalctl -u wutzcalc -f +``` + +Confirm `ExecStart` matches your Node path (`command -v node`) — it defaults to +`/usr/bin/node`. The unit creates `/var/lib/wutzcalc` via `StateDirectory`, so +the service user owns the database directory automatically. + ## Env vars - `PORT` (default `3000`) - `HOST` (default `0.0.0.0`) - `DB_PATH` (default `./wutz.db`) - `ADMIN_PASSWORD` (**required** for backoffice login) +- `WUTZ_TZ` (default `Europe/Berlin`) — timezone for stats display and grouping +- `WUTZ_DAY_CUTOFF_HOUR` (default `5`) — sales before this local hour count toward the previous business day diff --git a/client/src/admin/Admin.tsx b/client/src/admin/Admin.tsx index 77be805..7a7a38b 100644 --- a/client/src/admin/Admin.tsx +++ b/client/src/admin/Admin.tsx @@ -10,6 +10,7 @@ interface Drink { id: number; name: string; price_cents: number; archived: numbe 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 } +interface ByDay { day: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number } export function Admin() { const [authed, setAuthed] = useState(null); @@ -76,12 +77,44 @@ function Dashboard({ onLogout }: { onLogout: () => void }) { ); } +function fmtDay(day: string): string { + const [y, m, d] = day.split('-'); + return `${d}.${m}.${y}`; +} + function Stats() { - const [data, setData] = useState<{ totals: Totals[]; per_drink: PerDrink[] } | null>(null); - useEffect(() => { fetch('/admin/api/stats').then(j).then(setData); }, []); + const [data, setData] = useState<{ totals: Totals[]; per_drink: PerDrink[]; by_day: ByDay[] } | null>(null); + function reload() { fetch('/admin/api/stats').then(j).then(setData); } + useEffect(reload, []); + + async function reset() { + if (!confirm('Wirklich die gesamte Statistik (alle Transaktionen) unwiderruflich löschen?')) return; + await fetch('/admin/api/stats/reset', { method: 'POST' }); + reload(); + } + if (!data) return

Lade Statistik…

; return ( <> +
+

Umsatz nach Tagen

+ +
+ + + + {data.by_day.length === 0 && } + {data.by_day.map(d => ( + + + + + + + + ))} + +
TagTransaktionenBezahltCrew-TransaktionenPfand zurück
Noch keine Verkäufe
{fmtDay(d.day)}{d.tx_count}{formatCents(d.paid_cents)}{d.crew_count}{d.pfand_returns}

Umsatz pro Bar

@@ -153,6 +186,13 @@ function Drinks() { reload(); } + async function del(d: Drink) { + if (!confirm(`Getränk „${d.name}“ wirklich löschen?`)) return; + const res = await fetch(`/admin/api/drinks/${d.id}`, { method: 'DELETE' }); + if (!res.ok) { alert(`Fehler: ${(await res.json().catch(() => null))?.error ?? await res.text()}`); return; } + reload(); + } + return ( <>

Getränke

@@ -174,9 +214,12 @@ function Drinks() { ))} @@ -298,6 +341,13 @@ function Bars() { reload(); } + async function delBar(b: BarRow) { + if (!confirm(`Tresen „${b.name}“ wirklich löschen?`)) return; + const res = await fetch(`/admin/api/bars/${b.id}`, { method: 'DELETE' }); + if (!res.ok) { alert(`Fehler: ${(await res.json().catch(() => null))?.error ?? await res.text()}`); return; } + reload(); + } + async function addBar() { const name = newName.trim(); if (!name) return; @@ -338,6 +388,7 @@ function Bars() { } /> + , document.getElementById('app')!); diff --git a/client/src/api.ts b/client/src/api.ts index 97ae68e..21a12d2 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -27,6 +27,22 @@ export function formatCents(cents: number): string { return `${sign}${(abs / 100).toFixed(2)} €`; } +export type Theme = 'dark' | 'light'; +const THEME_KEY = 'wutz.theme'; + +export function getTheme(): Theme { + return localStorage.getItem(THEME_KEY) === 'light' ? 'light' : 'dark'; +} + +export function applyTheme(t: Theme = getTheme()): void { + document.documentElement.setAttribute('data-theme', t); +} + +export function setTheme(t: Theme): void { + localStorage.setItem(THEME_KEY, t); + applyTheme(t); +} + export function uuid(): string { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { return (crypto as any).randomUUID(); diff --git a/client/src/styles.css b/client/src/styles.css index 05707c6..6686b87 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -1,12 +1,37 @@ * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } +:root { + --bg: #111; + --fg: #eee; + --surface: #1a1a1a; + --surface-2: #2a2a2a; + --surface-3: #3a3a3a; + --border: #444; + --border-soft: #333; + --pfand: #9ab; + --danger: #ff8a8a; +} + +:root[data-theme="light"] { + --bg: #f4f4f5; + --fg: #18181b; + --surface: #ffffff; + --surface-2: #e7e7ea; + --surface-3: #d8d8dc; + --border: #bcbcc2; + --border-soft: #dddde2; + --pfand: #4a6a8a; + --danger: #c0392b; +} + html, body, #app { margin: 0; padding: 0; height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #111; - color: #eee; + background: var(--bg); + color: var(--fg); + font-weight: 600; overscroll-behavior: none; -webkit-user-select: none; user-select: none; @@ -14,14 +39,15 @@ html, body, #app { button { font: inherit; + font-weight: 700; color: inherit; - background: #2a2a2a; - border: 1px solid #444; + background: var(--surface-2); + border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; cursor: pointer; } -button:active { background: #3a3a3a; } +button:active { background: var(--surface-3); } button:disabled { opacity: 0.4; } /* ---------- Tablet UI (portrait) ---------- */ @@ -47,7 +73,14 @@ button:disabled { opacity: 0.4; } .bar-picker button { width: 280px; height: 96px; - font-size: 28px; + font-size: 30px; +} +.theme-toggle { + margin-top: 24px; + background: transparent; + border: 1px solid var(--border); + font-size: 18px; + padding: 10px 18px; } .topbar { @@ -55,12 +88,12 @@ button:disabled { opacity: 0.4; } justify-content: space-between; align-items: center; padding: 10px 16px; - background: #1a1a1a; - border-bottom: 1px solid #333; + background: var(--surface); + border-bottom: 1px solid var(--border-soft); flex: 0 0 auto; } -.topbar .bar-name { font-size: 20px; font-weight: bold; } -.topbar .change { font-size: 14px; padding: 8px 12px; } +.topbar .bar-name { font-size: 26px; font-weight: 800; } +.topbar .change { font-size: 17px; padding: 10px 14px; } /* Drink grid fills available space with no scrolling — tiles auto-size to fit. 3 columns on portrait iPad (~768px); rows divide the remaining height equally. */ @@ -86,20 +119,22 @@ button:disabled { opacity: 0.4; } align-items: center; text-align: center; padding: 8px; - font-size: 18px; + font-size: 22px; } -.drink .name { font-weight: bold; line-height: 1.2; } -.drink .price { font-size: 14px; opacity: 0.7; margin-top: 6px; } +.drink .name { font-weight: 800; line-height: 1.15; } +.drink .price { font-size: 22px; font-weight: 700; margin-top: 6px; } +.drink .pfand { font-size: 15px; font-weight: 600; color: var(--pfand); margin-top: 2px; } .drink.pfand-return { background: #5a2a2a; border-color: #7a3a3a; + color: #fff; } .cart { flex: 0 0 38vh; - background: #1a1a1a; - border-top: 1px solid #333; + background: var(--surface); + border-top: 1px solid var(--border-soft); padding: 8px 16px; display: flex; flex-direction: column; @@ -118,17 +153,20 @@ button:disabled { opacity: 0.4; } display: flex; justify-content: space-between; padding: 3px 0; - font-size: 16px; + font-size: 20px; + font-weight: 600; } -.cart-line.return { color: #ff8a8a; } +.cart-line.pfand { color: var(--pfand); } +.cart-line.return { color: var(--danger); } .cart-line.muted { opacity: 0.5; } .cart-total { - font-size: 26px; - font-weight: bold; + font-size: 56px; + font-weight: 800; text-align: right; + line-height: 1.1; padding: 4px 0 8px; } -.cart-total.negative { color: #ff8a8a; } +.cart-total.negative { color: var(--danger); } .actions { display: flex; @@ -137,7 +175,9 @@ button:disabled { opacity: 0.4; } .actions button { flex: 1; height: 64px; - font-size: 18px; + font-size: 22px; + font-weight: 800; + color: #fff; } .actions .cancel { background: #5a2a2a; border-color: #7a3a3a; } .actions .confirm { background: #2a5a2a; border-color: #3a7a3a; } @@ -152,9 +192,9 @@ button:disabled { opacity: 0.4; } } .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 th, .admin td { padding: 6px 8px; border-bottom: 1px solid var(--border-soft); text-align: left; } .admin input, .admin select { - background: #1a1a1a; color: #eee; border: 1px solid #444; border-radius: 4px; padding: 6px; + background: var(--surface); color: var(--fg); border: 1px solid var(--border); 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; } @@ -167,8 +207,8 @@ button:disabled { opacity: 0.4; } gap: 8px; padding: 6px 8px; margin-bottom: 4px; - background: #1a1a1a; - border: 1px solid #333; + background: var(--surface); + border: 1px solid var(--border-soft); border-radius: 6px; cursor: grab; } diff --git a/client/src/tablet/BarPicker.tsx b/client/src/tablet/BarPicker.tsx index d10d584..5192d7c 100644 --- a/client/src/tablet/BarPicker.tsx +++ b/client/src/tablet/BarPicker.tsx @@ -1,4 +1,6 @@ +import { useState } from 'preact/hooks'; import type { Bar } from '@wutzcalc/shared'; +import { getTheme, setTheme } from '../api'; interface Props { bars: Bar[] | null; @@ -6,6 +8,14 @@ interface Props { } export function BarPicker({ bars, onPick }: Props) { + const [theme, setThemeState] = useState(getTheme()); + + function toggleTheme() { + const next = theme === 'dark' ? 'light' : 'dark'; + setTheme(next); + setThemeState(next); + } + return (

Bar auswählen

@@ -18,6 +28,9 @@ export function BarPicker({ bars, onPick }: Props) { )) )} +
); } diff --git a/client/src/tablet/Sale.tsx b/client/src/tablet/Sale.tsx index c13e97d..8c5d6dd 100644 --- a/client/src/tablet/Sale.tsx +++ b/client/src/tablet/Sale.tsx @@ -51,6 +51,8 @@ export function Sale({ config, onChangeBar }: Props) { setPfandReturns(0); } + const pfandCount = useMemo(() => lines.reduce((sum, l) => sum + l.qty, 0), [lines]); + const isEmpty = lines.length === 0 && pfandReturns === 0; async function confirm(crew: boolean) { @@ -84,7 +86,10 @@ export function Sale({ config, onChangeBar }: Props) { {drinks.map(d => ( ))}
BarTransaktionenBezahltCrew-TransaktionenPfand zurück
{d.archived ? 'archiviert' : 'aktiv'} - +
+ + +