- scripts/pack-bundle.mjs zips dist-native deterministically (sorted paths,
fixed mtimes) into dist/downloads/waiter-bundle-<hash>.zip and writes
waiter-bundle.json {format, version "<APP_VERSION>-<hash12>", file,
sha256 (hex), size, min_shell_build, app_version, built_at}
- src/native/shell.js: MIN_SHELL_BUILD - lowest APK versionCode a bundle
runs in (bump with native changes)
- Dockerfile: node 22; builds web + native bundle + pack (APP_VERSION build
arg, from ${VERSION} in docker-compose.dev.yml)
- vite native mode strips public/downloads from dist-native - the 10.8 MB
APK was being copied into the native build (and would have ended up inside
the next APK); bundle 15 MB → 5 MB
- nginx /downloads/: json/zip types, CORS * (the app reads the manifest from
origin http://localhost), no-cache
- .dockerignore: android/, releases/, dist-native/ out of the build context
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The native app downloads per-venue UI bundles from the plain-HTTP LAN port
(integrity via the sha256 in the manifest it fetched over pinned TLS), so it
needs to know the published port. compose passes HTTP_PORT to the backend.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Android: MainActivity installs a WebViewClient whose onReceivedSslError
proceeds only when the server's SPKI SHA-256 is in TrustStore (pins of all
paired venues, SharedPreferences); XeniaTlsPlugin lets JS set that list.
Spike showed this one callback covers fetch/XHR, images AND WebSockets.
Pins are per key, not per address: IP changes, renewals and expiry never
need action; a different key is always refused.
- Pairing: QR key (k=) must equal the server's advertised pin, else refused;
typed addresses trust the advertised key on first use. Then the venue moves
to https://<ip>:<tls.port> (stays on HTTP if that port isn't reachable yet).
- upgradeToTls(): on every start, a plain-HTTP venue moves onto TLS once the
server offers it (never accepting a key different from the stored one).
- main.jsx pushes the venues' pins to native before the first request.
- Rediscovery made proactive: checks the saved address at start and every
minute while the live connection is down, instead of waiting for requests
to an unanswered IP to time out (minutes). HTTPS probes get 5s: the first
TLS connection in a fresh process takes ~2s (measured).
E2E on the emulator vs an isolated stack: wrong-key QR refused; pairing on
TLS; tables + wss live; impostor server with another key refused natively;
HTTP venue upgraded on start; server cert renewed (same key) - app keeps
working without re-pairing; rediscovery over TLS (11s). Step 5 HTTP
rediscovery (now 2.7s/4.8s) and cold-start login tests, and web modes, pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The native app refuses a server whose key differs from the QR, then talks
to it encrypted on :8443.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- new :8443 server (both copies byte-identical): TLS with the backend's
data/tls key+cert, LAN-only allow list, proxies the whole waiter
origin incl. /api/ws/ upgrades
- compose: backend healthcheck; proxy waits for backend healthy (TLS files
exist) and mounts ${DATA_PATH}/tls read-only; publishes 8443;
TLS_PORT passed to the backend so it advertises the published port
Verified on an isolated stack: served key == advertised pin, identity and
WebSocket over TLS, proxy starts only after the backend is healthy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
services/tls_identity.py creates an EC P-256 key + self-signed cert (10y,
SAN localhost/127.0.0.1/HOST_IP) under <data dir>/tls at startup, and
re-issues the cert with the SAME key when < 2 years remain. A cert that
doesn't belong to the key is replaced. Nothing to renew by hand; a backup of
the data directory keeps the identity. Runs in lifespan before the app is
healthy, so the proxy (which waits for healthy) always finds the files.
/api/system/identity and /api/system/status now include
tls: {port: TLS_PORT (default 8443), spki_sha256} - the base64 SHA-256 of the
public key that phones pin. Adds cryptography==46.0.4.
Tests: create / restart (no change) / renewal 8 years later keeps the key
and pin / foreign cert replaced; pin equals openssl's SPKI sha256.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the saved server address stops answering, the native app re-checks it
once and then probes every address in the same /24 (same scheme and port,
nearest first, 48 in parallel, 1.5s timeout) for /api/system/identity,
switching only to the server that reports THIS venue's site_id - never to
"any Xenia server". A found address is saved on the venue and the app
reloads onto the same screen (/offline → start page) after a short
"Ο server άλλαξε διεύθυνση" notice; the offline queue syncs after reload.
- src/native/rediscovery.js: subnet candidates + scan (pure, injectable)
- src/native/autoRediscover.js: re-check first, one scan at a time,
automatic attempts at most once a minute; skipped for dev / site-less venues
- ServerRediscovery: triggers when the connection is confirmed offline
(retry every 2 min) or any request fails with a network error
- Offline page: manual "Αναζήτηση server στο δίκτυο" with progress and
"not found → scan the QR" guidance
Tests: 11 unit checks (ordering, port kept, non-IP hosts, other venue never
chosen, early stop, progress). Emulator E2E with a real address change
(.99 → .2): logged in → found in ~3.5s, back on /tables with live WS;
logged out → found in ~6.5s, waiter list loads; server really down →
address untouched, manual search reports not found. Web modes unaffected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AuthRehydrator called logout() on ANY failure of /api/auth/me, including a
network error. So opening the app while WiFi was flaky, while the server was
restarting, or right after its IP changed silently threw the waiter back to
the login screen and offline mode could not survive an app restart. Now a
network error keeps the token and retries every 5s; a real server answer
(401 and other errors) still ends the session as before.
Verified on the Android emulator: cold start with the backend stopped keeps
the token; starting the backend brings the app to /tables on its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
App Info gets its own LAN card: the IP phones use and where it comes from
(manual / automatic detection / .env / browser address), an inline editor
to pin it or return to automatic, a note when detection is unavailable
(Docker Desktop, helper stopped/stale), and an amber warning with a one-click
fix when the pinned address is no longer this machine's. Waiter URL + pairing
QR and manager URL moved here from the System grid.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backend's bridge network can't see the host's NICs, so HOST_IP from
install.sh went stale silently after a DHCP change. Now:
- services/netinfo_helper.py runs as a new `netinfo` service (same backend
image, network_mode: host): every 60s it picks the PHYSICAL LAN address
(main-table default-route NIC if real hardware, else first real NIC with
IPv4; never WireGuard/Tailscale/ZeroTier/bridges/veths, ignores 169.254)
and writes it to the shared `netinfo` volume. Stdlib only. On Docker
Desktop (linuxkit/WSL2 kernel) it reports "unsupported" instead of the
VM's meaningless address.
- services/lan_ip.py: one resolver used by /api/system/status (lan_ip +
lan_ip_info), the pairing QR and the cloud heartbeat's local_ip:
override (pos_settings network.lan_ip_override) → live detection (ignored
when older than 5 min) → HOST_IP. Flags `mismatch` when a pinned address
is no longer on any of the machine's NICs.
- PUT /api/system/lan-ip-override (manager): set a private IPv4 or null to
return to automatic; public/loopback/link-local/IPv6 rejected (422).
- cloud_sync._get_local_ip uses the resolver (no more socket trick that
returned the container IP).
Tested: helper selection on a fake sysfs/route table (8 cases incl. VPN
default routes) + real ioctl/route parsing on a Linux kernel; resolver
priority/staleness/mismatch/validation (18 cases); isolated full stack:
HOST_IP fallback on Docker Desktop, override save/validate/auth, simulated
Linux detection incl. DHCP change and dead helper, heartbeat IP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ip route get 1.1.1.1` returns the tunnel address on servers running a
full-tunnel WireGuard/Tailscale/ZeroTier client, so phones got a QR code and
URL pointing at an address they can't reach. Detection now uses the
main-table default route's interface if it is real hardware
(/sys/class/net/<if>/device), else the first physical NIC with an IPv4,
else `hostname -I` as a last resort (the installer shows it for
confirmation). .env.example says HOST_IP must be the physical LAN address.
Tested with a stubbed `ip` + fake sysfs in Debian: wg-quick full tunnel,
tunnel owning the default route, no default route and WiFi-only all pick
the physical address; full install.sh scenarios unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- First run in the native app shows only the pairing screen (VenuesPage):
scan the manager's pairing QR or type the server address; the server must
answer /api/system/identity as xenia-pos with a supported api_version, and
a QR's ?pair=<site_id> must match - a code from another venue is refused
- /venues (native only): list of paired venues, switch (reloads into that
venue's own token/IndexedDB), remove (data kept - may hold unsynced
orders). Reachable from the login screen, the user menu and the offline page
- QrScannerModal: WebView camera via qr-scanner (no Google Play Services
dependency, carries over to iOS); requests the camera once up front so the
Android prompt appears a single time and "denied" gets its own message
- Hardware back: @capacitor/app listener - no-op on root screens (/, /tables,
/login), otherwise history back. Path-based because Chrome's history
intervention makes the WebView report canGoBack=false for the sentinel
entry AndroidBackGuard pushes
- Before pairing no IndexedDB is opened, so nothing is created under the
un-namespaced name
- InstallAppBanner now shows in plain-HTTP browser mode only when the venue
server actually serves the APK (HEAD /downloads/xenia-waiter.apk)
Verified on an Android 15 emulator (API 35) with real taps via adb and state
via WebView DevTools: wrong address -> error; other venue's QR -> refused;
pairing -> venue-namespaced storage, live WebSocket; back on /tables keeps the
app open; restart keeps venue + session; second venue pairs logged-out and
switching back keeps venue 1's session; camera deny -> one prompt + message;
allow -> live preview. Signed release APK installed and paired. Web modes
(same-origin, plain-HTTP LAN IP, VITE_SERVER_URL) re-verified in Edge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- capacitor.config.json: appId gr.bonamin.xenia, webDir dist-native,
androidScheme http (origin http://localhost - no mixed-content block when
calling venue servers over plain HTTP on the LAN)
- vite: `--mode native` builds to dist-native with the PWA plugin disabled
(no service worker inside the app)
- Android: minSdk 24 / target 36; CAMERA permission for QR pairing;
cleartext allowed via network_security_config (LAN IPs can't be listed
per-domain); allowBackup=false so backups never carry login tokens
- Release signing reads ~/.xenia/keystore.properties (override with
XENIA_KEYSTORE_PROPS) - the key never enters the repo; versionName 1.0.0 /
versionCode 1 with a bump-both rule for sideloaded updates
- npm scripts build:native, apk:debug, apk:release (scripts/build-apk.mjs);
APKs land in releases/ (gitignored); release APK is also copied to
public/downloads/ so venue servers serve it at /downloads/xenia-waiter.apk
- waiter nginx: /downloads/ served as application/vnd.android.package-archive,
real 404 when missing (never falls through to index.html)
- launcher icons + splash generated from the app icon (assets/ is the source)
- .gitattributes: gradlew LF, *.bat CRLF
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
App Info now lists 'Σερβιτόροι (WiFi)' http://<LAN IP> with a QR code
encoding http://<LAN IP>/?pair=<site_id> (phone camera opens the waiter app;
the native app will read the same code) and 'Διαχείριση (WiFi)'
http://<LAN IP>/manager. The LAN IP comes from HOST_IP, else from the
dashboard's own address when opened by IP; otherwise a hint to set HOST_IP.
QRModal takes an optional caption; the legacy waiter-domain QR is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /api/system/identity (unauthenticated, license-exempt) returns
{app: 'xenia-pos', site_id, venue_name, version, api_version: 1} so a phone
can confirm which venue a server is when pairing and when rediscovering the
server after an IP change. No secrets: SITE_KEY never leaves the server.
/api/system/status now also returns site_id and lan_ip (HOST_IP only - in
Docker any auto-detected address is the unreachable bridge IP).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phones can now open the waiter app at http://<LAN IP> with no domain, DNS
record or certificate (works around DNS-rebinding failures, KI-001). The
manager gets http://<LAN IP>:8081, with http://<LAN IP>/manager redirecting
there. waiter.*/manager.* hostnames on :80 still redirect to https, so
legacy domain sites behave as before.
- Both plain-HTTP servers are LAN-only (allow RFC1918/loopback/ULA/link-local,
deny all -> 403), so a router port-forward can't expose an unencrypted POS
- nginx-proxy/nginx.conf and the install.sh heredoc are now byte-identical
(one canonical config, routing map in its header)
- install.sh generates a 10-year self-signed cert when certs/ is empty (nginx
won't start its TLS listeners without one), detects HOST_IP via
'ip route get', prompts for it on fresh installs and backfills it into an
existing .env, always starts the stack, prints the LAN URLs
- docker-compose publishes 8081; .env.example documents HOST_IP
- pack README: ports/request path updated, CS-5 byte-identical check
Verified: nginx -t; install.sh in Debian (fresh / upgrade without HOST_IP /
re-run - no duplicate HOST_IP, cert SAN includes HOST_IP, key 600); full
stack from freshly built images: every entry point returns the expected
200/301/302, and removing the gateway's range from the allow list yields 403
on :80 and :8081.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New waiter_pwa/src/config/server.js is the single place that knows where the
backend is. Served by the venue's server (https domain or http://<LAN IP>)
nothing changes: same-origin URLs and the original storage keys, so existing
installs keep their token and unsynced offline queue. With an active venue
(native app, or dev builds with VITE_SERVER_URL) URLs become absolute and all
venue data is namespaced by siteId: token/savedUsername keys, the Dexie DB
(pos_snapshot__<siteId>, which also covers the WS cursor), favorites and
table-view prefs. Switching venue reloads the app.
- api client baseURL, WebSocket and SSE URLs routed through the layer
- product images / waiter avatars rendered via assetUrl()
- service-worker update prompt skipped in native builds
- InstallAppBanner: shown only in plain-HTTP browser mode and only when
VITE_APP_DOWNLOAD_URL is set at build time (dismiss for 7 days)
- VITE_SERVER_URL override is DEV-only (a URL-controlled server in prod would
let a crafted link capture PINs)
- pack README: rule CS-8 on never assuming same-origin
Verified with Playwright/Edge against a local backend: prod build same-origin,
prod build via LAN IP over plain HTTP (insecure context, no SW, banner shown),
and dev build pointed at the backend by URL - all three log in, reach /tables
and receive the WebSocket 'ready' frame; storage keys and IndexedDB names are
as expected. Lint: no new problems (103 before/after). Build passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
install.sh is copied from the Windows dev machine to Linux client boxes; with
core.autocrlf=true a fresh checkout would give it CRLF endings and bash would
fail on it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The nginx config written by install.sh proxied waiter.*, manager.* and the
IP default_server without proxy_http_version 1.1 or Upgrade/Connection
headers, so /api/ws/connect never upgraded and live events (new orders,
KDS status, chat, phone calls) never reached waiters or the manager. The
repo's nginx-proxy/nginx.conf had the same gap on the manager block.
Both configs now use a $connection_upgrade map, 1h read/send timeouts and
proxy_buffering off (SSE) on every proxied location. Verified with nginx -t
and a header-echo upstream: old config strips Upgrade, new one forwards it.
Existing sites: copy the new install.sh, re-run it, restart the proxy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Documents the request path through proxy -> waiter nginx -> backend,
the _run_migrations requirement, real-time event handling, offline
expectations and the duplicated nginx config in install.sh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
local_backend now uploads each product's image file to cloud_backend
during the existing ~5 min menu sync, so the QR menu can show real
photos without needing a manually-set digital_image_url. Only
re-uploads images whose content hash changed since the last push
(Product.cloud_image_hash), to avoid re-sending unchanged binaries
every cycle.
Also adds a manual "sync now" trigger (POST /api/system/sync-menu) and
a matching button in Settings → Operation, for pushing menu/price/image
changes immediately instead of waiting for the next automatic cycle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Backend: add cancellation_events (row count) to _enrich_shift and shift summary
- Backend: add cancellation_events field to current business day endpoint
- Today tab: hero number now shows cancellation_events (distinct cancel actions),
sub-label shows 'X παραγγελίες / Y είδη'
- ShiftsOverview: Λεπτομέρειες button is now icon-only (Eye, light blue) matching
the delete button style
- ShiftDetailModal: modal widened to 1080px; ΠΑΡΑΔΟΘΗΚΑΝ KPI replaced with
Ακυρώσεις showing 'X events / Y items'; all filter labels renamed (Πλήρης
Παραγγελία, Μόνο Πληρώθηκε, Μόνο Παρήγγειλε, Ανοιχτό ακόμη); new Ακυρώθηκε
filter added; cancelled items shown in red, Πληρώθηκε line hidden for cancelled;
legend updated; classify() handles cancelled status before other checks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- OrderItem model: add cancelled_at, cancelled_by, cancel_reason columns
(migration existed but model was missing them — caused 500 on cancellations_log)
- shifts.py _enrich_shift: count cancellations by quantity sum (not row count),
add cancellation_value (sum of unit_price * quantity)
- reports.py current business day: add cancelled_items count (per-item quantity sum,
across all orders in the day, not just fully-cancelled orders)
Manager dashboard:
- PrintFontsTab: fix SSE auth token key (access_token → manager_token, was causing 403)
- Today: show cancelled_items count as sub-label on Ακυρώσεις stat card
- OrderHistory: items/cancellations columns now use quantity sum, not row count
- WorkDaySummary drill-down: same quantity-sum fix
- ShiftsOverview: add Αξία Ακυρ. column next to Ακυρώσεις
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- settings.py: add orders.waiter_cancellations_allowed to VALID_SETTINGS
(was missing, causing 400 on every PUT attempt)
- StaffTab.jsx: include can_cancel_orders in edit waiter mutation payload
(was omitted, so the checkbox change was never sent to the backend)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- Add can_cancel_orders to User model and schema
- Add global orders.waiter_cancellations_allowed setting (migration)
- Cancel endpoints: mark items with cancelled_by/cancelled_at, fire cancellation print
- print_cancellation_ticket: routes to same printer zones, prints ΑΚΥΡΩΣΗ banner
- Fix cancellations_log: date filter, waiter filter, join syntax
- shift/orders: add cancellations count and hours_worked per waiter
- _enrich_shift: add cancellations count to shift data
- Add cancel-permissions endpoint for PWA
Manager dashboard:
- Global cancel setting toggle in Settings > Operation > Shift Settings
- Per-waiter can_cancel_orders checkbox in staff modal
- Manager cancel flow: print confirmation prompt (Ναι/Όχι) in DashboardPage
- ShiftsOverview: Ακυρώσεις column per shift
- Activity: multi-bar chart with ORDERS/ITEMS/CANCELLATIONS/ΕΣΟΔΑ/ΩΡΕΣ checkboxes,
grouped/stacked switch, right X-axis for hours, full waiter name on hover
- OrderHistory: cancelled items count column per order
- WorkDaySummary drill-down: cancelled items column in orders tab
Waiter PWA:
- Replace 3 pills with CLEAR | ALL | ACTIONS
- ACTIONS opens ItemActionModal for selected items
- ItemActionModal: ORDER AGAIN, MOVE TO OTHER TABLE, SPLIT, CANCEL ORDER
- ActionsSheet: Cancel Παραγγελίας option (greyed if no permission)
- CancelConfirmModal: requires confirmation before cancelling
- TableListPage: cancel order from long-press quick modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New GET /api/system/printers/scan SSE endpoint: parallel async TCP
scan of a /24 subnet, streams found/progress/done events
- New GET /api/system/printers/scan-hints: returns auto-detected local
subnets from socket.getaddrinfo, excludes loopback/docker ranges
- FindPrintersModal in Settings > Print: shows subnet+port inputs,
live progress bar, found printers list; selecting a result pre-fills
the new printer form (user still names it before saving)
- "Εύρεση εκτυπωτών" button added next to "+ Νέος εκτυπωτής"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `duplicates` column (0-9) to printers table via migration
- Print loop repeats job 1+duplicates times per printer zone
- PrinterForm in Settings > Print now has ΑΝΤΙΓΡΑΦΑ (0-9) field
- PrinterRow shows amber badge when duplicates > 0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add tz.py with local_strftime/to_local helpers that read system.timezone
from DB and convert UTC datetimes to venue local time before formatting
- Fix all strftime() calls in orders.py, reports.py, printer_service.py
that were formatting UTC datetimes without timezone conversion
- Fix get_order endpoint returning raw dicts without Z suffix on datetimes,
causing JS new Date() to treat timestamps as local instead of UTC
- Fix fmtDate() in tokens.js that stripped the T separator before parsing,
breaking UTC-to-local conversion for all report date displays
- Make open/partially_paid table chips more visually distinct on dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Backend reports: fix cost/profit calculation to handle null unit_cost
with has_gap flag; expose total_cost in product & workday summary
- StaffTab: add hourly_rate field in payroll section (admin-only)
- ProductFormModal: major refactor of form layout and structure
- ProductsTab: minor tweaks aligned with form changes
- Report pages (Today, WorkDaySummary, CategoryPerformance,
ProductPerformance, RevenueTrends): UI improvements and cost data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- usePhase2Features.js hook: manages localStorage-based feature flags for all 8 Phase 2
sidebar entries (notes, expenses, contacts, customers, tabs, waste, kds, schedule)
All default to enabled=true. setFeatureEnabled() writes to localStorage and dispatches
a 'phase2features' event so all subscribers re-render immediately without page reload
- Phase2FeaturesTab.jsx: new Settings tab 'Λειτουργίες' — toggle each Phase 2 page on/off
with master 'Ενεργοποίηση όλων' / 'Απενεργοποίηση όλων' buttons
Note shown: changes are localStorage-only, instant, no reload needed, no data deleted
- SettingsPage.jsx: adds 'Λειτουργίες' and reinstates 'Developer' tab (was missing import)
- Sidebar.jsx: subscribes to phase2features + storage events; filters ALL_NAV to remove
disabled Phase 2 entries; each phase2 item has a phase2 id that isFeatureEnabled() checks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- GET /api/reports/discounts: read-only audit of all OrderDiscount records
Filters: ?from=&to= or ?business_day_id=, ?applied_by=
Returns:
- discounts[]: id, order_id, table_name, applied_by_name, applied_at, discount_type,
discount_value, discount_amount (computed euro value), order_total_before,
item_id (null = whole-order), reason
- total_discount_value: sum of all euro discount amounts
- order_count: unique orders that received a discount
- by_waiter[]: grouped summary (waiter_name, count, total_value)
discount_amount computation: fixed → discount_value directly;
percent → computed from order or item total at query time
Frontend:
- DiscountsLog.jsx: read-only report under Reports → Λειτουργίες → Εκπτώσεις
- Filter bar: range/workday toggle, date range, waiter filter
- 3 stat cards: total discount value, orders with discounts, count of entries
- By-waiter summary table (hidden when only one waiter)
- Full audit table: date, order + table, waiter, type badge (% blue / € amber),
order total before, euro amount discounted in amber, reason
- Totals footer row
- No create/edit/delete — pure audit log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend — /api/business-day/summary now returns full financial summary:
- COGS: trackable cost (sum of unit_cost snapshots on paid items), uncosted item count
and revenue, gross_profit, cogs_has_gap flag
- Labor: total_labor_cost (sum of shift_pay for the day), labor_untracked_shifts count
- Waste: waste_item_count + waste_estimated_cost from waste_log for this business day
- Expenses: expenses_total, expenses_paid_today, expenses_due for expenses logged today
- Tabbed revenue: revenue from items with status='tabbed' (deferred, not yet collected)
- Net estimate: revenue - COGS - labor - waste - expenses_paid; net_has_unknowns flag
when any component has gaps (uncosted items or untracked shifts)
- compute_shift_pay import hoisted out of loop (bug fix)
Frontend — WorkdaySummaryModal OverviewTab fully expanded:
- New FinancialRow helper for consistent two-column P&L rows with accent/warn/indent
- REVENUE section: total sales, tabbed deduction, net collected
- COGS section: trackable cost, uncosted items warning, gross profit with gap indicator
- LABOR section: tracked labor cost, untracked shifts warning
- WASTE section: item count + estimated cost (hidden when zero)
- EXPENSES section: total, paid today, still due (hidden when zero)
- NET ESTIMATE: prominent bottom line, amber warning when unknowns exist
- Payment breakdown bar (existing)
- Cash reconciliation summary when store cash was counted
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- New model: ScheduledShift (scheduled_shifts table)
Planning data separate from waiter_shifts (actual data) — linked conceptually by user+date
start_time/end_time stored as "HH:MM" strings (SQLite has no native Time type)
- New router /api/schedule/:
- GET / — list by date range + optional user_id filter
- POST / — create scheduled shift (validates user exists)
- PUT /{id} — update times/notes
- DELETE /{id} — remove
- GET /week?week_start= — full week view: scheduled shifts with actual WaiterShift
comparison for the same user+date, estimated weekly labor cost, waiters list with rates
- Each ScheduledShiftOut includes duration_hours (handles overnight) + estimated_pay
(duration × hourly_rate, null if rate not set)
- Migration: CREATE TABLE IF NOT EXISTS scheduled_shifts
Frontend:
- SchedulePage (/schedule): weekly calendar grid — rows = staff, columns = Mon–Sun
- Week navigation (← Προηγ. / Αυτή η εβδομάδα / Επόμ. →)
- Estimated weekly labor cost in header when shifts have rates
- ShiftSlot cells: blue = scheduled only, green = scheduled + actual shift happened
Shows scheduled times + actual start/end from real WaiterShift data
- Click empty cell → AddShiftModal (pre-selects that row's waiter, any date in week)
- "+" row at bottom for waiters not yet scheduled this week
- Delete button (✕) per slot with confirm
- Legend explaining the color scheme
- Sidebar: CalendarDays icon for Πρόγραμμα (after KDS)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- New router /api/kds/:
- GET /items — returns all active order items for open orders, grouped by printer zone
(zone_id, zone_name, items array with table_name, product_name, qty, notes, added_at)
Zones sorted by zone_id asc, no-zone column last
- PUT /orders/{order_id}/items/{item_id}/status — mark item 'ready' (active→ready only)
Broadcasts item_status_changed SSE event to all connected clients
- No migration needed — 'ready' is a new valid string value for order_items.status
- pay_items endpoint: now accepts 'ready' items as payable (status.in_(['active','ready']))
- active_remaining count for order status also includes 'ready' items
Frontend:
- KdsPage (/kds): full-screen dark layout, columns per printer zone
- Zone columns: dark header with zone name + pending count, scrollable item cards
- Item cards: table name badge, elapsed time (colour-coded: green<8m, amber<15m, red>=15m),
product name + quantity, notes in amber, tap anywhere to mark ready
- Optimistic marking: card dims while request in flight, disappears on success
- SSE live updates: listens for order_updated / item_status_changed / order_paid / order_closed
- Fallback poll every 30s; manual ↻ Ανανέωση button; live clock in top bar
- Empty state: ✓ message when no pending items
- Sidebar: ChefHat icon for KDS (after Αποβλήτα)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- New model: WasteLog (waste_log table) — product, quantity (float), reason, reason_notes,
unit_cost_snapshot (copied at log time from product's effective cost), total_cost (stored),
logged_by, business_day_id
- Cost snapshot: same logic as Phase 2A — breakdown sum first, fallback to cost_simple, null if neither
- New router /api/waste/:
- GET / — list entries (?business_day_id= or ?from=&to=), ordered by logged_at desc
- POST / — log waste; snapshots cost; attaches to open business day automatically
- DELETE /{id} — manager only; only allowed within the same open business day
- GET /summary — totals by reason + by product for a period
- product_performance report: waste_qty + waste_cost added per product from the same period;
products with only waste (no sales) also included
- Migration: CREATE TABLE IF NOT EXISTS waste_log
Frontend:
- WastePage (/waste): fast log form (product picker, quantity, reason chips, notes);
today's entries list below (scoped to active business day); cost shown per entry;
delete button for today's entries; history section behind toggle with date-range picker
- ProductPerformance report: Απόβλητα column showing waste_qty + cost in amber
- Sidebar: Trash2 icon for Αποβλήτα (after Καρτέλες)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- New models: Tab, TabEntry, TabPayment (3 new tables)
- Tab: open|closed|forgiven, one open tab per customer enforced
- TabEntry: what went ON the tab (per-item snapshot with description)
- TabPayment: what came OFF the tab; balance = sum(entries) - sum(payments), never stored
- New router /api/tabs/:
- GET / — list open tabs (sorted by balance desc); ?tab_status= override
- GET /{id} — full tab detail with entries + payments
- POST / — open new tab for a customer (400 if one already exists)
- POST /{id}/entries — add entry directly (amount + description)
- POST /{id}/pay — record payment; validates amount <= balance
- POST /{id}/close — close tab (requires balance = 0)
- POST /{id}/forgive — write off remaining balance
- GET /customer/{customer_id} — all tabs for a customer (open first)
- POST /api/orders/{id}/items/{item_id}/tab:
- Requires order has a customer assigned
- Finds or auto-creates open tab for that customer
- Sets order_item.status = "tabbed" (new valid status value)
- Creates TabEntry with auto-generated description
- Broadcasts order_updated SSE event
- Migrations: CREATE TABLE IF NOT EXISTS for tabs, tab_entries, tab_payments
Frontend:
- TabsPage (/tabs): open tabs list sorted by balance; each card shows charges/payments
history, live balance, Πληρωμή modal (defaults to full balance), Κλείσιμο button
(only shown when balance=0), Χάρισμα Υπολοίπου with confirm modal + reason
- CustomersPage CustomerDetail: open tab banner showing balance + entry count
(appears between stats and contact info when customer has an open tab)
- OrderDetailPage: 📋 Καρτέλα button appears on active items when order has a customer;
tabbed items show a 📋 Καρτέλα badge; tabItem mutation calls /items/{id}/tab
- Sidebar: CreditCard icon for Καρτέλες (after Πελάτες)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- New model: Customer (customers table) — name, nickname, phone, email, notes, is_active
- Order model: add customer_id nullable FK → customers
- New router: /api/customers/ — list (with ?search=), create, get, update, soft-delete,
GET /{id}/orders (visit history with totals)
- Each CustomerOut includes visit_count + total_spent (computed from closed/paid orders)
- PUT /api/orders/{id}/customer — assign or unassign a customer; manager-only;
validates customer is active; broadcasts order_updated SSE event
- GET /api/orders/{id} now returns customer_id, customer_name (with nickname), customer_phone
- Migration: CREATE TABLE customers + ALTER TABLE orders ADD COLUMN customer_id
Frontend:
- CustomersPage (/customers): searchable list (name/nickname/phone), avatar initials,
visit count + total_spent on each row; click → detail panel slides in showing stats
(visits, total spent, avg ticket), contact info, notes, full visit history
- OrderDetailPage: new Πελάτης card — shows assigned customer in blue if set;
for open orders shows "+ Ανάθεση πελάτη" button → inline search dropdown → assign;
Αφαίρεση button to unassign
- Sidebar: Users icon for Πελάτες (between Επαφές and Ρυθμίσεις)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- New models: Contact, Expense, ExpensePayment (3 new tables)
- Expense status (paid/partial/due) is always computed from paid_amount vs total_amount — never stored
- paid_amount recomputed from sum of all payments on each payment write (no client trust)
- New router /api/contacts/: list, create, update, soft-delete (is_active=false)
- New router /api/expenses/: list (filter by status/category/contact), create, update, delete,
record payment, summary (total_due, total_paid, by_category)
- Migrations: CREATE TABLE IF NOT EXISTS for contacts, expenses, expense_payments
Frontend:
- ContactsPage (/contacts): searchable table, type badge (Προμηθευτής/Προσωπικό/Κοινή Χρεία/Άλλο),
create/edit modal, soft-delete
- ExpensesPage (/expenses): filter bar (status + category), expandable rows with payment history,
summary header showing total outstanding, inline Payment modal (defaults to full due amount),
create/edit expense modal
- Sidebar: Receipt icon for Έξοδα, BookUser icon for Επαφές
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New models: SiteNote, SiteTodo (site_notes, site_todos tables)
- New schemas: NoteOut, TodoOut with creator/done-by name enrichment
- New router: /api/notes/ — full CRUD for notes and todos
- Notes: create, list (pinned first), update (body + pin), delete
- Todos: create, list (undone high-priority first), toggle done, edit, delete
- Marking done records done_at + done_by_id; unchecking clears both
- Migrations: CREATE TABLE IF NOT EXISTS for both tables (additive, safe)
- NotesPage.jsx: two-column layout — notes left, todos right
- Notes: inline click-to-edit, pin toggle, Ctrl+Enter to save, pinned section on top
- Todos: inline edit, high-priority flag, completed collapse toggle
- /notes route added to App.jsx
- NotebookPen sidebar entry between Διαχείριση and Ρυθμίσεις
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>