diff --git a/README.md b/README.md index 4029716..c26aa6f 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,6 @@ 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 @@ -81,46 +70,9 @@ 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 7a7a38b..77be805 100644 --- a/client/src/admin/Admin.tsx +++ b/client/src/admin/Admin.tsx @@ -10,7 +10,6 @@ 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); @@ -77,44 +76,12 @@ 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[]; 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(); - } - + const [data, setData] = useState<{ totals: Totals[]; per_drink: PerDrink[] } | null>(null); + useEffect(() => { fetch('/admin/api/stats').then(j).then(setData); }, []); 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

@@ -186,13 +153,6 @@ 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

@@ -214,12 +174,9 @@ function Drinks() { ))} @@ -341,13 +298,6 @@ 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; @@ -388,7 +338,6 @@ function Bars() { } /> - , document.getElementById('app')!); diff --git a/client/src/api.ts b/client/src/api.ts index 21a12d2..97ae68e 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -27,22 +27,6 @@ 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 6686b87..05707c6 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -1,37 +1,12 @@ * { 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: var(--bg); - color: var(--fg); - font-weight: 600; + background: #111; + color: #eee; overscroll-behavior: none; -webkit-user-select: none; user-select: none; @@ -39,15 +14,14 @@ html, body, #app { button { font: inherit; - font-weight: 700; color: inherit; - background: var(--surface-2); - border: 1px solid var(--border); + background: #2a2a2a; + border: 1px solid #444; border-radius: 8px; padding: 12px 16px; cursor: pointer; } -button:active { background: var(--surface-3); } +button:active { background: #3a3a3a; } button:disabled { opacity: 0.4; } /* ---------- Tablet UI (portrait) ---------- */ @@ -73,14 +47,7 @@ button:disabled { opacity: 0.4; } .bar-picker button { width: 280px; height: 96px; - font-size: 30px; -} -.theme-toggle { - margin-top: 24px; - background: transparent; - border: 1px solid var(--border); - font-size: 18px; - padding: 10px 18px; + font-size: 28px; } .topbar { @@ -88,12 +55,12 @@ button:disabled { opacity: 0.4; } justify-content: space-between; align-items: center; padding: 10px 16px; - background: var(--surface); - border-bottom: 1px solid var(--border-soft); + background: #1a1a1a; + border-bottom: 1px solid #333; flex: 0 0 auto; } -.topbar .bar-name { font-size: 26px; font-weight: 800; } -.topbar .change { font-size: 17px; padding: 10px 14px; } +.topbar .bar-name { font-size: 20px; font-weight: bold; } +.topbar .change { font-size: 14px; padding: 8px 12px; } /* 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. */ @@ -119,22 +86,20 @@ button:disabled { opacity: 0.4; } align-items: center; text-align: center; padding: 8px; - font-size: 22px; + font-size: 18px; } -.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 .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; - color: #fff; } .cart { flex: 0 0 38vh; - background: var(--surface); - border-top: 1px solid var(--border-soft); + background: #1a1a1a; + border-top: 1px solid #333; padding: 8px 16px; display: flex; flex-direction: column; @@ -153,20 +118,17 @@ button:disabled { opacity: 0.4; } display: flex; justify-content: space-between; padding: 3px 0; - font-size: 20px; - font-weight: 600; + font-size: 16px; } -.cart-line.pfand { color: var(--pfand); } -.cart-line.return { color: var(--danger); } +.cart-line.return { color: #ff8a8a; } .cart-line.muted { opacity: 0.5; } .cart-total { - font-size: 56px; - font-weight: 800; + font-size: 26px; + font-weight: bold; text-align: right; - line-height: 1.1; padding: 4px 0 8px; } -.cart-total.negative { color: var(--danger); } +.cart-total.negative { color: #ff8a8a; } .actions { display: flex; @@ -175,9 +137,7 @@ button:disabled { opacity: 0.4; } .actions button { flex: 1; height: 64px; - font-size: 22px; - font-weight: 800; - color: #fff; + font-size: 18px; } .actions .cancel { background: #5a2a2a; border-color: #7a3a3a; } .actions .confirm { background: #2a5a2a; border-color: #3a7a3a; } @@ -192,9 +152,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 var(--border-soft); text-align: left; } +.admin th, .admin td { padding: 6px 8px; border-bottom: 1px solid #333; text-align: left; } .admin input, .admin select { - background: var(--surface); color: var(--fg); border: 1px solid var(--border); border-radius: 4px; padding: 6px; + 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; } @@ -207,8 +167,8 @@ button:disabled { opacity: 0.4; } gap: 8px; padding: 6px 8px; margin-bottom: 4px; - background: var(--surface); - border: 1px solid var(--border-soft); + background: #1a1a1a; + border: 1px solid #333; border-radius: 6px; cursor: grab; } diff --git a/client/src/tablet/BarPicker.tsx b/client/src/tablet/BarPicker.tsx index 5192d7c..d10d584 100644 --- a/client/src/tablet/BarPicker.tsx +++ b/client/src/tablet/BarPicker.tsx @@ -1,6 +1,4 @@ -import { useState } from 'preact/hooks'; import type { Bar } from '@wutzcalc/shared'; -import { getTheme, setTheme } from '../api'; interface Props { bars: Bar[] | null; @@ -8,14 +6,6 @@ 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

@@ -28,9 +18,6 @@ export function BarPicker({ bars, onPick }: Props) { )) )} -
); } diff --git a/client/src/tablet/Sale.tsx b/client/src/tablet/Sale.tsx index 8c5d6dd..c13e97d 100644 --- a/client/src/tablet/Sale.tsx +++ b/client/src/tablet/Sale.tsx @@ -51,8 +51,6 @@ 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) { @@ -86,10 +84,7 @@ export function Sale({ config, onChangeBar }: Props) { {drinks.map(d => ( ))}
BarTransaktionenBezahltCrew-TransaktionenPfand zurück
{d.archived ? 'archiviert' : 'aktiv'} -
- - -
+