Compare commits

..

44 Commits

Author SHA1 Message Date
934fda9405 feat: push product images to cloud for the QR menu
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>
2026-07-19 20:51:52 +03:00
34ae328b0d feat: bump client-services (accumulated feature work + deploy fixes)
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>
2026-07-19 10:00:14 +03:00
02ec1aa28f feat: ShiftDetailModal rework, Today cancellation events, shift details icon button
- 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>
2026-06-09 17:48:30 +03:00
8533a1452b fix: report cancellation bugs + missing OrderItem model columns
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>
2026-06-09 17:15:55 +03:00
36e9044972 fix: two bugs in cancellation settings
- 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>
2026-06-09 12:55:48 +03:00
8a5a6f8be9 feat: Feature 2 — waiter order cancellation + report updates
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>
2026-06-09 12:47:02 +03:00
b5b647422a feat: subnet printer discovery (Feature 3)
- 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>
2026-06-09 11:14:37 +03:00
f3d03bf85f feat: printer duplicate copies setting (Feature 1)
- 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>
2026-06-09 10:55:43 +03:00
72b12ddd9c fix: fix timezone handling across backend and frontend (v0.3.1)
- 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>
2026-06-08 19:18:46 +03:00
5da378a0ae feat: reports cost tracking, staff hourly rate, product form refactor
- 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>
2026-06-08 09:58:55 +03:00
34b58b3f2e feat: add Online Orders to feature flag toggles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 04:06:39 +03:00
f136abe17e feat: PIN gate on Λειτουργίες settings tab (code: 4034)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 04:03:37 +03:00
0fc027eb5f feat: Phase 2 feature flags — hide/show sidebar entries per client
- 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>
2026-06-08 04:01:54 +03:00
49f19ed65c fix: remove TypeScript type annotation from SchedulePage.jsx (plain JSX build)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 03:52:22 +03:00
492096007b feat: Phase 2L — discount audit report
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>
2026-06-08 03:43:19 +03:00
184bee5942 feat: Phase 2K — business day close summary (expanded)
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>
2026-06-08 03:40:12 +03:00
06dccb981e feat: Phase 2J — staff shift scheduling
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>
2026-06-08 03:33:45 +03:00
5e28273bb0 feat: Phase 2I — KDS Kitchen Display Screen
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>
2026-06-08 03:25:26 +03:00
9cfbde0e3e feat: Phase 2H — void/waste log
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>
2026-06-08 03:19:33 +03:00
2ff48730f7 feat: Phase 2G — pay-later tab system
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>
2026-06-08 03:07:22 +03:00
e9bdf61a4d feat: Phase 2F — customer CRM
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>
2026-06-08 02:59:53 +03:00
d39382e8dd fix: add Float import to business_day model (Phase 2E crash fix)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 02:49:16 +03:00
9da396b82a feat: Phase 2E — cash drawer reconciliation
Backend:
- WaiterShift: add counted_cash_end (physical cash handed over) + cash_discrepancy (computed)
- BusinessDay: add store_opening_cash, store_closing_cash, store_cash_discrepancy
- EndShiftRequest: accept counted_cash_end (optional)
- CloseBusinessDayRequest: accept store_closing_cash (optional)
- Shift end (both /end and /manager/end/{id}): compute cash_discrepancy =
  counted_cash_end - (starting_cash + total_collected); negative = short, positive = over
- Business day close: compute store_cash_discrepancy = store_closing_cash - expected;
  expected = store_opening_cash + sum(shift.total_collected for day)
- _enrich_shift: expose counted_cash_end + cash_discrepancy in all shift responses
- Business day summary: expose store cash fields + store_expected_closing + total_waiter_collected
- BusinessDayOut schema: expose new store cash fields
- Shifts CSV export: add counted_cash_end + cash_discrepancy columns

Frontend:
- EndShiftConfirmModal: 2-step flow — step 1 notes, step 2 cash handover with live discrepancy
  (shows ✓ clean / ⚠ short / ⚠ over in real time before confirming)
- WorkdaySummaryModal close flow: new Ταμείο tab with store cash count + live discrepancy;
  footer navigates overview → Ταμείο → Έλεγχοι; store_closing_cash included in close payload
- ShiftsOverview report: Ταμείο column showing counted_cash_end + discrepancy badge
- ShiftDetailModal row 2: replaces Εξ.Τραπεζιών with Παραδόθηκαν + Ταμείο discrepancy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 02:47:03 +03:00
a675c2e091 feat: Phase 2D — expense tracking + supplier contact book
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>
2026-06-08 02:33:20 +03:00
cc356077ba feat: Phase 2C — notes & todos
- 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>
2026-06-07 21:34:57 +03:00
b15fb27a37 feat: add payroll + tables served to shift detail and workday staff views
- business_day summary: waiter entries now include hourly_rate_snapshot, duration_hours,
  shift_pay (cumulative across day's shifts), tables_served (unique table count)
- ShiftDetailModal: 4-KPI row replaced with 2×4 grid —
  Row 1: Διάρκεια | Μισθός/Ώρα | Πληρωμή Βάρδιας | Αρχικά Μετρητά
  Row 2: Σύνολο Παραγγελιών | Εισπράξεις | Προς Παράδοση | Εξ. Τραπεζιών
- WorkdaySummaryModal WaitersTab: collapsed row uses ΠΑΡΑΓΓΕΛΙΕΣ | ΕΞ.ΤΡΑΠΕΖΙΩΝ |
  ΑΝΤΙΚ.ΕΙΣΠΡ. | ΕΙΣΠΡΑΞΗ; expanded grid is 2 rows:
  Row 1: ΕΝΑΡΞΗ | ΛΗΞΗ | ΜΙΣΘΟΣ/ΩΡΑ | ΜΙΣΘΟΣ (εως τώρα)
  Row 2: ΑΡΧΙΚΑ ΜΕΤΡΗΤΑ | ΤΑΜΕΙΟ ΤΩΡΑ | ΑΝΤΙΚ.ΕΙΣΠΡ. | ΕΞ.ΤΡΑΠΕΖΙΩΝ

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:24:01 +03:00
e5b8ef32be feat: Phase 2B — staff payroll / hourly rate
- Users: add hourly_rate (Float, nullable) — manager-only, not shown to waiter
- WaiterShift: add hourly_rate_snapshot — copied from user.hourly_rate at shift open, never updated
- Shift start (both /start and /manager/start): snapshot rate at shift creation
- compute_shift_pay(): calculates worked hours (minus breaks) × rate_snapshot; returns None if unset
- _enrich_shift(): now includes hourly_rate_snapshot, duration_hours, shift_pay
- shifts_report in reports.py: delegates to _enrich_shift so all shift endpoints are consistent
- Shifts CSV export: adds duration_hours, hourly_rate, shift_pay columns
- StaffTab: hourly_rate field in waiter edit modal (Μισθοδοσία section, col 3)
- ShiftsOverview report: Αμοιβή/ώρα + Αποδοχές columns; untracked shows 'Δεν παρακολουθείται'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:55:24 +03:00
d0dfd63415 feat: Phase 2A — product cost tracking
- Products: add cost_simple (flat €) and cost_breakdown (JSON line items)
- OrderItems: add unit_cost snapshot written at time of order creation
- Snapshot logic: breakdown sum takes priority over cost_simple; NULL if neither set
- Product schema: serialize/deserialize cost_breakdown as JSON; expose in API
- Product performance report: add trackable_profit, uncosted_revenue, has_gap per product
- Manager UI: cost section in product edit form (simple / detailed toggle with live margin %)
- Products list: show margin % or cost-error badge per product
- Product performance report: profit column + gap warning banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:46:33 +03:00
ac7ec45279 feat(frontend): workday summary modal, print analytics UI, staff zone-PIN management, report upgrades
manager_dashboard:
- DashboardPage: WorkdaySummaryModal integration (view / close-day flows); revenue chart upgrades
- WorkdaySummaryModal: new component — shows full shift/revenue summary before closing the day
- StaffTab: full rewrite — zone-assignment modal, reset-PIN modal, actions dropdown, richer staff card
- ProductsTab: digital-menu fields surfaced in product form
- PrintFontsTab: expanded font-size/weight controls per printer channel
- TablesConfigTab / TablesPage / tableColourStore: color picker and zone fields for tables
- FilterBar: unified filter component with date-range, printer, and category selectors
- CategoryPerformance / ProductPerformance / TableAnalytics: donut/bar charts,
  thermal+browser print modals, richer breakdown tables
- PrinterHistory: redesigned with per-printer drill-down and summary blocks
- TrafficAnalytics / WorkDaySummary / RevenueTrends / ShiftsOverview / StaffLeaderboard: polish pass
- tokens.js: design token updates

waiter_pwa:
- TableCard: show table color indicator from zone/colour config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:28:23 +03:00
79bd3b0f41 feat(local_backend): reservations, print analytics, workday summary, zone-PIN management
- New reservations module: model, schema, router (CRUD + status updates + upcoming alerts)
  and background task for auto-expiring stale reservations
- Reports: print_products, print_categories, print_tables analytics endpoints
  plus meta_products and business_day_summary for workday close/view flow
- printer_service: configurable font sizes/weights, donut/bar chart print layout helpers,
  analytics print blocks per printer
- tables/schemas: surfaced color, zone, and other new fields on Table, Product, User, Printer
- demo_seed.py for quick dev DB population; wipe_database.py utility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:25:22 +03:00
aed71a18d8 feat(manager): Part 2 — Online Orders page and sidebar integration
- connect_orders.py: add GET /api/connect/orders?status=active|history endpoints
- api/client.js: add getIncomingOrders, getActiveOrders, getOrderHistory,
  acceptOnlineOrder, rejectOnlineOrder, updateOnlineOrderStatus
- OnlineOrdersPage.jsx: three-tab page (Incoming / Active / History) with
  order cards, action buttons (accept/reject/status progression),
  20 s polling, SSE listener for online_order_received/updated events,
  and optimistic UI updates
- App.jsx: add /online-orders route
- Sidebar.jsx: add Online Orders nav item with red badge showing pending count,
  polling every 20 s independently of the page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:59:21 +03:00
37fb25c8c2 feat(manager): add Digital Menu tab to ProductFormModal
Adds a new "Digital Menu" tab with three sections:
- Visibility: digital_visible / digital_available toggles
- Display overrides: digital_name, digital_description, digital_image_url
- Pricing: digital_price / digital_discount with live customer-price preview

All 7 fields are wired into form state (buildFormFromProduct) and
included in the PUT request body (buildBody).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:43:42 +03:00
7958083fd8 fix: push menu + stats immediately on startup instead of waiting first cycle
_connect_loop now waits up to 30 seconds for the heartbeat to return
site_numeric_id, then immediately pushes the menu snapshot and stats
before entering the regular poll loop. Previously the first push
wouldn't fire until 5 minutes after startup, making the menu
unavailable until then.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 01:19:47 +03:00
3da316ef9b feat(connect): Phase 5 — stats snapshot push to cloud
Adds _push_stats_snapshot() to cloud_sync.py. Every 5 minutes
(piggybacked on the existing _connect_loop push tick alongside the
menu snapshot) it queries the local DB and POSTs a JSON stats blob
to POST /api/remote/snapshot (site API key auth).

Stats collected:
  - open_tables: count of open/partially_paid POS orders
  - today_revenue: sum of active+paid item prices on orders
    closed today
  - today_orders: count of paid/closed POS orders today
  - online_orders_pending: online orders awaiting acceptance
  - online_orders_today: all online orders opened today
  - current_shift: active waiter shift info (waiter_id, started_at)
    if a business day is open; null otherwise
  - as_of: UTC timestamp of the snapshot

The remote manager dashboard reads this via
GET /api/remote/sites/{id}/snapshot (manager JWT).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:17:28 +03:00
80842d9be3 fix(connect): store cloud order id locally to enable reliable status mirroring
The Phase 4 _mirror_status_to_cloud function had no way to look up the
cloud order's numeric id once it was marked synced (not in the pending
list anymore), so status updates from local staff could silently fail.

Fix:
  - models/order.py: online_order_cloud_id INTEGER column added to Order
  - main.py: migration for the new column
  - schemas/order.py: online_order_cloud_id exposed in OrderOut
  - cloud_sync.py: stores cloud_order["id"] as online_order_cloud_id
    when creating the local order during the pull
  - connect_orders.py: _mirror_status_to_cloud now takes the integer
    cloud id directly — no more pending-list lookup; function body
    reduced from ~50 lines to ~15

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:14:43 +03:00
4b1201ecaa feat(connect): Phase 4 — local backend online order integration
4.1 — cloud_sync.py
  - _push_menu_snapshot(): serializes digital-visible products+categories
    and POSTs to cloud /api/menu/sync every 5 minutes
  - _pull_pending_orders(): polls cloud /api/orders/pending/{site_id}
    every CONNECT_SYNC_INTERVAL_SECONDS (default 30s); creates local
    Order + OrderItem rows, marks synced on cloud, broadcasts SSE event
  - _connect_loop(): second asyncio task running the fast poll loop;
    piggybacked menu push fires every 5 min regardless of poll interval
  - _sync_once(): captures site_numeric_id from heartbeat response and
    stores it in license_state so Connect loops can use it
  - start_cloud_sync(): now creates and returns both tasks

4.2 — orders model/schema/migrations
  - models/order.py: table_id made nullable (online orders have no
    table); 7 new online_* columns added to Order
  - schemas/order.py: OrderOut table_id Optional, all 7 online_* fields
    added
  - main.py: 8 additive ALTER TABLE migrations for orders table

4.3 — routers/connect_orders.py (NEW)
  GET  /api/connect/orders/incoming      — pending online orders (any auth)
  POST /api/connect/orders/{id}/accept   — accept (manager)
  POST /api/connect/orders/{id}/reject   — reject with optional reason (manager)
  POST /api/connect/orders/{id}/status   — progress through lifecycle (manager)
  All state changes mirror to cloud via background task and broadcast SSE

4.4 — main.py router registration
  connect_orders router registered at /api/connect

config.py
  CONNECT_SYNC_INTERVAL_SECONDS setting added (default 30)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:12:01 +03:00
38bef6b1f4 fix: printer history report double-counts order totals on reprints
Backend (reports.py):
  - Cache order DB objects to avoid N+1 queries per log entry
  - Track seen_order_totals keyed by order_id so reprinting the same
    order doesn't inflate the total; returns total_amount pre-computed

Frontend (PrinterHistory.jsx):
  - Remove client-side accumulation that caused the same issue; use
    total_amount directly from the API response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:54:07 +03:00
18a75edf2a feat: drag-to-reorder table groups in zone tabs
Backend:
  - tables.py: PUT /api/tables/groups/reorder accepts an ordered list
    of group IDs and updates sort_order accordingly

Frontend (TablesConfigTab):
  - Zone tabs are now draggable for group tabs (not All/Ungrouped)
  - HTML5 drag-and-drop with visual drop-target highlight and a
    grab cursor; fires reorderGroups mutation on drop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:54:02 +03:00
607e78ea82 feat: printer beep support for kitchen tickets
Adds configurable buzzer control so the printer alerts kitchen staff
on each new ticket print.

Backend:
  - printer_service.py: ESC 0x07 beep command fired before paper cut,
    reads print.beep_on_ticket / print.beep_pattern settings; supports
    single/double/triple/long presets and a custom:n1:n2:n3 format
  - settings.py: registers the two new print.beep_* settings with
    defaults (beep_on_ticket=true, beep_pattern=double)
  - system.py: POST /api/system/printers/test-beep endpoint for
    live testing from the dashboard

Frontend (PrintFontsTab):
  - BeepSection component with preset grid, custom n1/n2/n3 inputs,
    and a live test-beep button targeting any configured printer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:53:58 +03:00
b9d6055c48 feat(connect): Phase 1 — add digital product fields to local_backend
Adds seven digital-menu-specific columns to the products table so
managers can control the public Xenia Connect menu independently of POS
data:

  - digital_visible   (INTEGER, default 1) — hide product from menu
  - digital_available (INTEGER, default 1) — show as out-of-stock
  - digital_name      (VARCHAR, nullable)  — override display name
  - digital_description (VARCHAR, nullable)
  - digital_price     (REAL, nullable)     — override price
  - digital_discount  (REAL, default 0.0)  — percentage discount
  - digital_image_url (VARCHAR, nullable)  — menu-specific image

Changes:
  - main.py: 7 additive ALTER TABLE migrations (safe on existing DBs)
  - models/product.py: matching SQLAlchemy columns on Product
  - schemas/product.py: ProductBase carries defaults (visible/available
    default True); ProductUpdate has all fields Optional; ProductOut
    inherits everything via ProductBase

No router changes needed — PUT /api/products/{id} picks up the new
fields from ProductUpdate automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:42:46 +03:00
a01a03623f feat: added synopsis print on on print reports tab 2026-05-28 10:59:14 +03:00
5de89a722c feat: major dashboard & waiter PWA overhaul
- Manager dashboard: replaced monolithic DashboardTab/OperationsPage with new
  DashboardPage; added OrderDetailModal, ShiftDetailModal, DeleteConfirmModal,
  PaymentMethodModal; updated Sidebar routing and App navigation
- Reports: reworked WorkDaySummary, OrderHistory, ShiftsOverview with detail modals
- Backend routers: extended orders, reports, shifts, products, business_day endpoints;
  updated cloud_sync service
- Waiter PWA: refreshed app icons, improved ConnectionLostModal UX, updated
  TableCard, SSEContext, connectionStore; added useProductCache hook; vite config tweaks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:24:54 +03:00
aa92623802 chore: set initial version to 0.1.0 (beta)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:49:02 +03:00
0d21b7f20b fix: deployment readiness — correct registry/cloud URLs, fix install.sh
- .env.example: set REGISTRY=registry.bonamin.gr, CLOUD_URL=https://xenia-admin.bonamin.gr, DATA_PATH=/opt/xenia/data
- install.sh: auto-create .env from example, prompt for SITE_ID/SITE_KEY/SECRET_KEY,
  clarify DNS subdomain requirements, add backend API proxy block to nginx config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:36:43 +03:00
237 changed files with 51946 additions and 5619 deletions

View File

@@ -1,14 +1,18 @@
# Registry
REGISTRY=registry.yourdomain.com
VERSION=1.0.0
REGISTRY=registry.bonamin.gr
VERSION=0.1.0
# Backend runtime secrets (get SITE_ID and SITE_KEY from sysadmin panel)
# Backend runtime secrets (get SITE_ID and SITE_KEY from the sysadmin panel)
SITE_ID=your-site-id
SITE_KEY=your-site-key
CLOUD_URL=https://api.yourdomain.com
CLOUD_URL=https://xenia-admin.bonamin.gr
SECRET_KEY=generate-with-openssl-rand-hex-32
LICENSE_GRACE_HOURS=24
# Volumes — absolute paths recommended on client machines
DATA_PATH=/home/user/appdata/pos/data
LOGO_PATH=/home/user/appdata/pos/logo.png
# Break-glass support account (leave blank to disable)
MASTER_USERNAME=
MASTER_PASSWORD=
# Volumes — absolute paths on the client machine
DATA_PATH=/opt/xenia/data
LOGO_PATH=/opt/xenia/logo.png

View File

@@ -4,6 +4,8 @@ services:
image: ${REGISTRY}/pos-backend:${VERSION:-latest}
ports:
- "8000:8000"
volumes:
- ${FISCAL_PATH:-./fiscal_test}:/mnt/fiscal
waiter_pwa:
build:

View File

@@ -10,9 +10,13 @@ services:
- LICENSE_GRACE_HOURS=${LICENSE_GRACE_HOURS:-24}
- DATABASE_URL=sqlite:////app/data/pos.db
- VERSION=${VERSION:-0.0.0}
- HOST_IP=${HOST_IP:-}
- MASTER_USERNAME=${MASTER_USERNAME:-}
- MASTER_PASSWORD=${MASTER_PASSWORD:-}
volumes:
- ${DATA_PATH}:/app/data
- ${LOGO_PATH}:/app/logo.png:ro
- ${FISCAL_PATH}:/mnt/fiscal
waiter_pwa:
image: ${REGISTRY}/pos-waiter:${VERSION:-latest}

View File

@@ -1,6 +1,6 @@
#!/bin/bash
# Xenia POS — first-time install script
# Run this on the server machine before starting the stack.
# Run this on the client machine before starting the stack.
# Usage: bash install.sh
set -e
@@ -11,13 +11,48 @@ echo "=== Xenia POS Install ==="
echo ""
# ── 1. Create required directories ───────────────────────────────────────────
echo "[ 1/4 ] Creating directories..."
echo "[ 1/5 ] Creating directories..."
mkdir -p "$SCRIPT_DIR/data"
mkdir -p "$SCRIPT_DIR/certs"
mkdir -p "$SCRIPT_DIR/nginx-proxy"
mkdir -p /opt/xenia/data
touch /opt/xenia/logo.png 2>/dev/null || true
# ── 2. Write nginx-proxy/nginx.conf ──────────────────────────────────────────
echo "[ 2/4 ] Writing nginx proxy config..."
# ── 2. Create .env from .env.example if missing ───────────────────────────────
echo "[ 2/5 ] Configuring environment..."
if [ ! -f "$SCRIPT_DIR/.env" ]; then
cp "$SCRIPT_DIR/.env.example" "$SCRIPT_DIR/.env"
echo ""
echo " A .env file has been created from .env.example."
echo " You must fill in SITE_ID, SITE_KEY, and SECRET_KEY before starting."
echo ""
echo " Get SITE_ID and SITE_KEY from: https://xenia-admin.bonamin.gr"
echo " Generate SECRET_KEY with: openssl rand -hex 32"
echo ""
read -rp " Enter SITE_ID: " INPUT_SITE_ID
read -rp " Enter SITE_KEY: " INPUT_SITE_KEY
read -rp " Enter SECRET_KEY (leave blank to auto-generate): " INPUT_SECRET_KEY
if [ -z "$INPUT_SECRET_KEY" ]; then
INPUT_SECRET_KEY=$(openssl rand -hex 32)
echo " Generated SECRET_KEY: $INPUT_SECRET_KEY"
fi
sed -i "s/^SITE_ID=.*/SITE_ID=${INPUT_SITE_ID}/" "$SCRIPT_DIR/.env"
sed -i "s/^SITE_KEY=.*/SITE_KEY=${INPUT_SITE_KEY}/" "$SCRIPT_DIR/.env"
sed -i "s/^SECRET_KEY=.*/SECRET_KEY=${INPUT_SECRET_KEY}/" "$SCRIPT_DIR/.env"
echo ""
echo " .env written. Review it at: $SCRIPT_DIR/.env"
echo ""
else
echo " .env already exists — skipping."
fi
# ── 3. Write nginx-proxy/nginx.conf ──────────────────────────────────────────
echo "[ 3/5 ] Writing nginx proxy config..."
cat > "$SCRIPT_DIR/nginx-proxy/nginx.conf" << 'EOF'
server {
listen 80;
@@ -26,7 +61,7 @@ server {
server {
listen 443 ssl;
server_name _;
server_name waiter.*;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
@@ -43,8 +78,8 @@ server {
}
server {
listen 4443 ssl;
server_name _;
listen 443 ssl;
server_name manager.*;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
@@ -59,39 +94,69 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl default_server;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://waiter_pwa:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
EOF
# ── 3. SSL certificates ───────────────────────────────────────────────────────
echo "[ 3/4 ] Setting up SSL certificates..."
# ── 4. SSL certificates ───────────────────────────────────────────────────────
echo "[ 4/5 ] Checking SSL certificates..."
if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; then
echo " Certificates already exist — skipping."
else
echo ""
echo " No certificates found in certs/"
echo " You need a cert for your domain (e.g. xeniapos.yourdomain.com)."
echo ""
echo " Option A — Let's Encrypt (recommended for production):"
echo " DNS requirement:"
echo " Two subdomains must point to this machine's IP:"
echo " waiter.YOURDOMAIN → this machine's IP"
echo " manager.YOURDOMAIN → this machine's IP"
echo " The waiter domain should also be registered in the sysadmin"
echo " panel as the 'Waiter Domain' so phones get the QR code."
echo ""
echo " Option A — Let's Encrypt (recommended):"
echo " sudo apt install certbot"
echo " sudo certbot certonly --manual --preferred-challenges dns -d YOUR_DOMAIN"
echo " sudo cp /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem certs/cert.pem"
echo " sudo cp /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem certs/key.pem"
echo " sudo certbot certonly --manual --preferred-challenges dns \\"
echo " -d waiter.YOURDOMAIN -d manager.YOURDOMAIN"
echo " sudo cp /etc/letsencrypt/live/waiter.YOURDOMAIN/fullchain.pem certs/cert.pem"
echo " sudo cp /etc/letsencrypt/live/waiter.YOURDOMAIN/privkey.pem certs/key.pem"
echo ""
echo " Option B — Self-signed (local testing only, requires CA install on each device):"
echo " Option B — Self-signed / mkcert (local testing only):"
echo " sudo apt install mkcert libnss3-tools"
echo " mkcert -install"
echo " mkcert -cert-file certs/cert.pem -key-file certs/key.pem YOUR_IP localhost"
echo " mkcert -cert-file certs/cert.pem -key-file certs/key.pem \\"
echo " waiter.YOURDOMAIN manager.YOURDOMAIN"
echo ""
echo " Add certs then re-run this script, or run: docker compose up -d"
echo " Add certs then run: docker compose up -d"
echo ""
fi
# ── 4. Create placeholder logo if missing ────────────────────────────────────
echo "[ 4/4 ] Checking logo..."
if [ ! -f "$SCRIPT_DIR/logo.png" ]; then
echo " WARNING: logo.png not found."
echo " Place your logo at: $SCRIPT_DIR/logo.png"
echo " Creating placeholder so the stack can start..."
# ── 5. Logo ───────────────────────────────────────────────────────────────────
echo "[ 5/5 ] Checking logo..."
if [ ! -s "$SCRIPT_DIR/logo.png" ]; then
echo " WARNING: logo.png not found or is empty."
echo " Place your restaurant logo at: $SCRIPT_DIR/logo.png"
touch "$SCRIPT_DIR/logo.png"
fi
@@ -104,7 +169,9 @@ if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; th
echo "Starting stack..."
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d
echo ""
echo "Done! Services are running."
echo "Done! Services running."
echo " Waiter app: https://waiter.YOURDOMAIN"
echo " Manager app: https://manager.YOURDOMAIN"
else
echo "Add SSL certificates to certs/ then run:"
echo " docker compose up -d"

View File

@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]

View File

@@ -19,7 +19,12 @@ class Settings(BaseSettings):
CLOUD_URL: str = ""
SECRET_KEY: str = "change-me-generate-a-long-random-string"
DATABASE_URL: str = f"sqlite:///{_DB_DEFAULT.as_posix()}"
VERSION: str = "0.0.0"
VERSION: str = "0.3.1"
CONNECT_SYNC_INTERVAL_SECONDS: int = 30 # how often to poll for pending online orders
# Break-glass master account — lives only in env vars, never in the DB
MASTER_USERNAME: str = ""
MASTER_PASSWORD: str = ""
model_config = {"env_file": str(_HERE / ".env"), "env_file_encoding": "utf-8"}

878
local_backend/demo_seed.py Normal file
View File

@@ -0,0 +1,878 @@
# -*- coding: utf-8 -*-
"""
Populates the POS database with realistic demo data for customer presentations.
Generates:
- 1 manager account (username: manager / PIN: 1234 / password: password)
- 4 waiters with real Greek names
- 2 table groups with 13 tables total
- A full menu: 3 categories, ~20 products with costs
- 120 days of backdated history with realistic time distribution
- Online orders (source="online") scattered through history
- Order cancellations (~10% of items)
- Site notes and todos
- Contacts (suppliers) and expenses over the period
- Customers and tabs
- Scheduled shifts for the next 3 weeks
Usage:
python demo_seed.py
Must be run from the local_backend directory. Designed to run on a clean DB
(after wipe_database.py), but safe to run multiple times — checks for the
DEMO guard before inserting anything.
"""
import json
import random
import sys
from datetime import datetime, timedelta, date, timezone
import bcrypt
from sqlalchemy import text
from database import engine, Base, SessionLocal
import models.user
import models.table
import models.printer
import models.product
import models.customers # noqa: F401 — must be before models.order (orders.customer_id FK)
import models.order
import models.business_day
import models.shift
import models.settings
import models.flag
import models.message
import models.notes # noqa: F401 — registers tables with Base.metadata
import models.expenses # noqa: F401
import models.tabs # noqa: F401
import models.schedule # noqa: F401
import models.waste # noqa: F401
import models.reservation # noqa: F401
from models.user import User, WaiterZone
from models.table import TableGroup, Table
from models.product import Category, Product
from models.order import Order, OrderItem, OrderWaiter, OrderAuditLog
from models.business_day import BusinessDay
from models.shift import WaiterShift
from models.notes import SiteNote, SiteTodo
from models.expenses import Contact, Expense, ExpensePayment
from models.customers import Customer
from models.tabs import Tab, TabEntry, TabPayment
from models.schedule import ScheduledShift
Base.metadata.create_all(bind=engine)
# ── Config ────────────────────────────────────────────────────────────────────
DEMO_DAYS = 120 # how many past days to generate (~4 months)
ORDERS_PER_DAY = (18, 35) # min/max orders per business day
# ── Helpers ───────────────────────────────────────────────────────────────────
def _utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _hash_pin(pin: str) -> str:
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
def _hash_password(pw: str) -> str:
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
def _audit(db, order_id, event_type, waiter_id=None, item_ids=None,
amount=None, payment_method=None, note=None, created_at=None):
log = OrderAuditLog(
order_id=order_id,
event_type=event_type,
waiter_id=waiter_id,
item_ids=json.dumps(item_ids) if item_ids is not None else None,
amount=amount,
payment_method=payment_method,
note=note,
)
if created_at:
log.created_at = _utc(created_at)
db.add(log)
def _random_time_in_day(day_date: date, hour_weights: list) -> datetime:
"""
Returns a random datetime in day_date, biased toward peak hours.
hour_weights: list of (hour, weight) tuples covering the operating hours.
"""
hours = [h for h, _ in hour_weights]
weights = [w for _, w in hour_weights]
h = random.choices(hours, weights=weights)[0]
m = random.randint(0, 59)
return datetime(day_date.year, day_date.month, day_date.day, h, m)
# Operating hours weighted toward lunch (12-14) and dinner peaks (19-22),
# with lighter morning coffee traffic (10-11) and afternoon lulls (15-18).
HOUR_WEIGHTS = [
(10, 3), (11, 5),
(12, 12), (13, 15), (14, 10),
(15, 4), (16, 3), (17, 4),
(18, 6), (19, 14), (20, 16), (21, 13), (22, 8),
]
# Day-of-week multipliers (Mon=0 … Sun=6) — weekends busier
DOW_MULTIPLIER = {0: 0.75, 1: 0.75, 2: 0.80, 3: 0.85, 4: 1.00, 5: 1.20, 6: 1.10}
# ── Menu definition ───────────────────────────────────────────────────────────
# (category_name, color, [(product_name, price, cost), ...])
MENU = [
("Ορεκτικά", "#f97316", [
("Τζατζίκι", 4.50, 1.20),
("Ταραμοσαλάτα", 4.50, 1.10),
("Χωριάτικη Σαλάτα",7.50, 2.00),
("Κεφτεδάκια", 8.00, 2.80),
("Σαγανάκι", 6.50, 2.20),
("Χούμους", 5.00, 1.40),
]),
("Κυρίως Πιάτα", "#ef4444", [
("Μπριζόλα Χοιρινή", 12.00, 4.50),
("Μπριζόλα Μοσχαρίσια", 16.00, 7.00),
("Σουβλάκι Κοτόπουλο", 10.00, 3.20),
("Μουσακάς", 11.00, 3.80),
("Παστίτσιο", 10.00, 3.50),
("Σπαγγέτι Μπολονέζ", 9.50, 3.00),
("Γύρος Χοιρινός", 8.50, 2.60),
]),
("Ποτά & Αναψυκτικά", "#3b82f6", [
("Νερό 500ml", 1.00, 0.20),
("Αναψυκτικό", 2.50, 0.60),
("Μπύρα Φιάλη", 4.00, 1.20),
("Κρασί (ποτήρι)", 4.50, 1.00),
("Καφές Ελληνικός", 2.50, 0.45),
("Φραπέ", 3.00, 0.50),
("Χυμός Πορτοκάλι", 3.50, 0.90),
]),
]
# Weighted product picker: drinks ordered ~40% of the time, mains ~35%, starters ~25%
CATEGORY_WEIGHTS = [0.25, 0.35, 0.40]
WAITER_NAMES = [
("Νίκος", "Νίκος Παπαδόπουλος", "demo_nikos", "2201"),
("Μαρία", "Μαρία Κωνσταντίνου", "demo_maria", "3302"),
("Πέτρος", "Πέτρος Αναστασίου", "demo_petros", "4403"),
("Έλενα", "Έλενα Μιχαηλίδου", "demo_elena", "5504"),
]
PAYMENT_METHODS = ["cash", "cash", "cash", "card", "card"] # 60% cash / 40% card
CANCEL_REASONS = [
"Λάθος παραγγελία",
"Ο πελάτης άλλαξε γνώμη",
"Το προϊόν δεν είναι διαθέσιμο",
"Λάθος τραπέζι",
]
# ── Main ──────────────────────────────────────────────────────────────────────
db = SessionLocal()
try:
# ── Guard: abort if demo data already present ─────────────────────────────
existing = db.query(User).filter(User.username == "demo_nikos").first()
if existing:
print("Demo data already present (found demo_nikos user). Nothing to do.")
print("Run python wipe_database.py first if you want a fresh seed.")
sys.exit(0)
print("Creating demo data...")
# ── Manager account ───────────────────────────────────────────────────────
manager = db.query(User).filter(User.username == "manager").first()
if not manager:
manager = User(
username="manager",
pin_hash=_hash_pin("1234"),
password_hash=_hash_password("password"),
role="manager",
full_name="Manager",
is_active=True,
)
db.add(manager)
db.flush()
print(" created manager (username: manager / PIN: 1234 / password: password)")
else:
if not manager.password_hash:
manager.password_hash = _hash_password("password")
db.flush()
print(" manager account already exists — updated password to 'password'")
# ── Waiters ───────────────────────────────────────────────────────────────
waiters = []
for nickname, full_name, username, pin in WAITER_NAMES:
w = db.query(User).filter(User.username == username).first()
if not w:
w = User(
username=username,
pin_hash=_hash_pin(pin),
role="waiter",
full_name=full_name,
nickname=nickname,
is_active=True,
)
db.add(w)
db.flush()
waiters.append(w)
print(f" created {len(waiters)} waiters")
# ── Table groups & tables ─────────────────────────────────────────────────
group_configs = [
("Κεντρική Αίθουσα", "Κ", "#6366f1", list(range(1, 9))), # tables 1-8
("Βεράντα", "Β", "#22c55e", list(range(1, 6))), # tables 1-5
]
all_tables = []
for g_name, g_prefix, g_color, table_numbers in group_configs:
grp = db.query(TableGroup).filter(TableGroup.name == g_name).first()
if not grp:
grp = TableGroup(name=g_name, prefix=g_prefix, color=g_color, sort_order=len(all_tables))
db.add(grp)
db.flush()
for n in table_numbers:
tbl = db.query(Table).filter(Table.group_id == grp.id, Table.number == n).first()
if not tbl:
tbl = Table(number=n, group_id=grp.id, is_active=True)
db.add(tbl)
db.flush()
all_tables.append(tbl)
print(f" created {len(all_tables)} tables across {len(group_configs)} groups")
# ── Waiter zone assignments ───────────────────────────────────────────────
for w in waiters:
existing_zone = db.query(WaiterZone).filter(
WaiterZone.waiter_id == w.id, WaiterZone.group_id.is_(None)
).first()
if not existing_zone:
db.add(WaiterZone(waiter_id=w.id, group_id=None))
db.flush()
# ── Menu ──────────────────────────────────────────────────────────────────
categories = []
all_products = []
category_product_map = []
for sort_idx, (cat_name, cat_color, items) in enumerate(MENU):
cat = db.query(Category).filter(Category.name == cat_name).first()
if not cat:
cat = Category(name=cat_name, color=cat_color, sort_order=sort_idx)
db.add(cat)
db.flush()
categories.append(cat)
cat_products = []
for prod_sort, (prod_name, prod_price, prod_cost) in enumerate(items):
p = db.query(Product).filter(Product.name == prod_name, Product.category_id == cat.id).first()
if not p:
p = Product(
name=prod_name,
category_id=cat.id,
base_price=prod_price,
cost_simple=prod_cost,
is_available=True,
lifecycle_status="active",
sort_order=prod_sort,
)
db.add(p)
db.flush()
else:
p.cost_simple = prod_cost
db.flush()
cat_products.append(p)
all_products.append(p)
category_product_map.append(cat_products)
print(f" created {len(categories)} categories with {len(all_products)} products (with costs)")
# ── Contacts (suppliers & utilities) ─────────────────────────────────────
contact_defs = [
("Κρεοπωλείο Παπαδόπουλος", "supplier", "+30 22310 41234", None, "Πιερία 12, Κατερίνη", "Κύριος προμηθευτής κρέατος"),
("Αλιεύματα Κόκκαλης", "supplier", "+30 22310 55678", None, "Λιμάνι, Κατερίνη", "Φρέσκα ψάρια και θαλασσινά"),
("Χονδρικό Ελαιόλαδο", "supplier", "+30 23310 22100", "oil@agrofarm.gr","Αγροτική Οδός 5", "Ελαιόλαδο και τυρί χύμα"),
("Αναψυκτικά ΑΕΒΕ", "supplier", "+30 210 9012345", "orders@aeve.gr", "Βιομηχανική Ζώνη, Αθήνα","Αναψυκτικά, νερά, μπύρες"),
("ΔΕΗ", "utility", "11500", None, None, "Ηλεκτρισμός"),
("ΕΥΔΑΠ / Τοπικό Δίκτυο", "utility", "11888", None, None, "Νερό"),
("Cosmote Επαγγελματικό", "utility", "13888", None, None, "Τηλεφωνία & internet"),
]
contacts = []
for c_name, c_type, c_phone, c_email, c_addr, c_notes in contact_defs:
c = db.query(Contact).filter(Contact.name == c_name).first()
if not c:
c = Contact(
name=c_name,
type=c_type,
phone=c_phone,
email=c_email,
address=c_addr,
notes=c_notes,
is_active=True,
)
db.add(c)
db.flush()
contacts.append(c)
print(f" created {len(contacts)} contacts")
# ── Customers ─────────────────────────────────────────────────────────────
customer_defs = [
("Γιώργος Σταθόπουλος", "Γιώργης", "+30 6944 111222", None, "Τακτικός πελάτης, κάθε Παρασκευή βράδυ"),
("Ελευθερία Νικολάου", "Ελευθερία","+30 6977 334455", "eleftheria@gmail.com", "Αλλεργία στη γλουτένη"),
("Δημήτρης Παππάς", "Μήτσος", "+30 6955 667788", None, "Προτιμά τραπέζι στη βεράντα"),
("Σοφία Αλεξίου", "Σοφία", "+30 6933 998877", "sofia.alex@hotmail.com", "VIP πελάτης"),
("Ομάδα Εργατών", None, "+30 6900 123456", None, "Ομάδα οικοδόμων — συχνά μεσημέρι"),
]
customers = []
for c_name, c_nick, c_phone, c_email, c_notes in customer_defs:
c = db.query(Customer).filter(Customer.name == c_name).first()
if not c:
c = Customer(
name=c_name,
nickname=c_nick,
phone=c_phone,
email=c_email,
notes=c_notes,
is_active=True,
created_by_id=manager.id,
)
db.add(c)
db.flush()
customers.append(c)
print(f" created {len(customers)} customers")
db.commit()
# ── Historical days ───────────────────────────────────────────────────────
today = datetime.now(timezone.utc).date()
total_orders = 0
total_revenue = 0.0
print(f"\nGenerating {DEMO_DAYS} days of history...")
# Keep track of created business days (date → BusinessDay) for expense linking
bday_by_date: dict[date, BusinessDay] = {}
for days_ago in range(DEMO_DAYS, 0, -1):
day_date = today - timedelta(days=days_ago)
dow = day_date.weekday()
multiplier = DOW_MULTIPLIER[dow]
open_time = _utc(datetime(day_date.year, day_date.month, day_date.day,
10, random.randint(0, 30)))
close_time = _utc(datetime(day_date.year, day_date.month, day_date.day,
23, random.randint(15, 45)))
bday = BusinessDay(
status="closed",
opened_at=open_time,
opened_by_id=manager.id,
closed_at=close_time,
closed_by_id=manager.id,
)
db.add(bday)
db.flush()
bday_by_date[day_date] = bday
# 2-4 waiters work each day
working_waiters = random.sample(waiters, k=random.randint(2, len(waiters)))
STARTING_CASH_OPTIONS = [50.0, 80.0, 100.0, 110.0, 120.0, 150.0, 200.0]
shifts = {}
shift_totals = {}
for w in working_waiters:
shift_start = open_time + timedelta(minutes=random.randint(0, 30))
shift_end = close_time - timedelta(minutes=random.randint(0, 20))
shift = WaiterShift(
waiter_id=w.id,
business_day_id=bday.id,
started_at=shift_start,
ended_at=shift_end,
starting_cash=random.choice(STARTING_CASH_OPTIONS),
)
db.add(shift)
db.flush()
shifts[w.id] = shift
shift_totals[shift.id] = 0.0
# Orders distributed realistically across the day
base_n = random.randint(*ORDERS_PER_DAY)
n_orders = max(5, int(base_n * multiplier))
# ~8% of days have 1-2 online orders
n_online = random.choices([0, 1, 2], weights=[0.60, 0.28, 0.12])[0]
n_pos = n_orders - n_online
order_times = []
for _ in range(n_pos):
order_times.append(_random_time_in_day(day_date, HOUR_WEIGHTS))
order_times.sort()
# Online orders land at lunch or dinner time
online_order_times = []
for _ in range(n_online):
h = random.choices([12, 13, 19, 20, 21], weights=[2, 2, 3, 3, 2])[0]
online_order_times.append(datetime(day_date.year, day_date.month, day_date.day,
h, random.randint(0, 59)))
table_busy_until: dict[int, datetime] = {}
# ── POS orders ────────────────────────────────────────────────────────
for opened_at_naive in order_times:
opened_at = _utc(opened_at_naive)
free_tables = [
t for t in all_tables
if table_busy_until.get(t.id, datetime.min.replace(tzinfo=timezone.utc)) <= opened_at
]
if not free_tables:
free_tables = all_tables
table = random.choice(free_tables)
waiter = random.choice(working_waiters)
shift = shifts[waiter.id]
duration_min = random.randint(20, 70)
closed_at = opened_at + timedelta(minutes=duration_min)
if closed_at > close_time:
closed_at = close_time - timedelta(minutes=2)
table_busy_until[table.id] = closed_at
# ~15% of orders linked to a known customer
order_customer = random.choice(customers) if random.random() < 0.15 else None
order = Order(
table_id=table.id,
opened_by=waiter.id,
business_day_id=bday.id,
status="closed",
closed_by=manager.id,
notes=None,
source="pos",
customer_id=order_customer.id if order_customer else None,
)
order.opened_at = opened_at
order.closed_at = closed_at
db.add(order)
db.flush()
db.add(OrderWaiter(order_id=order.id, waiter_id=waiter.id, assigned_at=opened_at))
_audit(db, order.id, "ORDER_OPENED", waiter_id=waiter.id, created_at=opened_at)
n_items = random.randint(2, 6)
chosen_category_lists = random.choices(category_product_map, weights=CATEGORY_WEIGHTS, k=n_items)
items_added = []
order_total = 0.0
items_added_at = opened_at + timedelta(minutes=random.randint(2, 8))
for prod_list in chosen_category_lists:
product = random.choice(prod_list)
qty = random.choices([1, 2, 3], weights=[0.7, 0.2, 0.1])[0]
# ~10% chance the item gets cancelled
is_cancelled = random.random() < 0.10
paid_at = closed_at - timedelta(minutes=random.randint(1, 5))
line_total = product.base_price * qty
item = OrderItem(
order_id=order.id,
product_id=product.id,
added_by=waiter.id,
quantity=qty,
unit_price=product.base_price,
unit_cost=product.cost_simple,
status="cancelled" if is_cancelled else "paid",
printed=True,
)
if is_cancelled:
cancel_at = items_added_at + timedelta(minutes=random.randint(1, 10))
item.cancelled_by = waiter.id
item.cancelled_at = _utc(cancel_at)
item.cancel_reason = random.choice(CANCEL_REASONS)
else:
payment_method = random.choice(PAYMENT_METHODS)
item.paid_by = waiter.id
item.paid_at = _utc(paid_at)
item.payment_method = payment_method
item.paid_in_shift_id = shift.id
order_total += line_total
shift_totals[shift.id] = round(shift_totals[shift.id] + line_total, 2)
item.added_at = _utc(items_added_at)
db.add(item)
db.flush()
items_added.append(item.id)
_audit(db, order.id, "ITEMS_ADDED",
waiter_id=waiter.id, item_ids=items_added, created_at=items_added_at)
payment_method = random.choice(PAYMENT_METHODS)
pay_at = closed_at - timedelta(minutes=1)
_audit(db, order.id, "PAYMENT",
waiter_id=waiter.id, item_ids=items_added,
amount=round(order_total, 2), payment_method=payment_method,
created_at=pay_at)
_audit(db, order.id, "ORDER_CLOSED", waiter_id=waiter.id, created_at=closed_at)
total_orders += 1
total_revenue += order_total
# ── Online orders ─────────────────────────────────────────────────────
online_ref_counter = days_ago * 10 # rough unique ref per day
for ot in online_order_times:
opened_at = _utc(ot)
duration_min = random.randint(25, 50)
closed_at = opened_at + timedelta(minutes=duration_min)
if closed_at > close_time:
closed_at = close_time - timedelta(minutes=2)
online_type = random.choice(["delivery", "dine_in"])
online_names = ["Αλέξης Κ.", "Μαρία Π.", "Νίκος Σ.", "Αθηνά Λ.", "Θανάσης Β.", "Γιώτα Δ."]
online_phones = ["+30 694 111 0000", "+30 697 222 1111", "+30 693 333 2222",
"+30 699 444 3333", "+30 698 555 4444"]
o_name = random.choice(online_names)
o_phone = random.choice(online_phones)
o_status = random.choices(
["delivered", "delivered", "delivered", "rejected"],
weights=[0.85, 0.05, 0.05, 0.05]
)[0]
ref = f"ORD-{online_ref_counter:04d}"
online_ref_counter += 1
order = Order(
table_id=None,
opened_by=manager.id,
business_day_id=bday.id,
status="closed" if o_status == "delivered" else "cancelled",
closed_by=manager.id,
source="online",
online_order_ref=ref,
online_status=o_status,
online_customer_name=o_name,
online_customer_phone=o_phone,
online_order_type=online_type,
online_customer_notes=random.choice([None, None, "Χωρίς κρεμμύδι", "Γρήγορα παρακαλώ", None]),
order_type="delivery" if online_type == "delivery" else "here",
)
order.opened_at = opened_at
order.closed_at = closed_at
db.add(order)
db.flush()
_audit(db, order.id, "ORDER_OPENED", waiter_id=manager.id, created_at=opened_at)
n_items = random.randint(1, 4)
chosen_category_lists = random.choices(category_product_map, weights=CATEGORY_WEIGHTS, k=n_items)
items_added = []
order_total = 0.0
items_added_at = opened_at + timedelta(minutes=2)
for prod_list in chosen_category_lists:
product = random.choice(prod_list)
qty = random.choices([1, 2], weights=[0.8, 0.2])[0]
line_total = product.base_price * qty
order_total += line_total
item = OrderItem(
order_id=order.id,
product_id=product.id,
added_by=manager.id,
quantity=qty,
unit_price=product.base_price,
unit_cost=product.cost_simple,
status="paid" if o_status == "delivered" else "cancelled",
printed=True,
paid_by=manager.id if o_status == "delivered" else None,
paid_at=_utc(closed_at) if o_status == "delivered" else None,
payment_method="card" if o_status == "delivered" else None,
)
item.added_at = _utc(items_added_at)
db.add(item)
db.flush()
items_added.append(item.id)
if o_status == "delivered":
_audit(db, order.id, "PAYMENT",
waiter_id=manager.id, item_ids=items_added,
amount=round(order_total, 2), payment_method="card",
created_at=closed_at - timedelta(minutes=1))
total_revenue += order_total
_audit(db, order.id, "ORDER_CLOSED", waiter_id=manager.id, created_at=closed_at)
total_orders += 1
# Write total_collected onto shifts
for w in working_waiters:
s = shifts[w.id]
s.total_collected = shift_totals[s.id]
db.commit()
print(f" {day_date} {n_orders:2d} orders {len(working_waiters)} waiters (dow={dow})")
# ── Expenses ──────────────────────────────────────────────────────────────
print("\nAdding expenses...")
supplier_meat = contacts[0]
supplier_fish = contacts[1]
supplier_olive = contacts[2]
supplier_bev = contacts[3]
util_power = contacts[4]
util_water = contacts[5]
util_phone = contacts[6]
def _add_expense(description, category, contact, amount, paid_amount,
days_back, notes=None):
exp_date = today - timedelta(days=days_back)
bday_obj = bday_by_date.get(exp_date)
due = _utc(datetime(exp_date.year, exp_date.month, exp_date.day, 9, 0))
exp = Expense(
description=description,
category=category,
contact_id=contact.id if contact else None,
total_amount=amount,
paid_amount=paid_amount,
due_date=due,
business_day_id=bday_obj.id if bday_obj else None,
created_by_id=manager.id,
notes=notes,
)
exp.created_at = due
db.add(exp)
db.flush()
if paid_amount > 0:
pmt = ExpensePayment(
expense_id=exp.id,
amount=paid_amount,
paid_by_id=manager.id,
notes=None,
)
pmt.paid_at = due + timedelta(hours=1)
db.add(pmt)
db.flush()
# Recurring weekly: meat supplier (~every 5 days)
for i in range(0, DEMO_DAYS, 5):
_add_expense("Κρέας εβδομάδας", "supplier", supplier_meat,
round(random.uniform(180, 280), 2), 0,
DEMO_DAYS - i, "Παράδοση Δευτέρα πρωί")
# Recurring bi-weekly: fish
for i in range(0, DEMO_DAYS, 10):
_add_expense("Ψάρια & θαλασσινά", "supplier", supplier_fish,
round(random.uniform(90, 160), 2), 0,
DEMO_DAYS - i)
# Olive oil once a month
for i in range(0, DEMO_DAYS, 30):
_add_expense("Ελαιόλαδο & τυρί", "supplier", supplier_olive,
round(random.uniform(120, 200), 2), 0,
DEMO_DAYS - i)
# Beverages every 2 weeks
for i in range(0, DEMO_DAYS, 14):
amt = round(random.uniform(200, 350), 2)
_add_expense("Αναψυκτικά & μπύρες", "supplier", supplier_bev,
amt, amt, # fully paid
DEMO_DAYS - i)
# Monthly utility bills
for month_offset in range(4):
days_back = 30 * (month_offset + 1)
_add_expense("ΔΕΗ — μηνιαίος λογαριασμός", "utilities", util_power,
round(random.uniform(280, 420), 2), 0, days_back)
_add_expense("Νερό — μηνιαίος λογαριασμός", "utilities", util_water,
round(random.uniform(45, 90), 2), 0, days_back + 2)
_add_expense("Τηλεφωνία & Internet", "utilities", util_phone,
49.90, 49.90, days_back + 1)
# Rent once a month
for month_offset in range(4):
days_back = 30 * (month_offset + 1) - 5
_add_expense("Ενοίκιο καταστήματος", "rent", None,
1200.0, 1200.0, days_back,
"Πάντα στις αρχές του μήνα")
# One maintenance expense
_add_expense("Επισκευή ψυγείου", "maintenance", None,
320.0, 320.0, 45, "Συμπιεστής — Τεχνίτης Σωτήρης")
db.commit()
print(" expenses done")
# ── Tabs ──────────────────────────────────────────────────────────────────
print("Adding tabs...")
def _add_tab(customer, entries_desc, payments, is_closed=False):
"""entries_desc: list of (amount, description). payments: list of amounts."""
tab_opened = _utc(datetime.now(timezone.utc) - timedelta(days=random.randint(5, 30)))
tab = Tab(
customer_id=customer.id,
status="closed" if is_closed else "open",
opened_at=tab_opened,
closed_by_id=manager.id if is_closed else None,
closed_at=tab_opened + timedelta(days=random.randint(3, 15)) if is_closed else None,
notes=None,
)
db.add(tab)
db.flush()
entry_time = tab_opened
for amount, desc in entries_desc:
entry_time += timedelta(days=random.randint(1, 3))
e = TabEntry(
tab_id=tab.id,
order_id=None,
order_item_id=None,
amount=amount,
description=desc,
created_by_id=manager.id,
)
e.created_at = entry_time
db.add(e)
pay_time = entry_time + timedelta(days=1)
for pay_amount in payments:
pay_time += timedelta(hours=random.randint(1, 24))
p = TabPayment(
tab_id=tab.id,
amount=pay_amount,
payment_method=random.choice(["cash", "card"]),
received_by_id=manager.id,
notes=None,
)
p.created_at = pay_time
db.add(p)
db.flush()
# Open tab: Γιώργης hasn't settled his tab yet
_add_tab(customers[0], [
(35.50, "Δείπνο 3 ατόμων — 15/05"),
(28.00, "Μεσημεριανό — 22/05"),
(42.00, "Δείπνο 4 ατόμων — 28/05"),
], [20.00]) # partial payment
# Closed tab: Σοφία settled hers
_add_tab(customers[3], [
(55.00, "Εταιρικό δείπνο — 10/04"),
(30.00, "Μεσημεριανό — 18/04"),
], [85.00], is_closed=True)
# Open tab: Ομάδα εργατών — running tab
_add_tab(customers[4], [
(22.00, "Μεσημεριανό ομάδα — 01/06"),
(25.50, "Μεσημεριανό ομάδα — 05/06"),
(19.00, "Μεσημεριανό ομάδα — 09/06"),
], [30.00])
db.commit()
print(" tabs done")
# ── Site Notes & Todos ────────────────────────────────────────────────────
print("Adding notes & todos...")
notes_data = [
("Ο φούρνος χρειάζεται σέρβις — να κλείσουμε ραντεβού με τεχνίτη.", True),
("Νέα τιμή μπύρας από 1 Ιουλίου: Heineken 4.50€ (από 4.00€).", False),
("Παραγγελία ελαιολάδου: 10 κιλά εξαιρετικό παρθένο από Αλεξόπουλο.", False),
("ΔΕΗ: η επόμενη πληρωμή λήγει στις 20 Ιουλίου. Να μην ξεχαστεί!", True),
("Το κλιματιστικό βεράντας λειτουργεί πάλι μετά την επισκευή (Παρ. 07/06).", False),
]
for body, pinned in notes_data:
note = SiteNote(
body=body,
created_by_id=manager.id,
is_pinned=pinned,
)
db.add(note)
todos_data = [
("Ανανέωση τιμοκαταλόγου για καλοκαίρι", "high", False),
("Αγορά νέων ποτηριών κρασιού (χάλασαν 4)", "normal", False),
("Έλεγχος πυροσβεστήρων — Ιούλιος", "high", False),
("Εκπαίδευση νέου σερβιτόρου στο σύστημα", "normal", False),
("Ανανέωση άδειας λειτουργίας", "high", False),
("Παραγγελία νέων χαρτοπετσετών και υλικών bar", "normal", True),
("Φωτογράφιση νέων πιάτων για online menu", "normal", False),
]
for body, priority, is_done in todos_data:
todo = SiteTodo(
body=body,
priority=priority,
is_done=is_done,
created_by_id=manager.id,
done_by_id=manager.id if is_done else None,
done_at=_utc(datetime.now(timezone.utc) - timedelta(days=2)) if is_done else None,
)
db.add(todo)
db.commit()
print(" notes & todos done")
# ── Schedule (next 3 weeks) ───────────────────────────────────────────────
print("Adding schedule for next 3 weeks...")
# Each waiter works 5 days out of 7, with realistic shift patterns
# Mon-Fri: full shifts, Sat: both sessions, Sun: afternoon/evening only
shift_patterns = {
0: [("10:00", "16:00"), ("16:00", "23:30")], # Mon — two shifts
1: [("10:00", "16:00"), ("16:00", "23:30")], # Tue
2: [("10:00", "16:00"), ("16:00", "23:30")], # Wed
3: [("10:00", "16:00"), ("16:00", "23:30")], # Thu
4: [("10:00", "17:00"), ("17:00", "23:30")], # Fri
5: [("10:00", "16:00"), ("16:00", "23:30")], # Sat — busy day
6: [("11:00", "16:00"), ("16:00", "23:00")], # Sun
}
# Assign waiters to shifts: each day, 2 waiters per slot
for weeks_ahead in range(3):
for day_offset in range(7):
sched_date = today + timedelta(weeks=weeks_ahead, days=day_offset)
dow = sched_date.weekday()
day_patterns = shift_patterns[dow]
# Shuffle waiters and assign 2 per slot
waiter_pool = waiters[:]
random.shuffle(waiter_pool)
for slot_idx, (start_t, end_t) in enumerate(day_patterns):
# Pick 2 waiters per slot (rotate through pool)
slot_waiters = [waiter_pool[slot_idx % len(waiter_pool)],
waiter_pool[(slot_idx + 1) % len(waiter_pool)]]
for w in slot_waiters:
# Don't double-schedule same waiter on same day
already = db.query(ScheduledShift).filter(
ScheduledShift.user_id == w.id,
ScheduledShift.scheduled_date == sched_date,
).first()
if not already:
sched = ScheduledShift(
user_id=w.id,
scheduled_date=sched_date,
start_time=start_t,
end_time=end_t,
notes=None,
created_by_id=manager.id,
)
db.add(sched)
db.commit()
print(" schedule done")
print(f"\nDone.")
print(f" Total orders : {total_orders}")
print(f" Total revenue : €{total_revenue:,.2f}")
print(f"\nLogin credentials:")
print(f" Manager — username: manager | password: password | PIN: 1234")
for w in waiters:
print(f" Waiter — username: {w.username} | nickname: {w.nickname}")
finally:
db.close()

View File

@@ -13,12 +13,23 @@ import models.user # noqa: F401 — also registers WaiterZone
import models.table # noqa: F401
import models.printer # noqa: F401
import models.product # noqa: F401
import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount
import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount, PrintJob
import models.business_day # noqa: F401
import models.shift # noqa: F401 — registers WaiterShift, ShiftBreak
import models.settings # noqa: F401
import models.flag # noqa: F401 — registers TableFlagDef, TableFlagAssignment
import models.message # noqa: F401 — registers StaffMessage, StaffMessageAck, QuickMessageTemplate
import models.reservation # noqa: F401
import models.notes # noqa: F401 — registers SiteNote, SiteTodo
import models.expenses # noqa: F401 — registers Contact, Expense, ExpensePayment
import models.customers # noqa: F401 — registers Customer
import models.tabs # noqa: F401 — registers Tab, TabEntry, TabPayment
import models.waste # noqa: F401 — registers WasteLog
import models.schedule # noqa: F401 — registers ScheduledShift
import models.chat # noqa: F401 — registers Conversation, ConversationParticipant, ChatMessage
import models.prep_zone # noqa: F401 — registers PrepZone, prep_zone_printers, product_prep_zones
import models.pricing # noqa: F401 — registers PriceGroup, PriceModifier, Deal, PriceEventLog, WaiterDiscountSettings
import models.recovery_code # noqa: F401 — registers RecoveryCode
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
from routers import business_day as business_day_router
@@ -27,7 +38,23 @@ from routers import settings as settings_router
from routers import flags as flags_router
from routers import messages as messages_router
from routers import sse as sse_router
from routers import ws as ws_router
from routers import data_transfer as data_transfer_router
from routers import connect_orders as connect_orders_router
from routers import reservations as reservations_router
from routers import notes as notes_router
from routers.expenses import contacts_router, expenses_router
from routers import customers as customers_router
from routers import tabs as tabs_router
from routers import waste as waste_router
from routers import kds as kds_router
from routers import schedule as schedule_router
from routers import chat as chat_router
from routers import prep_zones as prep_zones_router
from routers import pricing as pricing_router
from routers import fiscal as fiscal_router
from routers import phone as phone_router
from routers import recovery as recovery_router
def _run_migrations():
@@ -218,6 +245,495 @@ def _run_migrations():
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock_seconds', '300', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout', 'false', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout_seconds', '1800', CURRENT_TIMESTAMP)",
# Xenia Connect — online order fields on orders table
"ALTER TABLE orders ADD COLUMN source VARCHAR NOT NULL DEFAULT 'pos'",
"ALTER TABLE orders ADD COLUMN online_order_ref VARCHAR",
"ALTER TABLE orders ADD COLUMN online_order_cloud_id INTEGER",
"ALTER TABLE orders ADD COLUMN online_status VARCHAR",
"ALTER TABLE orders ADD COLUMN online_customer_name VARCHAR",
"ALTER TABLE orders ADD COLUMN online_customer_phone VARCHAR",
"ALTER TABLE orders ADD COLUMN online_customer_address TEXT",
"ALTER TABLE orders ADD COLUMN online_customer_notes TEXT",
"ALTER TABLE orders ADD COLUMN online_order_type VARCHAR",
# Xenia Connect — digital menu fields on products
"ALTER TABLE products ADD COLUMN digital_visible INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE products ADD COLUMN digital_available INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE products ADD COLUMN digital_name VARCHAR",
"ALTER TABLE products ADD COLUMN digital_description VARCHAR",
"ALTER TABLE products ADD COLUMN digital_price REAL",
"ALTER TABLE products ADD COLUMN digital_discount REAL NOT NULL DEFAULT 0.0",
"ALTER TABLE products ADD COLUMN digital_image_url VARCHAR",
# Per-printer line width (chars per line) — default 48 for 80mm Jolimark
"ALTER TABLE printers ADD COLUMN line_width INTEGER NOT NULL DEFAULT 48",
# Product description (plain-text, for menu display)
"ALTER TABLE products ADD COLUMN description TEXT",
# Staff note (internal memo on a waiter's profile)
"ALTER TABLE users ADD COLUMN note VARCHAR",
# Reservations table
"""CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guest_name VARCHAR NOT NULL,
party_size INTEGER NOT NULL,
phone VARCHAR,
email VARCHAR,
note TEXT,
reserved_for DATETIME NOT NULL,
table_id INTEGER REFERENCES tables(id),
status VARCHAR NOT NULL DEFAULT 'pending',
source VARCHAR NOT NULL DEFAULT 'manager_app',
created_by_user_id INTEGER REFERENCES users(id),
online_customer_id VARCHAR,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# Product unit of measure (piece / portion / kg / liter / gram / ml)
"ALTER TABLE products ADD COLUMN unit_type VARCHAR NOT NULL DEFAULT 'piece'",
# Phase 2A — product cost tracking
"ALTER TABLE products ADD COLUMN cost_simple REAL",
"ALTER TABLE products ADD COLUMN cost_breakdown TEXT",
"ALTER TABLE order_items ADD COLUMN unit_cost REAL",
# Phase 2B — staff payroll
"ALTER TABLE users ADD COLUMN hourly_rate REAL",
"ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot REAL",
# Phase 2J — staff shift scheduling
"""CREATE TABLE IF NOT EXISTS scheduled_shifts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
scheduled_date DATE NOT NULL,
start_time VARCHAR NOT NULL,
end_time VARCHAR NOT NULL,
notes TEXT,
created_by_id INTEGER NOT NULL REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# Phase 2H — void/waste log
"""CREATE TABLE IF NOT EXISTS waste_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL REFERENCES products(id),
quantity REAL NOT NULL,
unit_cost_snapshot REAL,
total_cost REAL,
reason VARCHAR NOT NULL,
reason_notes TEXT,
logged_by_id INTEGER NOT NULL REFERENCES users(id),
business_day_id INTEGER REFERENCES business_days(id),
logged_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# Phase 2G — pay-later tab system
"""CREATE TABLE IF NOT EXISTS tabs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id),
status VARCHAR NOT NULL DEFAULT 'open',
opened_at DATETIME DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME,
closed_by_id INTEGER REFERENCES users(id),
notes TEXT
)""",
"""CREATE TABLE IF NOT EXISTS tab_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tab_id INTEGER NOT NULL REFERENCES tabs(id),
order_id INTEGER REFERENCES orders(id),
order_item_id INTEGER REFERENCES order_items(id),
amount REAL NOT NULL,
description TEXT,
created_by_id INTEGER NOT NULL REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"""CREATE TABLE IF NOT EXISTS tab_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tab_id INTEGER NOT NULL REFERENCES tabs(id),
amount REAL NOT NULL,
payment_method VARCHAR,
received_by_id INTEGER NOT NULL REFERENCES users(id),
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
# Phase 2F — customer CRM
"""CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
nickname VARCHAR,
phone VARCHAR,
email VARCHAR,
notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by_id INTEGER NOT NULL REFERENCES users(id)
)""",
"ALTER TABLE orders ADD COLUMN customer_id INTEGER REFERENCES customers(id)",
# Phase 2E — cash drawer reconciliation
"ALTER TABLE waiter_shifts ADD COLUMN counted_cash_end REAL",
"ALTER TABLE waiter_shifts ADD COLUMN cash_discrepancy REAL",
"ALTER TABLE business_days ADD COLUMN store_opening_cash REAL",
"ALTER TABLE business_days ADD COLUMN store_closing_cash REAL",
"ALTER TABLE business_days ADD COLUMN store_cash_discrepancy REAL",
# Phase 2D — expense tracking + supplier contacts
"""CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
type VARCHAR NOT NULL DEFAULT 'supplier',
phone VARCHAR,
email VARCHAR,
address TEXT,
notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"""CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
description VARCHAR NOT NULL,
category VARCHAR NOT NULL,
contact_id INTEGER REFERENCES contacts(id),
total_amount REAL NOT NULL,
paid_amount REAL NOT NULL DEFAULT 0.0,
due_date DATETIME,
business_day_id INTEGER REFERENCES business_days(id),
created_by_id INTEGER NOT NULL REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
notes TEXT
)""",
"""CREATE TABLE IF NOT EXISTS expense_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
expense_id INTEGER NOT NULL REFERENCES expenses(id),
amount REAL NOT NULL,
paid_at DATETIME DEFAULT CURRENT_TIMESTAMP,
paid_by_id INTEGER NOT NULL REFERENCES users(id),
notes TEXT
)""",
# Phase 2C — notes & todos
"""CREATE TABLE IF NOT EXISTS site_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL,
created_by_id INTEGER NOT NULL REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_pinned INTEGER NOT NULL DEFAULT 0
)""",
"""CREATE TABLE IF NOT EXISTS site_todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL,
is_done INTEGER NOT NULL DEFAULT 0,
done_at DATETIME,
done_by_id INTEGER REFERENCES users(id),
created_by_id INTEGER NOT NULL REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
priority VARCHAR NOT NULL DEFAULT 'normal'
)""",
# Printer duplicate copies (0 = print once, 1 = print twice, etc.)
"ALTER TABLE printers ADD COLUMN duplicates INTEGER NOT NULL DEFAULT 0",
# Waiter cancellation permissions
"ALTER TABLE users ADD COLUMN can_cancel_orders INTEGER NOT NULL DEFAULT 0",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.waiter_cancellations_allowed', 'false', CURRENT_TIMESTAMP)",
# KDS — kitchen display system status tracking
"ALTER TABLE orders ADD COLUMN kds_status VARCHAR NOT NULL DEFAULT 'pending'",
"ALTER TABLE orders ADD COLUMN order_type VARCHAR NOT NULL DEFAULT 'here'",
"ALTER TABLE order_items ADD COLUMN kds_status VARCHAR NOT NULL DEFAULT 'pending'",
# KDS notifications — message type + zone tagging on staff messages
"ALTER TABLE staff_messages ADD COLUMN message_type VARCHAR NOT NULL DEFAULT 'manager'",
"ALTER TABLE staff_messages ADD COLUMN kds_zone VARCHAR",
# Chat system
"""CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type VARCHAR NOT NULL DEFAULT 'direct',
name VARCHAR,
is_system INTEGER NOT NULL DEFAULT 0,
created_by INTEGER REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"""CREATE TABLE IF NOT EXISTS conversation_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
user_id INTEGER NOT NULL REFERENCES users(id),
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_read_at DATETIME
)""",
"""CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
sender_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
)""",
# Prep Zones — replaces per-product printer assignment
"""CREATE TABLE IF NOT EXISTS prep_zones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
description VARCHAR
)""",
"""CREATE TABLE IF NOT EXISTS prep_zone_printers (
prep_zone_id INTEGER NOT NULL REFERENCES prep_zones(id),
printer_id INTEGER NOT NULL REFERENCES printers(id),
PRIMARY KEY (prep_zone_id, printer_id)
)""",
"""CREATE TABLE IF NOT EXISTS product_prep_zones (
product_id INTEGER NOT NULL REFERENCES products(id),
prep_zone_id INTEGER NOT NULL REFERENCES prep_zones(id),
PRIMARY KEY (product_id, prep_zone_id)
)""",
# KDS decline tracking on items
"ALTER TABLE order_items ADD COLUMN decline_note TEXT",
# Prep zone notification name
"ALTER TABLE prep_zones ADD COLUMN notification_name VARCHAR",
# KDS status change timestamp on orders (for "latest change" sorting in PWA)
"ALTER TABLE orders ADD COLUMN kds_status_changed_at DATETIME",
# Auto-close table after full payment setting
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.auto_close_on_full_payment', 'false', CURRENT_TIMESTAMP)",
# Quick Add: allow direct 1-unit add from product list without opening config modal
"ALTER TABLE products ADD COLUMN quick_add_enabled INTEGER NOT NULL DEFAULT 1",
# Per-waiter settings + favorites sync (JSON blob)
"ALTER TABLE users ADD COLUMN waiter_settings TEXT",
# Prep zone print settings — synced with KDS auto-print controls
"ALTER TABLE prep_zones ADD COLUMN auto_print VARCHAR NOT NULL DEFAULT 'none'",
"ALTER TABLE prep_zones ADD COLUMN print_copies INTEGER NOT NULL DEFAULT 1",
# Bypass KDS/serve flow: new orders immediately start as served
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.bypass_kds_serve', 'false', CURRENT_TIMESTAMP)",
# Prep zone: rename auto_print value 'primary' → 'master' for existing rows
"UPDATE prep_zones SET auto_print = 'master' WHERE auto_print = 'primary'",
# Prep zone: master printer (single FK) + per-role copy counts
"ALTER TABLE prep_zones ADD COLUMN master_printer_id INTEGER REFERENCES printers(id)",
"ALTER TABLE prep_zones ADD COLUMN master_copies INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE prep_zones ADD COLUMN secondary_copies INTEGER NOT NULL DEFAULT 1",
# Prep zone: item sort / category grouping options
"ALTER TABLE prep_zones ADD COLUMN sort_items_by VARCHAR NOT NULL DEFAULT 'order_time'",
"ALTER TABLE prep_zones ADD COLUMN group_by_category INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE prep_zones ADD COLUMN category_order TEXT NOT NULL DEFAULT '[]'",
# Prep zone: ticket formatting option
"ALTER TABLE prep_zones ADD COLUMN print_checkboxes INTEGER NOT NULL DEFAULT 0",
# Prep zone: KDS bypass options
"ALTER TABLE prep_zones ADD COLUMN bypass_pending INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE prep_zones ADD COLUMN bypass_kds INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE prep_zones ADD COLUMN auto_ready_to_served INTEGER NOT NULL DEFAULT 0",
# Prep zone: migrate existing master_printer_id from first printer in zone (best-effort)
# (handled in Python at runtime, not SQL — no-op SQL here)
# Product tags (JSON array of strings)
"ALTER TABLE products ADD COLUMN tags TEXT",
# Hide revenue from waiters on shift overview
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.hide_revenue_from_waiters', 'false', CURRENT_TIMESTAMP)",
# WebSocket event log — append-only, pruned to 24h on startup
"""CREATE TABLE IF NOT EXISTS sync_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type VARCHAR NOT NULL,
payload TEXT NOT NULL,
target_user_ids TEXT,
created_at VARCHAR NOT NULL
)""",
"CREATE INDEX IF NOT EXISTS ix_sync_events_created_at ON sync_events (created_at)",
# Migrate from legacy per-product printer to prep zones: clear old printer_zone_id
"UPDATE products SET printer_zone_id = NULL WHERE printer_zone_id IS NOT NULL",
# Per-printer ESC/POS code page index (n in ESC t n) — default 29 = CP737 on Jolimark
"ALTER TABLE printers ADD COLUMN codepage_n INTEGER NOT NULL DEFAULT 29",
# Price adjustment: per-item on-the-fly price delta (positive or negative)
"ALTER TABLE order_items ADD COLUMN price_adjustment REAL NOT NULL DEFAULT 0.0",
# Customer count: how many people are at the table for this order
"ALTER TABLE orders ADD COLUMN customer_count INTEGER",
# Service items: flag on products, these are non-inventoried table-service items
"ALTER TABLE products ADD COLUMN is_service_item INTEGER NOT NULL DEFAULT 0",
# Table seat count — physical seats at each table (foundation for per-seat dish assignment)
"ALTER TABLE tables ADD COLUMN seat_count INTEGER",
# Courses — optional course assignment for sequenced firing
"ALTER TABLE order_items ADD COLUMN course_id INTEGER",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.courses_enabled', 'false', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.courses', '[]', CURRENT_TIMESTAMP)",
# Print job lifecycle tracking — auto-retry system
"""CREATE TABLE IF NOT EXISTS print_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id),
printer_id INTEGER NOT NULL REFERENCES printers(id),
zone_id INTEGER,
item_ids TEXT NOT NULL,
copies INTEGER NOT NULL DEFAULT 1,
status VARCHAR NOT NULL DEFAULT 'pending',
retry_count INTEGER NOT NULL DEFAULT 0,
first_attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_attempted_at DATETIME,
succeeded_at DATETIME,
cancelled_at DATETIME,
cancel_reason VARCHAR
)""",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('orders.waiter_price_adjust_allowed', 'false', CURRENT_TIMESTAMP)",
# Pricing system — Phase 2 pricing overhaul
"""CREATE TABLE IF NOT EXISTS price_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
description TEXT,
color VARCHAR,
is_active INTEGER NOT NULL DEFAULT 0,
auto_enable_time VARCHAR,
auto_disable_time VARCHAR,
auto_days TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
)""",
"""CREATE TABLE IF NOT EXISTS price_modifiers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
description TEXT,
color VARCHAR,
is_active INTEGER NOT NULL DEFAULT 1,
is_favorite INTEGER NOT NULL DEFAULT 0,
allow_stack INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
scope VARCHAR NOT NULL DEFAULT 'global',
item_id INTEGER REFERENCES products(id),
action_type VARCHAR NOT NULL,
action_value REAL NOT NULL,
round_to VARCHAR,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id),
updated_at DATETIME,
updated_by INTEGER REFERENCES users(id)
)""",
"""CREATE TABLE IF NOT EXISTS price_modifier_conditions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
modifier_id INTEGER NOT NULL REFERENCES price_modifiers(id) ON DELETE CASCADE,
condition_type VARCHAR NOT NULL,
params TEXT NOT NULL DEFAULT '{}'
)""",
"""CREATE TABLE IF NOT EXISTS price_modifier_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
modifier_id INTEGER NOT NULL REFERENCES price_modifiers(id) ON DELETE CASCADE,
target_type VARCHAR NOT NULL,
target_id INTEGER,
target_tag VARCHAR
)""",
"""CREATE TABLE IF NOT EXISTS deals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
description TEXT,
color VARCHAR,
is_active INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
action_type VARCHAR NOT NULL,
action_modifier_id INTEGER REFERENCES price_modifiers(id),
action_value REAL,
action_free_item_id INTEGER REFERENCES products(id),
action_free_target_type VARCHAR,
action_free_target_ids TEXT,
action_free_quantity INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
)""",
"""CREATE TABLE IF NOT EXISTS deal_conditions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deal_id INTEGER NOT NULL REFERENCES deals(id) ON DELETE CASCADE,
condition_type VARCHAR NOT NULL,
params TEXT NOT NULL DEFAULT '{}'
)""",
"""CREATE TABLE IF NOT EXISTS deal_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deal_id INTEGER NOT NULL REFERENCES deals(id) ON DELETE CASCADE,
target_type VARCHAR NOT NULL,
target_id INTEGER,
target_tag VARCHAR
)""",
"""CREATE TABLE IF NOT EXISTS price_event_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id),
order_item_id INTEGER REFERENCES order_items(id),
event_type VARCHAR NOT NULL,
modifier_id INTEGER REFERENCES price_modifiers(id),
deal_id INTEGER REFERENCES deals(id),
price_before REAL,
price_after REAL,
delta_amount REAL,
applied_by_user_id INTEGER REFERENCES users(id),
waiter_note TEXT,
conditions_snapshot TEXT,
selected_item_ids TEXT,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)""",
"CREATE INDEX IF NOT EXISTS ix_price_event_log_order_id ON price_event_log (order_id)",
"CREATE INDEX IF NOT EXISTS ix_price_event_log_order_item_id ON price_event_log (order_item_id)",
"""CREATE TABLE IF NOT EXISTS waiter_discount_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
can_apply_discounts INTEGER NOT NULL DEFAULT 0,
max_discount_percent REAL,
max_discount_amount REAL,
max_total_value_shift REAL,
max_total_value_workday REAL,
max_items_per_shift INTEGER,
max_items_per_workday INTEGER,
max_items_per_order INTEGER
)""",
# Extend order_discounts with price snapshot columns for clean audit trail
"ALTER TABLE order_discounts ADD COLUMN price_before REAL",
"ALTER TABLE order_discounts ADD COLUMN price_after REAL",
# Global discount settings in pos_settings
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('discounts.enabled', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('discounts.max_total_value_workday', '', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('discounts.max_total_value_shift', '', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('discounts.max_items_per_shift', '', CURRENT_TIMESTAMP)",
# Deal item binding — which deal created this item, and which item it's linked to
"ALTER TABLE order_items ADD COLUMN deal_id INTEGER REFERENCES deals(id)",
"ALTER TABLE order_items ADD COLUMN linked_item_id INTEGER REFERENCES order_items(id)",
# Multi-select targets: JSON arrays of IDs/tags per target row
"ALTER TABLE price_modifier_targets ADD COLUMN target_ids TEXT",
"ALTER TABLE price_modifier_targets ADD COLUMN target_tags TEXT",
"ALTER TABLE deal_targets ADD COLUMN target_ids TEXT",
"ALTER TABLE deal_targets ADD COLUMN target_tags TEXT",
# Role & permissions overhaul — granular per-user permission flags
# Access permissions
"ALTER TABLE users ADD COLUMN perm_access_dashboard INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN perm_access_waiter_app INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE users ADD COLUMN perm_access_kds INTEGER NOT NULL DEFAULT 0",
# Order action permissions
"ALTER TABLE users ADD COLUMN perm_cancel_orders INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN perm_apply_discounts INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN perm_modify_prices INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN perm_open_orders INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE users ADD COLUMN perm_close_orders INTEGER NOT NULL DEFAULT 1",
# Management permissions
"ALTER TABLE users ADD COLUMN perm_view_reports INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN perm_manage_staff INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN perm_manage_tables INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN perm_manage_menu INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN perm_manage_settings INTEGER NOT NULL DEFAULT 0",
# Migrate existing managers/sysadmins to have dashboard access + all management perms
"UPDATE users SET perm_access_dashboard=1, perm_access_waiter_app=1, perm_access_kds=1, perm_cancel_orders=1, perm_apply_discounts=1, perm_modify_prices=1, perm_open_orders=1, perm_close_orders=1, perm_view_reports=1, perm_manage_staff=1, perm_manage_tables=1, perm_manage_menu=1, perm_manage_settings=1 WHERE role IN ('manager', 'sysadmin')",
# Migrate existing waiters: update can_cancel_orders → perm_cancel_orders
"UPDATE users SET perm_cancel_orders = can_cancel_orders WHERE role = 'waiter'",
# Rename legacy roles: manager → store_manager, sysadmin → superadmin
"UPDATE users SET role = 'superadmin' WHERE role = 'sysadmin'",
"UPDATE users SET role = 'store_manager' WHERE role = 'manager'",
# Feature: modifier groups (folders), multi-select extras, compact mode
"""CREATE TABLE IF NOT EXISTS product_modifier_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL REFERENCES products(id),
modifier_type VARCHAR NOT NULL,
name VARCHAR NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
)""",
"ALTER TABLE product_options ADD COLUMN multi_select INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_options ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_options ADD COLUMN group_id INTEGER REFERENCES product_modifier_groups(id)",
"ALTER TABLE product_ingredients ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_ingredients ADD COLUMN group_id INTEGER REFERENCES product_modifier_groups(id)",
"ALTER TABLE product_preference_choices ADD COLUMN is_compact INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE product_preference_sets ADD COLUMN group_id INTEGER REFERENCES product_modifier_groups(id)",
# Fiscal printer (ΦΗΜ) — product fields
"ALTER TABLE products ADD COLUMN fiscal_name VARCHAR",
"ALTER TABLE products ADD COLUMN fiscal_vat_group_id INTEGER",
# Fiscal status on orders (NULL=fiscal off, pending/success/failed)
"ALTER TABLE orders ADD COLUMN fiscal_status VARCHAR",
# Drop legacy can_cancel_orders column — data already migrated to perm_cancel_orders
"ALTER TABLE users DROP COLUMN can_cancel_orders",
# Recovery codes for account self-recovery
"""CREATE TABLE IF NOT EXISTS recovery_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash VARCHAR NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
used_at DATETIME
)""",
"CREATE INDEX IF NOT EXISTS ix_recovery_codes_user_id ON recovery_codes (user_id)",
# Multi-select preference sets
"ALTER TABLE product_preference_sets ADD COLUMN allow_multi_select INTEGER NOT NULL DEFAULT 0",
# Per-choice quantity on multi-select preference sets (x2, x3, …)
"ALTER TABLE product_preference_sets ADD COLUMN allow_choice_quantity INTEGER NOT NULL DEFAULT 0",
# Tracks the hash of the product image last pushed to the cloud (Xenia Connect)
"ALTER TABLE products ADD COLUMN cloud_image_hash VARCHAR",
]
for sql in migrations:
try:
@@ -228,16 +744,44 @@ def _run_migrations():
pass
def _init_chat():
"""Bootstrap the system group chat and sync all active users into it."""
from services.chat_service import ensure_system_group, add_user_to_system_group
from models.user import User
from database import SessionLocal
db = SessionLocal()
try:
ensure_system_group(db)
active_users = db.query(User).filter(User.is_active == True).all() # noqa: E712
for u in active_users:
add_user_to_system_group(db, u.id)
finally:
db.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
import asyncio
from services.sse_bus import init_loop
init_loop(asyncio.get_running_loop())
from services.sse_bus import init_loop as sse_init_loop
from services.ws_bus import init_loop as ws_init_loop, prune_old_events
from services.reservation_tasks import start_reservation_tasks
from services.printer_service import start_print_retry_thread
loop = asyncio.get_running_loop()
sse_init_loop(loop)
ws_init_loop(loop)
Base.metadata.create_all(bind=engine)
_run_migrations()
start_print_retry_thread()
pruned = prune_old_events(hours=24)
if pruned:
import logging
logging.getLogger(__name__).info("ws_bus: pruned %d old sync_events", pruned)
_init_chat()
sync_task = await start_cloud_sync()
reservation_task = await start_reservation_tasks()
yield
sync_task.cancel()
reservation_task.cancel()
app = FastAPI(title="POS Local Backend", lifespan=lifespan)
@@ -272,6 +816,23 @@ app.include_router(business_day_router.router, prefix="/api/business-day", tag
app.include_router(shifts_router.router, prefix="/api/shifts", tags=["shifts"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
app.include_router(flags_router.router, prefix="/api/flags", tags=["flags"])
app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"])
app.include_router(messages_router.router, prefix="/api/notifications", tags=["messages"])
app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"])
app.include_router(ws_router.router, prefix="/api/ws", tags=["ws"])
app.include_router(data_transfer_router.router, prefix="/api/data-transfer", tags=["data-transfer"])
app.include_router(connect_orders_router.router, prefix="/api/connect", tags=["connect"])
app.include_router(reservations_router.router, prefix="/api/reservations", tags=["reservations"])
app.include_router(notes_router.router, prefix="/api/notes", tags=["notes"])
app.include_router(contacts_router, prefix="/api/contacts", tags=["contacts"])
app.include_router(expenses_router, prefix="/api/expenses", tags=["expenses"])
app.include_router(customers_router.router, prefix="/api/customers", tags=["customers"])
app.include_router(tabs_router.router, prefix="/api/tabs", tags=["tabs"])
app.include_router(waste_router.router, prefix="/api/waste", tags=["waste"])
app.include_router(kds_router.router, prefix="/api/kds", tags=["kds"])
app.include_router(schedule_router.router, prefix="/api/schedule", tags=["schedule"])
app.include_router(chat_router.router, prefix="/api/chat", tags=["chat"])
app.include_router(prep_zones_router.router, prefix="/api/prep-zones", tags=["prep-zones"])
app.include_router(pricing_router.router, prefix="/api/pricing", tags=["pricing"])
app.include_router(fiscal_router.router, prefix="/api/fiscal", tags=["fiscal"])
app.include_router(phone_router.router, prefix="/api/phone", tags=["phone"])
app.include_router(recovery_router.router, prefix="/api/recovery", tags=["recovery"])

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
@@ -18,6 +18,10 @@ class BusinessDay(Base):
closed_at = Column(DateTime(timezone=True), nullable=True)
closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
notes = Column(Text, nullable=True)
# Phase 2E — store-level cash reconciliation
store_opening_cash = Column(Float, nullable=True) # physical cash when day opens
store_closing_cash = Column(Float, nullable=True) # physical cash counted at close
store_cash_discrepancy = Column(Float, nullable=True) # store_closing - expected
opener = relationship("User", foreign_keys=[opened_by_id])
closer = relationship("User", foreign_keys=[closed_by_id])

View File

@@ -0,0 +1,57 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class Conversation(Base):
__tablename__ = "conversations"
id = Column(Integer, primary_key=True, index=True)
type = Column(String, nullable=False, default="direct") # 'direct' | 'group'
name = Column(String, nullable=True) # required for group
is_system = Column(Boolean, nullable=False, default=False)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
participants = relationship(
"ConversationParticipant",
back_populates="conversation",
cascade="all, delete-orphan",
)
messages = relationship(
"ChatMessage",
back_populates="conversation",
cascade="all, delete-orphan",
)
class ConversationParticipant(Base):
__tablename__ = "conversation_participants"
id = Column(Integer, primary_key=True, index=True)
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
joined_at = Column(DateTime(timezone=True), default=_utcnow)
last_read_at = Column(DateTime(timezone=True), nullable=True)
conversation = relationship("Conversation", back_populates="participants")
user = relationship("User")
class ChatMessage(Base):
__tablename__ = "chat_messages"
id = Column(Integer, primary_key=True, index=True)
conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False)
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False)
body = Column(Text, nullable=False)
sent_at = Column(DateTime(timezone=True), default=_utcnow)
deleted_at = Column(DateTime(timezone=True), nullable=True)
conversation = relationship("Conversation", back_populates="messages")
sender = relationship("User")

View File

@@ -0,0 +1,25 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class Customer(Base):
__tablename__ = "customers"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
nickname = Column(String, nullable=True)
phone = Column(String, nullable=True)
email = Column(String, nullable=True)
notes = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_by = relationship("User", foreign_keys=[created_by_id])
orders = relationship("Order", back_populates="customer")

View File

@@ -0,0 +1,58 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class Contact(Base):
__tablename__ = "contacts"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
type = Column(String, default="supplier", nullable=False) # supplier|staff|utility|other
phone = Column(String, nullable=True)
email = Column(String, nullable=True)
address = Column(Text, nullable=True)
notes = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow)
expenses = relationship("Expense", back_populates="contact")
class Expense(Base):
__tablename__ = "expenses"
id = Column(Integer, primary_key=True, index=True)
description = Column(String, nullable=False)
category = Column(String, nullable=False) # supplier|utilities|rent|maintenance|staff|equipment|other
contact_id = Column(Integer, ForeignKey("contacts.id"), nullable=True)
total_amount = Column(Float, nullable=False)
paid_amount = Column(Float, default=0.0, nullable=False)
due_date = Column(DateTime(timezone=True), nullable=True)
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow)
notes = Column(Text, nullable=True)
contact = relationship("Contact", back_populates="expenses")
created_by = relationship("User", foreign_keys=[created_by_id])
payments = relationship("ExpensePayment", back_populates="expense", cascade="all, delete-orphan")
class ExpensePayment(Base):
__tablename__ = "expense_payments"
id = Column(Integer, primary_key=True, index=True)
expense_id = Column(Integer, ForeignKey("expenses.id"), nullable=False)
amount = Column(Float, nullable=False)
paid_at = Column(DateTime(timezone=True), default=_utcnow)
paid_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
notes = Column(Text, nullable=True)
expense = relationship("Expense", back_populates="payments")
paid_by = relationship("User", foreign_keys=[paid_by_id])

View File

@@ -20,7 +20,7 @@ class QuickMessageTemplate(Base):
class StaffMessage(Base):
"""A message sent from a manager to one or more waiters."""
"""A message sent from a manager or KDS to one or more waiters."""
__tablename__ = "staff_messages"
id = Column(Integer, primary_key=True, index=True)
@@ -29,6 +29,10 @@ class StaffMessage(Base):
# JSON arrays stored as text: "[1,2,3]" for waiter ids, "[5,6]" for table ids
target_waiter_ids = Column(Text, nullable=False, default="[]")
table_ids = Column(Text, nullable=False, default="[]")
# message_type: 'manager' | 'kds_order_done' | 'kds_item_done' | 'kds_call_order' | 'kds_call_general'
message_type = Column(String, nullable=False, default="manager")
# kds_zone: display name of the prep zone that sent this (e.g. "Κουζίνα")
kds_zone = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
sender = relationship("User", foreign_keys=[sender_id])

View File

@@ -0,0 +1,37 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class SiteNote(Base):
__tablename__ = "site_notes"
id = Column(Integer, primary_key=True, index=True)
body = Column(Text, nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow)
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
is_pinned = Column(Boolean, default=False, nullable=False)
created_by = relationship("User", foreign_keys=[created_by_id])
class SiteTodo(Base):
__tablename__ = "site_todos"
id = Column(Integer, primary_key=True, index=True)
body = Column(Text, nullable=False)
is_done = Column(Boolean, default=False, nullable=False)
done_at = Column(DateTime(timezone=True), nullable=True)
done_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow)
priority = Column(String, default="normal", nullable=False) # "normal" | "high"
created_by = relationship("User", foreign_keys=[created_by_id])
done_by = relationship("User", foreign_keys=[done_by_id])

View File

@@ -12,7 +12,7 @@ class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
table_id = Column(Integer, ForeignKey("tables.id"), nullable=True) # nullable for online orders
opened_by = Column(Integer, ForeignKey("users.id"), nullable=False)
opened_at = Column(DateTime(timezone=True), default=_utcnow)
status = Column(String, default="open", nullable=False) # open|partially_paid|paid|closed|cancelled
@@ -20,13 +20,38 @@ class Order(Base):
closed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
notes = Column(Text, nullable=True)
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
# KDS
kds_status = Column(String, default="pending", nullable=False) # pending|preparing|done
kds_status_changed_at = Column(DateTime(timezone=True), nullable=True)
order_type = Column(String, default="here", nullable=False) # here|takeaway|delivery
# Xenia Connect — online order fields
source = Column(String, default="pos", nullable=False) # "pos" | "online"
online_order_ref = Column(String, nullable=True) # e.g. "ORD-0042"
online_order_cloud_id = Column(Integer, nullable=True) # cloud DB pk for status mirroring
online_status = Column(String, nullable=True) # mirrors cloud status
online_customer_name = Column(String, nullable=True)
online_customer_phone = Column(String, nullable=True)
online_customer_address = Column(Text, nullable=True)
online_customer_notes = Column(Text, nullable=True)
online_order_type = Column(String, nullable=True) # "delivery" | "dine_in"
# Phase 2F — customer CRM
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True)
# Number of customers at the table for this order
customer_count = Column(Integer, nullable=True)
# Fiscal printer status: NULL = fiscal was off, "pending" | "success" | "failed"
fiscal_status = Column(String, nullable=True)
table = relationship("Table", back_populates="orders")
opener = relationship("User", foreign_keys=[opened_by], back_populates="orders_opened")
closer = relationship("User", foreign_keys=[closed_by], back_populates="orders_closed")
customer = relationship("Customer", back_populates="orders", foreign_keys=[customer_id])
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
waiters = relationship("OrderWaiter", back_populates="order", cascade="all, delete-orphan")
print_logs = relationship("PrintLog", back_populates="order", cascade="all, delete-orphan")
print_jobs = relationship("PrintJob", back_populates="order", cascade="all, delete-orphan")
audit_logs = relationship("OrderAuditLog", back_populates="order", cascade="all, delete-orphan")
discounts = relationship("OrderDiscount", back_populates="order", cascade="all, delete-orphan")
@@ -50,12 +75,13 @@ class OrderItem(Base):
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
added_by = Column(Integer, ForeignKey("users.id"), nullable=False)
quantity = Column(Integer, nullable=False)
quantity = Column(Float, nullable=False)
unit_price = Column(Float, nullable=False) # price snapshot at time of order
selected_options = Column(Text, nullable=True) # JSON array of option ids
removed_ingredients = Column(Text, nullable=True) # JSON array of ingredient ids
notes = Column(Text, nullable=True)
status = Column(String, default="active", nullable=False) # active|paid|cancelled
kds_status = Column(String, default="pending", nullable=False) # pending|preparing|done|served|declined
added_at = Column(DateTime(timezone=True), default=_utcnow)
printed = Column(Boolean, default=False, nullable=False)
@@ -65,11 +91,36 @@ class OrderItem(Base):
payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use
paid_in_shift_id = Column(Integer, ForeignKey("waiter_shifts.id"), nullable=True)
# Phase 2A — cost snapshot (copied from product at time of order, never updated)
unit_cost = Column(Float, nullable=True)
# On-the-fly price adjustment (positive = surcharge, negative = discount)
price_adjustment = Column(Float, nullable=False, default=0.0)
# Phase 2B — cancellation tracking
cancelled_by = Column(Integer, ForeignKey("users.id"), nullable=True)
cancelled_at = Column(DateTime(timezone=True), nullable=True)
cancel_reason = Column(Text, nullable=True)
# KDS decline tracking
decline_note = Column(Text, nullable=True) # reason set by kitchen when declining
# Courses — optional course assignment for sequenced firing
course_id = Column(Integer, nullable=True)
# Deal binding — which deal created this item, and which original item it's linked to
deal_id = Column(Integer, ForeignKey("deals.id"), nullable=True)
linked_item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True)
order = relationship("Order", back_populates="items")
product = relationship("Product", back_populates="order_items")
added_by_user = relationship("User", foreign_keys=[added_by], back_populates="order_items")
paid_by_user = relationship("User", foreign_keys=[paid_by], back_populates="items_paid")
@property
def unit_type(self) -> str:
return self.product.unit_type if self.product and self.product.unit_type else "piece"
class PrintLog(Base):
__tablename__ = "print_log"
@@ -86,6 +137,32 @@ class PrintLog(Base):
printer = relationship("Printer", back_populates="print_logs")
class PrintJob(Base):
"""
One row per (order, printer, zone) print job. Tracks the full lifecycle:
pending → success or cancelled. PrintLog rows record each individual attempt.
"""
__tablename__ = "print_jobs"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
printer_id = Column(Integer, ForeignKey("printers.id"), nullable=False)
zone_id = Column(Integer, nullable=True) # prep zone id, NULL for legacy
item_ids = Column(Text, nullable=False) # JSON array of order_item ids
copies = Column(Integer, nullable=False, default=1)
status = Column(String, nullable=False, default="pending") # pending|success|cancelled
retry_count = Column(Integer, nullable=False, default=0)
first_attempted_at = Column(DateTime(timezone=True), default=_utcnow)
last_attempted_at = Column(DateTime(timezone=True), nullable=True)
succeeded_at = Column(DateTime(timezone=True), nullable=True)
cancelled_at = Column(DateTime(timezone=True), nullable=True)
cancel_reason = Column(String, nullable=True) # "staff_cancelled" | "order_closed"
order = relationship("Order", back_populates="print_jobs")
printer = relationship("Printer", back_populates="print_jobs")
class OrderAuditLog(Base):
"""Immutable append-only audit trail for every action on an order."""
__tablename__ = "order_audit_log"

View File

@@ -0,0 +1,56 @@
from sqlalchemy import Column, Integer, String, Boolean, Table, ForeignKey
from sqlalchemy.orm import relationship
from database import Base
# Many-to-many: prep_zones <-> printers (all printers assigned to a zone)
prep_zone_printers = Table(
"prep_zone_printers",
Base.metadata,
Column("prep_zone_id", Integer, ForeignKey("prep_zones.id"), primary_key=True),
Column("printer_id", Integer, ForeignKey("printers.id"), primary_key=True),
)
# Many-to-many: products <-> prep_zones
product_prep_zones = Table(
"product_prep_zones",
Base.metadata,
Column("product_id", Integer, ForeignKey("products.id"), primary_key=True),
Column("prep_zone_id", Integer, ForeignKey("prep_zones.id"), primary_key=True),
)
class PrepZone(Base):
__tablename__ = "prep_zones"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(String, nullable=True)
notification_name = Column(String, nullable=True)
# Printer routing
# auto_print: 'none' | 'master' | 'all'
auto_print = Column(String, nullable=False, default='none')
# master_printer_id: single FK to the "primary" printer (nullable — may not be set yet)
master_printer_id = Column(Integer, ForeignKey("printers.id"), nullable=True)
master_copies = Column(Integer, nullable=False, default=1)
# secondary printers are those in the M2M table that are NOT the master
secondary_copies = Column(Integer, nullable=False, default=1)
# Legacy field kept for backward compat (use master_copies/secondary_copies instead)
print_copies = Column(Integer, nullable=False, default=1)
# Ticket formatting
# sort_items_by: 'order_time' | 'item_count' | 'alpha'
sort_items_by = Column(String, nullable=False, default='order_time')
group_by_category = Column(Integer, nullable=False, default=0) # bool as int
category_order = Column(String, nullable=False, default='[]') # JSON array of category IDs
print_checkboxes = Column(Integer, nullable=False, default=0) # bool as int
# KDS bypass / auto-progression
bypass_pending = Column(Integer, nullable=False, default=0) # skip pending+prep → straight to ready
bypass_kds = Column(Integer, nullable=False, default=0) # skip KDS entirely → straight to served (implies bypass_pending)
auto_ready_to_served = Column(Integer, nullable=False, default=0) # when item hits 'done' on KDS → auto-upgrade to served
# Relationships
printers = relationship("Printer", secondary=prep_zone_printers, back_populates="prep_zones")
products = relationship("Product", secondary=product_prep_zones, back_populates="prep_zones")

View File

@@ -0,0 +1,284 @@
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class PriceGroup(Base):
"""Named manual toggle (e.g. 'Happy Hour'). Modifiers reference these by ID."""
__tablename__ = "price_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
color = Column(String, nullable=True)
is_active = Column(Integer, nullable=False, default=0) # 0|1
# Optional auto-schedule: flip is_active based on time + day of week.
# Evaluated at condition-check time — no background daemon needed.
auto_enable_time = Column(String, nullable=True) # "HH:MM" 24h
auto_disable_time = Column(String, nullable=True) # "HH:MM" 24h
auto_days = Column(Text, nullable=True) # JSON [0..6], Mon=0
created_at = Column(DateTime(timezone=True), default=_utcnow)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
class PriceModifier(Base):
"""
A single pricing rule. Can be item-scoped (applies only to one product) or
global (applies to all targets listed in PriceModifierTarget).
Evaluation order is controlled by sort_order (ascending = higher priority).
Stacking algorithm:
- Collect all passing modifiers for a product.
- Separate into stackable (allow_stack=1) and non-stackable.
- Apply the FIRST passing non-stackable modifier (sort_order ascending).
- Apply ALL passing stackable modifiers in sort_order order, on the running price.
- Apply final system rounding to nearest 0.10 after all modifiers.
"""
__tablename__ = "price_modifiers"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
color = Column(String, nullable=True)
is_active = Column(Integer, nullable=False, default=1) # quick on/off toggle
is_favorite = Column(Integer, nullable=False, default=0) # show on dashboard
allow_stack = Column(Integer, nullable=False, default=0) # participates in stackable pile
sort_order = Column(Integer, nullable=False, default=0) # user-controlled priority
# Scope
scope = Column(String, nullable=False, default="global") # 'item' | 'global'
item_id = Column(Integer, ForeignKey("products.id"), nullable=True) # set when scope='item'
# Action — what happens to the price when this modifier fires
action_type = Column(String, nullable=False) # 'set' | 'add_amount' | 'add_percent'
action_value = Column(Float, nullable=False) # the number; negative = discount
# Optional per-modifier rounding applied immediately after this modifier's math.
# System-wide nearest-0.10 rounding is always applied as a final pass regardless.
round_to = Column(String, nullable=True) # '0.05'|'0.10'|'0.20'|'0.50'|'x.99'|'x.00'
created_at = Column(DateTime(timezone=True), default=_utcnow)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
updated_at = Column(DateTime(timezone=True), nullable=True)
updated_by = Column(Integer, ForeignKey("users.id"), nullable=True)
conditions = relationship("PriceModifierCondition", back_populates="modifier",
cascade="all, delete-orphan")
targets = relationship("PriceModifierTarget", back_populates="modifier",
cascade="all, delete-orphan")
item = relationship("Product", foreign_keys=[item_id])
class PriceModifierCondition(Base):
"""
One condition on a modifier. ALL conditions must pass for the modifier to fire (AND logic).
A modifier with zero conditions is 'manual-only' — never fires automatically.
condition_type registry and params shapes:
time_range {"from": "HH:MM", "to": "HH:MM"} (crosses midnight if from > to)
date_range {"from": "YYYY-MM-DD", "to": "YYYY-MM-DD"} (inclusive)
specific_date {"dates": ["YYYY-MM-DD", ...]}
day_of_week {"days": [0,1,2,3,4,5,6]} (Mon=0 Sun=6)
min_item_quantity {"min": N} (this product's qty in cart)
min_category_qty {"category_id": N, "min": N} (items in category in cart)
min_order_value {"min": 0.00} (cart total before modifiers)
order_channel {"channels": ["pos","online","qr","takeaway"]}
price_group_active {"price_group_id": N}
user_tier {"tiers": ["bronze","silver","gold"]} (placeholder, needs user accounts)
low_stock {"threshold": N} (placeholder, needs inventory)
"""
__tablename__ = "price_modifier_conditions"
id = Column(Integer, primary_key=True, index=True)
modifier_id = Column(Integer, ForeignKey("price_modifiers.id", ondelete="CASCADE"),
nullable=False)
condition_type = Column(String, nullable=False)
params = Column(Text, nullable=False, default="{}") # JSON
modifier = relationship("PriceModifier", back_populates="conditions")
class PriceModifierTarget(Base):
"""
Target rows exist only for global modifiers. Defines which products the modifier applies to.
A modifier matches a product if ANY of its target rows covers that product (OR across rows).
target_type='all' → applies to every product (target_id and target_tag are NULL)
target_type='item' → target_id is a product id
target_type='category' → target_id is a category id (includes subcategories)
target_type='prep_zone' → target_id is a prep_zone id
target_type='tag' → target_tag is a string matched against products.tags JSON array
"""
__tablename__ = "price_modifier_targets"
id = Column(Integer, primary_key=True, index=True)
modifier_id = Column(Integer, ForeignKey("price_modifiers.id", ondelete="CASCADE"),
nullable=False)
target_type = Column(String, nullable=False) # 'all'|'item'|'category'|'prep_zone'|'tag'
target_id = Column(Integer, nullable=True) # legacy single value
target_tag = Column(String, nullable=True) # legacy single value
target_ids = Column(Text, nullable=True) # JSON array of ints (multi-select)
target_tags = Column(Text, nullable=True) # JSON array of strings (multi-tag)
modifier = relationship("PriceModifier", back_populates="targets")
class Deal(Base):
"""
Event-driven promotion. When deal_conditions + deal_targets are met (a product/category/tag
is present in the cart in sufficient quantity), the action fires.
Evaluated on every add-items call. If conditions are newly met, the waiter is notified
via the response payload and prompted to confirm/select (never auto-applied silently).
action_type registry:
apply_modifier → action_modifier_id (FK to PriceModifier with no conditions)
set_price → action_value (exact new unit price)
add_amount → action_value (delta; negative = discount)
add_percent → action_value (percent; negative = discount)
free_item → action_free_item_id (specific product added free)
free_choice → pool defined by action_free_target_type + action_free_target_ids
"""
__tablename__ = "deals"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
color = Column(String, nullable=True)
is_active = Column(Integer, nullable=False, default=1)
sort_order = Column(Integer, nullable=False, default=0)
# Action
action_type = Column(String, nullable=False)
action_modifier_id = Column(Integer, ForeignKey("price_modifiers.id"), nullable=True)
action_value = Column(Float, nullable=True)
action_free_item_id = Column(Integer, ForeignKey("products.id"), nullable=True)
action_free_target_type = Column(String, nullable=True) # 'item'|'category'|'tag'
action_free_target_ids = Column(Text, nullable=True) # JSON array of IDs or tag strings
action_free_quantity = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime(timezone=True), default=_utcnow)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
conditions = relationship("DealCondition", back_populates="deal",
cascade="all, delete-orphan")
targets = relationship("DealTarget", back_populates="deal",
cascade="all, delete-orphan")
action_modifier = relationship("PriceModifier", foreign_keys=[action_modifier_id])
action_free_item = relationship("Product", foreign_keys=[action_free_item_id])
class DealCondition(Base):
"""Same condition_type registry as PriceModifierCondition."""
__tablename__ = "deal_conditions"
id = Column(Integer, primary_key=True, index=True)
deal_id = Column(Integer, ForeignKey("deals.id", ondelete="CASCADE"), nullable=False)
condition_type = Column(String, nullable=False)
params = Column(Text, nullable=False, default="{}") # JSON
deal = relationship("Deal", back_populates="conditions")
class DealTarget(Base):
"""
Defines the 'buy THESE' side of a deal (what must be in the cart for the deal to trigger).
Multiple rows are OR-combined: any matching item satisfies a row.
"""
__tablename__ = "deal_targets"
id = Column(Integer, primary_key=True, index=True)
deal_id = Column(Integer, ForeignKey("deals.id", ondelete="CASCADE"), nullable=False)
target_type = Column(String, nullable=False) # 'item'|'category'|'tag'|'any'
target_id = Column(Integer, nullable=True) # legacy single value
target_tag = Column(String, nullable=True) # legacy single value
target_ids = Column(Text, nullable=True) # JSON array of ints (multi-select)
target_tags = Column(Text, nullable=True) # JSON array of strings (multi-tag)
deal = relationship("Deal", back_populates="targets")
class PriceEventLog(Base):
"""
Immutable append-only audit record. Written for every pricing event on an order:
automatic modifier fires, deal triggers/accepts/dismisses, waiter discounts, free items.
Nothing is deleted from this table.
"""
__tablename__ = "price_event_log"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
order_item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True) # NULL for order-level events
# Event classification
event_type = Column(String, nullable=False)
# modifier_applied — automatic price modifier fired
# deal_triggered — deal conditions met, action executed on a specific item
# deal_offer_shown — waiter was shown a free-choice prompt
# deal_offer_accepted — waiter confirmed and selected item(s)
# deal_offer_dismissed — waiter dismissed the deal offer
# waiter_discount — manual waiter discount applied
# free_item_added — free item added to order as result of a deal
modifier_id = Column(Integer, ForeignKey("price_modifiers.id"), nullable=True)
deal_id = Column(Integer, ForeignKey("deals.id"), nullable=True)
# Price delta — NULL for events that don't change a price (offer_shown, dismissed)
price_before = Column(Float, nullable=True)
price_after = Column(Float, nullable=True)
delta_amount = Column(Float, nullable=True) # price_after - price_before; negative = discount
# Waiter discount fields
applied_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
waiter_note = Column(Text, nullable=True)
# Snapshot of the conditions that evaluated to True when the event fired.
# JSON object: {"condition_type": evaluated_value, ...}
# e.g. {"day_of_week": 5, "time_range": "21:45", "price_group_active": true}
conditions_snapshot = Column(Text, nullable=True)
# For deal_offer_accepted: product IDs the waiter chose
selected_item_ids = Column(Text, nullable=True) # JSON array of product IDs
applied_at = Column(DateTime(timezone=True), default=_utcnow)
order = relationship("Order", foreign_keys=[order_id])
order_item = relationship("OrderItem", foreign_keys=[order_item_id])
modifier = relationship("PriceModifier", foreign_keys=[modifier_id])
deal = relationship("Deal", foreign_keys=[deal_id])
applied_by = relationship("User", foreign_keys=[applied_by_user_id])
class WaiterDiscountSettings(Base):
"""
Per-waiter discount limits. All limit fields are nullable — NULL means no limit.
The master can_apply_discounts flag acts as an on/off switch for this waiter.
Global limits are stored in pos_settings under the 'discounts.*' namespace.
"""
__tablename__ = "waiter_discount_settings"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
can_apply_discounts = Column(Integer, nullable=False, default=0) # 0|1 master toggle
max_discount_percent = Column(Float, nullable=True) # e.g. 20.0 = max 20% off
max_discount_amount = Column(Float, nullable=True) # max € off a single item
max_total_value_shift = Column(Float, nullable=True) # max total € given away per shift
max_total_value_workday = Column(Float, nullable=True) # max total € given away per workday
max_items_per_shift = Column(Integer, nullable=True)
max_items_per_workday = Column(Integer, nullable=True)
max_items_per_order = Column(Integer, nullable=True)
user = relationship("User", foreign_keys=[user_id])

View File

@@ -12,6 +12,11 @@ class Printer(Base):
port = Column(Integer, default=9100, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
protocol = Column(String, default="escpos_tcp", nullable=False)
line_width = Column(Integer, default=48, nullable=False)
duplicates = Column(Integer, default=0, nullable=False)
codepage_n = Column(Integer, default=29, nullable=False)
products = relationship("Product", back_populates="printer_zone")
print_logs = relationship("PrintLog", back_populates="printer")
print_jobs = relationship("PrintJob", back_populates="printer")
prep_zones = relationship("PrepZone", secondary="prep_zone_printers", back_populates="printers")

View File

@@ -27,6 +27,7 @@ class Product(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
base_price = Column(Float, nullable=False)
is_available = Column(Boolean, default=True, nullable=False)
@@ -36,15 +37,63 @@ class Product(Base):
image_url = Column(String, nullable=True)
sort_order = Column(Integer, default=0, nullable=False)
# Xenia Connect — digital menu overrides
digital_visible = Column(Integer, default=1, nullable=False)
digital_available = Column(Integer, default=1, nullable=False)
digital_name = Column(String, nullable=True)
digital_description = Column(String, nullable=True)
digital_price = Column(Float, nullable=True)
digital_discount = Column(Float, default=0.0, nullable=False)
digital_image_url = Column(String, nullable=True)
# sha256 of the image_url file last successfully pushed to the cloud — lets
# cloud_sync skip re-uploading unchanged images on every sync tick
cloud_image_hash = Column(String, nullable=True)
# Unit of measure: "piece" | "portion" | "kg" | "liter" | "gram" | "ml"
unit_type = Column(String, default="piece", nullable=False)
# Phase 2A — cost tracking
cost_simple = Column(Float, nullable=True)
cost_breakdown = Column(Text, nullable=True) # JSON: [{"label": str, "amount": float}]
# Quick Add: allow adding 1 unit directly from product list without opening config modal
quick_add_enabled = Column(Boolean, default=True, nullable=False)
# Service item: non-inventoried table-service items (cutlery, glasses, etc.)
is_service_item = Column(Boolean, default=False, nullable=False)
# Tags: JSON array of strings e.g. ["vegan", "gluten-free"]
tags = Column(Text, nullable=True)
# Fiscal printer (ΦΗΜ) fields
fiscal_name = Column(String, nullable=True) # short name for receipt (falls back to name)
fiscal_vat_group_id = Column(Integer, nullable=True) # machine VAT group ID (1-based integer)
category = relationship("Category", back_populates="products")
printer_zone = relationship("Printer", back_populates="products")
prep_zones = relationship("PrepZone", secondary="product_prep_zones", back_populates="products")
quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan")
options = relationship("ProductOption", back_populates="product", cascade="all, delete-orphan")
ingredients = relationship("ProductIngredient", back_populates="product", cascade="all, delete-orphan")
preference_sets = relationship("ProductPreferenceSet", back_populates="product", cascade="all, delete-orphan")
modifier_groups = relationship("ProductModifierGroup", back_populates="product", cascade="all, delete-orphan")
order_items = relationship("OrderItem", back_populates="product")
class ProductModifierGroup(Base):
"""A named folder/group that can contain options, ingredients, or preference sets."""
__tablename__ = "product_modifier_groups"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
# "option" | "ingredient" | "preference"
modifier_type = Column(String, nullable=False)
name = Column(String, nullable=False)
sort_order = Column(Integer, default=0, nullable=False)
product = relationship("Product", back_populates="modifier_groups")
class ProductOption(Base):
__tablename__ = "product_options"
@@ -53,10 +102,16 @@ class ProductOption(Base):
name = Column(String, nullable=False)
extra_cost = Column(Float, default=0.0)
allow_multiple = Column(Boolean, default=False, nullable=False)
# JSON array [{name, extra_cost, is_default}] — sub-options shown when this option is checked
# When True, waiter can select more than one sub-choice simultaneously
multi_select = Column(Boolean, default=False, nullable=False)
# JSON array [{name, extra_cost, is_default, allow_multiple}] — sub-options shown when this option is checked
sub_choices = Column(Text, nullable=True)
is_favorite = Column(Boolean, default=False, nullable=False)
favorite_sort_order = Column(Integer, default=0, nullable=False)
# When True renders as half-width card on the PWA
is_compact = Column(Boolean, default=False, nullable=False)
# Optional group/folder this option belongs to
group_id = Column(Integer, ForeignKey("product_modifier_groups.id"), nullable=True)
product = relationship("Product", back_populates="options")
@@ -86,6 +141,10 @@ class ProductIngredient(Base):
extra_cost = Column(Float, default=0.0)
is_favorite = Column(Boolean, default=False, nullable=False)
favorite_sort_order = Column(Integer, default=0, nullable=False)
# When True renders as half-width card on the PWA
is_compact = Column(Boolean, default=False, nullable=False)
# Optional group/folder this ingredient belongs to
group_id = Column(Integer, ForeignKey("product_modifier_groups.id"), nullable=True)
product = relationship("Product", back_populates="ingredients")
@@ -102,6 +161,12 @@ class ProductPreferenceSet(Base):
shared_subset = Column(Text, nullable=True)
is_favorite = Column(Boolean, default=False, nullable=False)
favorite_sort_order = Column(Integer, default=0, nullable=False)
# Optional group/folder this preference set belongs to
group_id = Column(Integer, ForeignKey("product_modifier_groups.id"), nullable=True)
# When True, waiter can select multiple choices instead of just one
allow_multi_select = Column(Boolean, default=False, nullable=False)
# When True, each selected choice can have a quantity (x2, x3, …)
allow_choice_quantity = Column(Boolean, default=False, nullable=False)
product = relationship("Product", back_populates="preference_sets")
choices = relationship("ProductPreferenceChoice", back_populates="set", cascade="all, delete-orphan")
@@ -114,10 +179,12 @@ class ProductPreferenceChoice(Base):
set_id = Column(Integer, ForeignKey("product_preference_sets.id"), nullable=False)
name = Column(String, nullable=False)
extra_cost = Column(Float, default=0.0)
# JSON array of sub-choice objects: [{name, extra_cost, is_default}]
# JSON array of sub-choice objects: [{name, extra_cost, is_default, is_compact}]
# Per-choice inline sub-preference shown only when this choice is selected.
sub_choices = Column(Text, nullable=True)
# When True this choice hides the set-level shared_subset on the PWA.
disables_subset = Column(Boolean, default=False, nullable=False)
# When True renders as half-width card on the PWA
is_compact = Column(Boolean, default=False, nullable=False)
set = relationship("ProductPreferenceSet", back_populates="choices")

View File

@@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class RecoveryCode(Base):
__tablename__ = "recovery_codes"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
code_hash = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False)
used_at = Column(DateTime(timezone=True), nullable=True)
user = relationship("User", backref="recovery_codes")

View File

@@ -0,0 +1,42 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
class Reservation(Base):
__tablename__ = "reservations"
id = Column(Integer, primary_key=True, index=True)
# Guest info
guest_name = Column(String, nullable=False)
party_size = Column(Integer, nullable=False)
phone = Column(String, nullable=True)
email = Column(String, nullable=True)
note = Column(Text, nullable=True)
# When the customer wants the table
reserved_for = Column(DateTime, nullable=False)
# Optional table assignment (assignable/editable later)
table_id = Column(Integer, ForeignKey("tables.id"), nullable=True)
# Lifecycle: pending | arrived | cancelled | no_show
status = Column(String, nullable=False, default="pending")
# Source: manager_app | waiter_app | online
source = Column(String, nullable=False, default="manager_app")
# Who entered this reservation (staff user)
created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# For future online reservations: cloud-side customer identifier
online_customer_id = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
table = relationship("Table", foreign_keys=[table_id])
created_by = relationship("User", foreign_keys=[created_by_user_id])

View File

@@ -0,0 +1,24 @@
from sqlalchemy import Column, Integer, String, Date, Time, Text, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class ScheduledShift(Base):
__tablename__ = "scheduled_shifts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
scheduled_date = Column(Date, nullable=False)
start_time = Column(String, nullable=False) # "HH:MM" stored as string (SQLite has no Time type)
end_time = Column(String, nullable=False)
notes = Column(Text, nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow)
user = relationship("User", foreign_keys=[user_id])
created_by = relationship("User", foreign_keys=[created_by_id])

View File

@@ -19,6 +19,11 @@ class WaiterShift(Base):
starting_cash = Column(Float, nullable=True)
total_collected = Column(Float, nullable=True) # snapshot written at shift end
notes = Column(Text, nullable=True)
# Phase 2B — payroll snapshot (copied from user.hourly_rate at shift start, never updated)
hourly_rate_snapshot = Column(Float, nullable=True)
# Phase 2E — cash reconciliation
counted_cash_end = Column(Float, nullable=True) # physical cash waiter hands over
cash_discrepancy = Column(Float, nullable=True) # counted_cash_end - expected; neg = short
waiter = relationship("User", foreign_keys=[waiter_id])
business_day = relationship("BusinessDay", back_populates="shifts")

View File

@@ -24,6 +24,7 @@ class Table(Base):
label = Column(String, nullable=True)
group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
seat_count = Column(Integer, nullable=True)
floor_x = Column(Float, nullable=True)
floor_y = Column(Float, nullable=True)

View File

@@ -0,0 +1,58 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class Tab(Base):
__tablename__ = "tabs"
id = Column(Integer, primary_key=True, index=True)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
status = Column(String, default="open", nullable=False) # open | closed | forgiven
opened_at = Column(DateTime(timezone=True), default=_utcnow)
closed_at = Column(DateTime(timezone=True), nullable=True)
closed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
notes = Column(Text, nullable=True)
customer = relationship("Customer", foreign_keys=[customer_id])
closed_by = relationship("User", foreign_keys=[closed_by_id])
entries = relationship("TabEntry", back_populates="tab", cascade="all, delete-orphan")
payments = relationship("TabPayment", back_populates="tab", cascade="all, delete-orphan")
class TabEntry(Base):
"""What went ON the tab — a deferred charge."""
__tablename__ = "tab_entries"
id = Column(Integer, primary_key=True, index=True)
tab_id = Column(Integer, ForeignKey("tabs.id"), nullable=False)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=True)
order_item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True)
amount = Column(Float, nullable=False) # snapshot at time of tab entry
description = Column(Text, nullable=True) # auto-generated summary
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow)
tab = relationship("Tab", back_populates="entries")
created_by = relationship("User", foreign_keys=[created_by_id])
class TabPayment(Base):
"""What came OFF the tab — a payment reducing the balance."""
__tablename__ = "tab_payments"
id = Column(Integer, primary_key=True, index=True)
tab_id = Column(Integer, ForeignKey("tabs.id"), nullable=False)
amount = Column(Float, nullable=False)
payment_method = Column(String, nullable=True) # cash | card | other
received_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
tab = relationship("Tab", back_populates="payments")
received_by = relationship("User", foreign_keys=[received_by_id])

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
@@ -16,13 +16,39 @@ class User(Base):
pin_hash = Column(String, nullable=False)
password_hash = Column(String, nullable=True)
email = Column(String, nullable=True)
role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin'
# Role is a label + default-permission template. Valid values: see roles.py
role = Column(String, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
full_name = Column(String, nullable=True)
nickname = Column(String, nullable=True)
mobile_phone = Column(String, nullable=True)
note = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
# Phase 2B — payroll
hourly_rate = Column(Float, nullable=True)
# ── Access permissions (which sites/areas this user can enter) ────────────
perm_access_dashboard = Column(Boolean, default=False, nullable=False)
perm_access_waiter_app = Column(Boolean, default=True, nullable=False)
perm_access_kds = Column(Boolean, default=False, nullable=False)
# ── Order action permissions ──────────────────────────────────────────────
perm_cancel_orders = Column(Boolean, default=False, nullable=False)
perm_apply_discounts = Column(Boolean, default=False, nullable=False)
perm_modify_prices = Column(Boolean, default=False, nullable=False)
perm_open_orders = Column(Boolean, default=True, nullable=False)
perm_close_orders = Column(Boolean, default=True, nullable=False)
# ── Management permissions ────────────────────────────────────────────────
perm_view_reports = Column(Boolean, default=False, nullable=False)
perm_manage_staff = Column(Boolean, default=False, nullable=False)
perm_manage_tables = Column(Boolean, default=False, nullable=False)
perm_manage_menu = Column(Boolean, default=False, nullable=False)
perm_manage_settings = Column(Boolean, default=False, nullable=False)
# Per-waiter app settings + favorites (JSON blob, synced from PWA)
waiter_settings = Column(Text, nullable=True)
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")
@@ -42,6 +68,15 @@ class User(Base):
back_populates="assistant_waiter",
)
@property
def can_cancel_orders(self):
"""Back-compat alias used by orders router."""
return self.perm_cancel_orders
@property
def is_superadmin(self):
return self.role == "superadmin"
class WaiterZone(Base):
"""Maps a waiter to a table group they are allowed to operate in.

View File

@@ -0,0 +1,26 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
def _utcnow():
return datetime.now(timezone.utc)
class WasteLog(Base):
__tablename__ = "waste_log"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
quantity = Column(Float, nullable=False) # supports decimals
unit_cost_snapshot = Column(Float, nullable=True) # product's effective cost at log time
total_cost = Column(Float, nullable=True) # unit_cost_snapshot * quantity
reason = Column(String, nullable=False) # kitchen_error|dropped|expired|other
reason_notes = Column(Text, nullable=True)
logged_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
business_day_id = Column(Integer, ForeignKey("business_days.id"), nullable=True)
logged_at = Column(DateTime(timezone=True), default=_utcnow)
product = relationship("Product", foreign_keys=[product_id])
logged_by = relationship("User", foreign_keys=[logged_by_id])

View File

@@ -0,0 +1,114 @@
"""
Greek code page probe — NETUM NT8330L (or any unknown printer)
Usage: python print_codepage_probe.py <IP> [PORT]
Example: python print_codepage_probe.py 192.168.1.50 9100
Prints one long receipt that tries every candidate (n, encoding) combination.
Circle the block where you can read "ΚΑΛΗΜΕΡΑ ΕΛΛΑΔΑ" correctly.
That block tells you the codepage_n value to use in the printer profile.
"""
import sys
from escpos.printer import Network
IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.1.50"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 9100
# (n_value, python_encoding, label)
# Each will be tested independently with a fresh ESC @ reset.
CANDIDATES = [
# Standard Epson table assignments for Greek
(14, "cp737", "n=14 CP737 (Epson standard slot)"),
(38, "cp737", "n=38 CP737 (alt slot / CP869 area)"),
(11, "cp737", "n=11 CP737 (CP851 slot)"),
(47, "cp737", "n=47 CP737 (CP1253 slot, cp737 bytes)"),
# Same slots but with cp869 encoding (different byte layout)
(14, "cp869", "n=14 CP869 (Greek DOS #2)"),
(38, "cp869", "n=38 CP869 (alt slot)"),
# Windows / ISO encodings
(47, "cp1253", "n=47 CP1253 (Windows Greek)"),
(15, "iso8859_7", "n=15 ISO-8859-7"),
(14, "iso8859_7", "n=14 ISO-8859-7"),
# The Jolimark value, for reference
(29, "cp737", "n=29 CP737 (Jolimark confirmed)"),
# A few more obscure slots some Chinese-made printers use
(16, "cp737", "n=16 CP737 (rare slot)"),
(18, "cp737", "n=18 CP737 (rare slot)"),
(21, "cp737", "n=21 CP737 (rare slot)"),
]
GREEK_SAMPLE = "ΚΑΛΗΜΕΡΑ ΕΛΛΑΔΑ"
GREEK_LOWER = "καλημέρα ελλάδα"
GREEK_DIGITS = "ΤΙΜΗ: 12.50 ευρώ"
def probe(n: int, encoding: str, label: str):
try:
p = Network(IP, PORT, timeout=8)
p._raw(b'\x1b\x40') # ESC @ full reset
p._raw(bytes([0x1b, 0x74, n])) # ESC t n — select code page
# Label in plain ASCII (always safe)
p._raw(b'\x1b\x21\x00') # normal size
p._raw(f"[{label}]\n".encode('ascii', errors='replace'))
# Greek sample in the candidate encoding
try:
greek_bytes = GREEK_SAMPLE.encode(encoding, errors='replace')
lower_bytes = GREEK_LOWER.encode(encoding, errors='replace')
digit_bytes = GREEK_DIGITS.encode(encoding, errors='replace')
except LookupError:
p._raw(b' (encoding not available)\n')
p._raw(b'\n')
p.close()
return
p._raw(b'\x1b\x21\x08') # bold
p._raw(greek_bytes + b'\n')
p._raw(b'\x1b\x21\x00') # normal
p._raw(lower_bytes + b'\n')
p._raw(digit_bytes + b'\n')
p._raw(b'\n') # spacer
p.close()
print(f" OK {label}")
except Exception as e:
print(f" ERR {label}: {e}")
def main():
print(f"Probing {IP}:{PORT}")
print(f"Testing {len(CANDIDATES)} combinations...\n")
# Print header block
try:
p = Network(IP, PORT, timeout=8)
p._raw(b'\x1b\x40')
p._raw(b'\x1b\x61\x01') # center
p._raw(b'\x1b\x21\x30') # double width+height
p._raw(b'GREEK PROBE\n')
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x61\x00') # left
p._raw(b'Circle the block with readable Greek.\n')
p._raw(b'Looking for: KALIMERA ELLADA\n')
p._raw(b'================================================\n')
p._raw(b'\n')
p.close()
except Exception as e:
print(f"Header failed: {e}")
return
for (n, enc, label) in CANDIDATES:
probe(n, enc, label)
# Footer + cut
try:
p = Network(IP, PORT, timeout=8)
p._raw(b'\x1b\x40')
p._raw(b'================================================\n')
p._raw(b'END OF PROBE\n')
p._raw(b'\n\n\n')
p.cut()
p.close()
print("\nDone. Read the printout and note which block shows readable Greek.")
except Exception as e:
print(f"Footer failed: {e}")
if __name__ == "__main__":
main()

View File

@@ -1,5 +1,6 @@
fastapi==0.115.0
uvicorn==0.30.6
uvicorn[standard]==0.30.6
websockets==13.1
sqlalchemy==2.0.36
pydantic-settings==2.6.1
python-escpos==3.1

198
local_backend/roles.py Normal file
View File

@@ -0,0 +1,198 @@
# Role definitions for Xenia POS
#
# Roles serve two purposes:
# 1. Label what a staff member actually does in the venue
# 2. Set default permission values when a member is created or permissions are reset
#
# After creation, permissions are per-member and can be overridden freely.
# SUPERADMIN is always fully privileged — the backend enforces this regardless of
# stored boolean values.
ALL_ROLES: list[dict] = [
{"value": "superadmin", "label": "Superadmin", "label_el": "Superadmin"},
{"value": "owner", "label": "Owner", "label_el": "Ιδιοκτήτης"},
{"value": "store_manager", "label": "Store Manager", "label_el": "Διαχειριστής Καταστήματος"},
{"value": "staff_manager", "label": "Staff Manager", "label_el": "Διαχειριστής Προσωπικού"},
{"value": "chef", "label": "Chef", "label_el": "Σεφ"},
{"value": "assistant_chef", "label": "Assistant Chef", "label_el": "Βοηθός Σεφ"},
{"value": "barista", "label": "Barista", "label_el": "Barista"},
{"value": "barman", "label": "Barman / Barwoman", "label_el": "Barman / Barwoman"},
{"value": "waiter", "label": "Waiter", "label_el": "Σερβιτόρος"},
{"value": "assistant_waiter", "label": "Assistant Waiter", "label_el": "Βοηθός Σερβιτόρου"},
{"value": "custom", "label": "Custom", "label_el": "Προσαρμοσμένος"},
]
VALID_ROLES: set[str] = {r["value"] for r in ALL_ROLES}
# Roles that have manager-level dashboard access by default
MANAGER_ROLES: set[str] = {"superadmin", "owner", "store_manager", "staff_manager"}
# Full permission set — what a superadmin always has
_ALL_ON = dict(
perm_access_dashboard=True,
perm_access_waiter_app=True,
perm_access_kds=True,
perm_cancel_orders=True,
perm_apply_discounts=True,
perm_modify_prices=True,
perm_open_orders=True,
perm_close_orders=True,
perm_view_reports=True,
perm_manage_staff=True,
perm_manage_tables=True,
perm_manage_menu=True,
perm_manage_settings=True,
)
_ALL_OFF = {k: False for k in _ALL_ON}
# Default permissions keyed by role value.
# These are applied at creation time and on "Reset to defaults".
DEFAULT_PERMISSIONS: dict[str, dict] = {
"superadmin": _ALL_ON,
"owner": dict(
perm_access_dashboard=True,
perm_access_waiter_app=True,
perm_access_kds=True,
perm_cancel_orders=True,
perm_apply_discounts=True,
perm_modify_prices=True,
perm_open_orders=True,
perm_close_orders=True,
perm_view_reports=True,
perm_manage_staff=True,
perm_manage_tables=True,
perm_manage_menu=True,
perm_manage_settings=True,
),
"store_manager": dict(
perm_access_dashboard=True,
perm_access_waiter_app=True,
perm_access_kds=True,
perm_cancel_orders=True,
perm_apply_discounts=True,
perm_modify_prices=False,
perm_open_orders=True,
perm_close_orders=True,
perm_view_reports=True,
perm_manage_staff=True,
perm_manage_tables=True,
perm_manage_menu=True,
perm_manage_settings=False,
),
"staff_manager": dict(
perm_access_dashboard=True,
perm_access_waiter_app=True,
perm_access_kds=False,
perm_cancel_orders=False,
perm_apply_discounts=False,
perm_modify_prices=False,
perm_open_orders=False,
perm_close_orders=False,
perm_view_reports=True,
perm_manage_staff=True,
perm_manage_tables=False,
perm_manage_menu=False,
perm_manage_settings=False,
),
"chef": dict(
perm_access_dashboard=False,
perm_access_waiter_app=False,
perm_access_kds=True,
perm_cancel_orders=False,
perm_apply_discounts=False,
perm_modify_prices=False,
perm_open_orders=False,
perm_close_orders=False,
perm_view_reports=False,
perm_manage_staff=False,
perm_manage_tables=False,
perm_manage_menu=False,
perm_manage_settings=False,
),
"assistant_chef": dict(
perm_access_dashboard=False,
perm_access_waiter_app=False,
perm_access_kds=True,
perm_cancel_orders=False,
perm_apply_discounts=False,
perm_modify_prices=False,
perm_open_orders=False,
perm_close_orders=False,
perm_view_reports=False,
perm_manage_staff=False,
perm_manage_tables=False,
perm_manage_menu=False,
perm_manage_settings=False,
),
"barista": dict(
perm_access_dashboard=False,
perm_access_waiter_app=True,
perm_access_kds=False,
perm_cancel_orders=False,
perm_apply_discounts=False,
perm_modify_prices=False,
perm_open_orders=True,
perm_close_orders=True,
perm_view_reports=False,
perm_manage_staff=False,
perm_manage_tables=False,
perm_manage_menu=False,
perm_manage_settings=False,
),
"barman": dict(
perm_access_dashboard=False,
perm_access_waiter_app=True,
perm_access_kds=False,
perm_cancel_orders=False,
perm_apply_discounts=False,
perm_modify_prices=False,
perm_open_orders=True,
perm_close_orders=True,
perm_view_reports=False,
perm_manage_staff=False,
perm_manage_tables=False,
perm_manage_menu=False,
perm_manage_settings=False,
),
"waiter": dict(
perm_access_dashboard=False,
perm_access_waiter_app=True,
perm_access_kds=False,
perm_cancel_orders=False,
perm_apply_discounts=False,
perm_modify_prices=False,
perm_open_orders=True,
perm_close_orders=True,
perm_view_reports=False,
perm_manage_staff=False,
perm_manage_tables=False,
perm_manage_menu=False,
perm_manage_settings=False,
),
"assistant_waiter": dict(
perm_access_dashboard=False,
perm_access_waiter_app=True,
perm_access_kds=False,
perm_cancel_orders=False,
perm_apply_discounts=False,
perm_modify_prices=False,
perm_open_orders=False,
perm_close_orders=False,
perm_view_reports=False,
perm_manage_staff=False,
perm_manage_tables=False,
perm_manage_menu=False,
perm_manage_settings=False,
),
"custom": _ALL_OFF,
}
def get_default_permissions(role: str) -> dict:
"""Return default permission values for a given role."""
return DEFAULT_PERMISSIONS.get(role, _ALL_OFF).copy()
def is_manager_role(role: str) -> bool:
return role in MANAGER_ROLES

View File

@@ -1,17 +1,22 @@
import bcrypt
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
from config import settings
from models.user import User
from schemas.auth import LoginRequest, TokenResponse, UpdateMeRequest
from pydantic import BaseModel as _PydanticBase
_logger = logging.getLogger(__name__)
class LoginByIdRequest(_PydanticBase):
waiter_id: int
pin: str
from schemas.user import UserOut
from routers.deps import get_current_user, make_token, decode_token, blacklist_token
from routers.deps import get_current_user, make_token, decode_token, blacklist_token, _make_ghost_superadmin
from routers.recovery import try_recovery_code
router = APIRouter()
@@ -28,7 +33,7 @@ def login_no_auth(body: NoAuthLoginRequest, db: Session = Depends(get_db)):
if not setting or setting.value != "none":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No-auth login is not enabled.")
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
if not user or user.role not in ("manager", "sysadmin"):
if not user or not user.perm_access_dashboard:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = make_token(user)
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
@@ -36,6 +41,18 @@ def login_no_auth(body: NoAuthLoginRequest, db: Session = Depends(get_db)):
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)):
# Break-glass master account — checked before DB, never stored as a real user
if (
settings.MASTER_USERNAME
and settings.MASTER_PASSWORD
and body.username == settings.MASTER_USERNAME
and body.password == settings.MASTER_PASSWORD
):
_logger.warning("MASTER LOGIN USED from username=%s", body.username)
ghost = _make_ghost_superadmin()
token = make_token(ghost)
return TokenResponse(access_token=token, user=UserOut.model_validate(ghost))
user = db.query(User).filter(User.username == body.username, User.is_active == True).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
@@ -46,6 +63,10 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
elif body.pin and user.pin_hash:
authenticated = bcrypt.checkpw(body.pin.encode(), user.pin_hash.encode())
# If normal auth failed, check if the submitted value is a recovery code
if not authenticated and body.password:
authenticated = try_recovery_code(body.password, user, db)
if not authenticated:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
@@ -96,9 +117,9 @@ class PublicManagerOut(_PydanticBase):
@router.get("/managers", response_model=list[PublicManagerOut])
def public_manager_list(db: Session = Depends(get_db)):
"""Public endpoint — returns active manager/sysadmin accounts for login screen."""
"""Public endpoint — returns accounts with dashboard access for login screen."""
managers = db.query(User).filter(
User.role.in_(["manager", "sysadmin"]),
User.perm_access_dashboard == True,
User.is_active == True,
).all()
return [PublicManagerOut(id=m.id, username=m.username, full_name=m.full_name) for m in managers]
@@ -121,7 +142,7 @@ class PublicWaiterOut(_BaseModel):
def public_waiter_list(db: Session = Depends(get_db)):
"""Public endpoint — returns active waiters with on-shift flag. No auth required."""
from models.shift import WaiterShift
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
waiters = db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()
on_shift_ids = {
row.waiter_id
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
@@ -172,3 +193,26 @@ def update_me(body: UpdateMeRequest, db: Session = Depends(get_db), user: User =
db.commit()
db.refresh(user)
return user
class WaiterSettingsPayload(_PydanticBase):
settings: str # raw JSON string
@router.get("/me/settings")
def get_my_settings(user: User = Depends(get_current_user)):
"""Return the stored waiter settings JSON blob for the current user."""
return {"settings": user.waiter_settings or "{}"}
@router.put("/me/settings")
def put_my_settings(body: WaiterSettingsPayload, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Replace the stored waiter settings JSON blob."""
import json as _json
try:
_json.loads(body.settings) # validate it's valid JSON
except Exception:
raise HTTPException(status_code=422, detail="settings must be valid JSON")
user.waiter_settings = body.settings
db.commit()
return {"settings": user.waiter_settings}

View File

@@ -3,13 +3,16 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime, timezone
from pydantic import BaseModel
from database import get_db
from models.business_day import BusinessDay
from models.order import Order, OrderItem, OrderAuditLog
from models.order import Order, OrderItem, OrderAuditLog, PrintLog
from models.shift import WaiterShift
from models.flag import TableFlagAssignment
from models.message import StaffMessage, StaffMessageAck
from models.table import Table, TableGroup
from models.printer import Printer
from schemas.business_day import BusinessDayOut, OpenBusinessDayRequest, CloseBusinessDayRequest
from routers.deps import get_current_user, require_manager
from models.user import User
@@ -148,6 +151,15 @@ def close_business_day(
if body.notes:
day.notes = body.notes
# Phase 2E: store-level cash reconciliation
if body.store_closing_cash is not None:
day.store_closing_cash = body.store_closing_cash
# expected = store_opening_cash + sum of all waiter collected amounts this day
all_shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == day.id).all()
total_collected = sum(s.total_collected or 0.0 for s in all_shifts)
expected_closing = (day.store_opening_cash or 0.0) + total_collected
day.store_cash_discrepancy = round(body.store_closing_cash - expected_closing, 2)
db.commit()
db.refresh(day)
@@ -162,6 +174,406 @@ def close_business_day(
return day
class PatchBusinessDayRequest(BaseModel):
closed_at: str # ISO-8601 datetime string
@router.patch("/{day_id}", status_code=status.HTTP_200_OK)
def patch_business_day(
day_id: int,
body: "PatchBusinessDayRequest",
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Edit the close-time of a past (closed) business day.
Constraints:
- Day must already be closed.
- new closed_at must be ≥ the latest order closed_at in this day.
- new closed_at must be ≤ now (UTC).
"""
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
if not day:
raise HTTPException(status_code=404, detail="Business day not found")
if day.status != "closed":
raise HTTPException(status_code=400, detail="Can only edit a closed business day")
try:
new_closed = datetime.fromisoformat(body.closed_at.replace("Z", "+00:00"))
if new_closed.tzinfo is None:
new_closed = new_closed.replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(status_code=422, detail="Invalid datetime format")
now = datetime.now(timezone.utc)
if new_closed > now:
raise HTTPException(status_code=400, detail="Close time cannot be in the future")
# Must not be earlier than the latest order closed_at in this day
last_order_close = (
db.query(func.max(Order.closed_at))
.filter(Order.business_day_id == day_id, Order.closed_at != None)
.scalar()
)
if last_order_close:
if last_order_close.tzinfo is None:
last_order_close = last_order_close.replace(tzinfo=timezone.utc)
if new_closed < last_order_close:
raise HTTPException(
status_code=400,
detail=f"Close time cannot be earlier than the last order's close time ({_dt(last_order_close)})"
)
day.closed_at = new_closed
db.commit()
return {"id": day.id, "closed_at": _dt(day.closed_at)}
@router.delete("/{day_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_business_day(
day_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Permanently delete a business day. Only allowed when it has no shifts."""
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
if not day:
raise HTTPException(status_code=404, detail="Business day not found")
if day.status == "open":
raise HTTPException(status_code=400, detail="Cannot delete an open business day — close it first")
shift_count = db.query(WaiterShift).filter(WaiterShift.business_day_id == day_id).count()
if shift_count > 0:
raise HTTPException(
status_code=409,
detail=f"Δεν είναι δυνατή η διαγραφή: η εργάσιμη μέρα έχει {shift_count} βάρδια/ες. Διαγράψτε πρώτα όλες τις βάρδιες.",
)
db.query(Order).filter(Order.business_day_id == day_id).update(
{"business_day_id": None}, synchronize_session=False
)
db.delete(day)
db.commit()
@router.get("/summary")
def business_day_summary(
day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Full summary for a business day: per-waiter, per-zone, per-printer, and checks.
If day_id is omitted, uses the currently open business day."""
if day_id:
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
if not day:
raise HTTPException(status_code=404, detail="Business day not found")
else:
day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if not day:
raise HTTPException(status_code=404, detail="No open business day")
orders = db.query(Order).filter(Order.business_day_id == day.id).all()
order_ids = [o.id for o in orders]
all_items = db.query(OrderItem).filter(OrderItem.order_id.in_(order_ids)).all() if order_ids else []
paid_items = [i for i in all_items if i.status == "paid"]
active_items = [i for i in all_items if i.status == "active"]
shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == day.id).all()
users_map = {u.id: u for u in db.query(User).all()}
tables_map = {t.id: t for t in db.query(Table).all()}
groups_map = {g.id: g for g in db.query(TableGroup).all()}
printers_map = {p.id: p for p in db.query(Printer).all()}
# ── Per-waiter summary ──────────────────────────────────────────────────
waiter_stats: dict = {}
def _waiter_entry(uid):
if uid not in waiter_stats:
u = users_map.get(uid)
name = (u.full_name or u.nickname or u.username) if u else f"#{uid}"
role = u.role if u else "waiter"
waiter_stats[uid] = {
"waiter_id": uid,
"waiter_name": name,
"role": role,
"orders_opened": 0,
"items_ordered": 0,
"total_items_value": 0.0,
"items_paid": 0,
"total_collected": 0.0,
"cash": 0.0,
"card": 0.0,
"transfer": 0.0,
"treat": 0.0,
"other": 0.0,
"shift_started_at": None,
"shift_ended_at": None,
"starting_cash": None,
# Phase 2B payroll
"hourly_rate_snapshot": None,
"duration_hours": None,
"shift_pay": None,
# distinct tables served (not order count)
"tables_served": 0,
}
return waiter_stats[uid]
# track unique tables per waiter (opened_by)
waiter_tables: dict = {} # uid → set of table_ids
for o in orders:
if o.status == "cancelled":
continue
e = _waiter_entry(o.opened_by)
e["orders_opened"] += 1
if o.table_id:
waiter_tables.setdefault(o.opened_by, set()).add(o.table_id)
for item in all_items:
if item.status == "cancelled":
continue
e = _waiter_entry(item.added_by)
e["items_ordered"] += item.quantity
e["total_items_value"] += item.unit_price * item.quantity
for item in paid_items:
if item.paid_by:
e = _waiter_entry(item.paid_by)
val = item.unit_price * item.quantity
e["items_paid"] += item.quantity
e["total_collected"] += val
method = (item.payment_method or "cash").lower()
if method == "cash":
e["cash"] += val
elif method == "card":
e["card"] += val
elif method == "transfer":
e["transfer"] += val
elif method in ("treat", "comp"):
e["treat"] += val
else:
e["other"] += val
from routers.shifts import compute_shift_pay
for shift in shifts:
e = _waiter_entry(shift.waiter_id)
if e["shift_started_at"] is None or (shift.started_at and _dt(shift.started_at) < e["shift_started_at"]):
e["shift_started_at"] = _dt(shift.started_at)
if shift.starting_cash is not None:
e["starting_cash"] = shift.starting_cash
if shift.ended_at:
ended = _dt(shift.ended_at)
if e["shift_ended_at"] is None or ended > e["shift_ended_at"]:
e["shift_ended_at"] = ended
# payroll — take the most recent non-null snapshot
if shift.hourly_rate_snapshot is not None:
e["hourly_rate_snapshot"] = shift.hourly_rate_snapshot
pay_data = compute_shift_pay(shift)
e["duration_hours"] = round((e["duration_hours"] or 0) + pay_data["duration_hours"], 2)
if pay_data["shift_pay"] is not None:
e["shift_pay"] = round((e["shift_pay"] or 0) + pay_data["shift_pay"], 2)
# write tables_served counts
for uid, table_set in waiter_tables.items():
if uid in waiter_stats:
waiter_stats[uid]["tables_served"] = len(table_set)
# manager gets shift = day open/close
manager_opener = users_map.get(day.opened_by_id)
if manager_opener:
e = _waiter_entry(day.opened_by_id)
if e["shift_started_at"] is None:
e["shift_started_at"] = _dt(day.opened_at)
if e["shift_ended_at"] is None and day.closed_at:
e["shift_ended_at"] = _dt(day.closed_at)
# ── Per-zone summary ────────────────────────────────────────────────────
zone_stats: dict = {} # group_id → stats
def _zone_entry(gid, gname, gcolor):
if gid not in zone_stats:
zone_stats[gid] = {
"group_id": gid,
"group_name": gname,
"color": gcolor,
"orders": 0,
"items": 0,
"revenue": 0.0,
}
return zone_stats[gid]
for o in orders:
if o.status == "cancelled" or not o.table_id:
continue
table = tables_map.get(o.table_id)
group = groups_map.get(table.group_id) if table and table.group_id else None
gid = group.id if group else 0
gname = group.name if group else "Χωρίς Ζώνη"
gcolor = group.color if group else "#9ca3af"
entry = _zone_entry(gid, gname, gcolor)
entry["orders"] += 1
for item in o.items:
if item.status == "cancelled":
continue
entry["items"] += item.quantity
if item.status == "paid":
entry["revenue"] += item.unit_price * item.quantity
# ── Per-printer summary ─────────────────────────────────────────────────
# Build item_id → value lookup for revenue-per-printer calculation
items_value_map = {i.id: i.unit_price * i.quantity for i in all_items if i.status != "cancelled"}
printer_stats: dict = {}
if order_ids:
logs = db.query(PrintLog).filter(PrintLog.order_id.in_(order_ids)).all()
for log in logs:
pid = log.printer_id
if pid not in printer_stats:
pr = printers_map.get(pid)
printer_stats[pid] = {
"printer_id": pid,
"printer_name": pr.name if pr else f"#{pid}",
"ip_address": pr.ip_address if pr else "",
"jobs": 0,
"success": 0,
"failed": 0,
"items_value": 0.0,
}
printer_stats[pid]["jobs"] += 1
if log.success:
printer_stats[pid]["success"] += 1
try:
import json as _json
item_ids = _json.loads(log.item_ids) if log.item_ids else []
for iid in item_ids:
printer_stats[pid]["items_value"] += items_value_map.get(iid, 0.0)
except Exception:
pass
else:
printer_stats[pid]["failed"] += 1
for pid in printer_stats:
printer_stats[pid]["items_value"] = round(printer_stats[pid]["items_value"], 2)
# ── Checks ──────────────────────────────────────────────────────────────
open_orders = [o for o in orders if o.status in ("open", "partially_paid")]
with_unpaid_items = sum(
1 for o in open_orders
if any(i.status == "active" for i in o.items)
)
active_shift_count = sum(1 for s in shifts if s.ended_at is None)
total_revenue = sum(i.unit_price * i.quantity for i in paid_items)
total_items_ordered = sum(i.quantity for i in all_items if i.status != "cancelled")
total_orders = sum(1 for o in orders if o.status != "cancelled")
# Round float fields in waiter stats
for w in waiter_stats.values():
for key in ("total_collected", "total_items_value", "cash", "card", "transfer", "treat", "other"):
w[key] = round(w[key], 2)
# Phase 2E: store-level cash totals for summary view
total_waiter_collected = sum(s.total_collected or 0.0 for s in shifts)
store_expected_closing = (day.store_opening_cash or 0.0) + total_waiter_collected
# ── Phase 2K: full financial summary ──────────────<E29480><E29480>─────────────────────
# COGS from Phase 2A cost snapshots on paid items
from models.waste import WasteLog
from models.expenses import Expense
from models.tabs import Tab, TabEntry
cogs_trackable = 0.0
cogs_uncosted_revenue = 0.0
cogs_uncosted_count = 0
for item in paid_items:
rev = item.unit_price * item.quantity
if item.unit_cost is not None:
cogs_trackable += item.unit_cost * item.quantity
else:
cogs_uncosted_revenue += rev
cogs_uncosted_count += 1
gross_profit = round(total_revenue - cogs_trackable, 2)
cogs_has_gap = cogs_uncosted_count > 0
# LABOR from shifts
total_labor_cost = 0.0
labor_untracked_shifts = 0
for shift in shifts:
pay_data = compute_shift_pay(shift)
if pay_data["shift_pay"] is not None:
total_labor_cost += pay_data["shift_pay"]
else:
labor_untracked_shifts += 1
# WASTE from Phase 2H
waste_entries = db.query(WasteLog).filter(WasteLog.business_day_id == day.id).all()
waste_item_count = len(waste_entries)
waste_estimated_cost = round(sum(w.total_cost for w in waste_entries if w.total_cost is not None), 2)
# EXPENSES logged today
expenses_today = db.query(Expense).filter(Expense.business_day_id == day.id).all()
expenses_total = round(sum(e.total_amount for e in expenses_today), 2)
expenses_paid_today = round(sum(e.paid_amount for e in expenses_today), 2)
expenses_due = round(expenses_total - expenses_paid_today, 2)
# TABBED REVENUE (items moved to tabs — not yet collected)
tabbed_items = [i for i in all_items if i.status == "tabbed"]
tabbed_revenue = round(sum(i.unit_price * i.quantity for i in tabbed_items), 2)
# NET estimate (revenue - COGS - labor - waste - expenses paid)
net_estimate = round(
total_revenue
- cogs_trackable
- total_labor_cost
- waste_estimated_cost
- expenses_paid_today,
2
)
net_has_unknowns = cogs_has_gap or labor_untracked_shifts > 0
return {
"day_id": day.id,
"status": day.status,
"opened_at": _dt(day.opened_at),
"closed_at": _dt(day.closed_at),
"total_revenue": round(total_revenue, 2),
"total_orders": total_orders,
"total_items_ordered": total_items_ordered,
"waiters": sorted(waiter_stats.values(), key=lambda x: -x["total_collected"]),
"zones": sorted(zone_stats.values(), key=lambda x: -x["revenue"]),
"printers": list(printer_stats.values()),
"checks": {
"open_orders": len(open_orders),
"with_unpaid_items": with_unpaid_items,
"active_shifts": active_shift_count,
},
# Phase 2E — cash reconciliation
"store_opening_cash": day.store_opening_cash,
"store_closing_cash": day.store_closing_cash,
"store_cash_discrepancy": day.store_cash_discrepancy,
"store_expected_closing": round(store_expected_closing, 2),
"total_waiter_collected": round(total_waiter_collected, 2),
# Phase 2K — full financial summary
"cogs_trackable": round(cogs_trackable, 2),
"cogs_uncosted_revenue": round(cogs_uncosted_revenue, 2),
"cogs_uncosted_count": cogs_uncosted_count,
"cogs_has_gap": cogs_has_gap,
"gross_profit": gross_profit,
"total_labor_cost": round(total_labor_cost, 2),
"labor_untracked_shifts": labor_untracked_shifts,
"waste_item_count": waste_item_count,
"waste_estimated_cost": waste_estimated_cost,
"expenses_total": expenses_total,
"expenses_paid_today": expenses_paid_today,
"expenses_due": expenses_due,
"tabbed_revenue": tabbed_revenue,
"net_estimate": net_estimate,
"net_has_unknowns": net_has_unknowns,
}
@router.get("/history")
def business_day_history(
db: Session = Depends(get_db),

View File

@@ -0,0 +1,404 @@
"""
Chat router — /api/chat
"""
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from database import get_db
from routers.deps import get_current_user
from models.user import User
from models.chat import Conversation, ConversationParticipant, ChatMessage
from schemas.user import UserOut
from schemas.chat import (
ConversationCreate,
ConversationOut,
MessageCreate,
MessageOut,
ParticipantOut,
)
from services.sse_bus import broadcast_sync
router = APIRouter()
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _build_participant_out(p: ConversationParticipant) -> ParticipantOut:
return ParticipantOut(
user_id=p.user_id,
username=p.user.username,
joined_at=p.joined_at,
last_read_at=p.last_read_at,
)
def _build_message_out(msg: ChatMessage) -> MessageOut:
return MessageOut(
id=msg.id,
conversation_id=msg.conversation_id,
sender_id=msg.sender_id,
sender_name=msg.sender.username,
body=msg.body,
sent_at=msg.sent_at,
is_deleted=msg.deleted_at is not None,
)
def _unread_count(db: Session, conv_id: int, user: User) -> int:
"""
Count messages in a conversation that are 'unread' for this user:
- not soft-deleted
- not sent by this user (your own messages don't count)
- sent after user's last_read_at (or ALL messages if last_read_at is None)
"""
participant = (
db.query(ConversationParticipant)
.filter(
ConversationParticipant.conversation_id == conv_id,
ConversationParticipant.user_id == user.id,
)
.first()
)
if not participant:
return 0
q = (
db.query(ChatMessage)
.filter(
ChatMessage.conversation_id == conv_id,
ChatMessage.deleted_at == None, # noqa: E711
ChatMessage.sender_id != user.id,
)
)
if participant.last_read_at is not None:
q = q.filter(ChatMessage.sent_at > participant.last_read_at)
return q.count()
def _last_message(db: Session, conv_id: int) -> Optional[ChatMessage]:
return (
db.query(ChatMessage)
.filter(
ChatMessage.conversation_id == conv_id,
ChatMessage.deleted_at == None, # noqa: E711
)
.order_by(ChatMessage.sent_at.desc())
.first()
)
def _get_participant_ids(conv: Conversation) -> List[int]:
return [p.user_id for p in conv.participants]
def _build_conversation_out(db: Session, conv: Conversation, user: User) -> ConversationOut:
last_msg = _last_message(db, conv.id)
return ConversationOut(
id=conv.id,
type=conv.type,
name=conv.name,
is_system=conv.is_system,
created_at=conv.created_at,
participants=[_build_participant_out(p) for p in conv.participants],
last_message=_build_message_out(last_msg) if last_msg else None,
unread_count=_unread_count(db, conv.id, user),
)
def _require_participant(db: Session, conv_id: int, user: User) -> Conversation:
"""Return the conversation if user is a participant, else 403/404."""
conv = db.query(Conversation).filter(Conversation.id == conv_id).first()
if not conv:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
is_participant = any(p.user_id == user.id for p in conv.participants)
if not is_participant:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a participant")
return conv
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/conversations", response_model=List[ConversationOut])
def list_conversations(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""List all conversations the current user is a participant of, ordered by last message time desc."""
# Fetch all conversation_ids for this user
participant_rows = (
db.query(ConversationParticipant)
.filter(ConversationParticipant.user_id == user.id)
.all()
)
conv_ids = [p.conversation_id for p in participant_rows]
if not conv_ids:
return []
conversations = (
db.query(Conversation)
.filter(Conversation.id.in_(conv_ids))
.all()
)
# Sort by last message sent_at desc (conversations with no messages go last)
# SQLite returns naive datetimes even for timezone=True columns, so keep everything naive
def _sort_key(c: Conversation):
last = _last_message(db, c.id)
if not last:
return datetime.min
dt = last.sent_at
return dt.replace(tzinfo=None) if dt.tzinfo else dt
conversations.sort(key=_sort_key, reverse=True)
return [_build_conversation_out(db, c, user) for c in conversations]
@router.post("/conversations", response_model=ConversationOut, status_code=status.HTTP_201_CREATED)
def create_conversation(
payload: ConversationCreate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Create a direct or group conversation.
For direct: returns existing conversation if one already exists between the two users.
Creator is automatically added as a participant.
"""
# Normalise participant list — always include creator
participant_ids = list(set(payload.participant_ids + [user.id]))
if payload.type == "direct":
if len(participant_ids) != 2:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Direct conversations must have exactly 2 participants",
)
other_id = next(pid for pid in participant_ids if pid != user.id)
# Check for an existing non-system direct conversation between these two users
existing_parts_me = (
db.query(ConversationParticipant)
.filter(ConversationParticipant.user_id == user.id)
.all()
)
my_conv_ids = {p.conversation_id for p in existing_parts_me}
existing_parts_other = (
db.query(ConversationParticipant)
.filter(
ConversationParticipant.user_id == other_id,
ConversationParticipant.conversation_id.in_(my_conv_ids),
)
.all()
)
shared_conv_ids = {p.conversation_id for p in existing_parts_other}
for conv_id in shared_conv_ids:
conv = db.query(Conversation).filter(
Conversation.id == conv_id,
Conversation.type == "direct",
Conversation.is_system == False, # noqa: E712
).first()
if conv:
return _build_conversation_out(db, conv, user)
elif payload.type == "group":
if not payload.name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Group conversations require a name",
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="type must be 'direct' or 'group'",
)
# Create the conversation
conv = Conversation(
type=payload.type,
name=payload.name if payload.type == "group" else None,
is_system=False,
created_by=user.id,
)
db.add(conv)
db.flush()
now = _utcnow()
for uid in participant_ids:
db.add(ConversationParticipant(
conversation_id=conv.id,
user_id=uid,
joined_at=now,
))
db.commit()
db.refresh(conv)
return _build_conversation_out(db, conv, user)
@router.get("/conversations/{conv_id}", response_model=ConversationOut)
def get_conversation(
conv_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Get single conversation details with participants."""
conv = _require_participant(db, conv_id, user)
return _build_conversation_out(db, conv, user)
@router.get("/conversations/{conv_id}/messages", response_model=List[MessageOut])
def list_messages(
conv_id: int,
offset: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Paginated messages for a conversation, newest first. User must be a participant."""
_require_participant(db, conv_id, user)
messages = (
db.query(ChatMessage)
.filter(ChatMessage.conversation_id == conv_id)
.order_by(ChatMessage.sent_at.desc())
.offset(offset)
.limit(limit)
.all()
)
return [_build_message_out(m) for m in messages]
@router.post("/conversations/{conv_id}/messages", response_model=MessageOut, status_code=status.HTTP_201_CREATED)
def send_message(
conv_id: int,
payload: MessageCreate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Send a message to a conversation. Broadcasts chat_message SSE event to all participants."""
conv = _require_participant(db, conv_id, user)
if not payload.body.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Message body cannot be empty",
)
msg = ChatMessage(
conversation_id=conv_id,
sender_id=user.id,
body=payload.body,
sent_at=_utcnow(),
)
db.add(msg)
db.commit()
db.refresh(msg)
participant_ids = _get_participant_ids(conv)
# Ensure UTC Z suffix so JS Date() parses correctly regardless of tzinfo
sent_at_str = msg.sent_at.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z'
broadcast_sync(
"chat_message",
{
"conversation_id": conv_id,
"message_id": msg.id,
"sender_id": user.id,
"sender_name": user.username,
"body": msg.body,
"sent_at": sent_at_str,
},
user_ids=participant_ids,
)
return _build_message_out(msg)
@router.post("/conversations/{conv_id}/read", status_code=status.HTTP_204_NO_CONTENT)
def mark_read(
conv_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Update the current user's last_read_at to now. Broadcasts chat_read SSE event."""
conv = _require_participant(db, conv_id, user)
participant = (
db.query(ConversationParticipant)
.filter(
ConversationParticipant.conversation_id == conv_id,
ConversationParticipant.user_id == user.id,
)
.first()
)
now = _utcnow()
participant.last_read_at = now
db.commit()
participant_ids = _get_participant_ids(conv)
broadcast_sync(
"chat_read",
{
"conversation_id": conv_id,
"user_id": user.id,
"read_at": now.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z',
},
user_ids=participant_ids,
)
@router.post("/conversations/{conv_id}/leave", status_code=status.HTTP_204_NO_CONTENT)
def leave_conversation(
conv_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Remove current user from the conversation.
Not allowed on system conversations or direct conversations.
"""
conv = _require_participant(db, conv_id, user)
if conv.is_system:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot leave the system group conversation",
)
if conv.type == "direct":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot leave a direct conversation — just stop using it",
)
participant = (
db.query(ConversationParticipant)
.filter(
ConversationParticipant.conversation_id == conv_id,
ConversationParticipant.user_id == user.id,
)
.first()
)
db.delete(participant)
db.commit()
@router.get("/users", response_model=List[UserOut])
def list_chat_users(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""All active users — accessible to any authenticated staff member, for starting chats."""
return db.query(User).filter(User.is_active == True).order_by(User.username).all() # noqa: E712

View File

@@ -0,0 +1,176 @@
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from pydantic import BaseModel
from sqlalchemy.orm import Session
import httpx
from config import settings
from database import get_db
from models.order import Order
from schemas.order import OrderOut
from routers.deps import get_current_user, require_manager
from models.user import User
from services.sse_bus import broadcast_sync
logger = logging.getLogger(__name__)
router = APIRouter()
class RejectRequest(BaseModel):
reason: Optional[str] = None
class StatusUpdateRequest(BaseModel):
status: str # "preparing" | "ready" | "out_for_delivery" | "delivered"
# ── Helpers ───────────────────────────────────────────────────────────────────
def _get_online_order(order_id: int, db: Session) -> Order:
order = db.query(Order).filter(Order.id == order_id, Order.source == "online").first()
if not order:
raise HTTPException(status_code=404, detail="Online order not found")
return order
def _cloud_headers() -> dict:
return {"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY}
async def _mirror_status_to_cloud(cloud_order_id: int, new_status: str, rejection_reason: str = None):
"""Best-effort push of status update to cloud. Failures are logged, not raised."""
if not settings.CLOUD_URL or not cloud_order_id:
return
try:
body = {"status": new_status}
if rejection_reason:
body["rejection_reason"] = rejection_reason
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.patch(
f"{settings.CLOUD_URL}/api/orders/{cloud_order_id}/status",
headers=_cloud_headers(),
json=body,
)
resp.raise_for_status()
logger.info("Mirrored status %s for cloud order id %d", new_status, cloud_order_id)
except Exception as e:
logger.warning("Failed to mirror status to cloud for order id %d: %s", cloud_order_id, e)
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/orders", response_model=list[OrderOut])
def get_orders_by_status(
status: str,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
?status=active → accepted | preparing | ready | out_for_delivery, newest first
?status=history → delivered | rejected, newest first, limit 50
"""
if status == "active":
statuses = ["accepted", "preparing", "ready", "out_for_delivery"]
orders = (
db.query(Order)
.filter(Order.source == "online", Order.online_status.in_(statuses))
.order_by(Order.opened_at.desc())
.all()
)
elif status == "history":
statuses = ["delivered", "rejected"]
orders = (
db.query(Order)
.filter(Order.source == "online", Order.online_status.in_(statuses))
.order_by(Order.opened_at.desc())
.limit(50)
.all()
)
else:
raise HTTPException(status_code=400, detail=f"Unknown status filter '{status}'. Use 'active' or 'history'.")
return orders
@router.get("/orders/incoming", response_model=list[OrderOut])
def get_incoming_orders(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return all online orders pending acceptance, newest first."""
orders = (
db.query(Order)
.filter(Order.source == "online", Order.online_status == "pending_acceptance")
.order_by(Order.opened_at.desc())
.all()
)
return orders
@router.post("/orders/{order_id}/accept", response_model=OrderOut)
async def accept_order(
order_id: int,
background: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
order = _get_online_order(order_id, db)
if order.online_status != "pending_acceptance":
raise HTTPException(status_code=400, detail=f"Order is already '{order.online_status}'")
order.online_status = "accepted"
db.commit()
db.refresh(order)
background.add_task(_mirror_status_to_cloud, order.online_order_cloud_id, "accepted")
broadcast_sync("online_order_updated", {"order_id": order_id, "status": "accepted"})
return order
@router.post("/orders/{order_id}/reject", response_model=OrderOut)
async def reject_order(
order_id: int,
body: RejectRequest,
background: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
order = _get_online_order(order_id, db)
if order.online_status != "pending_acceptance":
raise HTTPException(status_code=400, detail=f"Order is already '{order.online_status}'")
order.online_status = "rejected"
order.status = "cancelled"
db.commit()
db.refresh(order)
background.add_task(_mirror_status_to_cloud, order.online_order_cloud_id, "rejected", body.reason)
broadcast_sync("online_order_updated", {"order_id": order_id, "status": "rejected"})
return order
@router.post("/orders/{order_id}/status", response_model=OrderOut)
async def update_order_status(
order_id: int,
body: StatusUpdateRequest,
background: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
allowed = {"preparing", "ready", "out_for_delivery", "delivered"}
if body.status not in allowed:
raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}'")
order = _get_online_order(order_id, db)
order.online_status = body.status
if body.status == "delivered":
order.status = "closed"
db.commit()
db.refresh(order)
background.add_task(_mirror_status_to_cloud, order.online_order_cloud_id, body.status)
broadcast_sync("online_order_updated", {"order_id": order_id, "status": body.status})
return order

View File

@@ -0,0 +1,117 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from database import get_db
from models.customers import Customer
from models.order import Order, OrderItem
from models.user import User
from schemas.customers import CustomerCreate, CustomerUpdate, CustomerOut, AssignCustomerRequest
from routers.deps import require_manager, get_current_user
router = APIRouter()
def _customer_out(c: Customer, db: Session) -> dict:
orders = db.query(Order).filter(
Order.customer_id == c.id,
Order.status.in_(["closed", "paid"]),
).all()
total_spent = sum(
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
for o in orders
)
return {
"id": c.id,
"name": c.name,
"nickname": c.nickname,
"phone": c.phone,
"email": c.email,
"notes": c.notes,
"is_active": c.is_active,
"created_at": c.created_at,
"created_by_id": c.created_by_id,
"visit_count": len(orders),
"total_spent": round(total_spent, 2),
}
@router.get("/", response_model=List[CustomerOut])
def list_customers(
search: Optional[str] = None,
include_inactive: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Customer)
if not include_inactive:
q = q.filter(Customer.is_active == True)
if search:
term = f"%{search}%"
q = q.filter(
Customer.name.ilike(term) |
Customer.nickname.ilike(term) |
Customer.phone.ilike(term)
)
customers = q.order_by(Customer.name).all()
return [_customer_out(c, db) for c in customers]
@router.post("/", response_model=CustomerOut, status_code=status.HTTP_201_CREATED)
def create_customer(body: CustomerCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
customer = Customer(**body.model_dump(), created_by_id=user.id)
db.add(customer)
db.commit()
db.refresh(customer)
return _customer_out(customer, db)
@router.get("/{customer_id}", response_model=CustomerOut)
def get_customer(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
c = db.query(Customer).filter(Customer.id == customer_id).first()
if not c:
raise HTTPException(status_code=404, detail="Customer not found")
return _customer_out(c, db)
@router.put("/{customer_id}", response_model=CustomerOut)
def update_customer(customer_id: int, body: CustomerUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
c = db.query(Customer).filter(Customer.id == customer_id).first()
if not c:
raise HTTPException(status_code=404, detail="Customer not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(c, field, value)
db.commit()
db.refresh(c)
return _customer_out(c, db)
@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_customer(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
c = db.query(Customer).filter(Customer.id == customer_id).first()
if not c:
raise HTTPException(status_code=404, detail="Customer not found")
c.is_active = False
db.commit()
@router.get("/{customer_id}/orders")
def customer_orders(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
c = db.query(Customer).filter(Customer.id == customer_id).first()
if not c:
raise HTTPException(status_code=404, detail="Customer not found")
from models.table import Table
orders = db.query(Order).filter(Order.customer_id == customer_id).order_by(Order.opened_at.desc()).all()
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
result = []
for o in orders:
total = sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
result.append({
"id": o.id,
"table_name": tables_map.get(o.table_id) if o.table_id else None,
"opened_at": o.opened_at,
"status": o.status,
"total": round(total, 2),
"item_count": sum(i.quantity for i in o.items if i.status in ("active", "paid")),
})
return {"orders": result}

View File

@@ -16,6 +16,45 @@ _blacklisted_tokens: set[str] = set()
TOKEN_EXPIRY_HOURS = 8
MASTER_USER_ID = 0 # sentinel — never a real DB row
def _make_ghost_superadmin():
"""Synthetic in-memory superadmin — plain namespace, never touches SQLAlchemy."""
from types import SimpleNamespace
return SimpleNamespace(
id=MASTER_USER_ID,
username=settings.MASTER_USERNAME,
full_name="Xenia Support",
role="superadmin",
is_active=True,
pin_hash=None,
password_hash=None,
email=None,
nickname=None,
mobile_phone=None,
note=None,
avatar_url=None,
waiter_settings=None,
hourly_rate=None,
created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
zone_assignments=[],
perm_access_dashboard=True,
perm_access_waiter_app=True,
perm_access_kds=True,
perm_cancel_orders=True,
perm_apply_discounts=True,
perm_modify_prices=True,
perm_open_orders=True,
perm_close_orders=True,
perm_view_reports=True,
perm_manage_staff=True,
perm_manage_tables=True,
perm_manage_menu=True,
perm_manage_settings=True,
)
def make_token(user: User) -> str:
payload = {
"sub": str(user.id),
@@ -46,19 +85,69 @@ def get_current_user(
db: Session = Depends(get_db),
) -> User:
payload = decode_token(credentials.credentials)
if int(payload["sub"]) == MASTER_USER_ID and payload.get("role") == "superadmin":
return _make_ghost_superadmin()
user = db.query(User).filter(User.id == int(payload["sub"]), User.is_active == True).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
def _has_perm(user: User, perm: str) -> bool:
"""Superadmins always have every permission."""
if user.role == "superadmin":
return True
return bool(getattr(user, perm, False))
def require_manager(user: User = Depends(get_current_user)) -> User:
if user.role not in ("manager", "sysadmin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Manager access required")
"""Allow anyone with dashboard access (formerly role-based manager check)."""
if not _has_perm(user, "perm_access_dashboard"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Dashboard access required")
return user
def require_sysadmin(user: User = Depends(get_current_user)) -> User:
if user.role != "sysadmin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sysadmin access required")
def require_staff_manager(user: User = Depends(get_current_user)) -> User:
if not _has_perm(user, "perm_manage_staff"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Staff management permission required")
return user
def require_reports(user: User = Depends(get_current_user)) -> User:
if not _has_perm(user, "perm_view_reports"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Reports permission required")
return user
def require_menu_manager(user: User = Depends(get_current_user)) -> User:
if not _has_perm(user, "perm_manage_menu"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Menu management permission required")
return user
def require_settings_manager(user: User = Depends(get_current_user)) -> User:
if not _has_perm(user, "perm_manage_settings"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Settings management permission required")
return user
def require_kds(user: User = Depends(get_current_user)) -> User:
if not _has_perm(user, "perm_access_kds"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Δεν έχετε πρόσβαση στο KDS")
return user
def require_tables_manager(user: User = Depends(get_current_user)) -> User:
if not _has_perm(user, "perm_manage_tables"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tables management permission required")
return user
def require_superadmin(user: User = Depends(get_current_user)) -> User:
if user.role != "superadmin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Superadmin access required")
return user
# Kept for any callers that still use the old name
require_sysadmin = require_superadmin

View File

@@ -0,0 +1,220 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from database import get_db
from models.expenses import Contact, Expense, ExpensePayment
from models.user import User
from schemas.expenses import (
ContactCreate, ContactUpdate, ContactOut,
ExpenseCreate, ExpenseUpdate, ExpenseOut,
ExpensePaymentCreate, ExpensePaymentOut,
)
from routers.deps import require_manager
contacts_router = APIRouter()
expenses_router = APIRouter()
def _utcnow():
return datetime.now(timezone.utc)
def _expense_status(total: float, paid: float) -> str:
if paid >= total:
return "paid"
if paid > 0:
return "partial"
return "due"
def _expense_out(e: Expense) -> dict:
contact_name = None
if e.contact:
contact_name = e.contact.name
creator_name = None
if e.created_by:
creator_name = e.created_by.full_name or e.created_by.username
due_amount = round(max(0.0, e.total_amount - e.paid_amount), 2)
payments_out = []
for p in e.payments:
payer_name = None
if p.paid_by:
payer_name = p.paid_by.full_name or p.paid_by.username
payments_out.append({
"id": p.id,
"expense_id": p.expense_id,
"amount": p.amount,
"paid_at": p.paid_at,
"paid_by_id": p.paid_by_id,
"paid_by_name": payer_name,
"notes": p.notes,
})
return {
"id": e.id,
"description": e.description,
"category": e.category,
"contact_id": e.contact_id,
"contact_name": contact_name,
"total_amount": e.total_amount,
"paid_amount": e.paid_amount,
"due_amount": due_amount,
"status": _expense_status(e.total_amount, e.paid_amount),
"due_date": e.due_date,
"business_day_id": e.business_day_id,
"created_by_id": e.created_by_id,
"created_by_name": creator_name,
"created_at": e.created_at,
"notes": e.notes,
"payments": payments_out,
}
# ── Contacts ──────────────────────────────────────────────────────────────────
@contacts_router.get("/", response_model=List[ContactOut])
def list_contacts(
include_inactive: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Contact)
if not include_inactive:
q = q.filter(Contact.is_active == True)
return q.order_by(Contact.name).all()
@contacts_router.post("/", response_model=ContactOut, status_code=status.HTTP_201_CREATED)
def create_contact(body: ContactCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
contact = Contact(**body.model_dump())
db.add(contact)
db.commit()
db.refresh(contact)
return contact
@contacts_router.put("/{contact_id}", response_model=ContactOut)
def update_contact(contact_id: int, body: ContactUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
contact = db.query(Contact).filter(Contact.id == contact_id).first()
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(contact, field, value)
db.commit()
db.refresh(contact)
return contact
@contacts_router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_contact(contact_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
contact = db.query(Contact).filter(Contact.id == contact_id).first()
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
contact.is_active = False
db.commit()
# ── Expenses ──────────────────────────────────────────────────────────────────
@expenses_router.get("/summary")
def expense_summary(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
expenses = db.query(Expense).all()
total_due = sum(max(0.0, e.total_amount - e.paid_amount) for e in expenses if e.paid_amount < e.total_amount)
total_paid = sum(e.paid_amount for e in expenses)
by_category: dict = {}
for e in expenses:
c = e.category
if c not in by_category:
by_category[c] = {"category": c, "total": 0.0, "paid": 0.0, "due": 0.0}
by_category[c]["total"] += e.total_amount
by_category[c]["paid"] += e.paid_amount
by_category[c]["due"] += max(0.0, e.total_amount - e.paid_amount)
return {
"total_due": round(total_due, 2),
"total_paid": round(total_paid, 2),
"by_category": list(by_category.values()),
}
@expenses_router.get("/", response_model=List[ExpenseOut])
def list_expenses(
expense_status: Optional[str] = Query(default=None, alias="status"),
category: Optional[str] = None,
contact_id: Optional[int] = None,
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
expenses = db.query(Expense).order_by(Expense.created_at.desc()).all()
result = [_expense_out(e) for e in expenses]
if expense_status:
result = [e for e in result if e["status"] == expense_status]
if category:
result = [e for e in result if e["category"] == category]
if contact_id:
result = [e for e in result if e["contact_id"] == contact_id]
if business_day_id:
result = [e for e in result if e["business_day_id"] == business_day_id]
return result
@expenses_router.post("/", response_model=ExpenseOut, status_code=status.HTTP_201_CREATED)
def create_expense(body: ExpenseCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
expense = Expense(**body.model_dump(), created_by_id=user.id)
db.add(expense)
db.commit()
db.refresh(expense)
return _expense_out(expense)
@expenses_router.put("/{expense_id}", response_model=ExpenseOut)
def update_expense(expense_id: int, body: ExpenseUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
expense = db.query(Expense).filter(Expense.id == expense_id).first()
if not expense:
raise HTTPException(status_code=404, detail="Expense not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(expense, field, value)
db.commit()
db.refresh(expense)
return _expense_out(expense)
@expenses_router.delete("/{expense_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_expense(expense_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
expense = db.query(Expense).filter(Expense.id == expense_id).first()
if not expense:
raise HTTPException(status_code=404, detail="Expense not found")
db.delete(expense)
db.commit()
@expenses_router.post("/{expense_id}/pay", response_model=ExpenseOut)
def record_payment(
expense_id: int,
body: ExpensePaymentCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
expense = db.query(Expense).filter(Expense.id == expense_id).first()
if not expense:
raise HTTPException(status_code=404, detail="Expense not found")
if body.amount <= 0:
raise HTTPException(status_code=400, detail="Payment amount must be positive")
payment = ExpensePayment(
expense_id=expense_id,
amount=body.amount,
paid_by_id=user.id,
notes=body.notes,
)
db.add(payment)
db.flush()
# recompute paid_amount from all payments (never trust client totals)
all_payments = db.query(ExpensePayment).filter(ExpensePayment.expense_id == expense_id).all()
expense.paid_amount = round(sum(p.amount for p in all_payments), 2)
db.commit()
db.refresh(expense)
return _expense_out(expense)

View File

@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from database import get_db
from routers.deps import require_settings_manager
from models.user import User
router = APIRouter()
class TestFoldersRequest(BaseModel):
out_folder: str
in_folder: str
@router.post("/test-folders")
def test_folders(
body: TestFoldersRequest,
db: Session = Depends(get_db),
user: User = Depends(require_settings_manager),
):
"""Verify that both fiscal folders are accessible (read + write) from inside the container."""
from services.fiscal_service import test_folders
return test_folders(body.out_folder.strip(), body.in_folder.strip())

View File

@@ -0,0 +1,823 @@
"""
KDS — Kitchen Display System router.
GET /api/kds/orders
Returns all open orders enriched with resolved modifier names,
grouped by nothing (flat list). Frontend handles grouping/filtering.
PUT /api/kds/orders/{order_id}/kds_status
Update an order's KDS aggregate status (pending|preparing|done).
Broadcasts kds_order_updated SSE event.
PUT /api/kds/orders/{order_id}/items/{item_id}/kds_status
Update a single item's KDS status (pending|preparing|done).
Broadcasts kds_item_updated SSE event.
PUT /api/kds/orders/{order_id}/order_type
Update an order's type (here|takeaway|delivery).
--- Legacy endpoint kept for backward compat (printer-zone grouped items) ---
GET /api/kds/items (unchanged)
PUT /api/kds/orders/{order_id}/items/{item_id}/status (unchanged)
"""
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from datetime import datetime, timezone
from sqlalchemy.orm import Session, joinedload
from pydantic import BaseModel
from typing import List, Optional
from database import get_db
from models.order import Order, OrderItem, OrderWaiter
from models.product import Product, ProductPreferenceSet
from models.prep_zone import PrepZone
from models.printer import Printer
from models.table import Table
from models.user import User
from models.message import StaffMessage, StaffMessageAck
from models.shift import WaiterShift
from routers.deps import get_current_user, require_kds
from services.sse_bus import broadcast_sync
router = APIRouter(dependencies=[Depends(require_kds)])
VALID_KDS_STATUSES = {"pending", "preparing", "done", "served", "declined"}
VALID_ORDER_TYPES = {"here", "takeaway", "delivery"}
# ─────────────────────────── helpers ───────────────────────────────────────
def _resolve_item_modifiers(item: OrderItem, product: Product | None, db: Session):
"""
Returns a dict with four lists:
removed - ingredient names removed (from removed_ingredients JSON id array)
extras - option names selected (from selected_options JSON id array)
prefs - preference choice names selected (from selected_options JSON id array)
notes - plain text note
"""
removed, extras, prefs = [], [], []
if product is None:
return {"removed": removed, "extras": extras, "prefs": prefs, "notes": item.notes}
# Build lookup maps (avoid N+1 — product relationships already loaded)
ing_map = {i.id: i.name for i in product.ingredients}
opt_map = {o.id: o.name for o in product.options}
quick_map = {q.id: q.name for q in product.quick_options}
# Preference choice lookup: pref_choice_map[choice_id] = (set_name, choice_name)
pref_choice_map: dict[int, tuple[str, str]] = {}
for ps in product.preference_sets:
for pc in ps.choices:
pref_choice_map[pc.id] = (ps.name, pc.name)
# Removed ingredients — stored as JSON array of name strings (not ids)
if item.removed_ingredients:
try:
vals = json.loads(item.removed_ingredients)
for v in vals:
if isinstance(v, str) and v:
removed.append(v)
elif isinstance(v, int):
# legacy: id reference
name = ing_map.get(v)
if name:
removed.append(name)
except (json.JSONDecodeError, TypeError):
pass
# Selected options — stored as JSON array of objects:
# [{"id": int, "name": str, "type": "extra"|"quick"|"pref"|"pref_sub", ...}]
# We use the stored name directly; type tag tells us which bucket.
if item.selected_options:
try:
opts = json.loads(item.selected_options)
for o in opts:
if isinstance(o, dict):
name = o.get("name") or ""
otype = o.get("type", "")
if not name:
continue
if otype in ("pref", "pref_sub"):
prefs.append(name)
else:
# "extra", "extra_sub", "quick", or unknown → extras
extras.append(name)
elif isinstance(o, int):
# legacy: id-only reference — fall back to lookup maps
if o in opt_map:
extras.append(opt_map[o])
elif o in quick_map:
extras.append(quick_map[o])
elif o in pref_choice_map:
_, choice_name = pref_choice_map[o]
prefs.append(choice_name)
except (json.JSONDecodeError, TypeError):
pass
return {"removed": removed, "extras": extras, "prefs": prefs, "notes": item.notes}
def _build_order_payload(order: Order, tables_map: dict, db: Session) -> dict:
"""Build the full order dict the KDS frontend expects."""
items_out = []
for item in order.items:
if item.status == "cancelled":
continue
if item.kds_status == "served":
continue
product = item.product
mods = _resolve_item_modifiers(item, product, db)
unit_type = product.unit_type if product else "piece"
prep_zone_ids = [z.id for z in product.prep_zones] if product and hasattr(product, 'prep_zones') else []
items_out.append({
"id": item.id,
"product_id": item.product_id,
"product_name": product.name if product else f"#{item.product_id}",
"quantity": item.quantity,
"unit_type": unit_type or "piece",
"kds_status": item.kds_status,
"decline_note": item.decline_note,
"added_at": item.added_at.isoformat() if item.added_at else None,
"_added_at_raw": item.added_at, # stripped before response
"prep_zone_ids": prep_zone_ids,
"course_id": item.course_id,
"removed": mods["removed"],
"extras": mods["extras"],
"prefs": mods["prefs"],
"notes": mods["notes"],
})
# Waiter names from first assignment
waiter_names = [ow.waiter.username for ow in order.waiters if ow.waiter] if order.waiters else []
# Clock = oldest added_at among pending/preparing items (the active batch).
# Falls back to order.opened_at only if no such item exists.
def _fmt_ts(dt):
if dt is None:
return None
return dt.isoformat() + "Z" if not dt.tzinfo else dt.isoformat()
active_batch_times = [
item["_added_at_raw"] for item in items_out
if item.get("_added_at_raw") and item["kds_status"] in ("pending", "preparing")
]
batch_ts = min(active_batch_times) if active_batch_times else None
opened_at_out = _fmt_ts(batch_ts) if batch_ts else _fmt_ts(order.opened_at)
for item in items_out:
item.pop("_added_at_raw", None)
return {
"id": order.id,
"kds_status": order.kds_status,
"order_type": order.order_type,
"table_name": tables_map.get(order.table_id) if order.table_id else None,
"table_id": order.table_id,
"opened_at": opened_at_out,
"closed_at": _fmt_ts(order.closed_at) if order.closed_at else None,
"notes": order.notes,
"waiters": waiter_names,
"items": items_out,
}
def _sync_order_kds_status(order: Order):
"""Derive and set order.kds_status from all non-cancelled items. Call before db.commit()."""
relevant = [i for i in order.items if i.status != "cancelled"]
if not relevant:
return
statuses = {i.kds_status for i in relevant}
if statuses <= {"served"}:
new_status = "served"
elif statuses <= {"done", "served"}:
new_status = "done"
elif statuses <= {"declined"}:
new_status = "declined"
elif "preparing" in statuses or "done" in statuses:
new_status = "preparing"
else:
new_status = "pending"
if new_status != order.kds_status:
order.kds_status = new_status
order.kds_status_changed_at = datetime.now(timezone.utc)
# ─────────────────────────── endpoints ─────────────────────────────────────
@router.get("/orders")
def kds_orders(
zone_id: Optional[int] = Query(default=None, description="Filter orders to only those containing items in this prep zone"),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return all open/partially_paid/paid orders that still have unserved items.
If zone_id is provided, only orders containing at least one item belonging to that prep zone are returned,
and items not in that zone are stripped from the payload.
"""
open_orders = (
db.query(Order)
.filter(Order.status.in_(["open", "partially_paid", "paid"]))
.options(
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.ingredients),
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.options),
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.quick_options),
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.preference_sets).joinedload(ProductPreferenceSet.choices),
joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.prep_zones),
joinedload(Order.waiters).joinedload(OrderWaiter.waiter),
)
.order_by(Order.opened_at.asc())
.all()
)
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
payloads = []
for o in open_orders:
p = _build_order_payload(o, tables_map, db)
if not p["items"]:
continue
# Zone filtering: keep only items whose product belongs to the requested zone
if zone_id is not None:
p["items"] = [it for it in p["items"] if zone_id in it.get("prep_zone_ids", [])]
if not p["items"]:
continue
payloads.append(p)
return {"orders": payloads}
class KdsStatusBody(BaseModel):
status: str
class OrderTypeBody(BaseModel):
order_type: str
@router.put("/orders/{order_id}/kds_status")
def update_order_kds_status(
order_id: int,
body: KdsStatusBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if body.status not in VALID_KDS_STATUSES:
raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}'")
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if body.status != order.kds_status:
order.kds_status = body.status
order.kds_status_changed_at = datetime.now(timezone.utc)
# Cascade order-level status to non-cancelled, non-served items only
if body.status in ("pending", "preparing", "done"):
for item in order.items:
if item.status != "cancelled" and item.kds_status != "served":
item.kds_status = body.status
# auto_ready_to_served: if cascading to 'done', immediately upgrade qualifying items
if body.status == "done" and item.product:
if any(bool(z.auto_ready_to_served) for z in item.product.prep_zones):
item.kds_status = "served"
db.commit()
broadcast_sync("kds_order_updated", {
"order_id": order_id,
"kds_status": body.status,
})
return {"order_id": order_id, "kds_status": body.status}
@router.put("/orders/{order_id}/items/{item_id}/kds_status")
def update_item_kds_status(
order_id: int,
item_id: int,
body: KdsStatusBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if body.status not in VALID_KDS_STATUSES:
raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}'")
item = db.query(OrderItem).filter(
OrderItem.id == item_id,
OrderItem.order_id == order_id,
).first()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
item.kds_status = body.status
# auto_ready_to_served: if item reaches 'done' and any of its zones has the flag,
# immediately upgrade it to 'served' — item was ready at the KDS, no explicit serve needed.
if body.status == "done" and item.product:
auto_serve = any(bool(z.auto_ready_to_served) for z in item.product.prep_zones)
if auto_serve:
item.kds_status = "served"
if item.order:
_sync_order_kds_status(item.order)
db.commit()
broadcast_sync("kds_item_updated", {
"order_id": order_id,
"item_id": item_id,
"kds_status": item.kds_status,
})
return {"item_id": item_id, "kds_status": item.kds_status}
@router.put("/orders/{order_id}/order_type")
def update_order_type(
order_id: int,
body: OrderTypeBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if body.order_type not in VALID_ORDER_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid order_type '{body.order_type}'")
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
order.order_type = body.order_type
db.commit()
return {"order_id": order_id, "order_type": body.order_type}
class MarkServedBody(BaseModel):
item_ids: list[int]
@router.post("/orders/{order_id}/items/mark-served")
def mark_items_served(
order_id: int,
body: MarkServedBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Mark a list of items as served (kds_status=served). Works on active and paid items."""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
updated = []
ids_set = set(body.item_ids)
for item in order.items:
if item.id in ids_set:
item.kds_status = "served"
updated.append(item.id)
_sync_order_kds_status(order)
db.commit()
broadcast_sync("kds_item_updated", {
"order_id": order_id,
"item_ids": updated,
"kds_status": "served",
})
return {"updated": updated}
@router.post("/orders/{order_id}/items/mark-all-served")
def mark_all_items_served(
order_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Mark all non-pending items in an order as served."""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
updated = []
for item in order.items:
if item.kds_status in ("preparing", "done"):
item.kds_status = "served"
updated.append(item.id)
_sync_order_kds_status(order)
db.commit()
broadcast_sync("kds_item_updated", {
"order_id": order_id,
"item_ids": updated,
"kds_status": "served",
})
return {"updated": updated}
# ─────────────────────────── KDS print endpoint ─────────────────────────────
class KdsPrintBody(BaseModel):
printer_id: Optional[int] = None
item_ids: Optional[List[int]] = None
copies: int = 1
zone_id: Optional[int] = None
mode: str = "primary" # none | primary | all
@router.post("/orders/{order_id}/print")
def kds_print_order(
order_id: int,
body: KdsPrintBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Print order to zone printers. mode=primary prints to first printer, mode=all prints to all."""
from services import printer_service
if body.mode == "none":
return {"printed": 0}
if body.printer_id is not None:
# Direct printer specified
result = printer_service.print_to_printer(order_id, body.item_ids, body.printer_id, body.copies, db)
return result
# Zone-based print
if body.zone_id is None:
return {"printed": 0, "message": "No zone configured"}
from models.prep_zone import PrepZone
zone = db.query(PrepZone).filter(PrepZone.id == body.zone_id).first()
if not zone or not zone.printers:
return {"printed": 0, "message": "Zone has no printers"}
printers_to_use = zone.printers if body.mode == "all" else [zone.printers[0]]
printed = 0
for printer in printers_to_use:
try:
printer_service.print_to_printer(order_id, body.item_ids, printer.id, body.copies, db)
printed += 1
except Exception:
pass
return {"printed": printed}
# ─────────────────────────── KDS decline endpoints ─────────────────────────
class DeclineBody(BaseModel):
decline_note: Optional[str] = None # reason string (preset or free text)
@router.put("/orders/{order_id}/decline")
def decline_order(
order_id: int,
body: DeclineBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Mark all active items on an order as declined."""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
for item in order.items:
if item.status != "cancelled":
item.kds_status = "declined"
item.decline_note = body.decline_note
order.kds_status = "declined"
db.commit()
broadcast_sync("kds_order_updated", {
"order_id": order_id,
"kds_status": "declined",
"decline_note": body.decline_note,
})
return {"order_id": order_id, "kds_status": "declined"}
@router.put("/orders/{order_id}/items/{item_id}/decline")
def decline_item(
order_id: int,
item_id: int,
body: DeclineBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Mark a single item as declined."""
item = db.query(OrderItem).filter(
OrderItem.id == item_id,
OrderItem.order_id == order_id,
).first()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
item.kds_status = "declined"
item.decline_note = body.decline_note
if item.order:
_sync_order_kds_status(item.order)
db.commit()
broadcast_sync("kds_item_updated", {
"order_id": order_id,
"item_id": item_id,
"kds_status": "declined",
"decline_note": body.decline_note,
})
return {"item_id": item_id, "kds_status": "declined"}
# ─────────────────────────── KDS notifications ──────────────────────────────
def _waiter_ids_for_order(order: Order) -> list[int]:
"""Return the list of waiter IDs assigned to an order."""
return [ow.waiter_id for ow in order.waiters if ow.waiter_id]
def _save_and_broadcast_message(
db: Session,
sender_id: int,
body: str,
target_waiter_ids: list[int],
message_type: str,
kds_zone: str | None,
table_ids: list[int] | None = None,
) -> dict:
"""Persist a StaffMessage and broadcast via SSE. Returns the SSE payload dict."""
from models.message import StaffMessage
from datetime import datetime, timezone
msg = StaffMessage(
sender_id=sender_id,
body=body,
target_waiter_ids=json.dumps(target_waiter_ids),
table_ids=json.dumps(table_ids or []),
message_type=message_type,
kds_zone=kds_zone,
)
db.add(msg)
db.commit()
db.refresh(msg)
payload = {
"id": msg.id,
"sender_id": msg.sender_id,
"sender_name": kds_zone or "KDS",
"body": msg.body,
"table_ids": msg.table_ids,
"message_type": message_type,
"kds_zone": kds_zone,
"created_at": msg.created_at.isoformat() if msg.created_at else None,
}
user_ids = target_waiter_ids if target_waiter_ids else None
broadcast_sync("message_sent", payload, user_ids=user_ids)
return payload
class KdsNotifyBody(BaseModel):
kds_zone: Optional[str] = None
class KdsCallWaiterBody(BaseModel):
kds_zone: Optional[str] = None
waiter_ids: List[int] # for call_general: list of on-shift waiter ids to notify
@router.post("/orders/{order_id}/notify-complete")
def kds_notify_order_complete(
order_id: int,
body: KdsNotifyBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Notify assigned waiters that their order is fully complete."""
order = db.query(Order).options(
joinedload(Order.waiters),
).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
waiter_ids = _waiter_ids_for_order(order)
if not waiter_ids:
return {"sent": False, "reason": "no_waiters"}
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
zone = body.kds_zone or "Κουζίνα"
msg_body = (
f"Η παραγγελία #{order.id} για το τραπέζι {table_name} "
f"είναι έτοιμη προς παραλαβή - {zone}"
)
_save_and_broadcast_message(
db, user.id, msg_body, waiter_ids,
"kds_order_done", zone,
table_ids=[order.table_id] if order.table_id else [],
)
return {"sent": True, "waiter_ids": waiter_ids}
class KdsNotifyItemsBody(BaseModel):
kds_zone: Optional[str] = None
ready_count: int
total_count: int
@router.post("/orders/{order_id}/notify-items-ready")
def kds_notify_items_ready(
order_id: int,
body: KdsNotifyItemsBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Notify assigned waiters that some (but not all) items are ready."""
order = db.query(Order).options(
joinedload(Order.waiters),
).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
waiter_ids = _waiter_ids_for_order(order)
if not waiter_ids:
return {"sent": False, "reason": "no_waiters"}
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
zone = body.kds_zone or "Κουζίνα"
msg_body = (
f"{body.ready_count} αντικείμενα από την παραγγελία #{order.id} "
f"για το τραπέζι {table_name} είναι έτοιμα - {zone}"
)
_save_and_broadcast_message(
db, user.id, msg_body, waiter_ids,
"kds_item_done", zone,
table_ids=[order.table_id] if order.table_id else [],
)
return {"sent": True, "waiter_ids": waiter_ids}
@router.post("/orders/{order_id}/call-waiter")
def kds_call_waiter_order(
order_id: int,
body: KdsNotifyBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Manually call the waiters assigned to a specific order."""
order = db.query(Order).options(
joinedload(Order.waiters),
).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
waiter_ids = _waiter_ids_for_order(order)
if not waiter_ids:
return {"sent": False, "reason": "no_waiters"}
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
table_name = tables_map.get(order.table_id, str(order.table_id)) if order.table_id else "Takeaway"
zone = body.kds_zone or "Κουζίνα"
msg_body = (
f"Παραγγελία #{order.id} - τραπέζι {table_name} "
f"- {zone}"
)
_save_and_broadcast_message(
db, user.id, msg_body, waiter_ids,
"kds_call_order", zone,
table_ids=[order.table_id] if order.table_id else [],
)
return {"sent": True, "waiter_ids": waiter_ids}
@router.post("/call-waiter-general")
def kds_call_waiter_general(
body: KdsCallWaiterBody,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Call one or more on-shift waiters to the prep zone."""
if not body.waiter_ids:
raise HTTPException(status_code=400, detail="waiter_ids must not be empty")
zone = body.kds_zone or "Κουζίνα"
msg_body = f"Κλήση από {zone}"
_save_and_broadcast_message(
db, user.id, msg_body, body.waiter_ids,
"kds_call_general", zone,
)
return {"sent": True, "waiter_ids": body.waiter_ids}
@router.get("/on-shift-waiters")
def kds_on_shift_waiters(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Return waiters currently on shift (ended_at IS NULL)."""
on_shift_ids = {
row.waiter_id
for row in db.query(WaiterShift).filter(WaiterShift.ended_at == None).all()
}
waiters = db.query(User).filter(
User.id.in_(on_shift_ids),
User.perm_access_waiter_app == True,
User.is_active == True,
).order_by(User.username).all()
return [
{"id": w.id, "username": w.username, "nickname": w.nickname, "avatar_url": w.avatar_url}
for w in waiters
]
# ─────────────────────────── legacy endpoints ──────────────────────────────
class ItemStatusUpdate(BaseModel):
status: str # only "ready" is accepted
@router.get("/items")
def kds_items(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Legacy: return active order items grouped by printer zone."""
open_orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid"])).all()
order_ids = [o.id for o in open_orders]
if not order_ids:
return {"zones": []}
order_map = {o.id: o for o in open_orders}
items = db.query(OrderItem).filter(
OrderItem.order_id.in_(order_ids),
OrderItem.status == "active",
).order_by(OrderItem.added_at.asc()).all()
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
printers_map = {p.id: p.name for p in db.query(Printer).all()}
zones: dict = {}
def _zone_key(zone_id):
return 0 if zone_id is None else zone_id
def _zone_name(zone_id):
if zone_id is None:
return "Χωρίς Ζώνη"
return printers_map.get(zone_id, f"Ζώνη #{zone_id}")
for item in items:
product = item.product
zone_id = product.printer_zone_id if product else None
zkey = _zone_key(zone_id)
if zkey not in zones:
zones[zkey] = {"zone_id": zone_id, "zone_name": _zone_name(zone_id), "items": []}
order = order_map.get(item.order_id)
table_name = tables_map.get(order.table_id) if order and order.table_id else None
zones[zkey]["items"].append({
"id": item.id,
"order_id": item.order_id,
"table_name": table_name,
"product_name": product.name if product else f"#{item.product_id}",
"quantity": item.quantity,
"notes": item.notes,
"added_at": item.added_at,
"status": item.status,
})
zone_list = sorted(zones.values(), key=lambda z: (z["zone_id"] is None, z["zone_id"] or 0))
return {"zones": zone_list}
@router.put("/orders/{order_id}/items/{item_id}/status")
def update_item_status_legacy(
order_id: int,
item_id: int,
body: ItemStatusUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Legacy: mark an item ready (active → ready)."""
if body.status != "ready":
raise HTTPException(status_code=400, detail="Only 'ready' is a valid status transition via this endpoint")
item = db.query(OrderItem).filter(
OrderItem.id == item_id,
OrderItem.order_id == order_id,
).first()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if item.status != "active":
raise HTTPException(status_code=400, detail=f"Item is '{item.status}' — only active items can be marked ready")
item.status = "ready"
db.commit()
broadcast_sync("item_status_changed", {
"order_id": order_id,
"item_id": item_id,
"status": "ready",
})
return {"status": "ready", "item_id": item_id}

View File

@@ -37,6 +37,8 @@ def _message_out(msg: StaffMessage) -> StaffMessageOut:
body=msg.body,
target_waiter_ids=msg.target_waiter_ids,
table_ids=msg.table_ids,
message_type=msg.message_type or "manager",
kds_zone=msg.kds_zone,
created_at=msg.created_at,
acked_by=[ack.waiter_id for ack in msg.acks],
)

View File

@@ -0,0 +1,151 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.notes import SiteNote, SiteTodo
from models.user import User
from schemas.notes import NoteCreate, NoteUpdate, NoteOut, TodoCreate, TodoUpdate, TodoOut
from routers.deps import require_manager
router = APIRouter()
def _utcnow():
return datetime.now(timezone.utc)
def _note_out(n: SiteNote) -> dict:
name = None
if n.created_by:
name = n.created_by.full_name or n.created_by.username
return {
"id": n.id,
"body": n.body,
"is_pinned": n.is_pinned,
"created_by_id": n.created_by_id,
"created_by_name": name,
"created_at": n.created_at,
"updated_at": n.updated_at,
}
def _todo_out(t: SiteTodo) -> dict:
creator_name = None
if t.created_by:
creator_name = t.created_by.full_name or t.created_by.username
done_name = None
if t.done_by:
done_name = t.done_by.full_name or t.done_by.username
return {
"id": t.id,
"body": t.body,
"is_done": t.is_done,
"done_at": t.done_at,
"done_by_id": t.done_by_id,
"done_by_name": done_name,
"created_by_id": t.created_by_id,
"created_by_name": creator_name,
"created_at": t.created_at,
"priority": t.priority,
}
# ── Notes ─────────────────────────────────────────────────────────────────────
@router.get("/", response_model=List[NoteOut])
def list_notes(db: Session = Depends(get_db), user: User = Depends(require_manager)):
notes = db.query(SiteNote).order_by(
SiteNote.is_pinned.desc(), SiteNote.updated_at.desc()
).all()
return [_note_out(n) for n in notes]
@router.post("/", response_model=NoteOut, status_code=status.HTTP_201_CREATED)
def create_note(body: NoteCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
note = SiteNote(body=body.body, is_pinned=body.is_pinned, created_by_id=user.id)
db.add(note)
db.commit()
db.refresh(note)
return _note_out(note)
@router.put("/{note_id}", response_model=NoteOut)
def update_note(note_id: int, body: NoteUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
note = db.query(SiteNote).filter(SiteNote.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
if body.body is not None:
note.body = body.body
if body.is_pinned is not None:
note.is_pinned = body.is_pinned
note.updated_at = _utcnow()
db.commit()
db.refresh(note)
return _note_out(note)
@router.delete("/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_note(note_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
note = db.query(SiteNote).filter(SiteNote.id == note_id).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
db.delete(note)
db.commit()
# ── Todos ─────────────────────────────────────────────────────────────────────
@router.get("/todos", response_model=List[TodoOut])
def list_todos(done: bool = None, db: Session = Depends(get_db), user: User = Depends(require_manager)):
q = db.query(SiteTodo)
if done is not None:
q = q.filter(SiteTodo.is_done == done)
# undone high-priority first, then undone normal, then done (by created_at desc)
todos = q.order_by(
SiteTodo.is_done.asc(),
SiteTodo.priority.desc(),
SiteTodo.created_at.desc(),
).all()
return [_todo_out(t) for t in todos]
@router.post("/todos", response_model=TodoOut, status_code=status.HTTP_201_CREATED)
def create_todo(body: TodoCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
todo = SiteTodo(body=body.body, priority=body.priority, created_by_id=user.id)
db.add(todo)
db.commit()
db.refresh(todo)
return _todo_out(todo)
@router.put("/todos/{todo_id}", response_model=TodoOut)
def update_todo(todo_id: int, body: TodoUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
todo = db.query(SiteTodo).filter(SiteTodo.id == todo_id).first()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
if body.body is not None:
todo.body = body.body
if body.priority is not None:
todo.priority = body.priority
if body.is_done is not None:
todo.is_done = body.is_done
if body.is_done and not todo.done_at:
todo.done_at = _utcnow()
todo.done_by_id = user.id
elif not body.is_done:
todo.done_at = None
todo.done_by_id = None
db.commit()
db.refresh(todo)
return _todo_out(todo)
@router.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_todo(todo_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
todo = db.query(SiteTodo).filter(SiteTodo.id == todo_id).first()
if not todo:
raise HTTPException(status_code=404, detail="Todo not found")
db.delete(todo)
db.commit()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
"""
Phone call event receiver + WebSocket broadcaster.
The Grandstream UCM sends a simple HTTP GET to /api/phone/call-event
with caller ID and extension details. We broadcast that to all connected
WebSocket clients (manager dashboard Phone page).
The call-event endpoint is intentionally unauthenticated so the UCM device
(which has no JWT) can POST to it directly on the local LAN.
"""
import asyncio
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
router = APIRouter()
logger = logging.getLogger(__name__)
# In-memory list of active WebSocket connections to the Phone page.
_connections: list[WebSocket] = []
@router.websocket("/ws")
async def phone_ws(websocket: WebSocket):
await websocket.accept()
_connections.append(websocket)
logger.debug("phone_ws: client connected (%d total)", len(_connections))
try:
# Keep the connection alive; client drives nothing, server pushes events.
while True:
await asyncio.sleep(25)
await websocket.send_text(json.dumps({"type": "ping"}))
except (WebSocketDisconnect, Exception):
pass
finally:
_connections.remove(websocket)
logger.debug("phone_ws: client disconnected (%d total)", len(_connections))
@router.get("/call-event")
async def call_event(
caller: str = Query(..., description="Caller ID number from UCM"),
ext: str = Query("", description="Dialled extension"),
):
"""
Called by the Grandstream UCM Action URL on every incoming call.
Example: GET /api/phone/call-event?caller=2106001234&ext=100
"""
payload = json.dumps({"type": "incoming_call", "caller": caller, "ext": ext})
dead = []
for ws in list(_connections):
try:
await ws.send_text(payload)
except Exception:
dead.append(ws)
for ws in dead:
try:
_connections.remove(ws)
except ValueError:
pass
logger.info("phone call-event: caller=%s ext=%s (notified %d clients)", caller, ext, len(_connections) - len(dead))
return {"status": "ok", "notified": len(_connections) - len(dead)}

View File

@@ -0,0 +1,215 @@
"""
Prep Zones router.
GET /api/prep-zones list all prep zones (with printer ids)
POST /api/prep-zones create a prep zone
PUT /api/prep-zones/{id} update name / description / printer assignments / all settings
DELETE /api/prep-zones/{id} delete a prep zone
"""
import json
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
from database import get_db
from models.prep_zone import PrepZone
from models.printer import Printer
from routers.deps import require_manager
from models.user import User
router = APIRouter()
class PrepZoneCreate(BaseModel):
name: str
description: Optional[str] = None
notification_name: Optional[str] = None
# Printer routing
printer_ids: List[int] = [] # all printers (master + secondary)
master_printer_id: Optional[int] = None
auto_print: str = 'none' # 'none' | 'master' | 'all'
master_copies: int = 1
secondary_copies: int = 1
# Ticket formatting
sort_items_by: str = 'order_time' # 'order_time' | 'item_count' | 'alpha'
group_by_category: bool = False
category_order: List[int] = []
print_checkboxes: bool = False
# KDS bypass / auto-progression
bypass_pending: bool = False
bypass_kds: bool = False
auto_ready_to_served: bool = False
class PrepZoneUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
notification_name: Optional[str] = None
# Printer routing
printer_ids: Optional[List[int]] = None
master_printer_id: Optional[int] = None
auto_print: Optional[str] = None
master_copies: Optional[int] = None
secondary_copies: Optional[int] = None
# Ticket formatting
sort_items_by: Optional[str] = None
group_by_category: Optional[bool] = None
category_order: Optional[List[int]] = None
print_checkboxes: Optional[bool] = None
# KDS bypass / auto-progression
bypass_pending: Optional[bool] = None
bypass_kds: Optional[bool] = None
auto_ready_to_served: Optional[bool] = None
def _zone_out(zone: PrepZone) -> dict:
all_printer_ids = [p.id for p in zone.printers]
secondary_ids = [pid for pid in all_printer_ids if pid != zone.master_printer_id]
return {
"id": zone.id,
"name": zone.name,
"description": zone.description,
"notification_name": zone.notification_name,
# Printer routing
"printer_ids": all_printer_ids,
"printers": [{"id": p.id, "name": p.name} for p in zone.printers],
"master_printer_id": zone.master_printer_id,
"secondary_printer_ids": secondary_ids,
"auto_print": zone.auto_print or 'none',
"master_copies": zone.master_copies or 1,
"secondary_copies": zone.secondary_copies or 1,
# Ticket formatting
"sort_items_by": zone.sort_items_by or 'order_time',
"group_by_category": bool(zone.group_by_category),
"category_order": json.loads(zone.category_order) if zone.category_order else [],
"print_checkboxes": bool(zone.print_checkboxes),
# KDS bypass / auto-progression
"bypass_pending": bool(zone.bypass_pending),
"bypass_kds": bool(zone.bypass_kds),
"auto_ready_to_served": bool(zone.auto_ready_to_served),
}
@router.get("")
def list_prep_zones(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
zones = db.query(PrepZone).order_by(PrepZone.id).all()
return [_zone_out(z) for z in zones]
@router.post("")
def create_prep_zone(
body: PrepZoneCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
zone = PrepZone(
name=body.name,
description=body.description,
notification_name=body.notification_name,
master_printer_id=body.master_printer_id,
auto_print=body.auto_print,
master_copies=max(1, body.master_copies),
secondary_copies=max(1, body.secondary_copies),
print_copies=max(1, body.master_copies), # keep legacy field in sync
sort_items_by=body.sort_items_by,
group_by_category=1 if body.group_by_category else 0,
category_order=json.dumps(body.category_order),
print_checkboxes=1 if body.print_checkboxes else 0,
bypass_pending=1 if (body.bypass_pending or body.bypass_kds) else 0,
bypass_kds=1 if body.bypass_kds else 0,
auto_ready_to_served=1 if body.auto_ready_to_served else 0,
)
if body.printer_ids:
printers = db.query(Printer).filter(Printer.id.in_(body.printer_ids)).all()
zone.printers = printers
db.add(zone)
db.commit()
db.refresh(zone)
return _zone_out(zone)
@router.put("/{zone_id}")
def update_prep_zone(
zone_id: int,
body: PrepZoneUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
zone = db.query(PrepZone).filter(PrepZone.id == zone_id).first()
if not zone:
raise HTTPException(status_code=404, detail="Prep zone not found")
if body.name is not None:
zone.name = body.name
if body.description is not None:
zone.description = body.description
if body.notification_name is not None:
zone.notification_name = body.notification_name
# Printer routing
if body.printer_ids is not None:
printers = db.query(Printer).filter(Printer.id.in_(body.printer_ids)).all()
zone.printers = printers
# master_printer_id: always write if field present in request body (null = clear)
if 'master_printer_id' in (body.model_fields_set if hasattr(body, 'model_fields_set') else {}):
zone.master_printer_id = body.master_printer_id
elif body.master_printer_id is not None:
zone.master_printer_id = body.master_printer_id
if body.auto_print is not None:
zone.auto_print = body.auto_print
if body.master_copies is not None:
zone.master_copies = max(1, body.master_copies)
zone.print_copies = zone.master_copies # keep legacy in sync
if body.secondary_copies is not None:
zone.secondary_copies = max(1, body.secondary_copies)
# Ticket formatting
if body.sort_items_by is not None:
zone.sort_items_by = body.sort_items_by
if body.group_by_category is not None:
zone.group_by_category = 1 if body.group_by_category else 0
if body.category_order is not None:
zone.category_order = json.dumps(body.category_order)
if body.print_checkboxes is not None:
zone.print_checkboxes = 1 if body.print_checkboxes else 0
# KDS bypass — bypass_kds implies bypass_pending
if body.bypass_kds is not None:
zone.bypass_kds = 1 if body.bypass_kds else 0
if body.bypass_kds:
zone.bypass_pending = 1
if body.bypass_pending is not None:
zone.bypass_pending = 1 if body.bypass_pending else 0
if body.auto_ready_to_served is not None:
zone.auto_ready_to_served = 1 if body.auto_ready_to_served else 0
db.commit()
db.refresh(zone)
return _zone_out(zone)
@router.delete("/{zone_id}")
def delete_prep_zone(
zone_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
zone = db.query(PrepZone).filter(PrepZone.id == zone_id).first()
if not zone:
raise HTTPException(status_code=404, detail="Prep zone not found")
db.delete(zone)
db.commit()
return {"ok": True}

View File

@@ -0,0 +1,684 @@
import json
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
from models.pricing import (
PriceGroup, PriceModifier, PriceModifierCondition, PriceModifierTarget,
Deal, DealCondition, DealTarget,
PriceEventLog, WaiterDiscountSettings,
)
from schemas.pricing import (
PriceGroupCreate, PriceGroupUpdate, PriceGroupOut,
PriceModifierCreate, PriceModifierUpdate, PriceModifierOut,
PriceModifierReorderRequest,
DealCreate, DealUpdate, DealOut,
WaiterDiscountSettingsIn, WaiterDiscountSettingsOut,
PriceEventOut,
)
from routers.deps import get_current_user, require_manager
from models.user import User
router = APIRouter()
def _utcnow():
return datetime.now(timezone.utc)
# =============================================================================
# PRICE GROUPS
# =============================================================================
@router.get("/groups", response_model=List[PriceGroupOut])
def list_price_groups(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return db.query(PriceGroup).order_by(PriceGroup.name).all()
@router.post("/groups", response_model=PriceGroupOut, status_code=status.HTTP_201_CREATED)
def create_price_group(
body: PriceGroupCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
pg = PriceGroup(
name=body.name,
description=body.description,
color=body.color,
is_active=int(body.is_active),
auto_enable_time=body.auto_enable_time,
auto_disable_time=body.auto_disable_time,
auto_days=json.dumps(body.auto_days) if body.auto_days is not None else None,
created_by=user.id,
)
db.add(pg)
db.commit()
db.refresh(pg)
return pg
@router.put("/groups/{group_id}", response_model=PriceGroupOut)
def update_price_group(
group_id: int,
body: PriceGroupUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
if not pg:
raise HTTPException(status_code=404, detail="Price group not found")
pg.name = body.name
pg.description = body.description
pg.color = body.color
pg.is_active = int(body.is_active)
pg.auto_enable_time = body.auto_enable_time
pg.auto_disable_time = body.auto_disable_time
pg.auto_days = json.dumps(body.auto_days) if body.auto_days is not None else None
db.commit()
db.refresh(pg)
return pg
@router.patch("/groups/{group_id}/toggle", response_model=PriceGroupOut)
def toggle_price_group(
group_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
if not pg:
raise HTTPException(status_code=404, detail="Price group not found")
pg.is_active = 0 if pg.is_active else 1
db.commit()
db.refresh(pg)
return pg
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_price_group(
group_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
if not pg:
raise HTTPException(status_code=404, detail="Price group not found")
db.delete(pg)
db.commit()
# =============================================================================
# PRICE MODIFIERS
# =============================================================================
@router.get("/modifiers", response_model=List[PriceModifierOut])
def list_modifiers(
scope: Optional[str] = None,
item_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
q = db.query(PriceModifier)
if scope:
q = q.filter(PriceModifier.scope == scope)
if item_id is not None:
q = q.filter(PriceModifier.item_id == item_id)
return q.order_by(PriceModifier.sort_order).all()
@router.get("/modifiers/favorites", response_model=List[PriceModifierOut])
def list_favorite_modifiers(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Returns modifiers flagged as favorites for the dashboard quick-toggle panel."""
return (
db.query(PriceModifier)
.filter(PriceModifier.is_favorite == 1)
.order_by(PriceModifier.sort_order)
.all()
)
@router.post("/modifiers", response_model=PriceModifierOut, status_code=status.HTTP_201_CREATED)
def create_modifier(
body: PriceModifierCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
m = PriceModifier(
name=body.name,
description=body.description,
color=body.color,
is_active=int(body.is_active),
is_favorite=int(body.is_favorite),
allow_stack=int(body.allow_stack),
sort_order=body.sort_order,
scope=body.scope,
item_id=body.item_id,
action_type=body.action_type,
action_value=body.action_value,
round_to=body.round_to,
created_by=user.id,
)
db.add(m)
db.flush()
_sync_conditions(m, body.conditions, db, model="modifier")
_sync_modifier_targets(m, body.targets, db)
db.commit()
db.refresh(m)
return m
@router.put("/modifiers/{modifier_id}", response_model=PriceModifierOut)
def update_modifier(
modifier_id: int,
body: PriceModifierUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
if not m:
raise HTTPException(status_code=404, detail="Modifier not found")
m.name = body.name
m.description = body.description
m.color = body.color
m.is_active = int(body.is_active)
m.is_favorite = int(body.is_favorite)
m.allow_stack = int(body.allow_stack)
m.sort_order = body.sort_order
m.scope = body.scope
m.item_id = body.item_id
m.action_type = body.action_type
m.action_value = body.action_value
m.round_to = body.round_to
m.updated_at = _utcnow()
m.updated_by = user.id
# Replace conditions and targets
for c in list(m.conditions):
db.delete(c)
for t in list(m.targets):
db.delete(t)
db.flush()
_sync_conditions(m, body.conditions, db, model="modifier")
_sync_modifier_targets(m, body.targets, db)
db.commit()
db.refresh(m)
return m
@router.patch("/modifiers/{modifier_id}/toggle", response_model=PriceModifierOut)
def toggle_modifier(
modifier_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
if not m:
raise HTTPException(status_code=404, detail="Modifier not found")
m.is_active = 0 if m.is_active else 1
m.updated_at = _utcnow()
m.updated_by = user.id
db.commit()
db.refresh(m)
return m
@router.patch("/modifiers/reorder")
def reorder_modifiers(
body: PriceModifierReorderRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
for item in body.items:
db.query(PriceModifier).filter(PriceModifier.id == item.id).update(
{"sort_order": item.sort_order}
)
db.commit()
return {"ok": True}
@router.delete("/modifiers/{modifier_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_modifier(
modifier_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
if not m:
raise HTTPException(status_code=404, detail="Modifier not found")
db.delete(m)
db.commit()
# =============================================================================
# DEALS
# =============================================================================
@router.get("/deals", response_model=List[DealOut])
def list_deals(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return db.query(Deal).order_by(Deal.sort_order).all()
@router.post("/deals", response_model=DealOut, status_code=status.HTTP_201_CREATED)
def create_deal(
body: DealCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
d = Deal(
name=body.name,
description=body.description,
color=body.color,
is_active=int(body.is_active),
sort_order=body.sort_order,
action_type=body.action_type,
action_modifier_id=body.action_modifier_id,
action_value=body.action_value,
action_free_item_id=body.action_free_item_id,
action_free_target_type=body.action_free_target_type,
action_free_target_ids=(
json.dumps(body.action_free_target_ids)
if body.action_free_target_ids is not None else None
),
action_free_quantity=body.action_free_quantity,
created_by=user.id,
)
db.add(d)
db.flush()
_sync_conditions(d, body.conditions, db, model="deal")
_sync_deal_targets(d, body.targets, db)
db.commit()
db.refresh(d)
return d
@router.put("/deals/{deal_id}", response_model=DealOut)
def update_deal(
deal_id: int,
body: DealUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
d = db.query(Deal).filter(Deal.id == deal_id).first()
if not d:
raise HTTPException(status_code=404, detail="Deal not found")
d.name = body.name
d.description = body.description
d.color = body.color
d.is_active = int(body.is_active)
d.sort_order = body.sort_order
d.action_type = body.action_type
d.action_modifier_id = body.action_modifier_id
d.action_value = body.action_value
d.action_free_item_id = body.action_free_item_id
d.action_free_target_type = body.action_free_target_type
d.action_free_target_ids = (
json.dumps(body.action_free_target_ids)
if body.action_free_target_ids is not None else None
)
d.action_free_quantity = body.action_free_quantity
for c in list(d.conditions):
db.delete(c)
for t in list(d.targets):
db.delete(t)
db.flush()
_sync_conditions(d, body.conditions, db, model="deal")
_sync_deal_targets(d, body.targets, db)
db.commit()
db.refresh(d)
return d
@router.patch("/deals/{deal_id}/toggle", response_model=DealOut)
def toggle_deal(
deal_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
d = db.query(Deal).filter(Deal.id == deal_id).first()
if not d:
raise HTTPException(status_code=404, detail="Deal not found")
d.is_active = 0 if d.is_active else 1
db.commit()
db.refresh(d)
return d
@router.delete("/deals/{deal_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_deal(
deal_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
d = db.query(Deal).filter(Deal.id == deal_id).first()
if not d:
raise HTTPException(status_code=404, detail="Deal not found")
db.delete(d)
db.commit()
# =============================================================================
# WAITER DISCOUNT SETTINGS
# =============================================================================
@router.get("/discount-settings/global")
def get_global_discount_settings(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.settings import PosSettings
keys = [
"discounts.enabled",
"discounts.max_total_value_workday",
"discounts.max_total_value_shift",
"discounts.max_items_per_shift",
]
rows = db.query(PosSettings).filter(PosSettings.key.in_(keys)).all()
row_map = {r.key.split(".", 1)[1]: r.value for r in rows}
# Coerce types so frontend toggle/number fields work correctly
def _coerce(k, v):
if v is None:
return None
if k == "enabled":
return v.lower() in ("true", "1", "yes")
try:
return float(v) if "." in str(v) else int(v)
except Exception:
return v
return {k: _coerce(k, v) for k, v in row_map.items()}
@router.put("/discount-settings/global")
def set_global_discount_settings(
body: dict,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.settings import PosSettings
allowed = {
"enabled", "max_total_value_workday",
"max_total_value_shift", "max_items_per_shift",
}
now = _utcnow().isoformat()
for k, v in body.items():
if k not in allowed:
continue
full_key = f"discounts.{k}"
row = db.query(PosSettings).filter(PosSettings.key == full_key).first()
if row:
row.value = str(v)
row.updated_at = now
else:
db.add(PosSettings(key=full_key, value=str(v), updated_at=now))
db.commit()
return {"ok": True}
@router.get("/discount-settings/me", response_model=WaiterDiscountSettingsOut)
def get_my_discount_settings(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
s = db.query(WaiterDiscountSettings).filter(
WaiterDiscountSettings.user_id == user.id
).first()
if not s:
return WaiterDiscountSettingsOut(id=0, user_id=user.id, can_apply_discounts=False)
return s
@router.get("/discount-settings/{user_id}", response_model=WaiterDiscountSettingsOut)
def get_waiter_discount_settings(
user_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
s = db.query(WaiterDiscountSettings).filter(
WaiterDiscountSettings.user_id == user_id
).first()
if not s:
# Return defaults (all None = no limits, disabled)
return WaiterDiscountSettingsOut(id=0, user_id=user_id, can_apply_discounts=False)
return s
@router.put("/discount-settings/{user_id}", response_model=WaiterDiscountSettingsOut)
def set_waiter_discount_settings(
user_id: int,
body: WaiterDiscountSettingsIn,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
s = db.query(WaiterDiscountSettings).filter(
WaiterDiscountSettings.user_id == user_id
).first()
if not s:
s = WaiterDiscountSettings(user_id=user_id)
db.add(s)
s.can_apply_discounts = int(body.can_apply_discounts)
s.max_discount_percent = body.max_discount_percent
s.max_discount_amount = body.max_discount_amount
s.max_total_value_shift = body.max_total_value_shift
s.max_total_value_workday = body.max_total_value_workday
s.max_items_per_shift = body.max_items_per_shift
s.max_items_per_workday = body.max_items_per_workday
s.max_items_per_order = body.max_items_per_order
db.commit()
db.refresh(s)
return s
# =============================================================================
# PRICE EVENT LOG (read-only, for order detail and reports)
# =============================================================================
@router.get("/events/order/{order_id}", response_model=List[PriceEventOut])
def get_order_price_events(
order_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
events = (
db.query(PriceEventLog)
.filter(PriceEventLog.order_id == order_id)
.order_by(PriceEventLog.applied_at)
.all()
)
result = []
for ev in events:
out = PriceEventOut.model_validate(ev)
if ev.modifier:
out.modifier_name = ev.modifier.name
if ev.deal:
out.deal_name = ev.deal.name
if ev.applied_by:
out.applied_by_username = ev.applied_by.username
result.append(out)
return result
# =============================================================================
# DEAL OFFER ACCEPT / DISMISS (PWA-facing)
# =============================================================================
@router.post("/deals/accept")
def accept_deal_offer(
body: dict,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Waiter confirms a deal offer. Logs the acceptance and directly adds free item(s)
to the order with deal_id + linked_item_id set for binding.
Returns added item IDs so the PWA can refresh.
"""
from models.pricing import PriceEventLog, Deal
from models.order import Order, OrderItem
from models.product import Product
deal_id = body.get("deal_id")
order_id = body.get("order_id")
# For free_choice: list of product_ids the waiter selected + their option snapshots
# Shape: [{ product_id, quantity, selected_options, notes }] or legacy [int, ...]
free_items_raw = body.get("free_items", [])
# Trigger item — the order_item_id that caused the deal to fire (for linking)
trigger_item_id = body.get("trigger_item_id", None)
deal = db.query(Deal).filter(Deal.id == deal_id).first()
if not deal:
raise HTTPException(status_code=404, detail="Deal not found")
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
added_item_ids = []
if deal.action_type in ("free_item", "free_choice"):
# Resolve which products to add
if deal.action_type == "free_item" and deal.action_free_item_id:
items_to_add = [{"product_id": deal.action_free_item_id, "quantity": deal.action_free_quantity,
"selected_options": None, "notes": None}]
else:
# free_choice: caller provides list of chosen products
items_to_add = []
for entry in free_items_raw:
if isinstance(entry, dict):
items_to_add.append({
"product_id": entry.get("product_id"),
"quantity": entry.get("quantity", deal.action_free_quantity),
"selected_options": entry.get("selected_options"),
"notes": entry.get("notes"),
})
else:
items_to_add.append({"product_id": int(entry), "quantity": deal.action_free_quantity,
"selected_options": None, "notes": None})
for entry in items_to_add:
product = db.query(Product).filter(Product.id == entry["product_id"]).first()
if not product:
continue
base_price = product.base_price or 0.0
new_item = OrderItem(
order_id=order_id,
product_id=product.id,
added_by=user.id,
quantity=entry["quantity"],
unit_price=base_price, # full price so the breakdown is readable
price_adjustment=-base_price, # negated to bring effective price to 0
selected_options=json.dumps(entry["selected_options"]) if entry["selected_options"] else None,
notes=entry.get("notes"),
deal_id=deal_id,
linked_item_id=trigger_item_id,
)
db.add(new_item)
db.flush()
added_item_ids.append(new_item.id)
db.add(PriceEventLog(
order_id=order_id,
order_item_id=new_item.id,
event_type="free_item_added",
deal_id=deal_id,
price_before=product.base_price or 0.0,
price_after=0.0,
delta_amount=-(product.base_price or 0.0),
applied_by_user_id=user.id,
))
# Log acceptance
db.add(PriceEventLog(
order_id=order_id,
order_item_id=trigger_item_id,
event_type="deal_offer_accepted",
deal_id=deal_id,
applied_by_user_id=user.id,
selected_item_ids=json.dumps([e.get("product_id") if isinstance(e, dict) else int(e) for e in free_items_raw]) if free_items_raw else None,
))
db.commit()
from services.sse_bus import broadcast_sync
broadcast_sync()
return {
"deal_id": deal_id,
"action_type": deal.action_type,
"added_item_ids": added_item_ids,
}
@router.post("/deals/dismiss")
def dismiss_deal_offer(
body: dict,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Waiter dismisses a deal offer. Logged so it can be re-triggered later."""
from models.pricing import PriceEventLog
deal_id = body.get("deal_id")
order_id = body.get("order_id")
db.add(PriceEventLog(
order_id=order_id,
order_item_id=None,
event_type="deal_offer_dismissed",
deal_id=deal_id,
applied_by_user_id=user.id,
))
db.commit()
return {"ok": True}
# =============================================================================
# Internal helpers
# =============================================================================
def _sync_conditions(parent, conditions_in, db: Session, model: str):
"""Write condition rows for a modifier or deal."""
for c in conditions_in:
if model == "modifier":
db.add(PriceModifierCondition(
modifier_id=parent.id,
condition_type=c.condition_type,
params=json.dumps(c.params),
))
else:
db.add(DealCondition(
deal_id=parent.id,
condition_type=c.condition_type,
params=json.dumps(c.params),
))
def _sync_modifier_targets(modifier, targets_in, db: Session):
for t in targets_in:
db.add(PriceModifierTarget(
modifier_id=modifier.id,
target_type=t.target_type,
target_id=t.target_id,
target_tag=t.target_tag,
target_ids=json.dumps(t.target_ids) if t.target_ids else None,
target_tags=json.dumps(t.target_tags) if t.target_tags else None,
))
def _sync_deal_targets(deal, targets_in, db: Session):
for t in targets_in:
db.add(DealTarget(
deal_id=deal.id,
target_type=t.target_type,
target_id=t.target_id,
target_tag=t.target_tag,
target_ids=json.dumps(t.target_ids) if t.target_ids else None,
target_tags=json.dumps(t.target_tags) if t.target_tags else None,
))

View File

@@ -2,11 +2,13 @@ import os
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from pydantic import BaseModel
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice
from models.product import Product, Category, ProductOption, ProductQuickOption, ProductIngredient, ProductPreferenceSet, ProductPreferenceChoice, ProductModifierGroup
from models.prep_zone import PrepZone
from models.order import OrderItem
from models.user import User
from schemas.product import (
@@ -14,15 +16,39 @@ from schemas.product import (
CategoryCreate, CategoryUpdate, CategoryOut, CategoryReorderItem,
SubcategoryReorderItem, ParentGeneralReorderItem,
PreferenceSetCreate, ProductQuickOptionCreate,
CategoryReparentRequest,
CategoryReparentRequest, ModifierGroupCreate, ModifierGroupOut,
)
from routers.deps import get_current_user, require_manager
from routers.deps import get_current_user, require_menu_manager
from services.sse_bus import broadcast_sync
router = APIRouter()
def _broadcast_products_changed():
broadcast_sync("products_changed", {})
IMAGE_DIR = "/app/data/product_images"
def _replace_modifier_groups(db, product, groups):
"""Recreate modifier groups and return a list of created DB objects (index-aligned)."""
for g in product.modifier_groups:
db.delete(g)
db.flush()
created = []
for i, g in enumerate(groups):
new_g = ProductModifierGroup(
product_id=product.id,
modifier_type=g.modifier_type,
name=g.name,
sort_order=i,
)
db.add(new_g)
db.flush()
created.append(new_g)
return created
def _replace_quick_options(db, product, quick_options):
for qo in product.quick_options:
db.delete(qo)
@@ -40,43 +66,52 @@ def _replace_quick_options(db, product, quick_options):
))
def _replace_options(db, product, options):
def _replace_options(db, product, options, group_id_map=None):
for opt in product.options:
db.delete(opt)
db.flush()
for opt in options:
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
resolved_group = group_id_map[opt.group_id] if (group_id_map and opt.group_id is not None and opt.group_id < len(group_id_map)) else None
db.add(ProductOption(
product_id=product.id,
name=opt.name,
extra_cost=opt.extra_cost,
allow_multiple=opt.allow_multiple,
multi_select=opt.multi_select,
sub_choices=sub_json,
is_favorite=opt.is_favorite,
favorite_sort_order=opt.favorite_sort_order,
is_compact=opt.is_compact,
group_id=resolved_group,
))
def _replace_ingredients(db, product, ingredients):
def _replace_ingredients(db, product, ingredients, group_id_map=None):
for ing in product.ingredients:
db.delete(ing)
db.flush()
for ing in ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
resolved_group = group_id_map[ing.group_id] if (group_id_map and ing.group_id is not None and ing.group_id < len(group_id_map)) else None
db.add(ProductIngredient(product_id=product.id, **ing.model_dump(exclude={'group_id'}), group_id=resolved_group))
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate], group_id_map=None):
for ps in product.preference_sets:
db.delete(ps)
db.flush()
for ps in sets:
shared_json = json.dumps(ps.shared_subset.model_dump()) if ps.shared_subset else None
resolved_group = group_id_map[ps.group_id] if (group_id_map and ps.group_id is not None and ps.group_id < len(group_id_map)) else None
new_set = ProductPreferenceSet(
product_id=product.id,
name=ps.name,
shared_subset=shared_json,
is_favorite=ps.is_favorite,
favorite_sort_order=ps.favorite_sort_order,
group_id=resolved_group,
allow_multi_select=ps.allow_multi_select,
allow_choice_quantity=ps.allow_choice_quantity,
)
db.add(new_set)
db.flush()
@@ -89,6 +124,7 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
extra_cost=ch.extra_cost,
sub_choices=sub_json,
disables_subset=ch.disables_subset,
is_compact=ch.is_compact,
)
db.add(choice)
db.flush()
@@ -99,13 +135,27 @@ def _replace_preference_sets(db, product, sets: List[PreferenceSetCreate]):
# ── Categories ────────────────────────────────────────────────────────────────
@router.get("/tags")
def list_all_tags(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Return sorted unique tag strings across all products."""
rows = db.query(Product.tags).filter(Product.tags != None, Product.tags != "[]").all()
tag_set = set()
for (tags_json,) in rows:
try:
tags = json.loads(tags_json) if tags_json else []
tag_set.update(tags)
except Exception:
pass
return sorted(tag_set)
@router.get("/categories", response_model=List[CategoryOut])
def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(Category).order_by(Category.sort_order).all()
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
# sort_order is among siblings (same parent_id level)
sibling_count = db.query(Category).filter(Category.parent_id == body.parent_id).count()
cat = Category(
@@ -118,40 +168,44 @@ def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: U
db.add(cat)
db.commit()
db.refresh(cat)
_broadcast_products_changed()
return cat
@router.put("/categories/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
if cat:
cat.sort_order = item.sort_order
db.commit()
_broadcast_products_changed()
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
"""Reorder sub-categories within their parent (sort_order among siblings)."""
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
if cat:
cat.sort_order = item.sort_order
db.commit()
_broadcast_products_changed()
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
"""Update general_sort_order on parent categories (position of the General group)."""
for item in items:
cat = db.query(Category).filter(Category.id == item.id).first()
if cat:
cat.general_sort_order = item.general_sort_order
db.commit()
_broadcast_products_changed()
@router.put("/categories/{category_id}/reparent", response_model=CategoryOut)
def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reparent_category(category_id: int, body: CategoryReparentRequest, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
"""Move a category to a new parent (or promote to top-level if parent_id is null).
All products assigned to this category follow it automatically (no product updates needed).
"""
@@ -176,11 +230,12 @@ def reparent_category(category_id: int, body: CategoryReparentRequest, db: Sessi
cat.sort_order = sibling_count
db.commit()
db.refresh(cat)
_broadcast_products_changed()
return cat
@router.put("/categories/{category_id}", response_model=CategoryOut)
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_category(category_id: int, body: CategoryUpdate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
cat = db.query(Category).filter(Category.id == category_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
@@ -188,16 +243,18 @@ def update_category(category_id: int, body: CategoryUpdate, db: Session = Depend
setattr(cat, field, value)
db.commit()
db.refresh(cat)
_broadcast_products_changed()
return cat
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def delete_category(category_id: int, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
cat = db.query(Category).filter(Category.id == category_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
db.delete(cat)
db.commit()
_broadcast_products_changed()
# ── Products ──────────────────────────────────────────────────────────────────
@@ -205,29 +262,54 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
@router.get("/", response_model=List[ProductOut])
def list_products(all: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
q = db.query(Product)
if not all or user.role not in ("manager", "sysadmin"):
has_dashboard = user.role in ("superadmin", "manager") or getattr(user, "perm_access_dashboard", False)
if not all or not has_dashboard:
# Waiters only see active, available products
q = q.filter(Product.is_available == True, Product.lifecycle_status == "active")
return q.order_by(Product.sort_order, Product.id).all()
class BulkPrepZoneBody(BaseModel):
product_ids: List[int]
prep_zone_ids: List[int]
@router.post("/bulk-prep-zones", status_code=status.HTTP_204_NO_CONTENT)
def bulk_assign_prep_zones(body: BulkPrepZoneBody, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
"""Replace the prep zone assignments for a list of products at once."""
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
products = db.query(Product).filter(Product.id.in_(body.product_ids)).all()
for p in products:
p.prep_zones = zones
db.commit()
_broadcast_products_changed()
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_manager)):
def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
for item in items:
product = db.query(Product).filter(Product.id == item.id).first()
if product:
product.sort_order = item.sort_order
db.commit()
_broadcast_products_changed()
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets"})
def create_product(body: ProductCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown", "prep_zone_ids", "tags"})
if data.get("sort_order") == 0:
data["sort_order"] = db.query(Product).count()
if body.cost_breakdown is not None:
data["cost_breakdown"] = json.dumps([item.model_dump() for item in body.cost_breakdown])
data["tags"] = json.dumps(body.tags) if body.tags is not None else None
product = Product(**data)
db.add(product)
db.flush()
# Assign prep zones
if body.prep_zone_ids:
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
product.prep_zones = zones
for i, qo in enumerate(body.quick_options):
db.add(ProductQuickOption(
product_id=product.id,
@@ -239,47 +321,82 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
favorite_sort_order=qo.favorite_sort_order,
is_compact=qo.is_compact,
))
created_groups = _replace_modifier_groups(db, product, body.modifier_groups)
group_id_map = [g.id for g in created_groups]
for opt in body.options:
sub_json = json.dumps([s.model_dump() for s in opt.sub_choices]) if opt.sub_choices else None
resolved_group = group_id_map[opt.group_id] if (opt.group_id is not None and opt.group_id < len(group_id_map)) else None
db.add(ProductOption(
product_id=product.id,
name=opt.name,
extra_cost=opt.extra_cost,
allow_multiple=opt.allow_multiple,
multi_select=opt.multi_select,
sub_choices=sub_json,
is_favorite=opt.is_favorite,
favorite_sort_order=opt.favorite_sort_order,
is_compact=opt.is_compact,
group_id=resolved_group,
))
for ing in body.ingredients:
db.add(ProductIngredient(product_id=product.id, **ing.model_dump()))
_replace_preference_sets(db, product, body.preference_sets)
resolved_group = group_id_map[ing.group_id] if (ing.group_id is not None and ing.group_id < len(group_id_map)) else None
db.add(ProductIngredient(product_id=product.id, **ing.model_dump(exclude={'group_id'}), group_id=resolved_group))
_replace_preference_sets(db, product, body.preference_sets, group_id_map)
db.commit()
db.refresh(product)
_broadcast_products_changed()
return product
@router.put("/{product_id}", response_model=ProductOut)
def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
for field, value in body.model_dump(exclude_none=True, exclude={"quick_options", "options", "ingredients", "preference_sets"}).items():
scalar_fields = body.model_dump(
exclude_none=True,
exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown", "prep_zone_ids", "tags", "modifier_groups"},
)
for field, value in scalar_fields.items():
setattr(product, field, value)
# Always clear legacy per-product printer — routing is now via prep zones only
product.printer_zone_id = None
# Update prep zones if provided
if body.prep_zone_ids is not None:
zones = db.query(PrepZone).filter(PrepZone.id.in_(body.prep_zone_ids)).all()
product.prep_zones = zones
# cost_breakdown is a list of objects — serialize to JSON for storage
if body.cost_breakdown is not None:
product.cost_breakdown = json.dumps([item.model_dump() for item in body.cost_breakdown])
elif "cost_breakdown" in body.model_fields_set:
# explicitly set to null — clear it
product.cost_breakdown = None
# tags is a list of strings — serialize to JSON for storage
if body.tags is not None:
product.tags = json.dumps(body.tags)
elif "tags" in body.model_fields_set:
product.tags = None
if body.quick_options is not None:
_replace_quick_options(db, product, body.quick_options)
# Modifier groups must be recreated before items so we have the ID map
group_id_map = None
if body.modifier_groups is not None:
created_groups = _replace_modifier_groups(db, product, body.modifier_groups)
group_id_map = [g.id for g in created_groups]
if body.options is not None:
_replace_options(db, product, body.options)
_replace_options(db, product, body.options, group_id_map)
if body.ingredients is not None:
_replace_ingredients(db, product, body.ingredients)
_replace_ingredients(db, product, body.ingredients, group_id_map)
if body.preference_sets is not None:
_replace_preference_sets(db, product, body.preference_sets)
_replace_preference_sets(db, product, body.preference_sets, group_id_map)
db.commit()
db.refresh(product)
_broadcast_products_changed()
return product
@router.post("/{product_id}/image", response_model=ProductOut)
async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
async def upload_product_image(product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
@@ -305,11 +422,59 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
product.image_url = f"/static/product_images/{filename}"
db.commit()
db.refresh(product)
_broadcast_products_changed()
return product
# ── Modifier Groups ───────────────────────────────────────────────────────────
@router.get("/{product_id}/modifier-groups", response_model=List[ModifierGroupOut])
def list_modifier_groups(product_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return db.query(ProductModifierGroup).filter(ProductModifierGroup.product_id == product_id).order_by(ProductModifierGroup.sort_order).all()
@router.post("/{product_id}/modifier-groups", response_model=ModifierGroupOut, status_code=status.HTTP_201_CREATED)
def create_modifier_group(product_id: int, body: ModifierGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
g = ProductModifierGroup(product_id=product_id, modifier_type=body.modifier_type, name=body.name, sort_order=body.sort_order)
db.add(g)
db.commit()
db.refresh(g)
_broadcast_products_changed()
return g
@router.put("/{product_id}/modifier-groups/{group_id}", response_model=ModifierGroupOut)
def update_modifier_group(product_id: int, group_id: int, body: ModifierGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
g = db.query(ProductModifierGroup).filter(ProductModifierGroup.id == group_id, ProductModifierGroup.product_id == product_id).first()
if not g:
raise HTTPException(status_code=404, detail="Group not found")
g.name = body.name
g.sort_order = body.sort_order
db.commit()
db.refresh(g)
_broadcast_products_changed()
return g
@router.delete("/{product_id}/modifier-groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_modifier_group(product_id: int, group_id: int, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
g = db.query(ProductModifierGroup).filter(ProductModifierGroup.id == group_id, ProductModifierGroup.product_id == product_id).first()
if not g:
raise HTTPException(status_code=404, detail="Group not found")
# Un-group all items in this group before deleting
db.query(ProductOption).filter(ProductOption.group_id == group_id).update({"group_id": None})
db.query(ProductIngredient).filter(ProductIngredient.group_id == group_id).update({"group_id": None})
db.query(ProductPreferenceSet).filter(ProductPreferenceSet.group_id == group_id).update({"group_id": None})
db.delete(g)
db.commit()
_broadcast_products_changed()
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def delete_product(product_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_menu_manager)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
@@ -329,3 +494,4 @@ def delete_product(product_id: int, hard: bool = False, db: Session = Depends(ge
else:
db.delete(product)
db.commit()
_broadcast_products_changed()

View File

@@ -0,0 +1,113 @@
import secrets
import bcrypt
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from database import get_db
from models.user import User
from models.recovery_code import RecoveryCode
from schemas.user import UserOut
from schemas.auth import TokenResponse
from routers.deps import get_current_user, make_token
router = APIRouter()
_logger = logging.getLogger(__name__)
CODES_PER_BATCH = 5
def _generate_code() -> str:
"""Return a human-readable code like XENIA-A3K9-MW2F."""
part = lambda: secrets.token_hex(2).upper()
return f"XENIA-{part()}-{part()}"
def _hash_code(plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
def _verify_code(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def try_recovery_code(plain: str, user: "User", db: "Session") -> bool:
"""Try to consume a recovery code for user. Returns True and burns the code if matched."""
unused = db.query(RecoveryCode).filter(
RecoveryCode.user_id == user.id,
RecoveryCode.used_at.is_(None),
).all()
matched = next((rc for rc in unused if _verify_code(plain, rc.code_hash)), None)
if not matched:
return False
matched.used_at = datetime.now(timezone.utc)
db.commit()
_logger.warning("RECOVERY CODE USED for user_id=%d username=%s code_id=%d", user.id, user.username, matched.id)
return True
# ─── Public: use a recovery code to log in (kept for direct API use) ─────────
class UseRecoveryCodeRequest(BaseModel):
username: str
code: str
@router.post("/use", response_model=TokenResponse)
def use_recovery_code(body: UseRecoveryCodeRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(
User.username == body.username,
User.is_active == True,
).first()
if not user or not try_recovery_code(body.code, user, db):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = make_token(user)
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
# ─── Authenticated: generate a new batch (burns all existing unused codes) ───
class RecoveryCodesGenerated(BaseModel):
codes: list[str]
remaining_after: int
@router.post("/generate", response_model=RecoveryCodesGenerated)
def generate_recovery_codes(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Burn all existing unused codes for this user
db.query(RecoveryCode).filter(
RecoveryCode.user_id == current_user.id,
RecoveryCode.used_at.is_(None),
).delete(synchronize_session=False)
db.flush()
plain_codes = [_generate_code() for _ in range(CODES_PER_BATCH)]
for plain in plain_codes:
db.add(RecoveryCode(user_id=current_user.id, code_hash=_hash_code(plain)))
db.commit()
_logger.info("Recovery codes regenerated for user_id=%d", current_user.id)
return RecoveryCodesGenerated(codes=plain_codes, remaining_after=CODES_PER_BATCH)
# ─── Authenticated: check how many unused codes remain ───────────────────────
class RecoveryCodeStatus(BaseModel):
unused_count: int
@router.get("/status", response_model=RecoveryCodeStatus)
def recovery_code_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
count = db.query(RecoveryCode).filter(
RecoveryCode.user_id == current_user.id,
RecoveryCode.used_at == None,
).count()
return RecoveryCodeStatus(unused_count=count)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime, timezone
from database import get_db
from models.reservation import Reservation
from models.table import Table
from schemas.reservation import (
ReservationCreate, ReservationUpdate, ReservationStatusUpdate, ReservationOut,
)
from routers.deps import get_current_user, require_manager
from models.user import User
router = APIRouter()
VALID_STATUSES = {"pending", "arrived", "cancelled", "no_show"}
def _enrich(r: Reservation) -> ReservationOut:
out = ReservationOut.model_validate(r)
if r.table:
out.table_label = r.table.label or f"T{r.table.number}"
if r.created_by:
out.created_by_name = (
r.created_by.full_name or r.created_by.nickname or r.created_by.username
)
return out
@router.post("/", response_model=ReservationOut, status_code=status.HTTP_201_CREATED)
def create_reservation(
body: ReservationCreate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if body.table_id:
if not db.query(Table).filter(Table.id == body.table_id, Table.is_active == True).first():
raise HTTPException(status_code=400, detail="Table not found or inactive")
r = Reservation(
guest_name=body.guest_name,
party_size=body.party_size,
phone=body.phone,
email=body.email,
note=body.note,
reserved_for=body.reserved_for,
table_id=body.table_id,
source=body.source,
online_customer_id=body.online_customer_id,
created_by_user_id=user.id,
status="pending",
)
db.add(r)
db.commit()
db.refresh(r)
return _enrich(r)
@router.get("/", response_model=List[ReservationOut])
def list_reservations(
tab: Optional[str] = Query(None), # "upcoming" | "history" — if omitted returns all
date: Optional[str] = Query(None), # ISO date string YYYY-MM-DD to filter by day
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
q = db.query(Reservation)
if date:
try:
day = datetime.fromisoformat(date).date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
from sqlalchemy import func
q = q.filter(func.date(Reservation.reserved_for) == day)
if tab == "upcoming":
# pending + no_show (actionable), ordered soonest first
q = q.filter(Reservation.status.in_(["pending", "no_show"]))
q = q.order_by(Reservation.reserved_for.asc())
elif tab == "history":
# arrived, cancelled, plus no_show/pending that are clearly in the past
now = datetime.now(timezone.utc)
q = q.filter(
(Reservation.status.in_(["arrived", "cancelled"])) |
(
Reservation.status.in_(["no_show", "pending"]) &
(Reservation.reserved_for < now)
)
)
q = q.order_by(Reservation.reserved_for.desc())
else:
q = q.order_by(Reservation.reserved_for.asc())
reservations = q.all()
return [_enrich(r) for r in reservations]
@router.get("/{reservation_id}", response_model=ReservationOut)
def get_reservation(
reservation_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
if not r:
raise HTTPException(status_code=404, detail="Reservation not found")
return _enrich(r)
@router.patch("/{reservation_id}", response_model=ReservationOut)
def update_reservation(
reservation_id: int,
body: ReservationUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
if not r:
raise HTTPException(status_code=404, detail="Reservation not found")
updates = body.model_dump(exclude_none=True)
if "table_id" in updates and updates["table_id"] is not None:
if not db.query(Table).filter(Table.id == updates["table_id"], Table.is_active == True).first():
raise HTTPException(status_code=400, detail="Table not found or inactive")
if "status" in updates and updates["status"] not in VALID_STATUSES:
raise HTTPException(status_code=400, detail=f"Invalid status: {updates['status']}")
for field, value in updates.items():
setattr(r, field, value)
r.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(r)
return _enrich(r)
@router.patch("/{reservation_id}/status", response_model=ReservationOut)
def update_reservation_status(
reservation_id: int,
body: ReservationStatusUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if body.status not in VALID_STATUSES:
raise HTTPException(status_code=400, detail=f"Invalid status: {body.status}")
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
if not r:
raise HTTPException(status_code=404, detail="Reservation not found")
r.status = body.status
r.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(r)
return _enrich(r)
@router.delete("/{reservation_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_reservation(
reservation_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
r = db.query(Reservation).filter(Reservation.id == reservation_id).first()
if not r:
raise HTTPException(status_code=404, detail="Reservation not found")
db.delete(r)
db.commit()

View File

@@ -0,0 +1,187 @@
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from database import get_db
from models.schedule import ScheduledShift
from models.shift import WaiterShift
from models.user import User
from schemas.schedule import ScheduledShiftCreate, ScheduledShiftUpdate, ScheduledShiftOut
from routers.deps import require_manager
router = APIRouter()
def _duration_hours(start: str, end: str) -> float:
"""Compute hours between two HH:MM strings. Handles overnight spans."""
sh, sm = int(start[:2]), int(start[3:5])
eh, em = int(end[:2]), int(end[3:5])
mins = (eh * 60 + em) - (sh * 60 + sm)
if mins <= 0:
mins += 24 * 60 # overnight
return round(mins / 60, 2)
def _enrich(s: ScheduledShift) -> dict:
u = s.user
user_name = (u.full_name or u.username) if u else f"#{s.user_id}"
hourly_rate = u.hourly_rate if u else None
dur = _duration_hours(s.start_time, s.end_time)
estimated_pay = round(dur * hourly_rate, 2) if hourly_rate else None
return {
"id": s.id,
"user_id": s.user_id,
"user_name": user_name,
"hourly_rate": hourly_rate,
"scheduled_date": s.scheduled_date,
"start_time": s.start_time,
"end_time": s.end_time,
"notes": s.notes,
"created_by_id": s.created_by_id,
"created_at": s.created_at,
"duration_hours": dur,
"estimated_pay": estimated_pay,
}
@router.get("/", response_model=List[ScheduledShiftOut])
def list_schedule(
from_date: Optional[date] = Query(default=None, alias="from"),
to_date: Optional[date] = Query(default=None, alias="to"),
user_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(ScheduledShift)
if from_date:
q = q.filter(ScheduledShift.scheduled_date >= from_date)
if to_date:
q = q.filter(ScheduledShift.scheduled_date <= to_date)
if user_id:
q = q.filter(ScheduledShift.user_id == user_id)
shifts = q.order_by(ScheduledShift.scheduled_date, ScheduledShift.start_time).all()
return [_enrich(s) for s in shifts]
@router.post("/", response_model=ScheduledShiftOut, status_code=status.HTTP_201_CREATED)
def create_scheduled_shift(
body: ScheduledShiftCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
target = db.query(User).filter(User.id == body.user_id, User.is_active == True).first()
if not target:
raise HTTPException(status_code=404, detail="User not found")
s = ScheduledShift(
user_id=body.user_id,
scheduled_date=body.scheduled_date,
start_time=body.start_time,
end_time=body.end_time,
notes=body.notes,
created_by_id=user.id,
)
db.add(s)
db.commit()
db.refresh(s)
return _enrich(s)
@router.put("/{shift_id}", response_model=ScheduledShiftOut)
def update_scheduled_shift(
shift_id: int,
body: ScheduledShiftUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
s = db.query(ScheduledShift).filter(ScheduledShift.id == shift_id).first()
if not s:
raise HTTPException(status_code=404, detail="Scheduled shift not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(s, field, value)
db.commit()
db.refresh(s)
return _enrich(s)
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_scheduled_shift(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
s = db.query(ScheduledShift).filter(ScheduledShift.id == shift_id).first()
if not s:
raise HTTPException(status_code=404, detail="Scheduled shift not found")
db.delete(s)
db.commit()
@router.get("/week")
def week_schedule(
week_start: Optional[date] = Query(default=None),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Current (or specified) week: scheduled shifts + actual WaiterShifts for comparison."""
if week_start is None:
today = date.today()
week_start = today - timedelta(days=today.weekday()) # Monday
week_end = week_start + timedelta(days=6) # Sunday
scheduled = db.query(ScheduledShift).filter(
ScheduledShift.scheduled_date >= week_start,
ScheduledShift.scheduled_date <= week_end,
).order_by(ScheduledShift.scheduled_date, ScheduledShift.start_time).all()
# Actual shifts that started within the week
week_start_dt = datetime.combine(week_start, datetime.min.time()).replace(tzinfo=timezone.utc)
week_end_dt = datetime.combine(week_end, datetime.max.time()).replace(tzinfo=timezone.utc)
actuals = db.query(WaiterShift).filter(
WaiterShift.started_at >= week_start_dt,
WaiterShift.started_at <= week_end_dt,
).all()
waiters = {u.id: u for u in db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()}
def _dt(dt):
if not dt:
return None
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
# Build actual shifts lookup: (user_id, date_str) → list of actual shifts
actuals_map: dict = {}
for a in actuals:
d = a.started_at.date() if a.started_at else None
if d:
key = (a.waiter_id, d.isoformat())
if key not in actuals_map:
actuals_map[key] = []
actuals_map[key].append({
"id": a.id,
"started_at": _dt(a.started_at),
"ended_at": _dt(a.ended_at),
"is_active": a.ended_at is None,
})
# Estimate total weekly labor cost
total_estimated_pay = 0.0
scheduled_out = []
for s in scheduled:
enriched = _enrich(s)
key = (s.user_id, s.scheduled_date.isoformat())
enriched["actual_shifts"] = actuals_map.get(key, [])
scheduled_out.append(enriched)
if enriched["estimated_pay"]:
total_estimated_pay += enriched["estimated_pay"]
return {
"week_start": week_start.isoformat(),
"week_end": week_end.isoformat(),
"scheduled": scheduled_out,
"total_estimated_pay": round(total_estimated_pay, 2),
"waiters": [
{"id": u.id, "name": u.full_name or u.username, "hourly_rate": u.hourly_rate}
for u in sorted(waiters.values(), key=lambda x: x.full_name or x.username)
],
}

View File

@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from database import get_db
from models.settings import PosSettings
from schemas.settings import UpdateSettingRequest
from routers.deps import get_current_user, require_manager
from routers.deps import get_current_user, require_settings_manager
from models.user import User
router = APIRouter()
@@ -20,6 +20,7 @@ VALID_SETTINGS = {
"security.auto_logout_seconds":"Seconds of inactivity before logging out (0 = disabled)",
"shifts.waiter_self_start": "Allow waiters to start their own shifts without manager action",
"shifts.waiter_self_end": "Allow waiters to end their own shifts without manager action",
"shifts.hide_revenue_from_waiters": "Hide ΕΙΣΠΡΑΞΗ and ΠΑΡΑΔ. ΠΟΣΟ stats from waiters in Shift Overview: 'true' | 'false'",
"business_day.force_close_allowed": "Allow force-closing business day with open tables",
"system.timezone": "IANA timezone name used by the backend container (e.g. Europe/Athens). Requires container restart to take effect.",
"ui.table_colours": "JSON blob of table card colour scheme (light + dark modes) for the Waiter PWA.",
@@ -27,6 +28,7 @@ VALID_SETTINGS = {
# Print layout
"print.ticket_mode": "Kitchen ticket layout mode: 'detailed' or 'compact'",
"print.divider_style": "Divider character used between sections: dash, equals, star, or empty",
"print.dot_style": "Dot-leader style for item lines: STYLE:SIZE:BOLD where STYLE is dot_space|dot|dash_space|underscore, SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1",
# Print font settings — values are "SIZE:BOLD:CAPS" where SIZE is ESC ! base byte (0/16/32/48), BOLD 0|1, CAPS 0|1
"print.font_order_number": "Font for order number header: SIZE:BOLD:CAPS",
"print.font_meta": "Font for table/waiter/time header block: SIZE:BOLD:CAPS",
@@ -37,6 +39,32 @@ VALID_SETTINGS = {
"print.font_ingredient": "Font for removed ingredient lines (- marker): SIZE:BOLD:CAPS",
"print.font_item_note": "Font for per-item note lines: SIZE:BOLD:CAPS",
"print.font_order_note": "Font for order-level notes: SIZE:BOLD:CAPS",
# Beep settings
"print.beep_on_ticket": "Play beep when a kitchen ticket prints: 'true' | 'false'",
"print.beep_pattern": "Beep pattern: 'single' | 'double' | 'triple' | 'long' | 'custom:n1:n2:n3'",
# Phase 2 — cancellations
"orders.waiter_cancellations_allowed": "Allow waiters with per-account permission to cancel sent orders: 'true' | 'false'",
"orders.waiter_price_adjust_allowed": "Allow waiters to adjust item prices (before or after ordering): 'true' | 'false'",
# Payment options
"payments.card_enabled": "Allow waiters to record card payments (in addition to cash): 'true' | 'false'",
"payments.waiter_revert_allowed": "Allow waiters to revert individual paid items back to unpaid: 'true' | 'false'",
# Tables & orders behaviour
"orders.auto_close_on_full_payment": "Automatically close the table order when all items have been paid: 'true' | 'false'",
"orders.bypass_kds_serve": "Skip KDS pending/serve flow — new orders and items are immediately marked as served: 'true' | 'false'",
# Courses
"orders.courses_enabled": "Enable per-item course assignment when ordering: 'true' | 'false'",
"orders.courses": "JSON array of course objects [{id, name, color}] defining available courses",
# Quick notes
"orders.quick_notes": "JSON array of quick-note strings shown in the waiter note tab",
# Fiscal printer (ΦΗΜ)
"fiscal.enabled": "Master switch for fiscal printing: 'true' | 'false'",
"fiscal.type": "Fiscal driver type: 'txt_file' (dTEC100extra) — more types coming",
"fiscal.out_folder": "Path the backend writes fiscal command files TO (input for the fiscal driver)",
"fiscal.in_folder": "Path the backend reads fiscal reply files FROM (output of the fiscal driver)",
"fiscal.clerk_id": "Clerk ID sent in CR/CD commands (integer string, e.g. '2')",
"fiscal.eftpos_id": "EFTPOS terminal ID sent in CD commands (integer string, e.g. '1')",
"fiscal.end_message": "JSON array of up to 5 strings printed at end of receipt via FM commands",
"fiscal.vat_groups": "JSON array of VAT group definitions: [{machine_id: int, friendly_name: str}]",
}
DEFAULTS = {
@@ -48,12 +76,14 @@ DEFAULTS = {
"security.auto_logout_seconds": "1800",
"shifts.waiter_self_start": "true",
"shifts.waiter_self_end": "true",
"shifts.hide_revenue_from_waiters": "false",
"business_day.force_close_allowed": "true",
"system.timezone": "Europe/Athens",
"ui.table_colours": "",
"ui.table_colours": '{"light":{"free":{"cardBg":"#dde5ef","badgeBg":"rgba(255,255,255,0.92)","nameText":"#3d5270","badgeText":"#3d5270"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"},"kds_ready":{"cardBg":"#22c55e","cardBg2":"#ffffff","badgeBg":"rgba(255,255,255,0.92)","badgeBg2":"rgba(255,255,255,0.92)","nameText":"#ffffff","nameText2":"#15803d","badgeText":"#15803d","badgeText2":"#22c55e","flash":true}},"dark":{"free":{"cardBg":"#243044","badgeBg":"rgba(255,255,255,0.92)","nameText":"#94b8d4","badgeText":"#94b8d4"},"mine":{"cardBg":"#e8610a","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#e8610a"},"open":{"cardBg":"#FF8F60","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#FF8F60"},"partially_paid":{"cardBg":"#FFDC67","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#d4a800"},"paid":{"cardBg":"#81D264","badgeBg":"rgba(255,255,255,0.92)","nameText":"#ffffff","badgeText":"#81D264"},"kds_ready":{"cardBg":"#16a34a","cardBg2":"#ffffff","badgeBg":"rgba(255,255,255,0.92)","badgeBg2":"rgba(255,255,255,0.92)","nameText":"#ffffff","nameText2":"#bbf7d0","badgeText":"#bbf7d0","badgeText2":"#16a34a","flash":true}}}',
"dev.spoof_printing": "false",
"print.ticket_mode": "detailed",
"print.divider_style": "dash",
"print.dot_style": "dot_space:0:0",
"print.font_order_number": "48:1:0",
"print.font_meta": "0:0:0",
"print.font_item_name": "16:1:0",
@@ -63,6 +93,25 @@ DEFAULTS = {
"print.font_ingredient": "0:0:0",
"print.font_item_note": "0:0:0",
"print.font_order_note": "0:1:0",
"print.beep_on_ticket": "true",
"print.beep_pattern": "double",
"payments.card_enabled": "false",
"payments.waiter_revert_allowed": "false",
"orders.auto_close_on_full_payment": "false",
"orders.bypass_kds_serve": "false",
"orders.waiter_price_adjust_allowed": "false",
"orders.courses_enabled": "false",
"orders.courses": "[]",
"orders.quick_notes": '["Χωρίς αλάτι","Βγάλτε γρήγορα","Αλλεργία!","Κόψτε σε μικρά κομμάτια","Έξτρα χαρτοπετσέτες"]',
# Fiscal printer (ΦΗΜ)
"fiscal.enabled": "false",
"fiscal.type": "txt_file",
"fiscal.out_folder": "",
"fiscal.in_folder": "",
"fiscal.clerk_id": "2",
"fiscal.eftpos_id": "1",
"fiscal.end_message": "[]",
"fiscal.vat_groups": "[]",
}
@@ -86,7 +135,7 @@ def update_setting(
key: str,
body: UpdateSettingRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_settings_manager),
):
if key not in VALID_SETTINGS:
raise HTTPException(status_code=400, detail=f"Unknown setting key: {key}")

View File

@@ -6,6 +6,7 @@ import bcrypt
from database import get_db
from models.user import User
from services.chat_service import add_user_to_system_group
router = APIRouter()
@@ -49,7 +50,7 @@ def security_config(db: Session = Depends(get_db)):
@router.get("/status", response_model=SetupStatusResponse)
def setup_status(db: Session = Depends(get_db)):
has_manager = db.query(User).filter(
User.role.in_(["manager", "sysadmin"]),
User.perm_access_dashboard == True,
User.is_active == True,
).first()
return SetupStatusResponse(needs_setup=has_manager is None)
@@ -58,7 +59,7 @@ def setup_status(db: Session = Depends(get_db)):
@router.post("/init", response_model=SetupInitResponse)
def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
has_manager = db.query(User).filter(
User.role.in_(["manager", "sysadmin"]),
User.perm_access_dashboard == True,
User.is_active == True,
).first()
if has_manager:
@@ -87,9 +88,13 @@ def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
password_hash=password_hash,
email=body.email,
full_name=body.full_name,
role="manager",
role="store_manager",
is_active=True,
)
# Apply store_manager default permissions
from roles import get_default_permissions
for field, value in get_default_permissions("store_manager").items():
setattr(user, field, value)
db.add(user)
# Persist venue settings if provided
@@ -113,4 +118,6 @@ def setup_init(body: SetupInitRequest, db: Session = Depends(get_db)):
db.add(PosSettings(key="venue.type", value=body.venue_type, updated_at=now))
db.commit()
db.refresh(user)
add_user_to_system_group(db, user.id)
return SetupInitResponse(ok=True)

View File

@@ -35,10 +35,77 @@ def compute_shift_total(shift_id: int, db: Session) -> float:
return round(sum(i.unit_price * i.quantity for i in items), 2)
def compute_shift_payment_split(shift_id: int, db: Session) -> dict:
"""Return cash_sales and card_sales for a shift (items paid in this shift)."""
items = db.query(OrderItem).filter(
OrderItem.paid_in_shift_id == shift_id,
OrderItem.status == "paid",
).all()
cash = 0.0
card = 0.0
for i in items:
amount = float(i.unit_price) * i.quantity
method = (i.payment_method or "cash").lower()
if method == "card":
card += amount
else:
cash += amount
return {"cash_sales": round(cash, 2), "card_sales": round(card, 2)}
def compute_shift_pay(shift: WaiterShift) -> dict:
"""Return duration_hours and shift_pay. shift_pay is None if no rate snapshot."""
now = datetime.now(timezone.utc)
start = shift.started_at
if start and start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
end = shift.ended_at
if end and end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
reference = end or now
total_seconds = (reference - start).total_seconds() if start else 0
# Subtract completed breaks
break_seconds = 0
for b in shift.breaks:
bs = b.started_at
be = b.ended_at
if bs and be:
if bs.tzinfo is None:
bs = bs.replace(tzinfo=timezone.utc)
if be.tzinfo is None:
be = be.replace(tzinfo=timezone.utc)
break_seconds += (be - bs).total_seconds()
worked_seconds = max(0, total_seconds - break_seconds)
duration_hours = round(worked_seconds / 3600, 4)
shift_pay = None
if shift.hourly_rate_snapshot is not None:
shift_pay = round(duration_hours * shift.hourly_rate_snapshot, 2)
return {"duration_hours": round(duration_hours, 2), "shift_pay": shift_pay}
def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
w = shift.waiter
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
split = compute_shift_payment_split(shift.id, db)
pay_data = compute_shift_pay(shift)
# Count cancelled items and their value attributed to this waiter during shift window
cancelled_q = db.query(OrderItem).filter(
OrderItem.status == "cancelled",
OrderItem.added_by == shift.waiter_id,
OrderItem.added_at >= shift.started_at,
)
if shift.ended_at:
cancelled_q = cancelled_q.filter(OrderItem.added_at <= shift.ended_at)
cancelled_rows = cancelled_q.all()
cancellation_events = len(cancelled_rows)
cancellations = sum(r.quantity for r in cancelled_rows)
cancellation_value = round(sum(r.unit_price * r.quantity for r in cancelled_rows), 2)
return {
"id": shift.id,
"waiter_id": shift.waiter_id,
@@ -48,9 +115,22 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
"ended_at": _dt(shift.ended_at),
"starting_cash": shift.starting_cash,
"total_collected": total,
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
"cash_sales": split["cash_sales"],
"card_sales": split["card_sales"],
"net_to_deliver": round(split["cash_sales"] + (shift.starting_cash or 0.0), 2),
"is_active": shift.ended_at is None,
"notes": shift.notes,
"hourly_rate_snapshot": shift.hourly_rate_snapshot,
"duration_hours": pay_data["duration_hours"],
"shift_pay": pay_data["shift_pay"],
"cancellation_events": cancellation_events,
"cancellations": cancellations,
"cancellation_value": cancellation_value,
# Phase 2E — discrepancy computed live against cash-only expected (starting_cash + cash_sales)
"counted_cash_end": shift.counted_cash_end,
"cash_discrepancy": round(
shift.counted_cash_end - (split["cash_sales"] + (shift.starting_cash or 0.0)), 2
) if shift.counted_cash_end is not None else None,
"breaks": [
{"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
for b in shift.breaks
@@ -76,14 +156,14 @@ def start_shift(
target_id = body.waiter_id
if target_id and target_id != user.id:
if user.role not in ("manager", "sysadmin"):
raise HTTPException(status_code=403, detail="Only managers can start shifts for other waiters")
if not user.perm_access_dashboard and user.role != "superadmin":
raise HTTPException(status_code=403, detail="Only managers can start shifts for other staff")
target = db.query(User).filter(User.id == target_id, User.is_active == True).first()
if not target:
raise HTTPException(status_code=404, detail="Waiter not found")
raise HTTPException(status_code=404, detail="Staff member not found")
else:
target_id = user.id
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true":
if not user.perm_access_dashboard and user.role != "superadmin" and _get_setting(db, "shifts.waiter_self_start") != "true":
raise HTTPException(status_code=403, detail="Shift start requires manager confirmation")
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
@@ -97,10 +177,12 @@ def start_shift(
if existing:
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
target_user = db.query(User).filter(User.id == target_id).first()
shift = WaiterShift(
waiter_id=target_id,
business_day_id=active_day.id,
starting_cash=body.starting_cash,
hourly_rate_snapshot=target_user.hourly_rate if target_user else None,
)
db.add(shift)
db.commit()
@@ -114,7 +196,7 @@ def end_shift(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true":
if not user.perm_access_dashboard and user.role != "superadmin" and _get_setting(db, "shifts.waiter_self_end") != "true":
raise HTTPException(status_code=403, detail="Shift end requires manager confirmation")
shift = db.query(WaiterShift).filter(
@@ -125,10 +207,14 @@ def end_shift(
raise HTTPException(status_code=404, detail="No active shift found")
now = datetime.now(timezone.utc)
shift.total_collected = compute_shift_total(shift.id, db)
total = compute_shift_total(shift.id, db)
shift.total_collected = total
shift.ended_at = now
if body.notes:
shift.notes = body.notes
# Phase 2E: cash reconciliation — store counted amount; discrepancy computed live in _enrich_shift
if body.counted_cash_end is not None:
shift.counted_cash_end = body.counted_cash_end
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
@@ -169,6 +255,7 @@ def manager_start_shift(
waiter_id=body.waiter_id,
business_day_id=active_day.id,
starting_cash=body.starting_cash,
hourly_rate_snapshot=target.hourly_rate,
)
db.add(shift)
db.commit()
@@ -191,10 +278,14 @@ def manager_end_shift(
raise HTTPException(status_code=404, detail="Active shift not found")
now = datetime.now(timezone.utc)
shift.total_collected = compute_shift_total(shift.id, db)
total = compute_shift_total(shift.id, db)
shift.total_collected = total
shift.ended_at = now
if body.notes:
shift.notes = body.notes
# Phase 2E: cash reconciliation — store counted amount; discrepancy computed live in _enrich_shift
if body.counted_cash_end is not None:
shift.counted_cash_end = body.counted_cash_end
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
@@ -216,7 +307,7 @@ def start_break(
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
if shift.waiter_id != user.id and not user.perm_access_dashboard and user.role != "superadmin":
raise HTTPException(status_code=403, detail="Access denied")
if shift.ended_at:
raise HTTPException(status_code=400, detail="Shift already ended")
@@ -243,7 +334,7 @@ def end_break(
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
if shift.waiter_id != user.id and not user.perm_access_dashboard and user.role != "superadmin":
raise HTTPException(status_code=403, detail="Access denied")
open_break = db.query(ShiftBreak).filter(
@@ -289,22 +380,66 @@ def get_shift(
return _enrich_shift(shift, db)
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_shift(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Permanently delete a shift, its breaks, and all orders opened during that shift."""
from models.order import Order, OrderItem, OrderAuditLog, OrderWaiter
import json as _json
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
if shift.ended_at is None:
raise HTTPException(status_code=400, detail="Cannot delete an active shift — end it first")
# Find all orders opened during this shift's time window by this waiter
orders = db.query(Order).filter(
Order.opened_by == shift.waiter_id,
Order.opened_at >= shift.started_at,
Order.opened_at <= shift.ended_at,
).all()
for order in orders:
db.query(OrderAuditLog).filter(OrderAuditLog.order_id == order.id).delete(synchronize_session=False)
db.query(OrderWaiter).filter(OrderWaiter.order_id == order.id).delete(synchronize_session=False)
db.query(OrderItem).filter(OrderItem.order_id == order.id).delete(synchronize_session=False)
db.delete(order)
db.delete(shift)
db.commit()
@router.get("/{shift_id}/summary")
def get_shift_summary(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Full shift summary: enriched shift data + paid items grouped by order."""
"""Full shift summary with complete per-item waiter attribution.
Returns all items that either:
- were paid in this shift (paid_in_shift_id == shift_id), OR
- were added by this waiter (added_by == waiter_id) and are active/unpaid or paid in another shift.
Each item includes added_by_name, paid_by_name, timestamps, and order context.
Orders include opener_name and closer_name.
"""
from models.order import Order
from models.table import Table
from sqlalchemy.orm import joinedload
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
from models.table import Table
items = db.query(OrderItem).options(
waiter_id = shift.waiter_id
# Collect all relevant items: paid in this shift OR added by this waiter (any status)
paid_items = db.query(OrderItem).options(
joinedload(OrderItem.product),
joinedload(OrderItem.order),
).filter(
@@ -312,48 +447,133 @@ def get_shift_summary(
OrderItem.status == "paid",
).all()
# Build table_id -> display name map for all referenced tables
table_ids = {item.order.table_id for item in items if item.order and item.order.table_id}
from datetime import timezone as tz
upper_bound = shift.ended_at or datetime.now(tz.utc)
ordered_items = db.query(OrderItem).options(
joinedload(OrderItem.product),
joinedload(OrderItem.order),
).filter(
OrderItem.added_by == waiter_id,
OrderItem.added_at >= shift.started_at,
OrderItem.added_at <= upper_bound,
(OrderItem.paid_in_shift_id != shift_id) | (OrderItem.paid_in_shift_id == None),
).all()
# Deduplicate: union by item id, paid_items takes precedence
items_by_id: dict[int, OrderItem] = {}
for item in ordered_items:
items_by_id[item.id] = item
for item in paid_items:
items_by_id[item.id] = item
all_items = list(items_by_id.values())
# Also include items cancelled within this shift window (may not be in ordered_items
# if they were cancelled after the shift ended or deduped out)
cancelled_items_for_summary = db.query(OrderItem).options(
joinedload(OrderItem.product),
joinedload(OrderItem.order),
).filter(
OrderItem.added_by == waiter_id,
OrderItem.status == "cancelled",
OrderItem.added_at >= shift.started_at,
OrderItem.added_at <= upper_bound,
).all()
for item in cancelled_items_for_summary:
if item.id not in items_by_id:
items_by_id[item.id] = item
all_items = list(items_by_id.values())
# Build lookup maps
all_waiter_ids = set()
for item in all_items:
all_waiter_ids.add(item.added_by)
if item.paid_by:
all_waiter_ids.add(item.paid_by)
all_order_ids = {item.order_id for item in all_items}
# Load orders with opener/closer
orders_db: dict[int, Order] = {}
if all_order_ids:
for o in db.query(Order).filter(Order.id.in_(all_order_ids)).all():
orders_db[o.id] = o
if o.opened_by:
all_waiter_ids.add(o.opened_by)
if o.closed_by:
all_waiter_ids.add(o.closed_by)
# Load table names
table_ids = {o.table_id for o in orders_db.values() if o.table_id}
tables_map: dict[int, str] = {}
if table_ids:
tbl_rows = db.query(Table).filter(Table.id.in_(table_ids)).all()
for t in tbl_rows:
for t in db.query(Table).filter(Table.id.in_(table_ids)).all():
prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else ""
tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}"
orders_seen = {}
for item in items:
oid = item.order_id
if oid not in orders_seen:
o = item.order
tid = o.table_id if o else None
orders_seen[oid] = {
# Load waiter names
waiters_map: dict[int, str] = {}
if all_waiter_ids:
for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all():
waiters_map[w.id] = w.full_name or w.username
def _wname(wid):
if wid is None:
return None
return waiters_map.get(wid, f"#{wid}")
# Build orders dict
orders_out: dict[int, dict] = {}
for oid, o in orders_db.items():
tid = o.table_id
orders_out[oid] = {
"order_id": oid,
"table_id": tid,
"table_name": tables_map.get(tid) if tid else None,
"opened_at": _dt(o.opened_at) if o else None,
"opened_at": _dt(o.opened_at),
"closed_at": _dt(o.closed_at),
"opener_name": _wname(o.opened_by),
"closer_name": _wname(o.closed_by),
"status": o.status,
"items": [],
}
orders_seen[oid]["items"].append({
# Attach items to their orders
for item in all_items:
oid = item.order_id
if oid not in orders_out:
continue
orders_out[oid]["items"].append({
"id": item.id,
"product_name": item.product.name if item.product else f"#{item.product_id}",
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"subtotal": round(float(item.unit_price) * item.quantity, 2),
"status": item.status,
"added_by_id": item.added_by,
"added_by_name": _wname(item.added_by),
"added_at": _dt(item.added_at),
"paid_by_id": item.paid_by,
"paid_by_name": _wname(item.paid_by),
"paid_at": _dt(item.paid_at),
"paid_in_shift_id": item.paid_in_shift_id,
"payment_method": item.payment_method,
})
# Compute hours worked
# Remove orders with no items (shouldn't happen but safety net)
populated_orders = [o for o in orders_out.values() if o["items"]]
# Compute duration
started = shift.started_at
ended = shift.ended_at
duration_minutes = None
if started and ended:
duration_minutes = int((ended - started).total_seconds() / 60)
elif started:
from datetime import datetime, timezone as tz
duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60)
from datetime import timezone as tz
now = datetime.now(tz.utc)
s = started if started.tzinfo else started.replace(tzinfo=tz.utc)
duration_minutes = int((now - s).total_seconds() / 60)
enriched = _enrich_shift(shift, db)
enriched["orders"] = list(orders_seen.values())
enriched["orders"] = populated_orders
enriched["duration_minutes"] = duration_minutes
return enriched

View File

@@ -1,5 +1,10 @@
import asyncio
import ipaddress
import json
import socket
import time
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from typing import List
@@ -11,7 +16,7 @@ from models.user import User
from models.product import Category, Product
from models.table import Table, TableGroup
from services import printer_service
from services.cloud_sync import _sync_once
from services.cloud_sync import _sync_once, _push_menu_snapshot
from middleware.license_check import license_state
from config import settings
@@ -101,6 +106,13 @@ async def sync_license_now(user: User = Depends(require_manager)):
}
@router.post("/sync-menu")
async def sync_menu_now(user: User = Depends(require_manager)):
"""Immediately push the digital menu snapshot (and any changed product images) to the cloud."""
await _push_menu_snapshot()
return {"ok": True}
@router.get("/printers", response_model=List[PrinterOut])
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
return db.query(Printer).all()
@@ -120,7 +132,7 @@ def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = De
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_print(printer.ip_address, printer.port, printer.name)
success, error = printer_service.send_test_print(printer.ip_address, printer.port, printer.name, printer.codepage_n)
return {"success": success, "error": error}
@@ -129,7 +141,16 @@ def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db)
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db, printer.line_width, printer.codepage_n)
return {"success": success, "error": error}
@router.post("/printers/test-beep")
def test_beep(printer_id: int, n1: int = 2, n2: int = 2, n3: int = 1, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_beep(printer.ip_address, printer.port, n1, n2, n3, printer.codepage_n)
return {"success": success, "error": error}
@@ -155,6 +176,103 @@ def delete_printer(printer_id: int, db: Session = Depends(get_db), user: User =
return {"ok": True}
def _detect_local_subnets() -> list[str]:
"""Return plausible /24 subnets based on local interface IPs, excluding loopback/docker."""
subnets = []
try:
hostname = socket.gethostname()
for info in socket.getaddrinfo(hostname, None):
ip_str = info[4][0]
try:
addr = ipaddress.IPv4Address(ip_str)
if addr.is_loopback or addr.is_link_local:
continue
# Exclude docker bridge ranges (172.16-31.x.x)
if addr.packed[0] == 172 and 16 <= addr.packed[1] <= 31:
continue
net = str(ipaddress.IPv4Network(f"{ip_str}/24", strict=False))
if net not in subnets:
subnets.append(net)
except (ValueError, OSError):
continue
except OSError:
pass
return subnets
async def _check_port(ip: str, port: int, timeout: float) -> bool:
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection(ip, port), timeout=timeout
)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
return True
except Exception:
return False
async def _scan_subnet_sse(subnet: str, port: int, token: str):
"""Async generator: yields SSE lines as printers are found."""
try:
network = ipaddress.IPv4Network(subnet, strict=False)
except ValueError as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return
hosts = list(network.hosts())
semaphore = asyncio.Semaphore(50) # max 50 concurrent connects
async def check(ip_str: str):
async with semaphore:
return ip_str, await _check_port(ip_str, port, timeout=0.4)
tasks = [asyncio.create_task(check(str(h))) for h in hosts]
yield f"data: {json.dumps({'type': 'start', 'total': len(tasks), 'subnet': subnet, 'port': port})}\n\n"
done_count = 0
for coro in asyncio.as_completed(tasks):
ip_str, reachable = await coro
done_count += 1
if reachable:
yield f"data: {json.dumps({'type': 'found', 'ip': ip_str, 'port': port})}\n\n"
if done_count % 20 == 0 or done_count == len(tasks):
yield f"data: {json.dumps({'type': 'progress', 'done': done_count, 'total': len(tasks)})}\n\n"
yield f"data: {json.dumps({'type': 'done'})}\n\n"
@router.get("/printers/scan")
async def scan_printers(
subnet: str = Query(default=""),
port: int = Query(default=9100),
user: User = Depends(require_manager),
):
"""SSE endpoint: scan a subnet for devices responding on the given port."""
# Fall back to auto-detected subnet if none provided
if not subnet:
detected = _detect_local_subnets()
subnet = detected[0] if detected else "192.168.1.0/24"
return StreamingResponse(
_scan_subnet_sse(subnet, port, ""),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@router.get("/printers/scan-hints")
def scan_hints(user: User = Depends(require_manager)):
"""Return auto-detected local subnets to suggest in the scan UI."""
return {"subnets": _detect_local_subnets()}
@router.get("/stats")
def system_stats(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return {
@@ -162,8 +280,8 @@ def system_stats(db: Session = Depends(get_db), user: User = Depends(get_current
"products": db.query(Product).filter(Product.lifecycle_status == "active").count(),
"tables": db.query(Table).filter(Table.is_active == True).count(),
"table_groups": db.query(TableGroup).count(),
"managers": db.query(User).filter(User.role == "manager", User.is_active == True).count(),
"waiters": db.query(User).filter(User.role == "waiter", User.is_active == True).count(),
"managers": db.query(User).filter(User.perm_access_dashboard == True, User.is_active == True).count(),
"waiters": db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).count(),
}

View File

@@ -1,19 +1,24 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Body
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime, timezone, timedelta
from database import get_db
from models.table import Table, TableGroup
from models.order import Order
from models.reservation import Reservation
from models.user import User, WaiterZone
from schemas.table import (
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut,
TableCreate, TableUpdate, TableFloorplanUpdate, TableOut, UpcomingReservation,
TableGroupCreate, TableGroupUpdate, TableGroupOut,
TableBatchCreate, MAX_TABLE_NAME_LENGTH,
)
from routers.deps import get_current_user, require_manager
from routers.deps import get_current_user, require_tables_manager
from services.sse_bus import broadcast_sync
# Tables with a pending reservation due within this many hours get the RESERVED badge
RESERVATION_LOOKAHEAD_HOURS = 4
router = APIRouter()
@@ -25,7 +30,7 @@ def list_groups(db: Session = Depends(get_db), user: User = Depends(get_current_
@router.post("/groups", response_model=TableGroupOut, status_code=status.HTTP_201_CREATED)
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
raise HTTPException(status_code=400, detail="Group name already exists")
sort_order = db.query(TableGroup).count()
@@ -36,8 +41,15 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
return group
@router.put("/groups/reorder", status_code=status.HTTP_204_NO_CONTENT)
def reorder_groups(body: List[int] = Body(...), db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
for idx, group_id in enumerate(body):
db.query(TableGroup).filter(TableGroup.id == group_id).update({"sort_order": idx})
db.commit()
@router.put("/groups/{group_id}", response_model=TableGroupOut)
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Group not found")
@@ -49,7 +61,7 @@ def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(ge
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Group not found")
@@ -72,7 +84,7 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
q = q.filter(Table.is_active == True)
# Zone-based filtering for waiters
if user.role not in ("manager", "sysadmin"):
if user.role != "superadmin" and not user.perm_access_dashboard:
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
# No zone rows → sees nothing
if not zones:
@@ -91,18 +103,40 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
).all()
}
# Fetch pending reservations due within the lookahead window for the RESERVED badge
now = datetime.now(timezone.utc)
lookahead = now + timedelta(hours=RESERVATION_LOOKAHEAD_HOURS)
upcoming_reservations = (
db.query(Reservation)
.filter(
Reservation.status == "pending",
Reservation.table_id.isnot(None),
Reservation.reserved_for >= now,
Reservation.reserved_for <= lookahead,
)
.all()
)
# Map table_id → soonest upcoming reservation
reservation_by_table: dict[int, Reservation] = {}
for res in sorted(upcoming_reservations, key=lambda r: r.reserved_for):
if res.table_id not in reservation_by_table:
reservation_by_table[res.table_id] = res
result = []
for t in tables:
out = TableOut.model_validate(t)
out.has_active_order = t.id in active_table_ids
res = reservation_by_table.get(t.id)
if res:
out.upcoming_reservation = UpcomingReservation.model_validate(res)
result.append(out)
return result
@router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED)
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
number = _next_global_number(db)
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True)
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True, seat_count=body.seat_count)
db.add(table)
db.commit()
db.refresh(table)
@@ -111,7 +145,7 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
@router.post("/batch", response_model=List[TableOut], status_code=status.HTTP_201_CREATED)
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
if body.count < 1 or body.count > 200:
raise HTTPException(status_code=400, detail="Count must be between 1 and 200")
@@ -160,7 +194,7 @@ def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), u
@router.put("/{table_id}", response_model=TableOut)
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
table = db.query(Table).filter(Table.id == table_id).first()
if not table:
raise HTTPException(status_code=404, detail="Table not found")
@@ -172,7 +206,7 @@ def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db)
@router.delete("/{table_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
table = db.query(Table).filter(Table.id == table_id).first()
if not table:
raise HTTPException(status_code=404, detail="Table not found")
@@ -216,7 +250,7 @@ def table_status(table_id: int, db: Session = Depends(get_db), user: User = Depe
@router.put("/{table_id}/floorplan", response_model=TableOut)
def update_floorplan(table_id: int, body: TableFloorplanUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_floorplan(table_id: int, body: TableFloorplanUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
table = db.query(Table).filter(Table.id == table_id).first()
if not table:
raise HTTPException(status_code=404, detail="Table not found")

View File

@@ -0,0 +1,223 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from database import get_db
from models.tabs import Tab, TabEntry, TabPayment
from models.customers import Customer
from models.order import Order, OrderItem
from models.user import User
from schemas.tabs import TabOut, TabEntryOut, TabPaymentOut, TabPayRequest, TabForgiveRequest, TabCloseRequest
from routers.deps import require_manager
router = APIRouter()
def _utcnow():
return datetime.now(timezone.utc)
def _tab_out(tab: Tab) -> dict:
c = tab.customer
customer_name = c.name if c else None
if c and c.nickname:
customer_name = f"{c.name} «{c.nickname}»"
customer_phone = c.phone if c else None
total_charged = round(sum(e.amount for e in tab.entries), 2)
total_paid = round(sum(p.amount for p in tab.payments), 2)
balance = round(total_charged - total_paid, 2)
closed_by_name = None
if tab.closed_by:
closed_by_name = tab.closed_by.full_name or tab.closed_by.username
entries_out = []
for e in sorted(tab.entries, key=lambda x: x.created_at):
creator = e.created_by.full_name or e.created_by.username if e.created_by else None
entries_out.append({
"id": e.id, "tab_id": e.tab_id, "order_id": e.order_id,
"order_item_id": e.order_item_id, "amount": e.amount,
"description": e.description, "created_by_id": e.created_by_id,
"created_by_name": creator, "created_at": e.created_at,
})
payments_out = []
for p in sorted(tab.payments, key=lambda x: x.created_at):
receiver = p.received_by.full_name or p.received_by.username if p.received_by else None
payments_out.append({
"id": p.id, "tab_id": p.tab_id, "amount": p.amount,
"payment_method": p.payment_method, "received_by_id": p.received_by_id,
"received_by_name": receiver, "notes": p.notes, "created_at": p.created_at,
})
return {
"id": tab.id,
"customer_id": tab.customer_id,
"customer_name": customer_name,
"customer_phone": customer_phone,
"status": tab.status,
"opened_at": tab.opened_at,
"closed_at": tab.closed_at,
"closed_by_id": tab.closed_by_id,
"notes": tab.notes,
"balance": balance,
"total_charged": total_charged,
"total_paid": total_paid,
"entries": entries_out,
"payments": payments_out,
}
@router.get("/", response_model=List[TabOut])
def list_tabs(
tab_status: Optional[str] = None,
customer_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Tab)
if tab_status:
q = q.filter(Tab.status == tab_status)
else:
q = q.filter(Tab.status == "open") # default: only open tabs
if customer_id:
q = q.filter(Tab.customer_id == customer_id)
tabs = q.order_by(Tab.opened_at.desc()).all()
result = [_tab_out(t) for t in tabs]
# Sort open tabs by balance descending
result.sort(key=lambda x: x["balance"], reverse=True)
return result
@router.get("/{tab_id}", response_model=TabOut)
def get_tab(tab_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
tab = db.query(Tab).filter(Tab.id == tab_id).first()
if not tab:
raise HTTPException(status_code=404, detail="Tab not found")
return _tab_out(tab)
@router.post("/", response_model=TabOut, status_code=status.HTTP_201_CREATED)
def open_tab(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
customer = db.query(Customer).filter(Customer.id == customer_id, Customer.is_active == True).first()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
existing = db.query(Tab).filter(Tab.customer_id == customer_id, Tab.status == "open").first()
if existing:
raise HTTPException(status_code=400, detail="Customer already has an open tab")
tab = Tab(customer_id=customer_id)
db.add(tab)
db.commit()
db.refresh(tab)
return _tab_out(tab)
@router.post("/{tab_id}/entries", response_model=TabOut)
def add_tab_entry(
tab_id: int,
order_id: Optional[int] = None,
order_item_id: Optional[int] = None,
amount: float = 0.0,
description: Optional[str] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first()
if not tab:
raise HTTPException(status_code=404, detail="Open tab not found")
if amount <= 0:
raise HTTPException(status_code=400, detail="Amount must be positive")
entry = TabEntry(
tab_id=tab_id, order_id=order_id, order_item_id=order_item_id,
amount=amount, description=description, created_by_id=user.id,
)
db.add(entry)
db.commit()
db.refresh(tab)
return _tab_out(tab)
@router.post("/{tab_id}/pay", response_model=TabOut)
def pay_tab(
tab_id: int,
body: TabPayRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first()
if not tab:
raise HTTPException(status_code=404, detail="Open tab not found")
if body.amount <= 0:
raise HTTPException(status_code=400, detail="Amount must be positive")
total_charged = sum(e.amount for e in tab.entries)
total_paid = sum(p.amount for p in tab.payments)
balance = total_charged - total_paid
if body.amount > balance + 0.005:
raise HTTPException(status_code=400, detail=f"Payment exceeds balance ({balance:.2f})")
payment = TabPayment(
tab_id=tab_id, amount=body.amount,
payment_method=body.payment_method, notes=body.notes,
received_by_id=user.id,
)
db.add(payment)
db.commit()
db.refresh(tab)
return _tab_out(tab)
@router.post("/{tab_id}/close", response_model=TabOut)
def close_tab(
tab_id: int,
body: TabCloseRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first()
if not tab:
raise HTTPException(status_code=404, detail="Open tab not found")
total_charged = sum(e.amount for e in tab.entries)
total_paid = sum(p.amount for p in tab.payments)
balance = round(total_charged - total_paid, 2)
if balance > 0.005:
raise HTTPException(status_code=400, detail=f"Tab still has a balance of €{balance:.2f} — pay it off first or use /forgive")
tab.status = "closed"
tab.closed_at = _utcnow()
tab.closed_by_id = user.id
if body.notes:
tab.notes = body.notes
db.commit()
db.refresh(tab)
return _tab_out(tab)
@router.post("/{tab_id}/forgive", response_model=TabOut)
def forgive_tab(
tab_id: int,
body: TabForgiveRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
tab = db.query(Tab).filter(Tab.id == tab_id, Tab.status == "open").first()
if not tab:
raise HTTPException(status_code=404, detail="Open tab not found")
tab.status = "forgiven"
tab.closed_at = _utcnow()
tab.closed_by_id = user.id
if body.reason:
tab.notes = body.reason
db.commit()
db.refresh(tab)
return _tab_out(tab)
# ── Customer tabs ──────────────────────────────────────────────────────────────
@router.get("/customer/{customer_id}", response_model=List[TabOut])
def customer_tabs(customer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
tabs = db.query(Tab).filter(Tab.customer_id == customer_id).order_by(
Tab.status.asc(), # open first
Tab.opened_at.desc(),
).all()
return [_tab_out(t) for t in tabs]

View File

@@ -8,41 +8,86 @@ from typing import List
from database import get_db
from models.user import User, AssistantAssignment, WaiterZone
from models.shift import WaiterShift
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest
from routers.deps import require_manager, get_current_user
from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest, PermissionToggle
from routers.deps import require_staff_manager, get_current_user
from services.chat_service import add_user_to_system_group
from roles import ALL_ROLES, VALID_ROLES, get_default_permissions
router = APIRouter()
AVATAR_DIR = "/app/data/avatars"
# Permission fields that can be toggled via the API
PERMISSION_FIELDS = {
"perm_access_dashboard",
"perm_access_waiter_app",
"perm_access_kds",
"perm_cancel_orders",
"perm_apply_discounts",
"perm_modify_prices",
"perm_open_orders",
"perm_close_orders",
"perm_view_reports",
"perm_manage_staff",
"perm_manage_tables",
"perm_manage_menu",
"perm_manage_settings",
}
# ── Helpers ───────────────────────────────────────────────────────────────────
def _waiter_or_404(waiter_id: int, db: Session) -> User:
w = db.query(User).filter(User.id == waiter_id).first()
if not w:
raise HTTPException(status_code=404, detail="Waiter not found")
return w
def _user_or_404(user_id: int, db: Session) -> User:
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(status_code=404, detail="Staff member not found")
return u
def _guard_superadmin_target(target: User, acting_user: User):
"""Prevent any modification of a superadmin by a non-superadmin."""
if target.role == "superadmin" and acting_user.role != "superadmin":
raise HTTPException(status_code=403, detail="Cannot modify a superadmin account")
def _apply_default_permissions(user: User, role: str):
"""Apply the default permission set for a role onto a User object."""
for field, value in get_default_permissions(role).items():
setattr(user, field, value)
# ── Metadata ──────────────────────────────────────────────────────────────────
@router.get("/roles")
def list_roles():
"""Return the full ordered list of available roles."""
return ALL_ROLES
# ── CRUD ──────────────────────────────────────────────────────────────────────
@router.get("/on-shift", response_model=List[UserOut])
def list_waiters_on_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Waiters with an active (not-ended) shift. Accessible to all staff."""
"""Staff with an active (not-ended) shift. Accessible to all staff."""
waiter_ids = db.query(WaiterShift.waiter_id).filter(WaiterShift.ended_at == None).subquery()
return db.query(User).filter(User.id.in_(waiter_ids), User.role == "waiter", User.is_active == True).all()
return db.query(User).filter(User.id.in_(waiter_ids), User.perm_access_waiter_app == True, User.is_active == True).all()
@router.get("/", response_model=List[UserOut])
def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)):
return db.query(User).filter(User.role == "waiter").all()
def list_staff(db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
"""List all staff members regardless of role."""
return db.query(User).order_by(User.created_at).all()
@router.post("/", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def create_staff(body: UserCreate, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
if body.role not in VALID_ROLES:
raise HTTPException(status_code=400, detail=f"Invalid role. Valid roles: {sorted(VALID_ROLES)}")
if body.role == "superadmin" and user.role != "superadmin":
raise HTTPException(status_code=403, detail="Only a superadmin can create another superadmin")
if db.query(User).filter(User.username == body.username).first():
raise HTTPException(status_code=400, detail="Username already exists")
pin_hash = bcrypt.hashpw(body.pin.encode(), bcrypt.gensalt()).decode()
new_user = User(
username=body.username,
@@ -52,99 +97,158 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: User =
full_name=body.full_name,
nickname=body.nickname,
mobile_phone=body.mobile_phone,
email=body.email,
note=body.note,
hourly_rate=body.hourly_rate,
)
# Apply role defaults (caller can override via the body fields)
_apply_default_permissions(new_user, body.role)
# Allow explicit overrides from the request body for any perm field that was set
for field in PERMISSION_FIELDS:
val = getattr(body, field, None)
if val is not None:
setattr(new_user, field, val)
db.add(new_user)
db.commit()
db.refresh(new_user)
# Give waiter-app users access to all zones by default
if new_user.perm_access_waiter_app:
db.add(WaiterZone(waiter_id=new_user.id, group_id=None))
db.commit()
add_user_to_system_group(db, new_user.id)
return new_user
@router.put("/{waiter_id}", response_model=UserOut)
def update_waiter(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
waiter = _waiter_or_404(waiter_id, db)
def update_staff(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
target = _user_or_404(waiter_id, db)
_guard_superadmin_target(target, user)
if body.role is not None and body.role not in VALID_ROLES:
raise HTTPException(status_code=400, detail=f"Invalid role. Valid roles: {sorted(VALID_ROLES)}")
if body.role == "superadmin" and user.role != "superadmin":
raise HTTPException(status_code=403, detail="Only a superadmin can assign the superadmin role")
for field, value in body.model_dump(exclude_none=True).items():
setattr(waiter, field, value)
setattr(target, field, value)
db.commit()
db.refresh(waiter)
return waiter
db.refresh(target)
return target
@router.put("/{waiter_id}/reset-pin")
def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_manager)):
waiter = _waiter_or_404(waiter_id, db)
waiter.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
target = _user_or_404(waiter_id, db)
_guard_superadmin_target(target, user)
target.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
db.commit()
return {"status": "pin reset"}
@router.put("/{waiter_id}/block")
def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
waiter = _waiter_or_404(waiter_id, db)
waiter.is_active = not waiter.is_active
def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
target = _user_or_404(waiter_id, db)
_guard_superadmin_target(target, user)
target.is_active = not target.is_active
db.commit()
return {"is_active": waiter.is_active}
return {"is_active": target.is_active}
@router.put("/{waiter_id}/permission")
def toggle_permission(
waiter_id: int,
body: PermissionToggle,
db: Session = Depends(get_db),
user: User = Depends(require_staff_manager),
):
"""Toggle a single permission flag for a staff member."""
target = _user_or_404(waiter_id, db)
_guard_superadmin_target(target, user)
if body.permission not in PERMISSION_FIELDS:
raise HTTPException(status_code=400, detail=f"Unknown permission: {body.permission}")
# Superadmins always have all permissions — we store the value but enforce it at auth time
setattr(target, body.permission, body.value)
db.commit()
return {"permission": body.permission, "value": body.value}
@router.put("/{waiter_id}/reset-permissions")
def reset_permissions(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
"""Reset a staff member's permissions to their role's defaults."""
target = _user_or_404(waiter_id, db)
_guard_superadmin_target(target, user)
_apply_default_permissions(target, target.role)
db.commit()
db.refresh(target)
return target
@router.delete("/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_waiter(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
waiter = _waiter_or_404(waiter_id, db)
db.delete(waiter)
def delete_staff(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
target = _user_or_404(waiter_id, db)
_guard_superadmin_target(target, user)
db.delete(target)
db.commit()
# ── Avatar upload / delete ───────────────────────────────────────────────────
@router.post("/{waiter_id}/avatar", response_model=UserOut)
async def upload_avatar(waiter_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
waiter = _waiter_or_404(waiter_id, db)
async def upload_avatar(waiter_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
target = _user_or_404(waiter_id, db)
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
# Delete old avatar file if present
if waiter.avatar_url:
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
if target.avatar_url:
old_path = os.path.join(AVATAR_DIR, os.path.basename(target.avatar_url))
if os.path.exists(old_path):
os.remove(old_path)
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"waiter_{waiter_id}_{uuid.uuid4().hex[:8]}{ext}"
filename = f"staff_{waiter_id}_{uuid.uuid4().hex[:8]}{ext}"
dest = os.path.join(AVATAR_DIR, filename)
os.makedirs(AVATAR_DIR, exist_ok=True)
content = await file.read()
with open(dest, "wb") as f:
f.write(content)
waiter.avatar_url = f"/static/avatars/{filename}"
target.avatar_url = f"/static/avatars/{filename}"
db.commit()
db.refresh(waiter)
return waiter
db.refresh(target)
return target
@router.delete("/{waiter_id}/avatar", response_model=UserOut)
def delete_avatar(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
waiter = _waiter_or_404(waiter_id, db)
if waiter.avatar_url:
old_path = os.path.join(AVATAR_DIR, os.path.basename(waiter.avatar_url))
def delete_avatar(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
target = _user_or_404(waiter_id, db)
if target.avatar_url:
old_path = os.path.join(AVATAR_DIR, os.path.basename(target.avatar_url))
if os.path.exists(old_path):
os.remove(old_path)
waiter.avatar_url = None
target.avatar_url = None
db.commit()
db.refresh(waiter)
return waiter
db.refresh(target)
return target
# ── Zone assignments ──────────────────────────────────────────────────────────
@router.put("/{waiter_id}/zones")
def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_db), user: User = Depends(require_manager)):
"""Replace all zone assignments for a waiter atomically.
def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
"""Replace all zone assignments for a staff member atomically.
- all_zones=True → single NULL group_id row (sees everything)
- group_ids=[1,2] → rows for groups 1 and 2 only
- group_ids=[] → no rows at all (sees nothing)
"""
_waiter_or_404(waiter_id, db)
# Wipe existing assignments
_user_or_404(waiter_id, db)
db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).delete()
if body.all_zones:
@@ -161,7 +265,7 @@ def set_zones(waiter_id: int, body: SetZonesRequest, db: Session = Depends(get_d
# ── Assistant assignments (kept for backwards compat) ─────────────────────────
@router.post("/{waiter_id}/assign-assistant", response_model=AssistantAssignmentOut)
def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
existing = db.query(AssistantAssignment).filter(
AssistantAssignment.primary_waiter_id == waiter_id,
AssistantAssignment.assistant_waiter_id == assistant_id,
@@ -176,7 +280,7 @@ def assign_assistant(waiter_id: int, assistant_id: int, db: Session = Depends(ge
@router.delete("/{waiter_id}/assistant", status_code=status.HTTP_204_NO_CONTENT)
def remove_assistant(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def remove_assistant(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_staff_manager)):
assignment = db.query(AssistantAssignment).filter(
AssistantAssignment.primary_waiter_id == waiter_id
).first()

View File

@@ -0,0 +1,182 @@
import json
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
from database import get_db
from models.waste import WasteLog
from models.product import Product
from models.business_day import BusinessDay
from models.user import User
from schemas.waste import WasteLogCreate, WasteLogOut, VALID_REASONS, REASON_LABELS
from routers.deps import require_manager
router = APIRouter()
def _effective_cost(product: Product) -> Optional[float]:
"""Return product's current effective cost (breakdown sum or simple), or None."""
if product.cost_breakdown:
try:
entries = json.loads(product.cost_breakdown)
total = sum(e.get("amount", 0.0) for e in entries if isinstance(e, dict))
if total > 0:
return total
except Exception:
pass
if product.cost_simple:
return product.cost_simple
return None
def _waste_out(w: WasteLog) -> dict:
product_name = w.product.name if w.product else f"#{w.product_id}"
logger_name = None
if w.logged_by:
logger_name = w.logged_by.full_name or w.logged_by.username
return {
"id": w.id,
"product_id": w.product_id,
"product_name": product_name,
"quantity": w.quantity,
"unit_cost_snapshot": w.unit_cost_snapshot,
"total_cost": w.total_cost,
"reason": w.reason,
"reason_label": REASON_LABELS.get(w.reason, w.reason),
"reason_notes": w.reason_notes,
"logged_by_id": w.logged_by_id,
"logged_by_name": logger_name,
"business_day_id": w.business_day_id,
"logged_at": w.logged_at,
}
@router.get("/")
def list_waste(
business_day_id: Optional[int] = None,
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from datetime import datetime
q = db.query(WasteLog).order_by(WasteLog.logged_at.desc())
if business_day_id:
q = q.filter(WasteLog.business_day_id == business_day_id)
elif from_dt and to_dt:
q = q.filter(
WasteLog.logged_at >= datetime.fromisoformat(from_dt),
WasteLog.logged_at <= datetime.fromisoformat(to_dt),
)
entries = q.all()
return {"entries": [_waste_out(w) for w in entries]}
@router.post("/", status_code=status.HTTP_201_CREATED)
def log_waste(
body: WasteLogCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
if body.reason not in VALID_REASONS:
raise HTTPException(status_code=400, detail=f"Invalid reason. Valid: {sorted(VALID_REASONS)}")
if body.quantity <= 0:
raise HTTPException(status_code=400, detail="Quantity must be positive")
product = db.query(Product).filter(Product.id == body.product_id).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
# Snapshot current effective cost
unit_cost = _effective_cost(product)
total_cost = round(unit_cost * body.quantity, 2) if unit_cost is not None else None
# Attach to open business day if any
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
entry = WasteLog(
product_id=body.product_id,
quantity=body.quantity,
reason=body.reason,
reason_notes=body.reason_notes,
unit_cost_snapshot=unit_cost,
total_cost=total_cost,
logged_by_id=user.id,
business_day_id=active_day.id if active_day else None,
)
db.add(entry)
db.commit()
db.refresh(entry)
return _waste_out(entry)
@router.delete("/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_waste(
entry_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
entry = db.query(WasteLog).filter(WasteLog.id == entry_id).first()
if not entry:
raise HTTPException(status_code=404, detail="Waste entry not found")
# Only allow deletion within the same business day
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
if active_day and entry.business_day_id != active_day.id:
raise HTTPException(status_code=403, detail="Can only delete waste entries from the current open business day")
db.delete(entry)
db.commit()
@router.get("/summary")
def waste_summary(
business_day_id: Optional[int] = None,
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from datetime import datetime
q = db.query(WasteLog)
if business_day_id:
q = q.filter(WasteLog.business_day_id == business_day_id)
elif from_dt and to_dt:
q = q.filter(
WasteLog.logged_at >= datetime.fromisoformat(from_dt),
WasteLog.logged_at <= datetime.fromisoformat(to_dt),
)
entries = q.all()
total_items = len(entries)
total_cost = round(sum(e.total_cost for e in entries if e.total_cost is not None), 2)
uncosted_count = sum(1 for e in entries if e.total_cost is None)
by_reason: dict = {}
by_product: dict = {}
for e in entries:
r = e.reason
if r not in by_reason:
by_reason[r] = {"reason": r, "label": REASON_LABELS.get(r, r), "count": 0, "total_cost": 0.0}
by_reason[r]["count"] += 1
by_reason[r]["total_cost"] += e.total_cost or 0.0
pid = e.product_id
pname = e.product.name if e.product else f"#{pid}"
if pid not in by_product:
by_product[pid] = {"product_id": pid, "product_name": pname, "qty": 0.0, "total_cost": 0.0}
by_product[pid]["qty"] += e.quantity
by_product[pid]["total_cost"] += e.total_cost or 0.0
for v in by_reason.values():
v["total_cost"] = round(v["total_cost"], 2)
for v in by_product.values():
v["total_cost"] = round(v["total_cost"], 2)
return {
"total_items": total_items,
"total_cost": total_cost,
"uncosted_count": uncosted_count,
"by_reason": sorted(by_reason.values(), key=lambda x: -x["count"]),
"by_product": sorted(by_product.values(), key=lambda x: -x["total_cost"]),
}

View File

@@ -0,0 +1,87 @@
"""
WebSocket endpoint — one persistent connection per connected client (waiter PWA, KDS).
Authentication: JWT token passed as query param ?token=<jwt>
(Same pattern as SSE — browser WebSocket API also cannot set custom headers.)
Protocol:
1. Client connects: ws://host/api/ws?token=<jwt>
2. Client immediately sends: { "cursor": <last_seq_id> }
(0 = first connect / no history; any positive int = resume from that point)
3. Server replays all missed events (seq > cursor) as individual frames
4. Server sends { "type": "ready" } to signal end of replay / start of live stream
5. Live events arrive as: { "seq": 123, "type": "...", "data": { ... } }
6. Every 25s server sends: { "type": "ping" }
7. Client may respond: { "type": "pong" } (ignored if not sent)
8. On disconnect/error the client reconnects and repeats from step 2
"""
import asyncio
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from starlette.websockets import WebSocketState
from routers.deps import decode_token
from services.ws_bus import connect, disconnect, get_events_since
router = APIRouter()
logger = logging.getLogger(__name__)
KEEPALIVE_INTERVAL = 25 # seconds
@router.websocket("/connect")
async def ws_endpoint(websocket: WebSocket, token: str = Query(...)):
# ── Auth ──────────────────────────────────────────────────────────────────
try:
payload = decode_token(token)
user_id: int = int(payload["sub"])
except Exception:
await websocket.close(code=4001)
return
await websocket.accept()
# ── Wait for cursor frame ─────────────────────────────────────────────────
cursor = 0
try:
raw = await asyncio.wait_for(websocket.receive_text(), timeout=10.0)
msg = json.loads(raw)
cursor = int(msg.get("cursor", 0))
except (asyncio.TimeoutError, Exception):
pass # no cursor sent — treat as fresh connect (cursor=0)
# ── Register this connection ──────────────────────────────────────────────
q = await connect(user_id)
try:
# ── Replay missed events ──────────────────────────────────────────────
if cursor > 0:
missed = get_events_since(cursor, user_id)
for event in missed:
await websocket.send_text(json.dumps(event))
# Signal end of replay / start of live stream
await websocket.send_text(json.dumps({"type": "ready"}))
# ── Live stream loop ──────────────────────────────────────────────────
while True:
try:
frame = await asyncio.wait_for(q.get(), timeout=KEEPALIVE_INTERVAL)
if websocket.client_state == WebSocketState.CONNECTED:
await websocket.send_text(frame)
except asyncio.TimeoutError:
# Send keepalive ping
if websocket.client_state == WebSocketState.CONNECTED:
await websocket.send_text(json.dumps({"type": "ping"}))
else:
break
except WebSocketDisconnect:
pass
except Exception as e:
logger.debug("ws_endpoint: connection closed for user %s: %s", user_id, e)
finally:
await disconnect(user_id, q)

View File

@@ -11,6 +11,10 @@ class BusinessDayOut(BaseModel):
closed_at: Optional[UTCDatetime] = None
closed_by_id: Optional[int] = None
notes: Optional[str] = None
# Phase 2E
store_opening_cash: Optional[float] = None
store_closing_cash: Optional[float] = None
store_cash_discrepancy: Optional[float] = None
model_config = {"from_attributes": True}
@@ -22,3 +26,4 @@ class OpenBusinessDayRequest(BaseModel):
class CloseBusinessDayRequest(BaseModel):
force: bool = False
notes: Optional[str] = None
store_closing_cash: Optional[float] = None # Phase 2E: physical cash counted at close

View File

@@ -0,0 +1,69 @@
from pydantic import BaseModel, field_serializer
from datetime import datetime, timezone
from typing import Optional, List
def _as_utc_iso(dt: datetime | None) -> str | None:
"""Serialize a datetime to an ISO-8601 string with explicit Z suffix.
SQLite stores datetimes without tzinfo; we treat all stored values as UTC."""
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z'
class ConversationCreate(BaseModel):
type: str # 'direct' | 'group'
name: Optional[str] = None
participant_ids: List[int]
class MessageCreate(BaseModel):
body: str
class ParticipantOut(BaseModel):
user_id: int
username: str
joined_at: datetime
last_read_at: Optional[datetime] = None
model_config = {"from_attributes": True}
@field_serializer('joined_at')
def ser_joined_at(self, v): return _as_utc_iso(v)
@field_serializer('last_read_at')
def ser_last_read_at(self, v): return _as_utc_iso(v)
class MessageOut(BaseModel):
id: int
conversation_id: int
sender_id: int
sender_name: str
body: str
sent_at: datetime
is_deleted: bool
model_config = {"from_attributes": True}
@field_serializer('sent_at')
def ser_sent_at(self, v): return _as_utc_iso(v)
class ConversationOut(BaseModel):
id: int
type: str
name: Optional[str] = None
is_system: bool
created_at: datetime
participants: List[ParticipantOut]
last_message: Optional[MessageOut] = None
unread_count: int
model_config = {"from_attributes": True}
@field_serializer('created_at')
def ser_created_at(self, v): return _as_utc_iso(v)

View File

@@ -0,0 +1,41 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class CustomerCreate(BaseModel):
name: str
nickname: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
notes: Optional[str] = None
class CustomerUpdate(BaseModel):
name: Optional[str] = None
nickname: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
class CustomerOut(BaseModel):
id: int
name: str
nickname: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
notes: Optional[str] = None
is_active: bool
created_at: datetime
created_by_id: int
# computed stats (set by router)
visit_count: int = 0
total_spent: float = 0.0
model_config = {"from_attributes": True}
class AssignCustomerRequest(BaseModel):
customer_id: Optional[int] = None # null = unassign

View File

@@ -0,0 +1,97 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
# ── Contacts ──────────────────────────────────────────────────────────────────
class ContactCreate(BaseModel):
name: str
type: str = "supplier"
phone: Optional[str] = None
email: Optional[str] = None
address: Optional[str] = None
notes: Optional[str] = None
class ContactUpdate(BaseModel):
name: Optional[str] = None
type: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
address: Optional[str] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
class ContactOut(BaseModel):
id: int
name: str
type: str
phone: Optional[str] = None
email: Optional[str] = None
address: Optional[str] = None
notes: Optional[str] = None
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
# ── Expenses ──────────────────────────────────────────────────────────────────
class ExpensePaymentCreate(BaseModel):
amount: float
notes: Optional[str] = None
class ExpensePaymentOut(BaseModel):
id: int
expense_id: int
amount: float
paid_at: datetime
paid_by_id: int
paid_by_name: Optional[str] = None
notes: Optional[str] = None
model_config = {"from_attributes": True}
class ExpenseCreate(BaseModel):
description: str
category: str
contact_id: Optional[int] = None
total_amount: float
due_date: Optional[datetime] = None
business_day_id: Optional[int] = None
notes: Optional[str] = None
class ExpenseUpdate(BaseModel):
description: Optional[str] = None
category: Optional[str] = None
contact_id: Optional[int] = None
total_amount: Optional[float] = None
due_date: Optional[datetime] = None
notes: Optional[str] = None
class ExpenseOut(BaseModel):
id: int
description: str
category: str
contact_id: Optional[int] = None
contact_name: Optional[str] = None
total_amount: float
paid_amount: float
due_amount: float # computed: total - paid
status: str # "paid" | "partial" | "due"
due_date: Optional[datetime] = None
business_day_id: Optional[int] = None
created_by_id: int
created_by_name: Optional[str] = None
created_at: datetime
notes: Optional[str] = None
payments: List[ExpensePaymentOut] = []
model_config = {"from_attributes": True}

View File

@@ -36,6 +36,8 @@ class StaffMessageOut(BaseModel):
body: str
target_waiter_ids: str # raw JSON string — frontend parses
table_ids: str
message_type: str = "manager"
kds_zone: Optional[str] = None
created_at: datetime
acked_by: List[int] = [] # waiter ids who have acked

View File

@@ -0,0 +1,51 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class NoteCreate(BaseModel):
body: str
is_pinned: bool = False
class NoteUpdate(BaseModel):
body: Optional[str] = None
is_pinned: Optional[bool] = None
class NoteOut(BaseModel):
id: int
body: str
is_pinned: bool
created_by_id: int
created_by_name: Optional[str] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TodoCreate(BaseModel):
body: str
priority: str = "normal"
class TodoUpdate(BaseModel):
body: Optional[str] = None
priority: Optional[str] = None
is_done: Optional[bool] = None
class TodoOut(BaseModel):
id: int
body: str
is_done: bool
done_at: Optional[datetime] = None
done_by_id: Optional[int] = None
done_by_name: Optional[str] = None
created_by_id: int
created_by_name: Optional[str] = None
created_at: datetime
priority: str
model_config = {"from_attributes": True}

View File

@@ -16,14 +16,19 @@ class SelectedOptionInput(BaseModel):
class OrderItemInput(BaseModel):
product_id: int
quantity: int
quantity: float
selected_options: Optional[List[SelectedOptionInput]] = None
removed_ingredients: Optional[List[str]] = None
notes: Optional[str] = None
price_adjustment: Optional[float] = None # on-the-fly price delta, applied at add-time
is_service_item: bool = False # priceless: print-once ephemeral; priced: saved as real item
course_id: Optional[int] = None
class AddItemsRequest(BaseModel):
items: List[OrderItemInput]
order_note: Optional[str] = None # whole-order note, written to Order.notes
customer_count: Optional[int] = None # number of customers at the table
class ProductNameOut(BaseModel):
@@ -38,18 +43,24 @@ class OrderItemOut(BaseModel):
product_id: int
product: Optional[ProductNameOut] = None
added_by: int
quantity: int
quantity: float
unit_type: str = "piece"
unit_price: float
selected_options: Optional[str] = None
removed_ingredients: Optional[str] = None
notes: Optional[str] = None
status: str
kds_status: str = "pending"
added_at: UTCDatetime
printed: bool
paid_by: Optional[int] = None
paid_at: Optional[UTCDatetime] = None
payment_method: Optional[str] = None
paid_in_shift_id: Optional[int] = None
price_adjustment: float = 0.0
course_id: Optional[int] = None
deal_id: Optional[int] = None
linked_item_id: Optional[int] = None
model_config = {"from_attributes": True}
@@ -63,12 +74,14 @@ class PrintResultOut(BaseModel):
class AddItemsResponse(BaseModel):
order: "OrderOut"
print_results: List[PrintResultOut]
deal_offers: List[dict] = [] # DealOfferOut dicts — serialized to avoid circular imports
model_config = {"from_attributes": True}
class OrderCreate(BaseModel):
table_id: int
table_id: Optional[int] = None
order_type: Optional[str] = "here" # here | takeaway | delivery
class PayItemsRequest(BaseModel):
@@ -109,9 +122,26 @@ class AuditLogOut(BaseModel):
model_config = {"from_attributes": True}
class PrintJobOut(BaseModel):
id: int
printer_id: int
zone_id: Optional[int] = None
item_ids: str
copies: int = 1
status: str
retry_count: int = 0
first_attempted_at: Optional[UTCDatetime] = None
last_attempted_at: Optional[UTCDatetime] = None
succeeded_at: Optional[UTCDatetime] = None
cancelled_at: Optional[UTCDatetime] = None
cancel_reason: Optional[str] = None
model_config = {"from_attributes": True}
class OrderOut(BaseModel):
id: int
table_id: int
table_id: Optional[int] = None
opened_by: int
opened_at: UTCDatetime
status: str
@@ -119,8 +149,21 @@ class OrderOut(BaseModel):
closed_by: Optional[int] = None
notes: Optional[str] = None
business_day_id: Optional[int] = None
# Xenia Connect — online order fields
source: str = "pos"
online_order_ref: Optional[str] = None
online_order_cloud_id: Optional[int] = None
online_status: Optional[str] = None
online_customer_name: Optional[str] = None
online_customer_phone: Optional[str] = None
online_customer_address: Optional[str] = None
online_customer_notes: Optional[str] = None
online_order_type: Optional[str] = None
order_type: str = "here"
customer_count: Optional[int] = None
items: List[OrderItemOut] = []
waiters: List[OrderWaiterOut] = []
audit_logs: List[AuditLogOut] = []
print_jobs: List[PrintJobOut] = []
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,384 @@
from __future__ import annotations
from typing import Optional, List, Any
from pydantic import BaseModel, field_validator
from datetime import datetime
# ---------------------------------------------------------------------------
# Price Groups
# ---------------------------------------------------------------------------
class PriceGroupCreate(BaseModel):
name: str
description: Optional[str] = None
color: Optional[str] = None
is_active: bool = False
auto_enable_time: Optional[str] = None # "HH:MM"
auto_disable_time: Optional[str] = None # "HH:MM"
auto_days: Optional[List[int]] = None # [0..6] Mon=0
class PriceGroupUpdate(PriceGroupCreate):
pass
class PriceGroupOut(BaseModel):
id: int
name: str
description: Optional[str]
color: Optional[str]
is_active: bool
auto_enable_time: Optional[str]
auto_disable_time: Optional[str]
auto_days: Optional[List[int]]
created_at: Optional[datetime]
model_config = {"from_attributes": True}
@field_validator("is_active", mode="before")
@classmethod
def coerce_bool(cls, v):
return bool(v)
@field_validator("auto_days", mode="before")
@classmethod
def parse_auto_days(cls, v):
if isinstance(v, str):
import json
try:
return json.loads(v)
except Exception:
return None
return v
# ---------------------------------------------------------------------------
# Price Modifier Conditions
# ---------------------------------------------------------------------------
class ConditionIn(BaseModel):
condition_type: str
params: dict = {}
class ConditionOut(BaseModel):
id: int
condition_type: str
params: dict
model_config = {"from_attributes": True}
@field_validator("params", mode="before")
@classmethod
def parse_params(cls, v):
if isinstance(v, str):
import json
try:
return json.loads(v)
except Exception:
return {}
return v or {}
# ---------------------------------------------------------------------------
# Price Modifier Targets
# ---------------------------------------------------------------------------
class ModifierTargetIn(BaseModel):
target_type: str # 'all'|'item'|'category'|'prep_zone'|'tag'
target_id: Optional[int] = None
target_tag: Optional[str] = None
target_ids: Optional[List[int]] = None
target_tags: Optional[List[str]] = None
class ModifierTargetOut(BaseModel):
id: int
target_type: str
target_id: Optional[int]
target_tag: Optional[str]
target_ids: Optional[List[int]] = None
target_tags: Optional[List[str]] = None
model_config = {"from_attributes": True}
@field_validator("target_ids", "target_tags", mode="before")
@classmethod
def parse_json_list(cls, v):
if isinstance(v, str):
import json
return json.loads(v)
return v
# ---------------------------------------------------------------------------
# Price Modifiers
# ---------------------------------------------------------------------------
class PriceModifierCreate(BaseModel):
name: str
description: Optional[str] = None
color: Optional[str] = None
is_active: bool = True
is_favorite: bool = False
allow_stack: bool = False
sort_order: int = 0
scope: str = "global" # 'item' | 'global'
item_id: Optional[int] = None
action_type: str # 'set' | 'add_amount' | 'add_percent'
action_value: float
round_to: Optional[str] = None # '0.05'|'0.10'|'0.20'|'0.50'|'x.99'|'x.00'
conditions: List[ConditionIn] = []
targets: List[ModifierTargetIn] = [] # only relevant for scope='global'
class PriceModifierUpdate(PriceModifierCreate):
pass
class PriceModifierOut(BaseModel):
id: int
name: str
description: Optional[str]
color: Optional[str]
is_active: bool
is_favorite: bool
allow_stack: bool
sort_order: int
scope: str
item_id: Optional[int]
action_type: str
action_value: float
round_to: Optional[str]
conditions: List[ConditionOut]
targets: List[ModifierTargetOut]
created_at: Optional[datetime]
updated_at: Optional[datetime]
model_config = {"from_attributes": True}
@field_validator("is_active", "is_favorite", "allow_stack", mode="before")
@classmethod
def coerce_bool(cls, v):
return bool(v)
class PriceModifierReorderItem(BaseModel):
id: int
sort_order: int
class PriceModifierReorderRequest(BaseModel):
items: List[PriceModifierReorderItem]
# ---------------------------------------------------------------------------
# Deals
# ---------------------------------------------------------------------------
class DealTargetIn(BaseModel):
target_type: str # 'item'|'category'|'tag'|'any'
target_id: Optional[int] = None
target_tag: Optional[str] = None
target_ids: Optional[List[int]] = None
target_tags: Optional[List[str]] = None
class DealTargetOut(BaseModel):
id: int
target_type: str
target_id: Optional[int]
target_tag: Optional[str]
target_ids: Optional[List[int]] = None
target_tags: Optional[List[str]] = None
model_config = {"from_attributes": True}
@field_validator("target_ids", "target_tags", mode="before")
@classmethod
def parse_json_list(cls, v):
if isinstance(v, str):
import json
return json.loads(v)
return v
class DealCreate(BaseModel):
name: str
description: Optional[str] = None
color: Optional[str] = None
is_active: bool = True
sort_order: int = 0
action_type: str
# Action payload — which fields are set depends on action_type
action_modifier_id: Optional[int] = None # for apply_modifier
action_value: Optional[float] = None # for set_price / add_amount / add_percent
action_free_item_id: Optional[int] = None # for free_item
action_free_target_type: Optional[str] = None # for free_choice: 'item'|'category'|'tag'
action_free_target_ids: Optional[List[Any]] = None # for free_choice
action_free_quantity: int = 1
conditions: List[ConditionIn] = []
targets: List[DealTargetIn] = []
class DealUpdate(DealCreate):
pass
class DealOut(BaseModel):
id: int
name: str
description: Optional[str]
color: Optional[str]
is_active: bool
sort_order: int
action_type: str
action_modifier_id: Optional[int]
action_value: Optional[float]
action_free_item_id: Optional[int]
action_free_target_type: Optional[str]
action_free_target_ids: Optional[List[Any]]
action_free_quantity: int
conditions: List[ConditionOut]
targets: List[DealTargetOut]
created_at: Optional[datetime]
model_config = {"from_attributes": True}
@field_validator("is_active", mode="before")
@classmethod
def coerce_bool(cls, v):
return bool(v)
@field_validator("action_free_target_ids", mode="before")
@classmethod
def parse_target_ids(cls, v):
if isinstance(v, str):
import json
try:
return json.loads(v)
except Exception:
return None
return v
# ---------------------------------------------------------------------------
# Waiter Discount Settings
# ---------------------------------------------------------------------------
class WaiterDiscountSettingsIn(BaseModel):
can_apply_discounts: bool = False
max_discount_percent: Optional[float] = None
max_discount_amount: Optional[float] = None
max_total_value_shift: Optional[float] = None
max_total_value_workday: Optional[float] = None
max_items_per_shift: Optional[int] = None
max_items_per_workday: Optional[int] = None
max_items_per_order: Optional[int] = None
class WaiterDiscountSettingsOut(WaiterDiscountSettingsIn):
id: int
user_id: int
model_config = {"from_attributes": True}
@field_validator("can_apply_discounts", mode="before")
@classmethod
def coerce_bool(cls, v):
return bool(v)
# ---------------------------------------------------------------------------
# Price Event Log
# ---------------------------------------------------------------------------
class PriceEventOut(BaseModel):
id: int
order_id: int
order_item_id: Optional[int]
event_type: str
modifier_id: Optional[int]
deal_id: Optional[int]
price_before: Optional[float]
price_after: Optional[float]
delta_amount: Optional[float]
applied_by_user_id: Optional[int]
waiter_note: Optional[str]
conditions_snapshot: Optional[dict]
selected_item_ids: Optional[List[int]]
applied_at: datetime
# Resolved names (joined at query time)
modifier_name: Optional[str] = None
deal_name: Optional[str] = None
applied_by_username: Optional[str] = None
model_config = {"from_attributes": True}
@field_validator("conditions_snapshot", mode="before")
@classmethod
def parse_snapshot(cls, v):
if isinstance(v, str):
import json
try:
return json.loads(v)
except Exception:
return None
return v
@field_validator("selected_item_ids", mode="before")
@classmethod
def parse_item_ids(cls, v):
if isinstance(v, str):
import json
try:
return json.loads(v)
except Exception:
return None
return v
# ---------------------------------------------------------------------------
# Waiter discount apply (PWA-facing)
# ---------------------------------------------------------------------------
class ApplyDiscountRequest(BaseModel):
item_ids: List[int]
discount_percent: float # e.g. 10.0 = 10%; final price rounds to nearest 0.10
note: Optional[str] = None
class ApplyDiscountResponse(BaseModel):
applied: List[dict] # [{item_id, price_before, price_after, delta}]
remaining_budget_shift: Optional[float] = None
remaining_budget_workday: Optional[float] = None
# ---------------------------------------------------------------------------
# Deal offer response (returned on add_items when deals are newly triggered)
# ---------------------------------------------------------------------------
class DealOfferOut(BaseModel):
deal_id: int
deal_name: str
action_type: str
free_item_id: Optional[int] = None
free_item_name: Optional[str] = None
free_choices: List[dict] = [] # [{id, name}]
free_quantity: int = 1
target_order_item_id: Optional[int] = None
price_before: Optional[float] = None
price_after: Optional[float] = None
# ---------------------------------------------------------------------------
# Deal offer accept (waiter confirms a deal)
# ---------------------------------------------------------------------------
class AcceptDealOfferRequest(BaseModel):
deal_id: int
selected_product_ids: Optional[List[int]] = None # for free_choice
class DismissDealOfferRequest(BaseModel):
deal_id: int

View File

@@ -4,6 +4,16 @@ from typing import Optional
PROTOCOLS = ["escpos_tcp"] # extend later as needed
# Known printer profiles — (codepage_n, python_encoding)
# codepage_n: the n byte in ESC t n sent on connect
# python_encoding: used to encode Greek text to bytes
PRINTER_PROFILES = {
"jolimark": {"label": "Jolimark TP850UE", "codepage_n": 29, "encoding": "cp737"},
"samas": {"label": "S4MAS Giant 100", "codepage_n": 29, "encoding": "cp737"},
"netum": {"label": "NETUM NT8330L", "codepage_n": 14, "encoding": "cp737"},
}
DEFAULT_PROFILE = "jolimark"
class PrinterBase(BaseModel):
name: str
@@ -11,6 +21,8 @@ class PrinterBase(BaseModel):
port: int = 9100
is_active: bool = True
protocol: str = "escpos_tcp"
line_width: int = 48
codepage_n: int = 29
class PrinterCreate(PrinterBase):
@@ -23,6 +35,8 @@ class PrinterUpdate(BaseModel):
port: Optional[int] = None
is_active: Optional[bool] = None
protocol: Optional[str] = None
line_width: Optional[int] = None
codepage_n: Optional[int] = None
class PrinterOut(PrinterBase):

View File

@@ -52,6 +52,24 @@ class ParentGeneralReorderItem(BaseModel):
general_sort_order: int
# ── Modifier Groups ───────────────────────────────────────────────────────────
class ModifierGroupCreate(BaseModel):
modifier_type: str # "option" | "ingredient" | "preference"
name: str
sort_order: int = 0
class ModifierGroupOut(BaseModel):
id: int
product_id: int
modifier_type: str
name: str
sort_order: int = 0
model_config = {"from_attributes": True}
# ── Quick Options ─────────────────────────────────────────────────────────────
class ProductQuickOptionCreate(BaseModel):
@@ -84,14 +102,23 @@ class OptionSubChoice(BaseModel):
name: str
extra_cost: float = 0.0
is_default: bool = False
# When True waiter can add more than 1 of this sub-choice
allow_multiple: bool = False
# When True renders as half-width card on the PWA
is_compact: bool = False
class ProductOptionBase(BaseModel):
name: str
extra_cost: float = 0.0
allow_multiple: bool = False
# When True waiter can select more than one sub-choice at once
multi_select: bool = False
is_favorite: bool = False
favorite_sort_order: int = 0
# When True renders the option itself as half-width card on the PWA
is_compact: bool = False
group_id: Optional[int] = None
class ProductOptionCreate(ProductOptionBase):
@@ -117,9 +144,12 @@ class ProductOptionOut(ProductOptionBase):
'name': data.name,
'extra_cost': data.extra_cost,
'allow_multiple': getattr(data, 'allow_multiple', False) or False,
'multi_select': getattr(data, 'multi_select', False) or False,
'sub_choices': parsed,
'is_favorite': getattr(data, 'is_favorite', False) or False,
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
'is_compact': getattr(data, 'is_compact', False) or False,
'group_id': getattr(data, 'group_id', None),
}
return data
@@ -131,6 +161,9 @@ class ProductIngredientBase(BaseModel):
extra_cost: float = 0.0
is_favorite: bool = False
favorite_sort_order: int = 0
# When True renders as half-width card on the PWA
is_compact: bool = False
group_id: Optional[int] = None
class ProductIngredientCreate(ProductIngredientBase):
@@ -150,6 +183,7 @@ class SubChoice(BaseModel):
name: str
extra_cost: float = 0.0
is_default: bool = False
is_compact: bool = False
# ── Shared subset (set-level, shown for all non-disabling choices) ─────────────
@@ -158,6 +192,7 @@ class SharedSubsetChoice(BaseModel):
name: str
extra_cost: float = 0.0
is_default: bool = False
is_compact: bool = False
class SharedSubset(BaseModel):
@@ -172,6 +207,8 @@ class PreferenceChoiceCreate(BaseModel):
extra_cost: float = 0.0
sub_choices: List[SubChoice] = []
disables_subset: bool = False
# When True renders as half-width card on the PWA
is_compact: bool = False
class PreferenceChoiceOut(BaseModel):
@@ -181,6 +218,7 @@ class PreferenceChoiceOut(BaseModel):
extra_cost: float = 0.0
sub_choices: List[SubChoice] = []
disables_subset: bool = False
is_compact: bool = False
model_config = {"from_attributes": True}
@@ -203,6 +241,7 @@ class PreferenceChoiceOut(BaseModel):
'extra_cost': data.extra_cost,
'sub_choices': parsed,
'disables_subset': data.disables_subset or False,
'is_compact': getattr(data, 'is_compact', False) or False,
}
return data
@@ -214,6 +253,9 @@ class PreferenceSetCreate(BaseModel):
shared_subset: Optional[SharedSubset] = None
is_favorite: bool = False
favorite_sort_order: int = 0
group_id: Optional[int] = None
allow_multi_select: bool = False
allow_choice_quantity: bool = False
class PreferenceSetOut(BaseModel):
@@ -225,6 +267,9 @@ class PreferenceSetOut(BaseModel):
shared_subset: Optional[SharedSubset] = None
is_favorite: bool = False
favorite_sort_order: int = 0
group_id: Optional[int] = None
allow_multi_select: bool = False
allow_choice_quantity: bool = False
model_config = {"from_attributes": True}
@@ -249,20 +294,52 @@ class PreferenceSetOut(BaseModel):
'shared_subset': parsed,
'is_favorite': getattr(data, 'is_favorite', False) or False,
'favorite_sort_order': getattr(data, 'favorite_sort_order', 0) or 0,
'group_id': getattr(data, 'group_id', None),
'allow_multi_select': getattr(data, 'allow_multi_select', False) or False,
'allow_choice_quantity': getattr(data, 'allow_choice_quantity', False) or False,
}
return data
# ── Products ──────────────────────────────────────────────────────────────────
class CostBreakdownItem(BaseModel):
label: str
amount: float
class ProductBase(BaseModel):
name: str
description: Optional[str] = None
category_id: Optional[int] = None
base_price: float
# piece | portion | kg | liter | gram | ml
unit_type: str = "piece"
is_available: bool = True
lifecycle_status: str = "active"
printer_zone_id: Optional[int] = None
printer_zone_id: Optional[int] = None # kept for backward compat; use prep_zone_ids instead
prep_zone_ids: List[int] = []
sort_order: int = 0
# Xenia Connect — digital menu overrides
digital_visible: bool = True
digital_available: bool = True
digital_name: Optional[str] = None
digital_description: Optional[str] = None
digital_price: Optional[float] = None
digital_discount: Optional[float] = None
digital_image_url: Optional[str] = None
# Phase 2A — cost tracking
cost_simple: Optional[float] = None
cost_breakdown: Optional[List[CostBreakdownItem]] = None
# Quick Add
quick_add_enabled: bool = True
# Tags
tags: Optional[List[str]] = None
# Service item flag
is_service_item: bool = False
# Fiscal printer (ΦΗΜ)
fiscal_name: Optional[str] = None
fiscal_vat_group_id: Optional[int] = None
class ProductCreate(ProductBase):
@@ -270,20 +347,46 @@ class ProductCreate(ProductBase):
options: List[ProductOptionCreate] = []
ingredients: List[ProductIngredientCreate] = []
preference_sets: List[PreferenceSetCreate] = []
# group_id on options/ingredients/preference_sets is an index into this list
modifier_groups: List[ModifierGroupCreate] = []
class ProductUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
category_id: Optional[int] = None
base_price: Optional[float] = None
unit_type: Optional[str] = None
is_available: Optional[bool] = None
lifecycle_status: Optional[str] = None
printer_zone_id: Optional[int] = None
prep_zone_ids: Optional[List[int]] = None
sort_order: Optional[int] = None
quick_options: Optional[List[ProductQuickOptionCreate]] = None
options: Optional[List[ProductOptionCreate]] = None
ingredients: Optional[List[ProductIngredientCreate]] = None
preference_sets: Optional[List[PreferenceSetCreate]] = None
modifier_groups: Optional[List[ModifierGroupCreate]] = None
# Xenia Connect — digital menu overrides
digital_visible: Optional[bool] = None
digital_available: Optional[bool] = None
digital_name: Optional[str] = None
digital_description: Optional[str] = None
digital_price: Optional[float] = None
digital_discount: Optional[float] = None
digital_image_url: Optional[str] = None
# Phase 2A — cost tracking
cost_simple: Optional[float] = None
cost_breakdown: Optional[List[CostBreakdownItem]] = None
# Quick Add
quick_add_enabled: Optional[bool] = None
# Tags
tags: Optional[List[str]] = None
# Service item flag
is_service_item: Optional[bool] = None
# Fiscal printer (ΦΗΜ)
fiscal_name: Optional[str] = None
fiscal_vat_group_id: Optional[int] = None
class ProductReorderItem(BaseModel):
@@ -297,6 +400,54 @@ class ProductOut(ProductBase):
options: List[ProductOptionOut] = []
ingredients: List[ProductIngredientOut] = []
preference_sets: List[PreferenceSetOut] = []
modifier_groups: List[ModifierGroupOut] = []
image_url: Optional[str] = None
model_config = {"from_attributes": True}
@model_validator(mode='before')
@classmethod
def parse_cost_breakdown(cls, data: Any) -> Any:
if hasattr(data, 'cost_breakdown'):
raw = data.cost_breakdown
parsed = None
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except Exception:
parsed = None
# Return a plain dict so other validators still see all fields
return {
'id': data.id,
'name': data.name,
'description': data.description,
'category_id': data.category_id,
'base_price': data.base_price,
'unit_type': getattr(data, 'unit_type', 'piece') or 'piece',
'is_available': data.is_available,
'lifecycle_status': data.lifecycle_status,
'printer_zone_id': data.printer_zone_id,
'prep_zone_ids': [z.id for z in data.prep_zones] if hasattr(data, 'prep_zones') else [],
'sort_order': data.sort_order,
'image_url': data.image_url,
'digital_visible': bool(data.digital_visible),
'digital_available': bool(data.digital_available),
'digital_name': data.digital_name,
'digital_description': data.digital_description,
'digital_price': data.digital_price,
'digital_discount': data.digital_discount,
'digital_image_url': data.digital_image_url,
'cost_simple': data.cost_simple,
'cost_breakdown': parsed,
'quick_add_enabled': getattr(data, 'quick_add_enabled', True),
'tags': json.loads(data.tags) if isinstance(getattr(data, 'tags', None), str) else (getattr(data, 'tags', None) or []),
'is_service_item': bool(getattr(data, 'is_service_item', False)),
'fiscal_name': getattr(data, 'fiscal_name', None),
'fiscal_vat_group_id': getattr(data, 'fiscal_vat_group_id', None),
'quick_options': list(data.quick_options),
'options': list(data.options),
'ingredients': list(data.ingredients),
'preference_sets': list(data.preference_sets),
'modifier_groups': list(data.modifier_groups) if hasattr(data, 'modifier_groups') else [],
}
return data

View File

@@ -0,0 +1,93 @@
from pydantic import BaseModel, field_validator
from typing import Optional
from datetime import datetime, timezone
class ReservationCreate(BaseModel):
guest_name: str
party_size: int
phone: Optional[str] = None
email: Optional[str] = None
note: Optional[str] = None
reserved_for: datetime
table_id: Optional[int] = None
source: str = "manager_app"
online_customer_id: Optional[str] = None
@field_validator("reserved_for", mode="before")
@classmethod
def strip_timezone(cls, v):
if isinstance(v, str):
dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
elif isinstance(v, datetime):
dt = v
else:
return v
# Store as naive local — strip any tz info so SQLite keeps it as-is
if dt.tzinfo is not None:
dt = dt.astimezone().replace(tzinfo=None)
return dt
class ReservationUpdate(BaseModel):
guest_name: Optional[str] = None
party_size: Optional[int] = None
phone: Optional[str] = None
email: Optional[str] = None
note: Optional[str] = None
reserved_for: Optional[datetime] = None
table_id: Optional[int] = None
status: Optional[str] = None
@field_validator("reserved_for", mode="before")
@classmethod
def strip_timezone(cls, v):
if v is None:
return v
if isinstance(v, str):
dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
elif isinstance(v, datetime):
dt = v
else:
return v
if dt.tzinfo is not None:
dt = dt.astimezone().replace(tzinfo=None)
return dt
class ReservationStatusUpdate(BaseModel):
status: str # arrived | cancelled | no_show | pending
class ReservationOut(BaseModel):
id: int
guest_name: str
party_size: int
phone: Optional[str] = None
email: Optional[str] = None
note: Optional[str] = None
reserved_for: datetime
table_id: Optional[int] = None
status: str
source: str
created_by_user_id: Optional[int] = None
online_customer_id: Optional[str] = None
created_at: datetime
updated_at: datetime
# Denormalized for convenience
table_label: Optional[str] = None
created_by_name: Optional[str] = None
model_config = {"from_attributes": True}
# Compact summary embedded in TableOut for the RESERVED badge
class ReservationSummary(BaseModel):
id: int
guest_name: str
party_size: int
reserved_for: datetime
status: str
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,35 @@
from pydantic import BaseModel
from typing import Optional
from datetime import date, datetime
class ScheduledShiftCreate(BaseModel):
user_id: int
scheduled_date: date
start_time: str # "HH:MM"
end_time: str # "HH:MM"
notes: Optional[str] = None
class ScheduledShiftUpdate(BaseModel):
start_time: Optional[str] = None
end_time: Optional[str] = None
notes: Optional[str] = None
class ScheduledShiftOut(BaseModel):
id: int
user_id: int
user_name: Optional[str] = None
hourly_rate: Optional[float] = None
scheduled_date: date
start_time: str
end_time: str
notes: Optional[str] = None
created_by_id: int
created_at: datetime
# computed
duration_hours: Optional[float] = None
estimated_pay: Optional[float] = None
model_config = {"from_attributes": True}

View File

@@ -36,3 +36,4 @@ class StartShiftRequest(BaseModel):
class EndShiftRequest(BaseModel):
notes: Optional[str] = None
counted_cash_end: Optional[float] = None # Phase 2E: physical cash waiter hands over

View File

@@ -1,5 +1,6 @@
from pydantic import BaseModel, field_validator
from typing import Optional, List
from datetime import datetime
MAX_TABLE_NAME_LENGTH = 6
@@ -35,6 +36,7 @@ class TableBase(BaseModel):
class TableCreate(BaseModel):
label: Optional[str] = None
group_id: Optional[int] = None
seat_count: Optional[int] = None
@field_validator("label")
@classmethod
@@ -57,6 +59,7 @@ class TableUpdate(BaseModel):
label: Optional[str] = None
group_id: Optional[int] = None
is_active: Optional[bool] = None
seat_count: Optional[int] = None
@field_validator("label")
@classmethod
@@ -73,15 +76,27 @@ class TableFloorplanUpdate(BaseModel):
floor_y: float
class UpcomingReservation(BaseModel):
id: int
guest_name: str
party_size: int
reserved_for: datetime
status: str
model_config = {"from_attributes": True}
class TableOut(BaseModel):
id: int
number: int
label: Optional[str] = None
group_id: Optional[int] = None
is_active: bool = True
seat_count: Optional[int] = None
floor_x: Optional[float] = None
floor_y: Optional[float] = None
group: Optional[TableGroupOut] = None
has_active_order: bool = False
upcoming_reservation: Optional[UpcomingReservation] = None
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,63 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class TabEntryOut(BaseModel):
id: int
tab_id: int
order_id: Optional[int] = None
order_item_id: Optional[int] = None
amount: float
description: Optional[str] = None
created_by_id: int
created_by_name: Optional[str] = None
created_at: datetime
model_config = {"from_attributes": True}
class TabPaymentOut(BaseModel):
id: int
tab_id: int
amount: float
payment_method: Optional[str] = None
received_by_id: int
received_by_name: Optional[str] = None
notes: Optional[str] = None
created_at: datetime
model_config = {"from_attributes": True}
class TabOut(BaseModel):
id: int
customer_id: int
customer_name: Optional[str] = None
customer_phone: Optional[str] = None
status: str
opened_at: datetime
closed_at: Optional[datetime] = None
closed_by_id: Optional[int] = None
notes: Optional[str] = None
balance: float = 0.0 # computed: sum(entries) - sum(payments)
total_charged: float = 0.0
total_paid: float = 0.0
entries: List[TabEntryOut] = []
payments: List[TabPaymentOut] = []
model_config = {"from_attributes": True}
class TabPayRequest(BaseModel):
amount: float
payment_method: Optional[str] = None
notes: Optional[str] = None
class TabForgiveRequest(BaseModel):
reason: Optional[str] = None
class TabCloseRequest(BaseModel):
notes: Optional[str] = None

View File

@@ -11,8 +11,27 @@ class UserBase(BaseModel):
full_name: Optional[str] = None
nickname: Optional[str] = None
mobile_phone: Optional[str] = None
note: Optional[str] = None
avatar_url: Optional[str] = None
email: Optional[str] = None
# Phase 2B — payroll
hourly_rate: Optional[float] = None
# Access permissions
perm_access_dashboard: bool = False
perm_access_waiter_app: bool = True
perm_access_kds: bool = False
# Order action permissions
perm_cancel_orders: bool = False
perm_apply_discounts: bool = False
perm_modify_prices: bool = False
perm_open_orders: bool = True
perm_close_orders: bool = True
# Management permissions
perm_view_reports: bool = False
perm_manage_staff: bool = False
perm_manage_tables: bool = False
perm_manage_menu: bool = False
perm_manage_settings: bool = False
class UserCreate(UserBase):
@@ -26,6 +45,26 @@ class UserUpdate(BaseModel):
full_name: Optional[str] = None
nickname: Optional[str] = None
mobile_phone: Optional[str] = None
email: Optional[str] = None
note: Optional[str] = None
# Phase 2B — payroll
hourly_rate: Optional[float] = None
# Access permissions
perm_access_dashboard: Optional[bool] = None
perm_access_waiter_app: Optional[bool] = None
perm_access_kds: Optional[bool] = None
# Order action permissions
perm_cancel_orders: Optional[bool] = None
perm_apply_discounts: Optional[bool] = None
perm_modify_prices: Optional[bool] = None
perm_open_orders: Optional[bool] = None
perm_close_orders: Optional[bool] = None
# Management permissions
perm_view_reports: Optional[bool] = None
perm_manage_staff: Optional[bool] = None
perm_manage_tables: Optional[bool] = None
perm_manage_menu: Optional[bool] = None
perm_manage_settings: Optional[bool] = None
class WaiterZoneOut(BaseModel):
@@ -40,6 +79,7 @@ class UserOut(UserBase):
id: int
created_at: UTCDatetime
zone_assignments: List[WaiterZoneOut] = []
waiter_settings: Optional[str] = None # JSON blob — per-waiter settings + favorites
model_config = {"from_attributes": True}
@@ -59,3 +99,9 @@ class AssistantAssignmentOut(BaseModel):
assigned_at: UTCDatetime
model_config = {"from_attributes": True}
class PermissionToggle(BaseModel):
"""Patch a single permission flag for a staff member."""
permission: str
value: bool

View File

@@ -0,0 +1,37 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
VALID_REASONS = {"kitchen_error", "dropped", "expired", "other"}
REASON_LABELS = {
"kitchen_error": "Λάθος κουζίνας",
"dropped": "Έπεσε",
"expired": "Έληξε",
"other": "Άλλο",
}
class WasteLogCreate(BaseModel):
product_id: int
quantity: float
reason: str
reason_notes: Optional[str] = None
class WasteLogOut(BaseModel):
id: int
product_id: int
product_name: Optional[str] = None
quantity: float
unit_cost_snapshot: Optional[float] = None
total_cost: Optional[float] = None
reason: str
reason_label: Optional[str] = None
reason_notes: Optional[str] = None
logged_by_id: int
logged_by_name: Optional[str] = None
business_day_id: Optional[int] = None
logged_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,88 @@
"""
Chat service — system group bootstrap helpers.
"""
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from models.chat import Conversation, ConversationParticipant
from models.user import User
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def ensure_system_group(db: Session) -> Conversation:
"""
Guarantee that the single system-wide 'Ομάδα' group conversation exists.
If it is missing, create it and add all currently active users as participants.
Returns the (possibly freshly created) Conversation.
"""
existing = (
db.query(Conversation)
.filter(Conversation.is_system == True) # noqa: E712
.first()
)
if existing:
return existing
# Determine creator — first admin/manager user, or user id=1 as fallback
creator = (
db.query(User)
.filter(User.perm_access_dashboard == True, User.is_active == True) # noqa: E712
.order_by(User.id)
.first()
)
creator_id = creator.id if creator else 1
conv = Conversation(
type="group",
name="Ομάδα",
is_system=True,
created_by=creator_id,
)
db.add(conv)
db.flush() # get conv.id before adding participants
active_users = (
db.query(User).filter(User.is_active == True).all() # noqa: E712
)
now = _utcnow()
for u in active_users:
participant = ConversationParticipant(
conversation_id=conv.id,
user_id=u.id,
joined_at=now,
)
db.add(participant)
db.commit()
db.refresh(conv)
return conv
def add_user_to_system_group(db: Session, user_id: int) -> None:
"""
Add a user to the system group if not already a participant.
Safe to call even when the system group does not exist yet (it will be created).
"""
conv = ensure_system_group(db)
already = (
db.query(ConversationParticipant)
.filter(
ConversationParticipant.conversation_id == conv.id,
ConversationParticipant.user_id == user_id,
)
.first()
)
if already:
return
participant = ConversationParticipant(
conversation_id=conv.id,
user_id=user_id,
joined_at=_utcnow(),
)
db.add(participant)
db.commit()

View File

@@ -15,6 +15,7 @@ License expiry behaviour:
import asyncio
import json
import logging
import os
import socket
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -24,6 +25,9 @@ import httpx
from config import settings
from middleware.license_check import license_state
ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes
@@ -76,7 +80,12 @@ def _compute_expiry_fields(expires_at_str: str | None) -> dict:
def _get_local_ip() -> str | None:
"""Best-effort detection of the machine's LAN IP address."""
"""Best-effort detection of the host machine's LAN IP address.
When running inside Docker the socket trick returns the container/bridge IP,
so we honour HOST_IP if it is explicitly provided via the environment."""
import os
if host_ip := os.environ.get("HOST_IP", "").strip():
return host_ip
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
@@ -140,6 +149,7 @@ async def _sync_once():
"expires_at": expires_at,
"latest_version": data.get("latest_version"),
"waiter_domain": data.get("waiter_domain"),
"site_numeric_id": data.get("site_numeric_id"),
"last_sync": datetime.now(timezone.utc).isoformat(),
"sync_failed": False,
**expiry_fields,
@@ -167,6 +177,225 @@ async def _sync_once():
license_state.update(expiry_fields)
IMAGE_DIR = Path("/app/data/product_images")
async def _push_product_images(db, products):
"""Upload the local image file for each product whose picture changed since
the last successful push (tracked via Product.cloud_image_hash), so the
public QR menu can show it without needing a manually-set digital_image_url.
Skips products that already have a manual digital_image_url override."""
import hashlib
site_numeric_id = license_state.get("site_numeric_id")
if not site_numeric_id:
return
for p in products:
if p.digital_image_url or not p.image_url:
continue
filename = os.path.basename(p.image_url)
filepath = IMAGE_DIR / filename
if not filepath.exists():
continue
try:
contents = filepath.read_bytes()
image_hash = hashlib.sha256(contents).hexdigest()
if image_hash == p.cloud_image_hash:
continue
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
f"{settings.CLOUD_URL}/api/menu/sync-image",
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
data={"product_id": str(p.id)},
files={"file": (filename, contents)},
)
resp.raise_for_status()
p.cloud_image_hash = image_hash
db.commit()
logger.info("Pushed image for product %d to cloud", p.id)
except Exception as e:
logger.warning("Image push failed for product %d: %s", p.id, e)
async def _push_menu_snapshot():
"""Serialize all digital-visible products+categories and POST to cloud."""
if not settings.SITE_ID or not settings.CLOUD_URL:
return
try:
from database import SessionLocal
from models.product import Category, Product
db = SessionLocal()
try:
categories = db.query(Category).filter(Category.parent_id == None).all()
payload_categories = []
all_products = []
for cat in categories:
products = (
db.query(Product)
.filter(
Product.category_id == cat.id,
Product.digital_visible == 1,
Product.lifecycle_status == "active",
)
.order_by(Product.sort_order)
.all()
)
all_products.extend(products)
product_list = []
for p in products:
product_list.append({
"id": p.id,
"name": p.name,
"digital_name": p.digital_name,
"digital_description": p.digital_description,
"digital_price": p.digital_price,
"base_price": p.base_price,
"digital_discount": p.digital_discount,
"digital_available": bool(p.digital_available),
"digital_image_url": p.digital_image_url,
"image_url": p.image_url,
"quick_options": [
{"id": o.id, "name": o.name, "price": o.price}
for o in p.quick_options
],
})
if product_list:
payload_categories.append({
"id": cat.id,
"name": cat.name,
"sort_order": cat.sort_order,
"products": product_list,
})
snapshot_json = json.dumps({"categories": payload_categories})
# Resolve numeric site_id from license state (set by heartbeat response)
site_numeric_id = license_state.get("site_numeric_id")
if not site_numeric_id:
logger.debug("Menu push skipped — site_numeric_id not yet known")
return
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{settings.CLOUD_URL}/api/menu/sync",
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
json={"site_id": site_numeric_id, "snapshot_json": snapshot_json},
)
resp.raise_for_status()
logger.info("Menu snapshot pushed (%d categories)", len(payload_categories))
await _push_product_images(db, all_products)
finally:
db.close()
except Exception as e:
logger.warning("Menu snapshot push failed: %s", e)
async def _pull_pending_orders():
"""Fetch online orders from cloud that haven't been synced to local yet."""
if not settings.SITE_ID or not settings.CLOUD_URL:
return
site_numeric_id = license_state.get("site_numeric_id")
if not site_numeric_id:
return
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{settings.CLOUD_URL}/api/orders/pending/{site_numeric_id}",
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
)
resp.raise_for_status()
orders = resp.json()
if not orders:
return
from database import SessionLocal
from models.order import Order, OrderItem
from models.product import Product
from services.sse_bus import broadcast_sync
db = SessionLocal()
try:
# Use a system user id=1 (first manager/sysadmin) as the opener
from models.user import User
system_user = db.query(User).filter(User.perm_access_dashboard == True).first()
opener_id = system_user.id if system_user else 1
for cloud_order in orders:
# Create local Order row — no table_id for online orders
local_order = Order(
table_id=None,
opened_by=opener_id,
status="open",
source="online",
online_order_ref=cloud_order["public_ref"],
online_order_cloud_id=cloud_order["id"],
online_status="pending_acceptance",
online_customer_name=cloud_order.get("customer_name"),
online_customer_phone=cloud_order.get("customer_phone"),
online_customer_address=cloud_order.get("customer_address"),
online_customer_notes=cloud_order.get("customer_notes"),
online_order_type=cloud_order.get("order_type"),
)
db.add(local_order)
db.flush() # get local_order.id
# Create OrderItem rows
items = json.loads(cloud_order.get("items_json", "[]"))
for item in items:
product = db.query(Product).filter(
Product.id == item.get("product_id")
).first()
db.add(OrderItem(
order_id=local_order.id,
product_id=item.get("product_id"),
added_by=opener_id,
quantity=item.get("quantity", 1),
unit_price=item.get("unit_price", 0.0),
selected_options=json.dumps(item.get("options", [])),
status="active",
printed=False,
))
db.commit()
# Mark synced on cloud
async with httpx.AsyncClient(timeout=10) as client:
await client.post(
f"{settings.CLOUD_URL}/api/orders/{cloud_order['id']}/synced",
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
json={"local_order_id": local_order.id},
)
logger.info("Online order %s pulled and created as local order %d",
cloud_order["public_ref"], local_order.id)
db.close()
# Broadcast SSE so the manager dashboard lights up
broadcast_sync("online_order_received", {"count": len(orders)})
except Exception as e:
db.rollback()
db.close()
logger.error("Failed to process pulled orders: %s", e)
except Exception as e:
logger.warning("Order pull failed: %s", e)
async def _sync_loop():
_load_persisted_state()
while True:
@@ -174,6 +403,125 @@ async def _sync_loop():
await asyncio.sleep(SYNC_INTERVAL_SECONDS)
async def _push_stats_snapshot():
"""Collect live operational stats and POST to cloud for the remote manager dashboard."""
if not settings.SITE_ID or not settings.CLOUD_URL:
return
site_numeric_id = license_state.get("site_numeric_id")
if not site_numeric_id:
return
try:
from database import SessionLocal
from models.order import Order, OrderItem
from models.business_day import BusinessDay
from models.shift import WaiterShift
from sqlalchemy import func
db = SessionLocal()
try:
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
# Open tables = orders currently in status open or partially_paid
open_tables = db.query(Order).filter(
Order.status.in_(["open", "partially_paid"]),
Order.source == "pos",
).count()
# Today's completed POS orders (paid or closed today)
today_pos_orders = db.query(Order).filter(
Order.status.in_(["paid", "closed"]),
Order.source == "pos",
Order.closed_at >= today_start,
).all()
today_order_count = len(today_pos_orders)
# Today's revenue: sum of paid items on those orders
today_revenue = 0.0
for o in today_pos_orders:
for item in o.items:
if item.status in ("active", "paid"):
today_revenue += item.unit_price * item.quantity
# Online orders today
online_orders_today = db.query(Order).filter(
Order.source == "online",
Order.opened_at >= today_start,
).count()
online_orders_pending = db.query(Order).filter(
Order.source == "online",
Order.online_status == "pending_acceptance",
).count()
# Active business day
open_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
current_shift = None
if open_day:
active_shift = db.query(WaiterShift).filter(
WaiterShift.business_day_id == open_day.id,
WaiterShift.ended_at == None,
).first()
if active_shift:
current_shift = {
"waiter_id": active_shift.waiter_id,
"started_at": active_shift.started_at.isoformat(),
}
finally:
db.close()
snapshot = {
"open_tables": open_tables,
"today_revenue": round(today_revenue, 2),
"today_orders": today_order_count,
"online_orders_pending": online_orders_pending,
"online_orders_today": online_orders_today,
"current_shift": current_shift,
"as_of": now.isoformat(),
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{settings.CLOUD_URL}/api/remote/snapshot",
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
json={"site_id": site_numeric_id, "snapshot_json": json.dumps(snapshot)},
)
resp.raise_for_status()
logger.info("Stats snapshot pushed: %s", snapshot)
except Exception as e:
logger.warning("Stats snapshot push failed: %s", e)
async def _connect_loop():
"""Faster loop: pulls pending online orders every CONNECT_SYNC_INTERVAL_SECONDS.
Also pushes menu snapshot and stats every 5 minutes (piggybacking on this loop).
On startup, waits for the first heartbeat to populate site_numeric_id then
immediately pushes menu + stats without waiting a full cycle."""
push_every = max(1, (5 * 60) // ORDER_POLL_INTERVAL)
# Wait for the heartbeat loop to get site_numeric_id, then do an immediate push
for _ in range(30): # wait up to 30 seconds
if license_state.get("site_numeric_id"):
break
await asyncio.sleep(1)
await _push_menu_snapshot()
await _push_stats_snapshot()
tick = 0
while True:
await asyncio.sleep(ORDER_POLL_INTERVAL)
await _pull_pending_orders()
tick += 1
if tick % push_every == 0:
await _push_menu_snapshot()
await _push_stats_snapshot()
async def start_cloud_sync() -> asyncio.Task:
task = asyncio.create_task(_sync_loop())
return task
heartbeat_task = asyncio.create_task(_sync_loop())
asyncio.create_task(_connect_loop())
return heartbeat_task

View File

@@ -0,0 +1,280 @@
"""
Fiscal printer service for dTEC100extra ΦΗΜ machines.
Protocol: plain TXT files dropped into an OUT folder. The fiscal driver reads
them, processes the receipt, then writes a reply into the IN folder.
Files must be encoded in CP737 (Greek) with CRLF line endings.
All item text must be UPPER CASE.
File naming: fp-YYMMDD-HHMM-NNNNNNN.txt (NNNNNNN = zero-padded counter)
The driver deletes the command file after reading it and will never re-read the
same filename, so uniqueness is critical.
"""
import os
import json
import time
import threading
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
# Shared counter — persisted only in-process; restarts reset to 0 but the
# date+time prefix keeps filenames unique across restarts.
_counter_lock = threading.Lock()
_counter = 0
def _next_counter() -> int:
global _counter
with _counter_lock:
_counter += 1
return _counter
def _unique_filename() -> str:
now = datetime.now()
date_part = now.strftime("%y%m%d")
time_part = now.strftime("%H%M")
seq = _next_counter()
return f"fp-{date_part}-{time_part}-{seq:07d}.txt"
def _get_folders(db) -> tuple[str, str]:
"""Return (out_folder, in_folder) from pos_settings. Raises if not configured."""
from models.settings import PosSettings
rows = {
r.key: r.value
for r in db.query(PosSettings).filter(
PosSettings.key.in_(["fiscal.out_folder", "fiscal.in_folder"])
).all()
}
out_folder = rows.get("fiscal.out_folder", "").strip()
in_folder = rows.get("fiscal.in_folder", "").strip()
if not out_folder or not in_folder:
raise ValueError("Fiscal folder paths are not configured")
return out_folder, in_folder
def _get_setting(db, key: str, default: str = "") -> str:
from models.settings import PosSettings
row = db.query(PosSettings).filter(PosSettings.key == key).first()
return row.value if row else default
def is_fiscal_enabled(db) -> bool:
return _get_setting(db, "fiscal.enabled", "false") == "true"
def _build_command_file(items: list[dict], payment_method: str, total: float, db) -> str:
"""
Build the fiscal command file content as a string.
items: list of {"name": str, "qty": int|float, "unit_price": float, "vat_group_id": int}
payment_method: "cash" | "card"
total: sum of all items (already computed by caller)
"""
clerk_id = _get_setting(db, "fiscal.clerk_id", "2")
eftpos_id = _get_setting(db, "fiscal.eftpos_id", "1")
end_message_raw = _get_setting(db, "fiscal.end_message", "[]")
try:
end_lines: list[str] = json.loads(end_message_raw)
except Exception:
end_lines = []
lines = ["FR"]
for item in items:
name = (item["name"] or "").upper().strip()
qty = item["qty"]
price = item["unit_price"]
vat = item["vat_group_id"]
# Quantity: integer if whole, otherwise 3 decimal places (kg/liter support)
if isinstance(qty, float) and qty != int(qty):
qty_str = f"{qty:.3f}"
else:
qty_str = str(int(qty))
price_str = f"{price:.2f}"
lines.append(f"SI|{name}|{qty_str}|{price_str}|{vat}")
# End message lines (FM commands go BEFORE the close command)
for i, msg_line in enumerate(end_lines, start=1):
if msg_line.strip():
lines.append(f"FM|{i}|{msg_line.upper()}")
if payment_method == "card":
total_str = f"{total:.2f}"
lines.append(f"CD|{clerk_id}|{eftpos_id}|1|{total_str}")
else:
lines.append(f"CR|{clerk_id}")
return "\r\n".join(lines) + "\r\n"
def _write_and_poll(items: list[dict], payment_method: str, total: float,
out_folder: str, in_folder: str, content: str,
filename: str, timeout_seconds: int) -> dict:
"""Write the command file and poll for a reply. Pure logic, no DB access."""
out_path = os.path.join(out_folder, filename)
reply_filename = f"answer{filename}"
in_path = os.path.join(in_folder, reply_filename)
try:
with open(out_path, "w", encoding="cp737", errors="replace") as f:
f.write(content)
except OSError as e:
return {"success": False, "filename": filename, "reply": None,
"error": f"Failed to write fiscal file: {e}"}
logger.info("Fiscal: wrote %s (%d bytes)", out_path, len(content))
deadline = time.monotonic() + timeout_seconds
reply_content = None
while time.monotonic() < deadline:
if os.path.exists(in_path):
try:
with open(in_path, "r", encoding="cp737", errors="replace") as f:
reply_content = f.read().strip()
break
except OSError:
pass
time.sleep(0.5)
if reply_content is None:
return {"success": False, "filename": filename, "reply": None,
"error": f"Fiscal timeout: no reply after {timeout_seconds}s"}
first_line = reply_content.splitlines()[0].strip() if reply_content else "1"
success = first_line == "0"
logger.info("Fiscal: reply for %s — success=%s content=%r", filename, success, reply_content)
return {"success": success, "filename": filename, "reply": reply_content,
"error": None if success else f"ΦΗΜ error: {reply_content}"}
def send_fiscal_receipt(items: list[dict], payment_method: str, total: float, db,
timeout_seconds: int = 30) -> dict:
"""
Build and send a fiscal receipt, then block waiting for the reply.
Returns: {"success": bool, "filename": str, "reply": str | None, "error": str | None}
"""
out_folder, in_folder = _get_folders(db)
os.makedirs(out_folder, exist_ok=True)
os.makedirs(in_folder, exist_ok=True)
filename = _unique_filename()
content = _build_command_file(items, payment_method, total, db)
return _write_and_poll(items, payment_method, total, out_folder, in_folder,
content, filename, timeout_seconds)
def send_fiscal_receipt_background(items: list[dict], payment_method: str, total: float,
db, order_id: int, db_factory,
timeout_seconds: int = 60) -> None:
"""
Write the fiscal file and poll for the reply in a background thread.
Updates order.fiscal_status in the DB when done (success or failure/timeout).
db_factory: a callable () -> Session (e.g. SessionLocal) so the thread can
open its own DB session independently of the request session.
"""
try:
out_folder, in_folder = _get_folders(db)
os.makedirs(out_folder, exist_ok=True)
os.makedirs(in_folder, exist_ok=True)
filename = _unique_filename()
content = _build_command_file(items, payment_method, total, db)
except Exception as e:
logger.error("Fiscal background setup failed for order %s: %s", order_id, e)
_update_fiscal_status(order_id, "failed", db_factory)
return
def _run():
result = _write_and_poll(items, payment_method, total, out_folder, in_folder,
content, filename, timeout_seconds)
status = "success" if result["success"] else "failed"
_update_fiscal_status(order_id, status, db_factory)
thread = threading.Thread(target=_run, daemon=True)
thread.start()
def _update_fiscal_status(order_id: int, status: str, db_factory) -> None:
"""Open a fresh DB session and set order.fiscal_status."""
try:
session = db_factory()
try:
from models.order import Order
order = session.query(Order).filter(Order.id == order_id).first()
if order:
order.fiscal_status = status
session.commit()
logger.info("Fiscal: order %s fiscal_status → %s", order_id, status)
finally:
session.close()
except Exception as e:
logger.error("Fiscal: failed to update fiscal_status for order %s: %s", order_id, e)
def validate_items_for_fiscal(order_items, db) -> list[str]:
"""
Check that every non-cancelled item in the list has a fiscal_vat_group_id.
Returns a list of product names that are missing it (empty = all OK).
"""
missing = []
for item in order_items:
product = item.product
if product is None:
missing.append(f"#product_{item.product_id}")
continue
vat_group = getattr(product, "fiscal_vat_group_id", None)
if not vat_group:
missing.append(product.name)
return missing
def build_fiscal_items(order_items) -> list[dict]:
"""
Convert a list of OrderItem objects into the dicts that send_fiscal_receipt expects.
Uses fiscal_name if set, else falls back to product.name.
Applies price_adjustment to the unit price.
"""
result = []
for item in order_items:
product = item.product
name = (getattr(product, "fiscal_name", None) or "").strip() or (product.name if product else f"#{item.product_id}")
adj = getattr(item, "price_adjustment", 0.0) or 0.0
unit_price = item.unit_price + adj
result.append({
"name": name,
"qty": item.quantity,
"unit_price": unit_price,
"vat_group_id": product.fiscal_vat_group_id,
})
return result
def test_folders(out_folder: str, in_folder: str) -> dict:
"""
Verify the two folders are accessible (read + write).
Returns {"ok": bool, "out_folder": str|None, "in_folder": str|None}
where each value is None if accessible, or an error string if not.
"""
def check(path):
try:
os.makedirs(path, exist_ok=True)
test_file = os.path.join(path, ".fiscal_test")
with open(test_file, "w") as f:
f.write("test")
os.remove(test_file)
return None
except Exception as e:
return str(e)
out_err = check(out_folder)
in_err = check(in_folder)
return {
"ok": out_err is None and in_err is None,
"out_folder_error": out_err,
"in_folder_error": in_err,
}

View File

@@ -0,0 +1,727 @@
"""
Pricing resolution engine.
resolve_price() is called once per OrderItem at add-to-order time.
The result is the final snapshotted unit_price — it never changes after that.
Stacking algorithm (per design spec):
1. Collect all passing modifiers for this product (item-scoped first, then global).
2. Separate into stackable (allow_stack=1) and non-stackable piles.
3. From the non-stackable pile, apply only the first passing one (lowest sort_order).
4. From the stackable pile, apply ALL passing ones in sort_order order,
each on the running price result.
5. Apply system-wide rounding to nearest 0.10 as a final pass.
Deal evaluation:
evaluate_deals() is called after items are added. It returns a list of DealOffer
objects that the API caller surfaces to the waiter as prompts.
"""
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone, time as dtime
from typing import Optional
from sqlalchemy.orm import Session
from tz import to_local
# ---------------------------------------------------------------------------
# Data structures (no ORM dependency — safe to import anywhere)
# ---------------------------------------------------------------------------
@dataclass
class OrderContext:
"""Snapshot of order-level facts needed to evaluate conditions."""
channel: str # 'pos' | 'online' | 'qr' | 'takeaway'
now: datetime # current UTC time
cart_items: list # list of {product_id, category_id, quantity, unit_price}
active_price_group_ids: set # set of PriceGroup.id that are currently active
user_tier: Optional[str] = None # 'bronze'|'silver'|'gold' — placeholder
@dataclass
class PriceEvent:
"""In-memory representation of a price event, before it's written to DB."""
order_id: int
order_item_id: Optional[int] # set after OrderItem is committed
event_type: str
modifier_id: Optional[int] = None
deal_id: Optional[int] = None
price_before: Optional[float] = None
price_after: Optional[float] = None
delta_amount: Optional[float] = None
applied_by_user_id: Optional[int] = None
waiter_note: Optional[str] = None
conditions_snapshot: Optional[dict] = None
selected_item_ids: Optional[list] = None
@dataclass
class DealOffer:
"""Returned to the API when a deal is triggered and needs waiter confirmation."""
deal_id: int
deal_name: str
action_type: str # mirrors Deal.action_type
# For free_item: the single product to add
free_item_id: Optional[int] = None
free_item_name: Optional[str] = None
# For free_choice: pool of selectable products
free_choices: list = field(default_factory=list) # [{id, name}]
free_quantity: int = 1
# For price-modifying deals applied to a specific item
target_order_item_id: Optional[int] = None
price_before: Optional[float] = None
price_after: Optional[float] = None
# ---------------------------------------------------------------------------
# Condition evaluators
# ---------------------------------------------------------------------------
def _eval_condition(ctype: str, params: dict, ctx: OrderContext,
product_id: int, quantity: float) -> tuple[bool, dict]:
"""
Evaluate one condition. Returns (passed, snapshot_dict).
snapshot_dict records the actual runtime value — written to price_event_log for audit.
Unknown or placeholder conditions pass silently (forward-compatible).
"""
now_local = to_local(ctx.now)
current_time = now_local.time()
current_date = now_local.date()
current_weekday = now_local.weekday() # Mon=0 Sun=6
if ctype == "time_range":
t_from = dtime.fromisoformat(params["from"])
t_to = dtime.fromisoformat(params["to"])
if t_from <= t_to:
passed = t_from <= current_time <= t_to
else:
# Crosses midnight
passed = current_time >= t_from or current_time <= t_to
return passed, {"time_range": current_time.strftime("%H:%M")}
if ctype == "date_range":
from datetime import date
d_from = date.fromisoformat(params["from"])
d_to = date.fromisoformat(params["to"])
passed = d_from <= current_date <= d_to
return passed, {"date_range": current_date.isoformat()}
if ctype == "specific_date":
from datetime import date
passed = current_date.isoformat() in params.get("dates", [])
return passed, {"specific_date": current_date.isoformat()}
if ctype == "day_of_week":
passed = current_weekday in params.get("days", [])
return passed, {"day_of_week": current_weekday}
if ctype == "min_item_quantity":
cart_qty = sum(
ci["quantity"] for ci in ctx.cart_items
if ci["product_id"] == product_id
)
passed = cart_qty >= params.get("min", 1)
return passed, {"min_item_quantity": cart_qty}
if ctype == "min_category_qty":
cat_id = params.get("category_id")
cart_qty = sum(
ci["quantity"] for ci in ctx.cart_items
if ci.get("category_id") == cat_id
)
passed = cart_qty >= params.get("min", 1)
return passed, {"min_category_qty": cart_qty}
if ctype == "min_order_value":
total = sum(ci["quantity"] * ci["unit_price"] for ci in ctx.cart_items)
passed = total >= params.get("min", 0.0)
return passed, {"min_order_value": round(total, 2)}
if ctype == "order_channel":
passed = ctx.channel in params.get("channels", [])
return passed, {"order_channel": ctx.channel}
if ctype == "price_group_active":
pgid = params.get("price_group_id")
passed = pgid in ctx.active_price_group_ids
return passed, {"price_group_active": passed}
if ctype == "user_tier":
passed = ctx.user_tier in params.get("tiers", [])
return passed, {"user_tier": ctx.user_tier}
if ctype == "low_stock":
# Placeholder until inventory is implemented — always passes silently
return True, {"low_stock": "placeholder"}
# Unknown condition type — pass silently for forward compatibility
return True, {ctype: "unknown_type_skipped"}
def _all_conditions_pass(conditions, ctx: OrderContext,
product_id: int, quantity: float) -> tuple[bool, dict]:
"""AND-evaluate all conditions. Returns (all_passed, merged_snapshot)."""
snapshot = {}
for cond in conditions:
params = json.loads(cond.params) if isinstance(cond.params, str) else cond.params
passed, snap = _eval_condition(cond.condition_type, params, ctx, product_id, quantity)
snapshot.update(snap)
if not passed:
return False, snapshot
return True, snapshot
# ---------------------------------------------------------------------------
# Modifier action math
# ---------------------------------------------------------------------------
def _apply_action(price: float, action_type: str, action_value: float,
round_to: Optional[str]) -> float:
if action_type == "set":
result = action_value
elif action_type == "add_amount":
result = price + action_value
elif action_type == "add_percent":
result = price * (1 + action_value / 100.0)
else:
result = price # unknown action — no-op
result = max(result, 0.0) # price can never go below 0
if round_to:
result = _round_to(result, round_to)
return result
def _round_to(value: float, mode: str) -> float:
if mode == "0.05":
return round(value / 0.05) * 0.05
if mode == "0.10":
return round(value / 0.10) * 0.10
if mode == "0.20":
return round(value / 0.20) * 0.20
if mode == "0.50":
return round(value / 0.50) * 0.50
if mode == "x.99":
return float(int(value)) + 0.99 if value >= 1 else value
if mode == "x.00":
return float(round(value))
return value
def _system_round(value: float) -> float:
"""System-wide final rounding: nearest 0.10."""
return round(round(value / 0.10) * 0.10, 2)
# ---------------------------------------------------------------------------
# PriceGroup auto-schedule evaluation
# ---------------------------------------------------------------------------
def _price_group_is_active(pg, now: datetime) -> bool:
"""
Returns True if this price group should currently be considered active.
Checks is_active flag, then auto-schedule if configured.
"""
if pg.auto_enable_time and pg.auto_disable_time and pg.auto_days:
now_local = now.astimezone()
weekday = now_local.weekday()
try:
auto_days = json.loads(pg.auto_days)
except (ValueError, TypeError):
auto_days = []
if weekday in auto_days:
t_enable = dtime.fromisoformat(pg.auto_enable_time)
t_disable = dtime.fromisoformat(pg.auto_disable_time)
current = now_local.time()
if t_enable <= t_disable:
return t_enable <= current <= t_disable
else:
return current >= t_enable or current <= t_disable
return bool(pg.is_active)
def build_active_price_group_ids(db: Session, now: datetime) -> set:
"""Fetch all price groups once per request and return active IDs."""
from models.pricing import PriceGroup
groups = db.query(PriceGroup).all()
return {pg.id for pg in groups if _price_group_is_active(pg, now)}
# ---------------------------------------------------------------------------
# Modifier target matching
# ---------------------------------------------------------------------------
def _parse_ids(raw) -> list:
if not raw:
return []
if isinstance(raw, list):
return raw
try:
return json.loads(raw)
except Exception:
return []
def _modifier_targets_product(modifier, product, db: Session) -> bool:
"""Return True if any target row on a global modifier covers this product."""
from models.product import Product
for t in modifier.targets:
if t.target_type == "all":
return True
if t.target_type == "item":
ids = _parse_ids(t.target_ids) or ([t.target_id] if t.target_id else [])
if product.id in ids:
return True
if t.target_type == "category":
ids = _parse_ids(t.target_ids) or ([t.target_id] if t.target_id else [])
if product.category_id in ids:
return True
if t.target_type == "prep_zone":
ids = _parse_ids(t.target_ids) or ([t.target_id] if t.target_id else [])
zone_ids = {pz.id for pz in product.prep_zones}
if any(i in zone_ids for i in ids):
return True
if t.target_type == "tag":
prod_tags = set(json.loads(product.tags) if product.tags else [])
tags = _parse_ids(t.target_tags) or ([t.target_tag] if t.target_tag else [])
if any(tag in prod_tags for tag in tags):
return True
return False
# ---------------------------------------------------------------------------
# Main resolver
# ---------------------------------------------------------------------------
def resolve_price(
product,
quantity: float,
options_extra: float,
ctx: OrderContext,
db: Session,
) -> tuple[float, list[PriceEvent]]:
"""
Compute the final unit_price for one order item.
Returns (final_unit_price, list_of_PriceEvent_to_log).
PriceEvents have order_item_id=None — caller sets it after the OrderItem is committed.
order_id must be set on the PriceEvents before writing them; the caller does this.
"""
from models.pricing import PriceModifier
base = product.base_price + options_extra
events: list[PriceEvent] = []
# --- Load modifiers ---
# Item-scoped modifiers for this product
item_modifiers = (
db.query(PriceModifier)
.filter(
PriceModifier.scope == "item",
PriceModifier.item_id == product.id,
PriceModifier.is_active == 1,
)
.order_by(PriceModifier.sort_order)
.all()
)
# Global modifiers (all active ones, filtered by target in Python)
all_global = (
db.query(PriceModifier)
.filter(PriceModifier.scope == "global", PriceModifier.is_active == 1)
.order_by(PriceModifier.sort_order)
.all()
)
global_modifiers = [
m for m in all_global
if _modifier_targets_product(m, product, db)
]
# Item-scoped take priority: evaluated first, then global appended
all_modifiers = item_modifiers + global_modifiers
if not all_modifiers:
return _system_round(base), events
# --- Separate stackable vs non-stackable ---
stackable = []
non_stackable = []
for m in all_modifiers:
passed, snapshot = _all_conditions_pass(m.conditions, ctx, product.id, quantity)
if passed:
if m.allow_stack:
stackable.append((m, snapshot))
else:
non_stackable.append((m, snapshot))
price = base
# --- Apply first non-stackable ---
if non_stackable:
m, snapshot = non_stackable[0]
price_before = price
price = _apply_action(price, m.action_type, m.action_value, m.round_to)
events.append(PriceEvent(
order_id=0, # caller will fill in
order_item_id=None,
event_type="modifier_applied",
modifier_id=m.id,
price_before=price_before,
price_after=price,
delta_amount=round(price - price_before, 4),
conditions_snapshot=snapshot,
))
# --- Apply all stackable modifiers ---
for m, snapshot in stackable:
price_before = price
price = _apply_action(price, m.action_type, m.action_value, m.round_to)
events.append(PriceEvent(
order_id=0,
order_item_id=None,
event_type="modifier_applied",
modifier_id=m.id,
price_before=price_before,
price_after=price,
delta_amount=round(price - price_before, 4),
conditions_snapshot=snapshot,
))
final = _system_round(price)
# Update the last event's price_after to reflect the final rounded value
if events:
events[-1].price_after = final
events[-1].delta_amount = round(final - events[-1].price_before, 4)
return final, events
# ---------------------------------------------------------------------------
# Deal evaluation
# ---------------------------------------------------------------------------
def _deal_targets_met(deal, ctx: OrderContext, db: Session) -> bool:
"""Return True if the cart contains items that satisfy the deal's target rules."""
from models.product import Product
if not deal.targets:
return True # no target = always matches
for target in deal.targets:
if target.target_type == "any":
if ctx.cart_items:
return True
if target.target_type == "item":
ids = _parse_ids(target.target_ids) or ([target.target_id] if target.target_id else [])
if any(ci["product_id"] in ids for ci in ctx.cart_items):
return True
if target.target_type == "category":
ids = _parse_ids(target.target_ids) or ([target.target_id] if target.target_id else [])
if any(ci.get("category_id") in ids for ci in ctx.cart_items):
return True
if target.target_type == "tag":
tags = _parse_ids(target.target_tags) or ([target.target_tag] if target.target_tag else [])
if tags:
for ci in ctx.cart_items:
prod = db.query(Product).filter(Product.id == ci["product_id"]).first()
if prod:
prod_tags = set(json.loads(prod.tags) if prod.tags else [])
if any(tag in prod_tags for tag in tags):
return True
return False
def evaluate_deals(
order_id: int,
ctx: OrderContext,
already_fired_deal_ids: set,
db: Session,
) -> list[DealOffer]:
"""
Check all active deals against the current cart. Returns DealOffer objects
for deals that are newly triggered (not already fired this order).
already_fired_deal_ids: set of deal IDs that have already been offered/applied
on this order (derived from price_event_log by the caller).
"""
from models.pricing import Deal
from models.product import Product
deals = db.query(Deal).filter(Deal.is_active == 1).order_by(Deal.sort_order).all()
offers: list[DealOffer] = []
for deal in deals:
if deal.id in already_fired_deal_ids:
continue
# Check conditions
conditions_pass, _ = _all_conditions_pass(deal.conditions, ctx, 0, 0)
if not conditions_pass:
continue
# Check cart targets
if not _deal_targets_met(deal, ctx, db):
continue
# Build the offer
offer = DealOffer(
deal_id=deal.id,
deal_name=deal.name,
action_type=deal.action_type,
)
if deal.action_type == "free_item" and deal.action_free_item_id:
prod = db.query(Product).filter(Product.id == deal.action_free_item_id).first()
offer.free_item_id = deal.action_free_item_id
offer.free_item_name = prod.name if prod else None
offer.free_quantity = deal.action_free_quantity
elif deal.action_type == "free_choice" and deal.action_free_target_ids:
try:
target_ids = json.loads(deal.action_free_target_ids)
except (ValueError, TypeError):
target_ids = []
if deal.action_free_target_type == "item":
prods = db.query(Product).filter(Product.id.in_(target_ids)).all()
offer.free_choices = [{"id": p.id, "name": p.name} for p in prods]
elif deal.action_free_target_type == "category":
prods = db.query(Product).filter(
Product.category_id.in_(target_ids),
Product.lifecycle_status == "active",
Product.is_available == True, # noqa: E712
).all()
offer.free_choices = [{"id": p.id, "name": p.name} for p in prods]
elif deal.action_free_target_type == "tag":
all_prods = db.query(Product).filter(
Product.lifecycle_status == "active",
Product.is_available == True, # noqa: E712
).all()
offer.free_choices = [
{"id": p.id, "name": p.name}
for p in all_prods
if any(tag in (json.loads(p.tags) if p.tags else []) for tag in target_ids)
]
offer.free_quantity = deal.action_free_quantity
offers.append(offer)
return offers
# ---------------------------------------------------------------------------
# Waiter discount limit enforcement
# ---------------------------------------------------------------------------
def check_waiter_discount_limits(
waiter_id: int,
order_id: int,
item_ids: list[int],
discount_percent: float,
item_unit_prices: dict[int, float], # {order_item_id: current_unit_price}
db: Session,
) -> tuple[bool, str]:
"""
Validate that applying the given discount does not exceed any configured limit.
Returns (ok, error_message). error_message is empty string when ok=True.
"""
from models.pricing import WaiterDiscountSettings
from models.order import OrderDiscount
from models.settings import PosSettings
from models.business_day import BusinessDay
from models.shift import WaiterShift
# --- Global kill-switch ---
global_row = db.query(PosSettings).filter(PosSettings.key == "discounts.enabled").first()
if global_row and global_row.value == "false":
return False, "Discounts are globally disabled"
# --- Per-waiter settings ---
settings = db.query(WaiterDiscountSettings).filter(
WaiterDiscountSettings.user_id == waiter_id
).first()
if not settings or not settings.can_apply_discounts:
return False, "You do not have permission to apply discounts"
# --- Validate percent limits ---
if settings.max_discount_percent is not None:
if discount_percent > settings.max_discount_percent:
return False, f"Discount exceeds your maximum of {settings.max_discount_percent:.0f}%"
# --- Compute proposed euro amounts ---
proposed_amounts = {
item_id: round(unit_price * discount_percent / 100.0, 2)
for item_id, unit_price in item_unit_prices.items()
if item_id in item_ids
}
proposed_total = sum(proposed_amounts.values())
proposed_count = len(item_ids)
# --- Per-item amount limit ---
if settings.max_discount_amount is not None:
for item_id, amount in proposed_amounts.items():
if amount > settings.max_discount_amount:
return False, f"Single-item discount of €{amount:.2f} exceeds your limit of €{settings.max_discount_amount:.2f}"
# --- Items per order ---
if settings.max_items_per_order is not None:
existing_count_order = db.query(OrderDiscount).filter(
OrderDiscount.order_id == order_id
).count()
if existing_count_order + proposed_count > settings.max_items_per_order:
remaining = settings.max_items_per_order - existing_count_order
return False, f"You can only discount {remaining} more item(s) on this order"
# --- Current workday ---
bd = db.query(BusinessDay).filter(BusinessDay.closed_at == None).first() # noqa: E711
if bd:
# Items per workday
if settings.max_items_per_workday is not None:
from models.order import Order
wd_order_ids = [
o.id for o in db.query(Order).filter(Order.business_day_id == bd.id).all()
]
wd_count = db.query(OrderDiscount).filter(
OrderDiscount.applied_by == waiter_id,
OrderDiscount.order_id.in_(wd_order_ids),
).count()
if wd_count + proposed_count > settings.max_items_per_workday:
remaining = settings.max_items_per_workday - wd_count
return False, f"You can only discount {remaining} more item(s) today"
# Total value per workday
if settings.max_total_value_workday is not None:
from models.order import Order
wd_order_ids = [
o.id for o in db.query(Order).filter(Order.business_day_id == bd.id).all()
]
existing_rows = db.query(OrderDiscount).filter(
OrderDiscount.applied_by == waiter_id,
OrderDiscount.order_id.in_(wd_order_ids),
).all()
existing_wd_total = sum(_discount_euro(r) for r in existing_rows)
if existing_wd_total + proposed_total > settings.max_total_value_workday:
remaining = settings.max_total_value_workday - existing_wd_total
return False, f"You have only €{remaining:.2f} of discount budget left today"
# --- Current shift ---
shift = db.query(WaiterShift).filter(
WaiterShift.waiter_id == waiter_id,
WaiterShift.ended_at == None, # noqa: E711
).order_by(WaiterShift.started_at.desc()).first()
if shift:
# Items per shift
if settings.max_items_per_shift is not None:
from models.order import Order
shift_order_ids = [
o.id for o in db.query(Order).filter(
Order.opened_at >= shift.started_at
).all()
]
shift_count = db.query(OrderDiscount).filter(
OrderDiscount.applied_by == waiter_id,
OrderDiscount.order_id.in_(shift_order_ids),
).count()
if shift_count + proposed_count > settings.max_items_per_shift:
remaining = settings.max_items_per_shift - shift_count
return False, f"You can only discount {remaining} more item(s) this shift"
# Total value per shift
if settings.max_total_value_shift is not None:
from models.order import Order
shift_order_ids = [
o.id for o in db.query(Order).filter(
Order.opened_at >= shift.started_at
).all()
]
existing_rows = db.query(OrderDiscount).filter(
OrderDiscount.applied_by == waiter_id,
OrderDiscount.order_id.in_(shift_order_ids),
).all()
existing_shift_total = sum(_discount_euro(r) for r in existing_rows)
if existing_shift_total + proposed_total > settings.max_total_value_shift:
remaining = settings.max_total_value_shift - existing_shift_total
return False, f"You have only €{remaining:.2f} of discount budget left this shift"
# --- Global limits from pos_settings ---
def _gs(key):
row = db.query(PosSettings).filter(PosSettings.key == key).first()
return float(row.value) if row and row.value else None
g_max_wd_value = _gs("discounts.max_total_value_workday")
g_max_shift_val = _gs("discounts.max_total_value_shift")
g_max_shift_cnt = _gs("discounts.max_items_per_shift")
if bd and g_max_wd_value is not None:
from models.order import Order
wd_order_ids = [o.id for o in db.query(Order).filter(Order.business_day_id == bd.id).all()]
all_wd_rows = db.query(OrderDiscount).filter(
OrderDiscount.order_id.in_(wd_order_ids)
).all()
all_wd_total = sum(_discount_euro(r) for r in all_wd_rows)
if all_wd_total + proposed_total > g_max_wd_value:
remaining = g_max_wd_value - all_wd_total
return False, f"Store-wide discount budget has only €{remaining:.2f} left today"
if shift and (g_max_shift_val is not None or g_max_shift_cnt is not None):
from models.order import Order
shift_order_ids = [
o.id for o in db.query(Order).filter(Order.opened_at >= shift.started_at).all()
]
all_shift_rows = db.query(OrderDiscount).filter(
OrderDiscount.order_id.in_(shift_order_ids)
).all()
if g_max_shift_val is not None:
total = sum(_discount_euro(r) for r in all_shift_rows)
if total + proposed_total > g_max_shift_val:
remaining = g_max_shift_val - total
return False, f"Store-wide shift discount budget has only €{remaining:.2f} left"
if g_max_shift_cnt is not None:
count = len(all_shift_rows)
if count + proposed_count > int(g_max_shift_cnt):
remaining = int(g_max_shift_cnt) - count
return False, f"Store-wide shift item limit: only {remaining} discount(s) left"
return True, ""
def _discount_euro(row) -> float:
"""Convert an OrderDiscount row to a euro amount."""
if row.price_before is not None and row.price_after is not None:
return max(0.0, row.price_before - row.price_after)
# Fallback for legacy rows without price_before/after
if row.discount_type == "fixed":
return row.discount_value
return 0.0 # percent rows without snapshots can't be computed retroactively
# ---------------------------------------------------------------------------
# Log helpers
# ---------------------------------------------------------------------------
def write_price_events(events: list[PriceEvent], order_id: int,
order_item_id: int, db: Session):
"""Persist PriceEvent objects to price_event_log. Call after OrderItem is committed."""
from models.pricing import PriceEventLog
for ev in events:
db.add(PriceEventLog(
order_id=order_id,
order_item_id=order_item_id,
event_type=ev.event_type,
modifier_id=ev.modifier_id,
deal_id=ev.deal_id,
price_before=ev.price_before,
price_after=ev.price_after,
delta_amount=ev.delta_amount,
applied_by_user_id=ev.applied_by_user_id,
waiter_note=ev.waiter_note,
conditions_snapshot=json.dumps(ev.conditions_snapshot) if ev.conditions_snapshot else None,
selected_item_ids=json.dumps(ev.selected_item_ids) if ev.selected_item_ids else None,
))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
"""Background task: auto-mark overdue pending reservations as no_show."""
import asyncio
import logging
from datetime import datetime, timezone, timedelta
logger = logging.getLogger(__name__)
# Grace period after reserved_for before a pending reservation becomes no_show
NO_SHOW_GRACE_MINUTES = 30
# How often the checker runs
CHECK_INTERVAL_SECONDS = 300 # 5 minutes
async def _run_no_show_checker():
# Import here to avoid circular imports at module load time
from database import SessionLocal
from models.reservation import Reservation
while True:
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
try:
db = SessionLocal()
try:
cutoff = datetime.now(timezone.utc) - timedelta(minutes=NO_SHOW_GRACE_MINUTES)
overdue = (
db.query(Reservation)
.filter(
Reservation.status == "pending",
Reservation.reserved_for < cutoff,
)
.all()
)
if overdue:
for r in overdue:
r.status = "no_show"
r.updated_at = datetime.now(timezone.utc)
db.commit()
logger.info("no_show_checker: marked %d reservation(s) as no_show", len(overdue))
finally:
db.close()
except Exception:
logger.exception("no_show_checker: unexpected error")
async def start_reservation_tasks() -> asyncio.Task:
task = asyncio.create_task(_run_no_show_checker())
return task

View File

@@ -54,8 +54,14 @@ async def unsubscribe(user_id: int, q: asyncio.Queue) -> None:
def broadcast_sync(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None:
"""
Fire-and-forget broadcast from a synchronous FastAPI route (thread-pool worker).
Uses call_soon_threadsafe so the coroutine runs on the main event loop, not the thread.
Delegates to ws_bus which persists the event and pushes to WS clients.
Also pushes to legacy SSE clients so the old endpoint keeps working during transition.
"""
# WS bus handles persistence + WS delivery
from services.ws_bus import broadcast_sync as ws_broadcast_sync
ws_broadcast_sync(event_type, data, user_ids=user_ids)
# Legacy SSE push (no persistence needed — WS is the source of truth)
if _main_loop is None:
return
_main_loop.call_soon_threadsafe(

View File

@@ -0,0 +1,170 @@
"""
WebSocket Event Bus — replaces sse_bus.py for real-time communication.
Drop-in replacement: all routers continue calling broadcast_sync() unchanged.
The WS endpoint uses connect()/disconnect()/replay_missed() to manage clients.
Protocol (JSON frames):
Server → Client: { "seq": 123, "type": "order_updated", "data": { ... } }
Client → Server: { "cursor": 120 } (sent immediately after connect)
Server → Client: { "type": "ping" } (every 25s keepalive)
Client → Server: { "type": "pong" } (optional, ignored if not sent)
On connect the client sends its last known seq. The server replays everything
it has stored since that seq, then switches to live streaming. Events are stored
in the sync_events SQLite table (written here, pruned on startup).
"""
import asyncio
import json
import logging
from typing import Dict, Set
from datetime import datetime, timedelta, timezone
from fastapi import WebSocket
logger = logging.getLogger(__name__)
# ── Event loop (captured once at startup) ────────────────────────────────────
_main_loop: asyncio.AbstractEventLoop | None = None
def init_loop(loop: asyncio.AbstractEventLoop) -> None:
global _main_loop
_main_loop = loop
# ── Connected clients: user_id → set of asyncio.Queue ────────────────────────
_connections: Dict[int, Set[asyncio.Queue]] = {}
async def connect(user_id: int) -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue(maxsize=512)
if user_id not in _connections:
_connections[user_id] = set()
_connections[user_id].add(q)
return q
async def disconnect(user_id: int, q: asyncio.Queue) -> None:
if user_id in _connections:
_connections[user_id].discard(q)
if not _connections[user_id]:
del _connections[user_id]
# ── Persistence helpers ───────────────────────────────────────────────────────
def _get_db():
from database import SessionLocal
return SessionLocal()
def _persist_event(event_type: str, data: dict, user_ids: list[int] | None) -> int:
"""Write event to sync_events table, return the new seq_id."""
from sqlalchemy import text
db = _get_db()
try:
result = db.execute(
text(
"INSERT INTO sync_events (event_type, payload, target_user_ids, created_at) "
"VALUES (:et, :payload, :uids, :now)"
),
{
"et": event_type,
"payload": json.dumps(data),
"uids": json.dumps(user_ids) if user_ids is not None else None,
"now": datetime.now(timezone.utc).isoformat(),
},
)
db.commit()
return result.lastrowid
finally:
db.close()
def get_events_since(cursor: int, user_id: int) -> list[dict]:
"""Return all events with seq_id > cursor that are visible to user_id."""
from sqlalchemy import text
db = _get_db()
try:
rows = db.execute(
text(
"SELECT id, event_type, payload, target_user_ids FROM sync_events "
"WHERE id > :cursor ORDER BY id ASC LIMIT 500"
),
{"cursor": cursor},
).fetchall()
finally:
db.close()
events = []
for row in rows:
target_user_ids = json.loads(row[3]) if row[3] else None
if target_user_ids is None or user_id in target_user_ids:
events.append({
"seq": row[0],
"type": row[1],
"data": json.loads(row[2]),
})
return events
def prune_old_events(hours: int = 24) -> int:
"""Delete events older than `hours`. Called on startup."""
from sqlalchemy import text
db = _get_db()
try:
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
result = db.execute(
text("DELETE FROM sync_events WHERE created_at < :cutoff"),
{"cutoff": cutoff},
)
db.commit()
return result.rowcount
finally:
db.close()
# ── Broadcast ─────────────────────────────────────────────────────────────────
def broadcast_sync(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None:
"""
Called from sync route thread-pool workers.
Persists the event to DB (so reconnecting clients can replay it),
then schedules an async push to all currently connected sockets.
"""
try:
seq_id = _persist_event(event_type, data, user_ids)
except Exception:
logger.exception("ws_bus: failed to persist event %s", event_type)
seq_id = 0
if _main_loop is None:
return
_main_loop.call_soon_threadsafe(
_main_loop.create_task,
_broadcast_live(seq_id, event_type, data, user_ids),
)
async def _broadcast_live(
seq_id: int,
event_type: str,
data: dict,
user_ids: list[int] | None,
) -> None:
frame = json.dumps({"seq": seq_id, "type": event_type, "data": data})
targets = (
{uid: qs for uid, qs in _connections.items() if uid in user_ids}
if user_ids is not None
else dict(_connections)
)
for qs in targets.values():
for q in list(qs):
try:
q.put_nowait(frame)
except asyncio.QueueFull:
pass # slow client — drop live frame; they'll replay on reconnect

39
local_backend/tz.py Normal file
View File

@@ -0,0 +1,39 @@
"""Timezone utilities for converting UTC datetimes to the venue's local time."""
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from datetime import datetime, timezone
from database import SessionLocal
from models.settings import PosSettings
_FALLBACK = "Europe/Athens"
def _get_tz_name() -> str:
db = SessionLocal()
try:
row = db.query(PosSettings).filter(PosSettings.key == "system.timezone").first()
return row.value if row and row.value else _FALLBACK
except Exception:
return _FALLBACK
finally:
db.close()
def to_local(dt: datetime) -> datetime:
"""Convert a UTC-aware datetime to the venue's configured local timezone."""
if dt is None:
return dt
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
try:
tz = ZoneInfo(_get_tz_name())
except ZoneInfoNotFoundError:
tz = ZoneInfo(_FALLBACK)
return dt.astimezone(tz)
def local_strftime(dt: datetime, fmt: str) -> str:
"""Format a UTC-aware datetime in local time using the venue's timezone."""
if dt is None:
return ""
return to_local(dt).strftime(fmt)

View File

@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""
Wipes the POS database completely and re-applies the schema + default seed data.
Usage:
python wipe_database.py
Safe to run even if the backend is stopped. Does NOT delete the .db file —
it drops all tables and recreates them so the file path stays intact.
After running this, run demo_seed.py to populate with demo data.
"""
import sys
# ── Confirmation prompt ───────────────────────────────────────────────────────
print("=" * 60)
print(" WARNING: This will PERMANENTLY DELETE all data in the")
print(" POS database including orders, products, users, etc.")
print("=" * 60)
answer = input("\nType YES to confirm: ").strip()
if answer != "YES":
print("Aborted.")
sys.exit(0)
# ── Imports (after confirmation, so we don't waste time if user bails) ────────
from sqlalchemy import text, inspect
from database import engine, Base, SessionLocal
# Import all models so Base.metadata knows about every table.
# Order matters: tables with no FK dependencies first, then dependents.
import models.user # noqa: F401
import models.table # noqa: F401
import models.printer # noqa: F401
import models.product # noqa: F401
import models.customers # noqa: F401 must be before models.order (orders.customer_id FK)
import models.order # noqa: F401
import models.business_day # noqa: F401
import models.shift # noqa: F401
import models.settings # noqa: F401
import models.flag # noqa: F401
import models.message # noqa: F401
import models.notes # noqa: F401
import models.expenses # noqa: F401
import models.tabs # noqa: F401
import models.schedule # noqa: F401
import models.waste # noqa: F401
import models.reservation # noqa: F401
print("\nDropping all tables...")
# SQLite doesn't support DROP TABLE ... CASCADE, so we disable FK checks,
# drop every table we know about, then re-enable them.
with engine.connect() as conn:
conn.execute(text("PRAGMA foreign_keys = OFF"))
conn.commit()
# Reflect the actual DB to catch any tables we might have missed in models
inspector = inspect(engine)
existing_tables = inspector.get_table_names()
# Drop in reverse dependency order using reflected metadata
with engine.connect() as conn:
for table_name in existing_tables:
conn.execute(text(f"DROP TABLE IF EXISTS [{table_name}]"))
print(f" dropped: {table_name}")
conn.execute(text("PRAGMA foreign_keys = ON"))
conn.commit()
print("\nRecreating schema...")
Base.metadata.create_all(bind=engine)
# Re-run the same migration/seed SQL from main.py so default settings,
# flags, and quick message templates are present after the wipe.
print("Applying seed migrations...")
seed_migrations = [
# pos_settings defaults
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_start', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('shifts.waiter_self_end', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('business_day.force_close_allowed', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('flags.display_mode', 'both', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.ticket_mode', 'detailed', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_number', '48:1:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_meta', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_name', '16:1:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_quick', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_pref', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_extra', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_ingredient', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_item_note', '0:0:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('print.font_order_note', '0:1:0', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.name', '', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('venue.type', '', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.login_method', 'password', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.autofill_username', 'true', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock', 'false', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_lock_seconds', '300', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout', 'false', CURRENT_TIMESTAMP)",
"INSERT OR IGNORE INTO pos_settings (key, value, updated_at) VALUES ('security.auto_logout_seconds', '1800', CURRENT_TIMESTAMP)",
# Default table flag definitions
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (1, 'Χρειάζεται καθάρισμα', '🧹', '#ef4444', 1)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (2, 'Χρειάζεται Βοήθεια', '🆘', '#f97316', 2)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (3, 'Χρειάζεται Σερβιτόρο', '🔔', '#eab308', 3)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (4, 'Περιμένει να πληρώσει', '💳', '#3b82f6', 4)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (5, 'VIP', '', '#8b5cf6', 5)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (6, 'Ευγενικός Πελάτης', '😊', '#22c55e', 6)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (7, 'Αγενής Πελάτης', '😤', '#dc2626', 7)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (8, 'Αλλεργίες', '⚠️', '#f59e0b', 8)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (9, 'Παιδιά στο τραπέζι', '👶', '#06b6d4', 9)",
"INSERT OR IGNORE INTO table_flag_defs (id, name, emoji, color, sort_order) VALUES (10, 'Επέτειος / Γενέθλια', '🎂', '#ec4899', 10)",
# Default quick message templates
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (1, 'Σε χρειάζομαι τώρα', 1)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (2, 'Πάρε διάλειμμα', 2)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (3, 'Ετοιμάσου για κλείσιμο', 3)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (4, 'Ήρθε νέος πελάτης', 4)",
"INSERT OR IGNORE INTO quick_message_templates (id, body, sort_order) VALUES (5, 'Ο πελάτης περιμένει να πληρώσει', 5)",
]
for sql in seed_migrations:
try:
with engine.connect() as conn:
conn.execute(text(sql))
conn.commit()
except Exception as e:
print(f" [skip] {e}")
print("\nDone. Database is clean and ready.")
print("Run python demo_seed.py to populate with demo data.")

View File

@@ -4,9 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>POS Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>

View File

@@ -6,6 +6,9 @@ server {
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600;

Binary file not shown.

View File

@@ -2,15 +2,57 @@ import { useEffect, useState } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'
import useAuthStore from './store/authStore'
import AppLayout from './layouts/AppLayout'
import client from './api/client'
// Auth / setup
import LoginPage from './pages/LoginPage'
import SetupWizard from './pages/SetupWizard'
import OperationsPage from './pages/OperationsPage'
// Standalone (no shell)
import KdsPage from './pages/KdsPage'
// Operation
import DashboardPage from './pages/DashboardPage'
import TablesPage from './pages/TablesPage'
import OrderDetailPage from './pages/OrderDetailPage'
import ManagementPage from './pages/ManagementPage'
import ReportsPage from './pages/reports/ReportsPage'
import OnlineOrdersPage from './pages/OnlineOrdersPage'
// CRM
import ContactsPage from './pages/crm/ContactsPage'
import CustomersPage from './pages/crm/CustomersPage'
// Financials
import ExpensesPage from './pages/financials/ExpensesPage'
import TabsPage from './pages/financials/TabsPage'
// Management
import ProductsPage from './pages/Management/ProductsPage'
import TablesConfigPage from './pages/Management/TablesConfigPage'
import StaffPage from './pages/Management/StaffPage'
import ManagementSchedulePage from './pages/Management/SchedulePage'
import PrepZonesConfigPage from './pages/Management/PrepZonesConfigPage'
import PricingPage from './pages/Management/PricingPage'
// Inventory
import ThrowawaysPage from './pages/inventory/ThrowawaysPage'
import StockPage from './pages/inventory/StockPage'
// Reports
import TodayPage from './pages/reports/TodayPage'
import StorePage from './pages/reports/StorePage'
import StaffReportPage from './pages/reports/StaffReportPage'
import ProductsReportPage from './pages/reports/ProductsReportPage'
import PrepZonesPage from './pages/reports/PrepZonesPage'
import ExpensesReportPage from './pages/reports/ExpensesReportPage'
import TechnicalPage from './pages/reports/TechnicalPage'
import MiscPage from './pages/reports/MiscPage'
// Other
import AppsPage from './pages/AppsPage'
import PhonePage from './pages/PhonePage'
import SettingsPage from './pages/Settings/SettingsPage'
import client from './api/client'
import NotesPage from './pages/NotesPage'
import PermissionGuard from './components/PermissionGuard'
function Spinner() {
return (
@@ -20,8 +62,6 @@ function Spinner() {
)
}
// Rehydrates user from stored token before rendering any routes.
// Prevents the flicker where a valid token causes a redirect to /login on refresh.
function AuthRehydrator({ children }) {
const { token, user, rehydrate, logout } = useAuthStore()
const [ready, setReady] = useState(false)
@@ -46,7 +86,6 @@ function RequireAuth({ children }) {
return token ? children : <Navigate to="/login" replace />
}
// Checks /api/setup/status on mount and redirects to /setup if no managers exist.
function SetupGuard({ children }) {
const [checked, setChecked] = useState(false)
const navigate = useNavigate()
@@ -56,9 +95,7 @@ function SetupGuard({ children }) {
.then(({ data }) => {
if (data.needs_setup) navigate('/setup', { replace: true })
})
.catch(() => {
// Backend unreachable — proceed, login will surface the error.
})
.catch(() => {})
.finally(() => setChecked(true))
}, [navigate])
@@ -73,15 +110,62 @@ export default function App() {
<Routes>
<Route path="/setup" element={<SetupWizard />} />
<Route path="/login" element={<SetupGuard><LoginPage /></SetupGuard>} />
<Route path="/kds" element={<RequireAuth><KdsPage /></RequireAuth>} />
<Route path="/" element={<RequireAuth><AppLayout /></RequireAuth>}>
<Route index element={<Navigate to="/operations" replace />} />
<Route path="dashboard" element={<Navigate to="/operations" replace />} />
<Route path="operations" element={<OperationsPage />} />
<Route index element={<Navigate to="/dashboard" replace />} />
{/* ── Operation ── */}
<Route path="dashboard" element={<DashboardPage />} />
<Route path="tables" element={<TablesPage />} />
<Route path="online-orders" element={<OnlineOrdersPage />} />
<Route path="orders/:orderId" element={<OrderDetailPage />} />
<Route path="management" element={<ManagementPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="settings" element={<SettingsPage />} />
{/* ── CRM ── */}
<Route path="crm/contacts" element={<ContactsPage />} />
<Route path="crm/customers" element={<CustomersPage />} />
{/* ── Financials ── */}
<Route path="financials/expenses" element={<ExpensesPage />} />
<Route path="financials/tabs" element={<TabsPage />} />
{/* ── Management (Headquarters) ── */}
<Route path="management/products" element={<PermissionGuard perm="manageMenu"><ProductsPage /></PermissionGuard>} />
<Route path="management/tables" element={<PermissionGuard perm="manageTables"><TablesConfigPage /></PermissionGuard>} />
<Route path="management/staff" element={<PermissionGuard perm="manageStaff"><StaffPage /></PermissionGuard>} />
<Route path="management/schedule" element={<PermissionGuard perm="manageStaff"><ManagementSchedulePage /></PermissionGuard>} />
<Route path="management/prep-zones" element={<PermissionGuard perm="manageMenu"><PrepZonesConfigPage /></PermissionGuard>} />
<Route path="management/pricing" element={<PermissionGuard perm="manageMenu"><PricingPage /></PermissionGuard>} />
{/* ── Inventory ── */}
<Route path="inventory/throwaways" element={<ThrowawaysPage />} />
<Route path="inventory/stock" element={<StockPage />} />
{/* ── Reports (Overwatch) ── */}
<Route path="reports/today" element={<PermissionGuard perm="viewReports"><TodayPage /></PermissionGuard>} />
<Route path="reports/store" element={<PermissionGuard perm="viewReports"><StorePage /></PermissionGuard>} />
<Route path="reports/staff" element={<PermissionGuard perm="viewReports"><StaffReportPage /></PermissionGuard>} />
<Route path="reports/products" element={<PermissionGuard perm="viewReports"><ProductsReportPage /></PermissionGuard>} />
<Route path="reports/prep-zones" element={<PermissionGuard perm="viewReports"><PrepZonesPage /></PermissionGuard>} />
<Route path="reports/expenses" element={<PermissionGuard perm="viewReports"><ExpensesReportPage /></PermissionGuard>} />
<Route path="reports/technical" element={<PermissionGuard perm="viewReports"><TechnicalPage /></PermissionGuard>} />
<Route path="reports/misc" element={<PermissionGuard perm="viewReports"><MiscPage /></PermissionGuard>} />
{/* ── Apps & Settings ── */}
<Route path="apps" element={<AppsPage />} />
<Route path="apps/phone" element={<PhonePage />} />
<Route path="settings" element={<PermissionGuard perm="manageSettings"><SettingsPage /></PermissionGuard>} />
<Route path="notes" element={<NotesPage />} />
{/* ── Legacy redirects (old URLs → new locations) ── */}
<Route path="contacts" element={<Navigate to="/crm/contacts" replace />} />
<Route path="customers" element={<Navigate to="/crm/customers" replace />} />
<Route path="expenses" element={<Navigate to="/financials/expenses" replace />} />
<Route path="tabs" element={<Navigate to="/financials/tabs" replace />} />
<Route path="waste" element={<Navigate to="/inventory/throwaways" replace />} />
<Route path="schedule" element={<Navigate to="/management/schedule" replace />} />
<Route path="management" element={<Navigate to="/management/products" replace />} />
<Route path="reports" element={<Navigate to="/reports/today" replace />} />
</Route>
</Routes>
</AuthRehydrator>

View File

@@ -23,3 +23,18 @@ client.interceptors.response.use(
)
export default client
// ── Online Orders (Xenia Connect) ─────────────────────────────────────────────
export const getIncomingOrders = () => client.get('/api/connect/orders/incoming')
export const getActiveOrders = () => client.get('/api/connect/orders?status=active')
export const getOrderHistory = () => client.get('/api/connect/orders?status=history')
export const acceptOnlineOrder = (id) =>
client.post(`/api/connect/orders/${id}/accept`)
export const rejectOnlineOrder = (id, reason) =>
client.post(`/api/connect/orders/${id}/reject`, { reason })
export const updateOnlineOrderStatus = (id, status) =>
client.post(`/api/connect/orders/${id}/status`, { status })

View File

@@ -0,0 +1,25 @@
import { ShieldAlert } from 'lucide-react'
import usePermissions from '../hooks/usePermissions'
/**
* Wraps a route element. Renders a 403 screen if the user lacks the required permission.
* Usage: <PermissionGuard perm="viewReports"><TodayPage /></PermissionGuard>
*/
export default function PermissionGuard({ perm, children }) {
const perms = usePermissions()
if (!perms[perm]) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-center p-8">
<ShieldAlert size={48} className="text-red-400" />
<h2 className="text-xl font-semibold text-gray-800">Δεν έχετε πρόσβαση</h2>
<p className="text-gray-500 max-w-sm">
Δεν έχετε τα απαραίτητα δικαιώματα για αυτή τη σελίδα.
Επικοινωνήστε με τον διαχειριστή εάν νομίζετε ότι πρόκειται για λάθος.
</p>
</div>
)
}
return children
}

View File

@@ -0,0 +1,358 @@
/**
* Mounts once inside AppLayout. Holds the WebSocket connection to /api/phone/ws,
* matches incoming callers against the customer list, and renders the call popup
* over whatever page is currently open.
*/
import { useEffect, useCallback, useRef, useState } from 'react'
import { PhoneIncoming, X, Mail, Phone, MessageSquare, Tag,
ShoppingBag, TrendingUp, Clock, User } from 'lucide-react'
import client from '../api/client'
import usePhoneStore from '../store/phoneStore'
/* ─── Phone normalisation ─────────────────────────────────────────────────── */
function normalisePhone(raw) {
if (!raw) return ''
let s = raw.replace(/[\s\-().+]/g, '')
if (s.startsWith('0030')) s = s.slice(4)
else if (s.startsWith('30') && s.length > 10) s = s.slice(2)
return s
}
function phonesMatch(a, b) {
const na = normalisePhone(a), nb = normalisePhone(b)
return na.length > 0 && na === nb
}
function buildWsUrl() {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
return `${proto}://${window.location.host}/api/phone/ws`
}
/* ─── Helpers ─────────────────────────────────────────────────────────────── */
function formatTime(date) {
return date.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
}
function formatDate(dateStr) {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleDateString('el-GR', { day: '2-digit', month: 'short', year: 'numeric' })
}
function formatCurrency(n) {
return `${Number(n || 0).toFixed(2)}`
}
/* ─── Ripple rings ────────────────────────────────────────────────────────── */
function RippleRings({ color }) {
return (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
{[0, 1, 2].map(i => (
<span key={i} style={{
position: 'absolute', width: 80, height: 80, borderRadius: '50%',
border: `2px solid ${color}`, opacity: 0,
animation: `phoneRipple 2s ease-out ${i * 0.6}s infinite`,
}} />
))}
</div>
)
}
/* ─── Known customer modal ────────────────────────────────────────────────── */
function KnownCustomerModal({ call, customer, orders, onClose }) {
const recent = (orders || []).slice(0, 3)
return (
<div style={{ fontFamily: "'DM Sans','Segoe UI',sans-serif" }}>
<div onClick={onClose} style={{
position: 'fixed', inset: 0, zIndex: 9990,
background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(8px)',
animation: 'fadeIn 0.2s ease',
}} />
<div style={{
position: 'fixed', zIndex: 9991,
top: '50%', left: '50%',
transform: 'translate(-50%,-50%)',
width: '100%', maxWidth: 480, padding: '0 16px',
animation: 'slideUp 0.3s cubic-bezier(0.16,1,0.3,1)',
}}>
<div style={{
background: '#0f1117', borderRadius: 20, overflow: 'hidden',
boxShadow: '0 32px 80px rgba(0,0,0,0.8),0 0 0 1px rgba(255,255,255,0.06)',
}}>
<div style={{ height: 3, background: 'linear-gradient(90deg,#d4a853,#f0c87a,#d4a853)' }} />
<div style={{ padding: '24px 24px 20px', position: 'relative' }}>
<button onClick={onClose} style={{
position: 'absolute', top: 20, right: 20,
width: 32, height: 32, borderRadius: 8,
background: 'rgba(255,255,255,0.07)', border: '1px solid rgba(255,255,255,0.1)',
color: '#9ca3af', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.12)'; e.currentTarget.style.color = '#fff' }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.07)'; e.currentTarget.style.color = '#9ca3af' }}
><X size={14} /></button>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<div style={{ position: 'relative', width: 44, height: 44 }}>
<RippleRings color="#d4a853" />
<div style={{
width: 44, height: 44, borderRadius: '50%',
background: 'linear-gradient(135deg,#d4a853,#f0c87a)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
position: 'relative', zIndex: 1,
}}><PhoneIncoming size={18} color="#0f1117" /></div>
</div>
<div>
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.12em', color: '#d4a853', textTransform: 'uppercase' }}>Εισερχόμενη Κλήση</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 1 }}>{formatTime(call.at)} · {call.caller}</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<div style={{
width: 56, height: 56, borderRadius: 14, flexShrink: 0,
background: 'linear-gradient(135deg,#1e293b,#334155)',
border: '1px solid rgba(212,168,83,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, fontWeight: 700, color: '#d4a853',
}}>{customer.name?.[0]?.toUpperCase() || '?'}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 22, fontWeight: 700, color: '#f9fafb', lineHeight: 1.2, letterSpacing: '-0.01em' }}>{customer.name}</div>
{customer.nickname && (
<div style={{ fontSize: 13, color: '#d4a853', marginTop: 2, display: 'flex', alignItems: 'center', gap: 4 }}>
<Tag size={11} />{customer.nickname}
</div>
)}
</div>
</div>
</div>
<div style={{ height: 1, background: 'rgba(255,255,255,0.06)', margin: '0 24px' }} />
<div style={{ padding: '16px 24px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{customer.email && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Mail size={13} color="#6b7280" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 13, color: '#d1d5db' }}>{customer.email}</span>
</div>
)}
{customer.phone && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Phone size={13} color="#6b7280" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 13, color: '#d1d5db', fontVariantNumeric: 'tabular-nums' }}>{customer.phone}</span>
</div>
)}
{customer.notes && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginTop: 2 }}>
<MessageSquare size={13} color="#6b7280" style={{ flexShrink: 0, marginTop: 1 }} />
<span style={{ fontSize: 13, color: '#9ca3af', lineHeight: 1.5, fontStyle: 'italic' }}>"{customer.notes}"</span>
</div>
)}
</div>
<div style={{
margin: '0 24px',
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 12, display: 'grid', gridTemplateColumns: '1fr 1fr',
}}>
<div style={{ padding: '12px 16px', borderRight: '1px solid rgba(255,255,255,0.07)' }}>
<div style={{ fontSize: 10, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>Επισκέψεις</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#f9fafb', display: 'flex', alignItems: 'center', gap: 6 }}>
<ShoppingBag size={14} color="#d4a853" />{customer.visit_count ?? 0}
</div>
</div>
<div style={{ padding: '12px 16px' }}>
<div style={{ fontSize: 10, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>Σύνολο</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#f9fafb', display: 'flex', alignItems: 'center', gap: 6 }}>
<TrendingUp size={14} color="#d4a853" />{formatCurrency(customer.total_spent)}
</div>
</div>
</div>
<div style={{ padding: '16px 24px 0' }}>
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.1em', color: '#6b7280', textTransform: 'uppercase', marginBottom: 8 }}>Πρόσφατες Παραγγελίες</div>
{recent.length === 0 ? (
<div style={{ padding: '12px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.03)', border: '1px dashed rgba(255,255,255,0.08)', fontSize: 12, color: '#4b5563', textAlign: 'center' }}>
Δεν υπάρχουν παραγγελίες ακόμα
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{recent.map(order => (
<div key={order.id} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 12px', borderRadius: 10,
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)',
}}>
<Clock size={12} color="#6b7280" style={{ flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, color: '#d1d5db', fontVariantNumeric: 'tabular-nums' }}>
{formatDate(order.opened_at)}
{order.table_name && <span style={{ color: '#6b7280' }}> · {order.table_name}</span>}
</div>
<div style={{ fontSize: 11, color: '#6b7280', marginTop: 1 }}>{order.item_count} είδη</div>
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#d4a853', fontVariantNumeric: 'tabular-nums' }}>{formatCurrency(order.total)}</div>
</div>
))}
</div>
)}
</div>
<div style={{ padding: '20px 24px' }}>
<button onClick={onClose} style={{
width: '100%', padding: '11px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
background: 'rgba(212,168,83,0.12)', color: '#d4a853', fontSize: 13, fontWeight: 600,
letterSpacing: '0.02em', transition: 'all 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(212,168,83,0.2)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(212,168,83,0.12)' }}
>Κλείσιμο</button>
</div>
</div>
</div>
</div>
)
}
/* ─── Unknown caller modal ────────────────────────────────────────────────── */
function UnknownCallerModal({ call, onClose }) {
return (
<div style={{ fontFamily: "'DM Sans','Segoe UI',sans-serif" }}>
<div onClick={onClose} style={{
position: 'fixed', inset: 0, zIndex: 9990,
background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(8px)',
animation: 'fadeIn 0.2s ease',
}} />
<div style={{
position: 'fixed', zIndex: 9991,
top: '50%', left: '50%',
transform: 'translate(-50%,-50%)',
width: '100%', maxWidth: 360, padding: '0 16px',
animation: 'slideUp 0.3s cubic-bezier(0.16,1,0.3,1)',
}}>
<div style={{
background: '#0f1117', borderRadius: 20, overflow: 'hidden',
boxShadow: '0 32px 80px rgba(0,0,0,0.8),0 0 0 1px rgba(255,255,255,0.06)',
}}>
<div style={{ height: 3, background: 'linear-gradient(90deg,#3b82f6,#6366f1,#3b82f6)' }} />
<div style={{ padding: '28px 24px 24px', position: 'relative' }}>
<button onClick={onClose} style={{
position: 'absolute', top: 20, right: 20,
width: 32, height: 32, borderRadius: 8,
background: 'rgba(255,255,255,0.07)', border: '1px solid rgba(255,255,255,0.1)',
color: '#9ca3af', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.12)'; e.currentTarget.style.color = '#fff' }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.07)'; e.currentTarget.style.color = '#9ca3af' }}
><X size={14} /></button>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 20 }}>
<div style={{ position: 'relative', width: 44, height: 44 }}>
<RippleRings color="#3b82f6" />
<div style={{
width: 44, height: 44, borderRadius: '50%',
background: 'linear-gradient(135deg,#1d4ed8,#3b82f6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
position: 'relative', zIndex: 1,
}}><PhoneIncoming size={18} color="#fff" /></div>
</div>
<div>
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.12em', color: '#3b82f6', textTransform: 'uppercase' }}>Εισερχόμενη Κλήση</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 1 }}>{formatTime(call.at)}</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, padding: '20px 0 8px' }}>
<div style={{
width: 56, height: 56, borderRadius: 14,
background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}><User size={24} color="#4b5563" /></div>
<div style={{ fontSize: 12, color: '#6b7280' }}>Άγνωστος καλών</div>
<div style={{ fontSize: 28, fontWeight: 700, color: '#f9fafb', letterSpacing: '0.02em', fontVariantNumeric: 'tabular-nums' }}>
{call.caller || '—'}
</div>
{call.ext && <div style={{ fontSize: 12, color: '#6b7280' }}>Εσωτερικό: {call.ext}</div>}
</div>
<div style={{ marginTop: 20 }}>
<button onClick={onClose} style={{
width: '100%', padding: '11px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
background: 'rgba(59,130,246,0.1)', color: '#3b82f6', fontSize: 13, fontWeight: 600,
transition: 'all 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(59,130,246,0.18)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(59,130,246,0.1)' }}
>Κλείσιμο</button>
</div>
</div>
</div>
</div>
</div>
)
}
/* ─── PhoneListener ───────────────────────────────────────────────────────── */
export default function PhoneListener() {
const { activeCall, dismissCall, setActiveCall, addCallToLog } = usePhoneStore()
const [customers, setCustomers] = useState([])
useEffect(() => {
client.get('/api/customers/')
.then(r => setCustomers(r.data || []))
.catch(() => {})
}, [])
const handleIncoming = useCallback(async ({ caller, ext }) => {
const at = new Date()
const matched = customers.find(c => phonesMatch(c.phone, caller))
let orders = null
if (matched) {
try {
const r = await client.get(`/api/customers/${matched.id}/orders`)
orders = r.data?.orders ?? []
} catch { orders = [] }
}
const entry = { caller, ext, at, customer: matched || null, orders }
addCallToLog(entry)
setActiveCall(entry)
}, [customers, addCallToLog, setActiveCall])
useEffect(() => {
let ws, dead = false
function connect() {
if (dead) return
ws = new WebSocket(buildWsUrl())
ws.onclose = () => { if (!dead) setTimeout(connect, 3000) }
ws.onerror = () => ws.close()
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data)
if (data.type === 'incoming_call') handleIncoming({ caller: data.caller, ext: data.ext })
} catch {}
}
}
connect()
return () => { dead = true; ws?.close() }
}, [handleIncoming])
if (!activeCall) return null
return (
<>
<style>{`
@keyframes fadeIn { from{opacity:0} to{opacity:1} }
@keyframes slideUp {
from{opacity:0;transform:translate(-50%,calc(-50% + 24px))}
to{opacity:1;transform:translate(-50%,-50%)}
}
@keyframes phoneRipple {
0%{transform:scale(1);opacity:0.6}
100%{transform:scale(2.8);opacity:0}
}
`}</style>
{activeCall.customer
? <KnownCustomerModal call={activeCall} customer={activeCall.customer} orders={activeCall.orders} onClose={dismissCall} />
: <UnknownCallerModal call={activeCall} onClose={dismissCall} />
}
</>
)
}

View File

@@ -1,47 +1,331 @@
import { NavLink } from 'react-router-dom'
import { useState } from 'react'
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft } from 'lucide-react'
import { NavLink, useLocation } from 'react-router-dom'
import { useState, useEffect, useRef } from 'react'
import {
LayoutDashboard, LayoutGrid, ShoppingBag,
BookUser, Users, Receipt, CreditCard,
Package, TableProperties, UserCog, CalendarDays, Trash2, Boxes,
BarChart2, TrendingUp, Users2, ShoppingCart, Tag, Activity, Printer, Ban, Percent, ConciergeBell, FileText,
AppWindow, Settings, ChevronDown, CookingPot, StickyNote,
} from 'lucide-react'
import { getIncomingOrders } from '../api/client'
import { isFeatureEnabled } from '../hooks/usePhase2Features'
import usePermissions from '../hooks/usePermissions'
const NAV = [
{ to: '/operations', icon: BarChart2, label: 'Διοίκηση' },
{ to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
{ to: '/management', icon: Package, label: 'Διαχείριση' },
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
// ─── Nav structure ────────────────────────────────────────────────────────────
// `perm` — key from usePermissions(). If set, item is hidden when user lacks that permission.
function buildNav(pendingCount) {
return [
{
category: 'Operation',
items: [
{ type: 'solo', to: '/dashboard', icon: LayoutDashboard, label: 'Εικόνα' },
{ type: 'solo', to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
{ type: 'solo', to: '/online-orders', icon: ShoppingBag, label: 'Online Παραγγ.', badge: pendingCount, phase2: 'online-orders' },
],
},
{
category: 'Financial',
items: [
{
type: 'group', id: 'crm', icon: BookUser, label: 'CRM',
children: [
{ to: '/crm/contacts', icon: BookUser, label: 'Επαφές', phase2: 'contacts' },
{ to: '/crm/customers', icon: Users, label: 'Πελάτες', phase2: 'customers' },
],
},
{
type: 'group', id: 'financials', icon: Receipt, label: 'Οικονομικά',
children: [
{ to: '/financials/expenses', icon: Receipt, label: 'Έξοδα', phase2: 'expenses' },
{ to: '/financials/tabs', icon: CreditCard, label: 'Καρτέλες Πελ.', phase2: 'tabs' },
],
},
],
},
{
category: 'Headquarters',
items: [
{
type: 'group', id: 'management', icon: Package, label: 'Διαχείριση',
children: [
{ to: '/management/products', icon: Package, label: 'Προϊόντα', perm: 'manageMenu' },
{ to: '/management/tables', icon: TableProperties, label: 'Τραπέζια', perm: 'manageTables' },
{ to: '/management/staff', icon: UserCog, label: 'Προσωπικό', perm: 'manageStaff' },
{ to: '/management/schedule', icon: CalendarDays, label: 'Πρόγραμμα', perm: 'manageStaff', phase2: 'schedule' },
{ to: '/management/prep-zones', icon: CookingPot, label: 'Ζώνες Ετοιμασίας', perm: 'manageMenu' },
{ to: '/management/pricing', icon: Percent, label: 'Προσφορές & Τιμές', perm: 'manageMenu' },
{ to: '/notes', icon: StickyNote, label: 'Σημειώσεις' },
],
},
{
type: 'group', id: 'inventory', icon: Boxes, label: 'Αποθήκη',
children: [
{ to: '/inventory/throwaways', icon: Trash2, label: 'Απορρίμματα', phase2: 'waste' },
{ to: '/inventory/stock', icon: Boxes, label: 'Απόθεμα', placeholder: true },
],
},
],
},
{
category: 'Overwatch',
perm: 'viewReports',
items: [
{
type: 'group', id: 'reports', icon: BarChart2, label: 'Αναφορές',
children: [
{ to: '/reports/today', icon: Activity, label: 'Σήμερα' },
{ to: '/reports/store', icon: TrendingUp, label: 'Κατάστημα' },
{ to: '/reports/staff', icon: Users2, label: 'Προσωπικό' },
{ to: '/reports/products', icon: ShoppingCart, label: 'Προϊόντα' },
{ to: '/reports/prep-zones', icon: ConciergeBell, label: 'Ζώνες Ετοιμασίας' },
{ to: '/reports/expenses', icon: FileText, label: 'Έξοδα' },
{ to: '/reports/technical', icon: Printer, label: 'Τεχνικά' },
{ to: '/reports/misc', icon: Tag, label: 'Λοιπά' },
],
},
],
},
]
}
const BOTTOM_ITEMS = [
{ to: '/apps', icon: AppWindow, label: 'Εφαρμογές' },
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις', perm: 'manageSettings' },
]
export default function Sidebar() {
const [collapsed, setCollapsed] = useState(false)
// ─── Helpers ──────────────────────────────────────────────────────────────────
function isChildActive(children, pathname) {
return children.some(c => pathname === c.to || pathname.startsWith(c.to + '/'))
}
// ─── Child link ───────────────────────────────────────────────────────────────
function ChildLink({ to, icon: Icon, label, placeholder }) {
if (placeholder) {
return (
<div className="flex items-center gap-2.5 px-3 py-2 mx-2 rounded-md text-[13px] text-primary-400 cursor-default select-none">
<Icon size={14} className="shrink-0 opacity-50" />
<span>{label}</span>
<span className="ml-auto text-[10px] font-semibold bg-primary-700 text-primary-300 px-1.5 py-0.5 rounded">soon</span>
</div>
)
}
return (
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
{/* Logo / collapse toggle */}
<div className="flex items-center justify-between px-4 py-4 border-b border-primary-700">
{!collapsed && <span className="font-bold text-lg tracking-wide">POS</span>}
<button
onClick={() => setCollapsed(c => !c)}
className="p-1 rounded hover:bg-primary-700 transition-colors ml-auto"
aria-label="Toggle sidebar"
>
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
<nav className="flex-1 py-4 space-y-1 px-2">
{NAV.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-3 rounded-lg font-medium transition-colors min-h-[44px] ` +
(isActive ? 'bg-primary-600 text-white' : 'text-primary-100 hover:bg-primary-700')
`flex items-center gap-2.5 px-3 py-2 mx-2 rounded-md text-[13px] font-medium transition-colors ` +
(isActive
? 'bg-primary-600 text-white'
: 'text-primary-200 hover:bg-primary-700 hover:text-white')
}
>
<Icon size={20} className="shrink-0" />
{!collapsed && <span className="text-sm">{label}</span>}
{({ isActive }) => (
<>
<Icon size={14} className="shrink-0" />
<span>{label}</span>
{isActive && <span className="ml-auto w-1 h-1 rounded-full bg-sky-400 shrink-0" />}
</>
)}
</NavLink>
)
}
// ─── Group item (collapsible parent) ─────────────────────────────────────────
function GroupItem({ id, icon: Icon, label, children, openGroup, onToggle, perms }) {
const location = useLocation()
const hasActive = isChildActive(children, location.pathname)
const isOpen = openGroup === id
// Filter out phase2-gated children and children the user lacks permission for
const visibleChildren = children.filter(c => {
if (c.phase2 && !isFeatureEnabled(c.phase2)) return false
if (c.perm && !perms[c.perm]) return false
return true
})
if (visibleChildren.length === 0) return null
return (
<div>
<button
onClick={() => onToggle(id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ` +
(hasActive
? 'text-white bg-primary-700'
: 'text-primary-100 hover:bg-primary-700 hover:text-white')}
>
<Icon size={18} className="shrink-0" />
<span className="flex-1 text-left">{label}</span>
<ChevronDown
size={14}
className={`shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{isOpen && (
<div className="mt-0.5 mb-1 space-y-0.5 py-1 border-l border-primary-600 ml-5">
{visibleChildren.map(child => (
<ChildLink key={child.to} {...child} />
))}
</div>
)}
</div>
)
}
// ─── Solo item (direct link) ──────────────────────────────────────────────────
function SoloItem({ to, icon: Icon, label, badge, onNavigate }) {
return (
<NavLink
to={to}
end
onClick={onNavigate}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ` +
(isActive ? 'bg-primary-600 text-white' : 'text-primary-100 hover:bg-primary-700 hover:text-white')
}
>
<div className="relative shrink-0">
<Icon size={18} />
{badge > 0 && (
<span className="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] font-bold w-4 h-4 rounded-full flex items-center justify-center leading-none">
{badge > 9 ? '9+' : badge}
</span>
)}
</div>
<span>{label}</span>
</NavLink>
)
}
// ─── Sidebar ──────────────────────────────────────────────────────────────────
export default function Sidebar() {
const [pendingCount, setPendingCount] = useState(0)
const [openGroup, setOpenGroup] = useState(null)
const [, featTick] = useState(0)
const location = useLocation()
const pollRef = useRef(null)
const perms = usePermissions()
// Online orders badge polling
useEffect(() => {
async function fetchPending() {
try {
const { data } = await getIncomingOrders()
setPendingCount(data.length)
} catch { /* non-critical */ }
}
fetchPending()
pollRef.current = setInterval(fetchPending, 20_000)
return () => clearInterval(pollRef.current)
}, [])
// Re-render when feature flags change
useEffect(() => {
const handler = () => featTick(n => n + 1)
window.addEventListener('phase2features', handler)
window.addEventListener('storage', handler)
return () => {
window.removeEventListener('phase2features', handler)
window.removeEventListener('storage', handler)
}
}, [])
// Auto-open the group that contains the active route on location change
useEffect(() => {
const nav = buildNav(0)
for (const section of nav) {
for (const item of section.items) {
if (item.type === 'group' && isChildActive(item.children, location.pathname)) {
setOpenGroup(item.id)
return
}
}
}
}, [location.pathname])
function handleGroupToggle(id) {
setOpenGroup(prev => (prev === id ? null : id))
}
function handleSoloClick() {
setOpenGroup(null)
}
const nav = buildNav(pendingCount)
return (
<aside className="flex flex-col bg-primary-800 text-white shrink-0 w-56 h-full sidebar-scroll">
{/* Brand */}
<div className="flex items-center px-4 py-4 border-b border-primary-700 shrink-0">
<span className="font-bold text-lg tracking-wide">XeniaPOS</span>
</div>
{/* Main nav */}
<nav className="flex-1 py-3 px-2 space-y-4">
{nav.map(({ category, perm: sectionPerm, items }) => {
// Hide entire section if user lacks section-level permission
if (sectionPerm && !perms[sectionPerm]) return null
const visibleItems = items.filter(item => {
if (item.type === 'solo') {
if (item.perm && !perms[item.perm]) return false
return !item.phase2 || isFeatureEnabled(item.phase2)
}
if (item.type === 'group') {
const visible = item.children.filter(c => {
if (c.phase2 && !isFeatureEnabled(c.phase2)) return false
if (c.perm && !perms[c.perm]) return false
return true
})
return visible.length > 0
}
return true
})
if (visibleItems.length === 0) return null
return (
<div key={category}>
<div className="flex items-center gap-2 px-3 mb-1.5 select-none" style={{ opacity: 0.45 }}>
<span className="text-[9.5px] font-bold uppercase tracking-widest text-primary-100 whitespace-nowrap">
{category}
</span>
<div className="flex-1" style={{ height: 1, background: 'rgba(255,255,255,0.2)' }} />
</div>
<div className="space-y-0.5">
{visibleItems.map(item =>
item.type === 'solo' ? (
<SoloItem key={item.to} {...item} onNavigate={handleSoloClick} />
) : (
<GroupItem
key={item.id}
{...item}
openGroup={openGroup}
onToggle={handleGroupToggle}
perms={perms}
/>
)
)}
</div>
</div>
)
})}
</nav>
{/* Divider + bottom items */}
<div className="shrink-0 px-2 pb-3 border-t border-primary-700 pt-3 space-y-0.5">
{BOTTOM_ITEMS.filter(item => !item.perm || perms[item.perm]).map(item => (
<SoloItem key={item.to} {...item} onNavigate={handleSoloClick} />
))}
</div>
</aside>
)
}

View File

@@ -0,0 +1,907 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
// ── Helpers ──────────────────────────────────────────────────────────────────
function fmt(n) {
if (n == null) return '—'
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
}
function fmtTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
}
function fmtDateTime(iso) {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
const MANAGER_ROLES = new Set(['superadmin', 'owner', 'store_manager', 'staff_manager'])
function roleBadge(role) {
if (MANAGER_ROLES.has(role)) {
return { label: 'Διευθυντής', bg: '#ede9fe', color: '#6d28d9' }
}
return { label: 'Σερβιτόρος', bg: '#e0f2fe', color: '#0369a1' }
}
// All payment methods with their display config
const PAY_METHODS = [
{ key: 'cash', label: 'Μετρητά', color: '#16a34a', bg: '#dcfce7' },
{ key: 'card', label: 'Κάρτα', color: '#2563eb', bg: '#dbeafe' },
{ key: 'transfer', label: 'Τρ. Μεταφ.', color: '#7c3aed', bg: '#ede9fe' },
{ key: 'treat', label: 'Κεράστηκε', color: '#b45309', bg: '#fef3c7' },
{ key: 'other', label: 'Άλλο', color: '#6b7280', bg: '#f3f4f6' },
]
// ── Sub-components ────────────────────────────────────────────────────────────
function TabBtn({ label, active, onClick, badge, warn }) {
return (
<button
onClick={onClick}
style={{
padding: '9px 18px',
border: 'none',
background: 'none',
fontSize: 13,
fontWeight: active ? 700 : 500,
color: active ? '#111315' : '#6b7280',
cursor: 'pointer',
borderBottom: active ? '2px solid #111315' : '2px solid transparent',
transition: 'all 150ms',
display: 'flex',
alignItems: 'center',
gap: 6,
whiteSpace: 'nowrap',
}}
>
{label}
{badge != null && (
<span style={{
minWidth: 18, height: 18, borderRadius: 99,
background: warn ? '#fee2e2' : '#f3f4f6',
color: warn ? '#dc2626' : '#374151',
fontSize: 11, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 5px',
}}>{badge}</span>
)}
</button>
)
}
function StatPill({ label, value, accent = '#374151' }) {
return (
<div style={{
background: '#f9fafb', border: '1px solid #f0f0ef',
borderRadius: 10, padding: '12px 16px',
display: 'flex', flexDirection: 'column', gap: 2,
}}>
<span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</span>
<span style={{ fontSize: 22, fontWeight: 800, color: accent, letterSpacing: '-0.02em' }}>{value}</span>
</div>
)
}
function SectionTitle({ children }) {
return (
<div style={{
fontSize: 11, fontWeight: 700, color: '#9ca3af',
textTransform: 'uppercase', letterSpacing: '0.06em',
marginBottom: 10,
}}>{children}</div>
)
}
function PaymentBar({ w, total }) {
if (!total) return null
const bars = PAY_METHODS.filter(m => (w[m.key] || 0) > 0)
if (bars.length === 0) return null
return (
<div style={{ marginTop: 6 }}>
<div style={{ display: 'flex', height: 7, borderRadius: 99, overflow: 'hidden', gap: 1 }}>
{bars.map(m => (
<div key={m.key} style={{ flex: w[m.key], background: m.color }}
title={`${m.label}: ${Math.round((w[m.key] / total) * 100)}%`} />
))}
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 5, flexWrap: 'wrap' }}>
{bars.map(m => (
<span key={m.key} style={{ fontSize: 10, color: m.color, fontWeight: 700 }}>
{m.label} {fmt(w[m.key])}
</span>
))}
</div>
</div>
)
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
function FinancialRow({ label, value, sub, accent, warn, indent = false }) {
return (
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
padding: indent ? '4px 0 4px 16px' : '5px 0',
borderBottom: indent ? 'none' : '1px solid #f9fafb',
}}>
<span style={{ fontSize: indent ? 12 : 13, color: warn ? '#d97706' : indent ? '#6b7280' : '#374151', flex: 1 }}>
{warn && <span style={{ marginRight: 5 }}></span>}{label}
</span>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<span style={{ fontSize: indent ? 12 : 13.5, fontWeight: indent ? 500 : 700, color: accent || '#111315', fontVariantNumeric: 'tabular-nums' }}>
{value}
</span>
{sub && <div style={{ fontSize: 10.5, color: '#9ca3af' }}>{sub}</div>}
</div>
</div>
)
}
function OverviewTab({ data }) {
const waiters = data.waiters.filter(w => w.role === 'waiter')
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Top stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10 }}>
<StatPill label="Συνολικά Έσοδα" value={fmt(data.total_revenue)} accent="#111315" />
<StatPill label="Παραγγελίες" value={String(data.total_orders)} accent="#3758c9" />
<StatPill label="Αντικείμενα" value={String(data.total_items_ordered)} accent="#7a44c9" />
<StatPill label="Σερβιτόροι" value={String(waiters.length)} accent="#0d7a8a" />
</div>
{/* Hours */}
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '10px 14px', flex: 1, minWidth: 130 }}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>ΑΝΟΙΓΜΑ</div>
<div style={{ fontSize: 14, fontWeight: 700, marginTop: 2 }}>{fmtDateTime(data.opened_at)}</div>
</div>
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '10px 14px', flex: 1, minWidth: 130 }}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>ΚΛΕΙΣΙΜΟ</div>
<div style={{ fontSize: 14, fontWeight: 700, marginTop: 2 }}>
{data.closed_at ? fmtDateTime(data.closed_at) : <span style={{ color: '#16a34a' }}>Ημέρα Ενεργή</span>}
</div>
</div>
</div>
{/* Full P&L */}
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden' }}>
{/* REVENUE */}
<div style={{ padding: '10px 16px', background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
<SectionTitle>Έσοδα</SectionTitle>
<FinancialRow label="Σύνολο πωλήσεων" value={fmt(data.total_revenue)} accent="#111315" />
{data.tabbed_revenue > 0 && (
<FinancialRow label="Καρτέλες (μη εισπραχθέντα)" value={` ${fmt(data.tabbed_revenue)}`} accent="#d97706" indent />
)}
<FinancialRow
label="Καθαρά εισπραχθέντα"
value={fmt(data.total_revenue - (data.tabbed_revenue || 0))}
accent="#16a34a"
/>
</div>
{/* COGS */}
<div style={{ padding: '10px 16px', borderBottom: '1px solid #e5e7eb' }}>
<SectionTitle>Κόστος Πωληθέντων (COGS)</SectionTitle>
<FinancialRow label="Κόστος καταγεγραμμένων" value={fmt(data.cogs_trackable)} accent="#374151" />
{data.cogs_has_gap && (
<FinancialRow
label={`${data.cogs_uncosted_count} αντ. χωρίς κόστος (${fmt(data.cogs_uncosted_revenue)} έσοδα)`}
value="?" warn indent
/>
)}
<FinancialRow
label="Μικτό κέρδος"
value={fmt(data.gross_profit)}
accent={data.gross_profit >= 0 ? '#16a34a' : '#dc2626'}
warn={data.cogs_has_gap}
sub={data.cogs_has_gap ? 'Μερικό — υπάρχουν αντ. χωρίς κόστος' : undefined}
/>
</div>
{/* LABOR */}
<div style={{ padding: '10px 16px', borderBottom: '1px solid #e5e7eb' }}>
<SectionTitle>Μισθοδοσία</SectionTitle>
<FinancialRow label="Κόστος εργασίας (καταγεγρ.)" value={fmt(data.total_labor_cost)} accent="#374151" />
{data.labor_untracked_shifts > 0 && (
<FinancialRow label={`${data.labor_untracked_shifts} βάρδιες χωρίς ωριαία αμοιβή`} value="?" warn indent />
)}
</div>
{/* WASTE */}
{(data.waste_item_count > 0) && (
<div style={{ padding: '10px 16px', borderBottom: '1px solid #e5e7eb' }}>
<SectionTitle>Αποβλήτα</SectionTitle>
<FinancialRow
label={`${data.waste_item_count} καταχωρήσεις`}
value={fmt(data.waste_estimated_cost)}
accent="#d97706"
/>
</div>
)}
{/* EXPENSES */}
{data.expenses_total > 0 && (
<div style={{ padding: '10px 16px', borderBottom: '1px solid #e5e7eb' }}>
<SectionTitle>Έξοδα (σήμερα)</SectionTitle>
<FinancialRow label="Σύνολο εξόδων" value={fmt(data.expenses_total)} accent="#374151" />
<FinancialRow label="Πλη<CEBB><CEB7>ωμένα σήμερα" value={fmt(data.expenses_paid_today)} accent="#374151" indent />
{data.expenses_due > 0 && (
<FinancialRow label="Ακόμη οφείλονται" value={fmt(data.expenses_due)} accent="#dc2626" warn indent />
)}
</div>
)}
{/* NET */}
<div style={{ padding: '12px 16px', background: '#f9fafb' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#374151' }}>
Εκτιμώμενο Καθαρό Αποτέλεσμα
</div>
{data.net_has_unknowns && (
<div style={{ fontSize: 11, color: '#d97706', marginTop: 1 }}>
Μερικό υπάρχουν άγνωστα κόστη
</div>
)}
</div>
<div style={{
fontSize: 20, fontWeight: 800,
color: data.net_estimate >= 0 ? '#16a34a' : '#dc2626',
fontVariantNumeric: 'tabular-nums',
}}>
{fmt(data.net_estimate)}
</div>
</div>
</div>
</div>
{/* Payment breakdown */}
<div>
<SectionTitle>Κατανομή Πληρωμών</SectionTitle>
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '14px 16px' }}>
{(() => {
const totals = { cash: 0, card: 0, transfer: 0, treat: 0, other: 0 }
for (const w of data.waiters) {
for (const k of Object.keys(totals)) totals[k] += (w[k] || 0)
}
return <PaymentBar w={totals} total={data.total_revenue} />
})()}
</div>
</div>
{/* Cash reconciliation summary (if counted) */}
{data.store_closing_cash != null && (
<div>
<SectionTitle>Ταμείο</SectionTitle>
<div style={{ background: '#f9fafb', border: '1px solid #f0f0ef', borderRadius: 10, padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 6 }}>
<FinancialRow label="Αναμενόμενο ταμείο" value={fmt(data.store_expected_closing)} />
<FinancialRow label="Μετρημένο ταμείο" value={fmt(data.store_closing_cash)} />
<FinancialRow
label="Διαφορά"
value={data.store_cash_discrepancy === 0 ? '✓ Εντάξει' : fmt(data.store_cash_discrepancy)}
accent={data.store_cash_discrepancy === 0 ? '#16a34a' : '#dc2626'}
warn={data.store_cash_discrepancy !== 0}
/>
</div>
</div>
)}
</div>
)
}
function WaitersTab({ data }) {
const [expanded, setExpanded] = useState(null)
// Fixed column widths for the collapsed summary row
const colPx = { orders: 88, tables: 88, items: 80, collected: 104 }
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.waiters.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>Δεν βρέθηκαν σερβιτόροι</div>
)}
{data.waiters.map(w => {
const badge = roleBadge(w.role)
const isOpen = expanded === w.waiter_id
// Compute current cash in drawer: starting_cash + collected so far
const drawerNow = (w.starting_cash ?? 0) + (w.total_collected ?? 0)
return (
<div key={w.waiter_id} style={{
border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden',
}}>
<button
onClick={() => setExpanded(isOpen ? null : w.waiter_id)}
style={{
width: '100%', background: 'white', border: 'none', cursor: 'pointer',
padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 12, textAlign: 'left',
}}
>
{/* Avatar */}
<div style={{
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
background: badge.bg, color: badge.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 15, fontWeight: 800,
}}>
{w.waiter_name.charAt(0).toUpperCase()}
</div>
{/* Name + role */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>{w.waiter_name}</div>
<span style={{
fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 99,
background: badge.bg, color: badge.color,
}}>{badge.label}</span>
</div>
{/* 4 fixed-width metric columns */}
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{[
{ label: 'ΠΑΡΑΓΓΕΛΙΕΣ', value: w.orders_opened, width: colPx.orders, color: '#111315' },
{ label: 'ΕΞ. ΤΡΑΠΕΖΙΩΝ', value: w.tables_served ?? 0, width: colPx.tables, color: '#111315' },
{ label: 'ΑΝΤΙΚ. ΕΙΣΠΡ.', value: w.items_paid, width: colPx.items, color: '#111315' },
{ label: 'ΕΙΣΠΡΑΞΗ', value: fmt(w.total_collected), width: colPx.collected, color: '#16a34a' },
].map(col => (
<div key={col.label} style={{
width: col.width, flexShrink: 0, textAlign: 'right', padding: '0 6px',
}}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 600, whiteSpace: 'nowrap' }}>{col.label}</div>
<div style={{ fontSize: 15, fontWeight: 800, color: col.color, whiteSpace: 'nowrap' }}>{col.value}</div>
</div>
))}
</div>
{/* Chevron */}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, transform: isOpen ? 'rotate(180deg)' : 'none', transition: 'transform 200ms' }}>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{isOpen && (
<div style={{ borderTop: '1px solid #f0f0ef', background: '#fafafa', padding: '14px 18px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Row 1: shift timing + payroll */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
{[
{ label: 'ΕΝΑΡΞΗ ΒΑΡΔΙΑΣ', value: fmtTime(w.shift_started_at), color: '#111315' },
{
label: 'ΛΗΞΗ ΒΑΡΔΙΑΣ',
value: w.shift_ended_at ? fmtTime(w.shift_ended_at) : 'Βάρδια Ενεργή',
color: w.shift_ended_at ? '#111315' : '#16a34a',
},
{
label: 'ΜΙΣΘΟΣ / ΩΡΑ',
value: w.hourly_rate_snapshot != null ? fmt(w.hourly_rate_snapshot) : '—',
color: '#111315',
},
{
label: 'ΜΙΣΘΟΣ (εως τώρα)',
value: w.shift_pay != null ? fmt(w.shift_pay) : 'Δεν παρακολ.',
color: w.shift_pay != null ? '#059669' : '#9ca3af',
},
].map(cell => (
<div key={cell.label} style={{ background: 'white', border: '1px solid #e5e7eb', borderRadius: 8, padding: '9px 12px' }}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.label}</div>
<div style={{ fontSize: 14, fontWeight: 700, color: cell.color, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.value}</div>
</div>
))}
</div>
{/* Row 2: cash + collections */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
{[
{
label: 'ΑΡΧΙΚΑ ΜΕΤΡΗΤΑ',
value: w.starting_cash != null ? fmt(w.starting_cash) : '—',
color: '#111315',
},
{
label: 'ΤΑΜΕΙΟ ΤΩΡΑ',
value: fmt(drawerNow),
color: '#3758c9',
},
{
label: 'ΑΝΤΙΚ. ΕΙΣΠΡ.',
value: w.items_paid,
color: '#111315',
},
{
label: 'ΕΞ. ΤΡΑΠΕΖΙΩΝ',
value: w.tables_served ?? 0,
color: '#111315',
},
].map(cell => (
<div key={cell.label} style={{ background: 'white', border: '1px solid #e5e7eb', borderRadius: 8, padding: '9px 12px' }}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.label}</div>
<div style={{ fontSize: 14, fontWeight: 700, color: cell.color, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{cell.value}</div>
</div>
))}
</div>
<PaymentBar w={w} total={w.total_collected} />
</div>
)}
</div>
)
})}
</div>
)
}
function ZonesTab({ data }) {
const maxRev = Math.max(...data.zones.map(z => z.revenue), 1)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.zones.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>Δεν βρέθηκαν ζώνες</div>
)}
{data.zones.map(z => (
<div key={z.group_id} style={{
border: '1px solid #e5e7eb', borderRadius: 12,
padding: '16px 18px', background: 'white',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{
width: 12, height: 12, borderRadius: 3, flexShrink: 0,
background: z.color || '#9ca3af',
}} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#111315', flex: 1 }}>{z.group_name}</span>
<span style={{ fontSize: 20, fontWeight: 800, color: '#111315' }}>{fmt(z.revenue)}</span>
</div>
<div style={{ height: 6, background: '#f3f4f6', borderRadius: 99, marginBottom: 12, overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 99,
background: z.color || '#9ca3af',
width: `${Math.round((z.revenue / maxRev) * 100)}%`,
transition: 'width 400ms ease',
}} />
</div>
<div style={{ display: 'flex', gap: 20 }}>
<div>
<span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600 }}>ΠΑΡΑΓΓΕΛΙΕΣ </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{z.orders}</span>
</div>
<div>
<span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600 }}>ΑΝΤΙΚΕΙΜΕΝΑ </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{z.items}</span>
</div>
{z.orders > 0 && (
<div>
<span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600 }}>Μ.Ο./ΠΑΡΑΓ. </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{fmt(z.revenue / z.orders)}</span>
</div>
)}
</div>
</div>
))}
</div>
)
}
function PrintersTab({ data }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.printers.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>Δεν βρέθηκαν δεδομένα εκτύπωσης</div>
)}
{data.printers.map(p => {
const successRate = p.jobs > 0 ? Math.round((p.success / p.jobs) * 100) : 0
return (
<div key={p.printer_id} style={{
border: '1px solid #e5e7eb', borderRadius: 12,
padding: '16px 18px', background: 'white',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{
width: 36, height: 36, borderRadius: 9, flexShrink: 0,
background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#6b7280" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>{p.printer_name}</div>
<div style={{ fontSize: 11, color: '#9ca3af' }}>{p.ip_address}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 18, fontWeight: 800, color: '#111315' }}>{fmt(p.items_value)}</div>
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>αξία εκτυπ.</div>
</div>
<span style={{
fontSize: 12, fontWeight: 700, padding: '3px 10px', borderRadius: 99, marginLeft: 4,
background: successRate === 100 ? '#dcfce7' : successRate >= 80 ? '#fef9c3' : '#fee2e2',
color: successRate === 100 ? '#16a34a' : successRate >= 80 ? '#a16207' : '#dc2626',
}}>{successRate}%</span>
</div>
<div style={{ height: 6, background: '#f3f4f6', borderRadius: 99, marginBottom: 12, overflow: 'hidden' }}>
<div style={{ height: '100%', display: 'flex' }}>
<div style={{ flex: p.success, background: '#16a34a' }} />
<div style={{ flex: p.failed, background: '#dc2626' }} />
</div>
</div>
<div style={{ display: 'flex', gap: 20 }}>
<div><span style={{ fontSize: 11, color: '#9ca3af', fontWeight: 600 }}>ΕΡΓΑΣΙΕΣ </span><span style={{ fontSize: 14, fontWeight: 700 }}>{p.jobs}</span></div>
<div><span style={{ fontSize: 11, color: '#16a34a', fontWeight: 600 }}>ΕΠΙΤΥΧΕΙΣ </span><span style={{ fontSize: 14, fontWeight: 700 }}>{p.success}</span></div>
{p.failed > 0 && <div><span style={{ fontSize: 11, color: '#dc2626', fontWeight: 600 }}>ΑΠΟΤΥΧΕΣ </span><span style={{ fontSize: 14, fontWeight: 700 }}>{p.failed}</span></div>}
</div>
</div>
)
})}
</div>
)
}
function CashReconciliationTab({ data, storeCash, onStoreCashChange }) {
const expected = data.store_expected_closing ?? 0
const counted = storeCash !== '' ? parseFloat(storeCash) : null
const discrepancy = counted != null ? counted - expected : null
const isShort = discrepancy != null && discrepancy < -0.005
const isOver = discrepancy != null && discrepancy > 0.005
const isClean = discrepancy != null && !isShort && !isOver
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, padding: '16px 18px', background: '#f9fafb' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>Σύνοψη Εισπράξεων Βάρδιας</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 13.5 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#6b7280' }}>Σύνολο εισπράξεων σερβιτόρων</span>
<span style={{ fontWeight: 700, color: '#111315' }}>{fmt(data.total_waiter_collected)}</span>
</div>
{data.store_opening_cash != null && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#6b7280' }}>Αρχικό ταμείο</span>
<span style={{ fontWeight: 600, color: '#111315' }}>{fmt(data.store_opening_cash)}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', borderTop: '1px solid #e5e7eb', paddingTop: 6, marginTop: 2 }}>
<span style={{ color: '#374151', fontWeight: 600 }}>Αναμενόμενο ταμείο τώρα</span>
<span style={{ fontWeight: 800, color: '#111315', fontSize: 15 }}>{fmt(expected)}</span>
</div>
</div>
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>
Μετρήστε το ταμείο πόσα μετρητά υπάρχουν;
<span style={{ fontWeight: 400, color: '#9ca3af' }}> (προαιρετικό)</span>
</label>
<input
type="number"
step="0.01"
min="0"
value={storeCash}
onChange={e => onStoreCashChange(e.target.value)}
placeholder="0.00"
style={{
width: '100%', padding: '10px 14px', border: '1.5px solid #e5e7eb',
borderRadius: 10, fontSize: 18, fontWeight: 700, color: '#111315',
outline: 'none', boxSizing: 'border-box', background: 'white',
}}
/>
</div>
{discrepancy != null && (
<div style={{
padding: '14px 16px', borderRadius: 10,
background: isClean ? '#f0fdf4' : '#fef2f2',
border: `1.5px solid ${isClean ? '#86efac' : '#fca5a5'}`,
}}>
<div style={{ fontSize: 14, fontWeight: 700, color: isClean ? '#16a34a' : '#dc2626', marginBottom: 4 }}>
{isClean && '✓ Ταμείο εντάξει'}
{isShort && `⚠ Έλλειμμα ${fmt(Math.abs(discrepancy))}`}
{isOver && `⚠ Πλεόνασμα ${fmt(Math.abs(discrepancy))}`}
</div>
<div style={{ fontSize: 12.5, color: isClean ? '#166534' : '#7f1d1d', lineHeight: 1.5 }}>
{isClean && 'Το φυσικό ταμείο συμφωνεί με τις καταγεγραμμένες εισπράξεις.'}
{isShort && `Το ταμείο έχει ${fmt(Math.abs(discrepancy))} λιγότερα από το αναμενόμενο. Το κενό δεν αποδίδεται σε κάποιον σερβιτόρο συγκεκριμένα.`}
{isOver && `Το ταμείο έχει ${fmt(Math.abs(discrepancy))} περισσότερα από το αναμενόμενο.`}
</div>
</div>
)}
{storeCash === '' && (
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0 }}>
Αν παραλείψετε τη μέτρηση, η επαλήθευση δεν θα καταγραφεί.
</p>
)}
</div>
)
}
function ChecksTab({ data, onForceClose, closing, isOpen }) {
const { checks } = data
const allGood = checks.open_orders === 0
const hasUnpaid = checks.with_unpaid_items > 0
const hasActiveShifts = checks.active_shifts > 0
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{
border: `1px solid ${checks.open_orders > 0 ? '#fecaca' : '#bbf7d0'}`,
borderRadius: 12, padding: '16px 18px', background: checks.open_orders > 0 ? '#fff5f5' : '#f0fdf4',
display: 'flex', alignItems: 'flex-start', gap: 12,
}}>
<div style={{
width: 34, height: 34, borderRadius: 99, flexShrink: 0, marginTop: 1,
background: checks.open_orders > 0 ? '#fee2e2' : '#dcfce7',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15,
}}>
{checks.open_orders > 0 ? '⚠️' : '✓'}
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Ανοιχτά Τραπέζια</div>
{checks.open_orders > 0 ? (
<div style={{ fontSize: 13, color: '#dc2626', marginTop: 2 }}>
{checks.open_orders} τραπέζια ακόμα ανοιχτά
{hasUnpaid && ` · ${checks.with_unpaid_items} με απλήρωτα αντικείμενα`}
</div>
) : (
<div style={{ fontSize: 13, color: '#16a34a', marginTop: 2 }}>Όλα τα τραπέζια έχουν κλείσει</div>
)}
</div>
</div>
<div style={{
border: `1px solid ${hasActiveShifts ? '#fde68a' : '#bbf7d0'}`,
borderRadius: 12, padding: '16px 18px', background: hasActiveShifts ? '#fffbeb' : '#f0fdf4',
display: 'flex', alignItems: 'flex-start', gap: 12,
}}>
<div style={{
width: 34, height: 34, borderRadius: 99, flexShrink: 0, marginTop: 1,
background: hasActiveShifts ? '#fef3c7' : '#dcfce7',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15,
}}>
{hasActiveShifts ? '⏳' : '✓'}
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Ενεργές Βάρδιες</div>
{hasActiveShifts ? (
<div style={{ fontSize: 13, color: '#92400e', marginTop: 2 }}>
{checks.active_shifts} βάρδι{checks.active_shifts === 1 ? 'α' : 'ες'} ακόμα ενεργ{checks.active_shifts === 1 ? 'ή' : 'ές'} θα κλείσουν αυτόματα
</div>
) : (
<div style={{ fontSize: 13, color: '#16a34a', marginTop: 2 }}>Δεν υπάρχουν ενεργές βάρδιες</div>
)}
</div>
</div>
{allGood ? (
<div style={{
border: '1px solid #bbf7d0', borderRadius: 12, padding: '16px 18px',
background: '#f0fdf4', display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ fontSize: 20 }}></span>
<div style={{ fontSize: 14, fontWeight: 600, color: '#166534' }}>
Ο έλεγχος πέρασε η ημέρα είναι έτοιμη να κλείσει.
</div>
</div>
) : hasUnpaid ? (
<div style={{ border: '1px solid #fecaca', borderRadius: 12, padding: '16px 18px', background: '#fff5f5' }}>
<div style={{ fontSize: 13, color: '#7f1d1d', lineHeight: 1.5 }}>
<strong>Προσοχή:</strong> Υπάρχουν απλήρωτα αντικείμενα. Αν κλείσετε αναγκαστικά, τα απλήρωτα ποσά δεν θα καταγραφούν στις αναφορές.
</div>
</div>
) : (
<div style={{ border: '1px solid #fde68a', borderRadius: 12, padding: '16px 18px', background: '#fffbeb' }}>
<div style={{ fontSize: 13, color: '#78350f', lineHeight: 1.5 }}>
Υπάρχουν ανοιχτά τραπέζια χωρίς απλήρωτα αντικείμενα. Θα κλείσουν αυτόματα.
</div>
</div>
)}
{isOpen && (
<button
onClick={onForceClose}
disabled={closing}
style={{
marginTop: 4, height: 46, borderRadius: 10, border: 'none',
cursor: closing ? 'default' : 'pointer',
background: hasUnpaid ? '#dc2626' : '#111315',
color: 'white', fontSize: 14, fontWeight: 700,
opacity: closing ? 0.7 : 1, transition: 'opacity 150ms',
}}
>
{closing ? 'Κλείσιμο…' : hasUnpaid ? '⚠ Αναγκαστικό Κλείσιμο' : 'Κλείσε Ημέρα'}
</button>
)}
</div>
)
}
// ── Main modal ────────────────────────────────────────────────────────────────
export default function WorkdaySummaryModal({ dayId, onClose, showCloseAction = false, onDayClosed }) {
const [tab, setTab] = useState('overview')
const [closing, setClosing] = useState(false)
const [storeCash, setStoreCash] = useState('') // Phase 2E: store cash count
const { data, isLoading, error } = useQuery({
queryKey: ['workday-summary', dayId ?? 'current'],
queryFn: () => client.get('/api/business-day/summary' + (dayId ? `?day_id=${dayId}` : '')).then(r => r.data),
refetchInterval: showCloseAction ? 15_000 : false,
})
async function handleClose(force = false) {
setClosing(true)
try {
const payload = { force }
if (storeCash !== '') payload.store_closing_cash = parseFloat(storeCash)
await client.post('/api/business-day/close', payload)
onDayClosed?.()
} catch (e) {
const detail = e.response?.data?.detail
if (e.response?.status === 409 && detail?.open_orders) {
setTab('checks')
} else {
toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος')
}
} finally {
setClosing(false)
}
}
const tabs = [
{ id: 'overview', label: 'Επισκόπηση' },
{ id: 'waiters', label: 'Προσωπικό', badge: data?.waiters?.length },
{ id: 'zones', label: 'Ζώνες', badge: data?.zones?.length },
{ id: 'printers', label: 'Εκτυπωτές', badge: data?.printers?.length },
...(showCloseAction ? [
{ id: 'cash', label: 'Ταμείο' },
{
id: 'checks', label: 'Έλεγχοι',
badge: data?.checks ? (data.checks.open_orders + data.checks.active_shifts) : null,
warn: data?.checks?.with_unpaid_items > 0,
},
] : []),
]
const isOpen = data?.status === 'open'
return (
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000, padding: 16,
}}
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div style={{
background: 'white', borderRadius: 16, width: '100%', maxWidth: 760,
maxHeight: '92vh', display: 'flex', flexDirection: 'column',
boxShadow: '0 20px 60px rgba(0,0,0,0.18)',
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
padding: '20px 24px 0', borderBottom: '1px solid #f0f0ef',
display: 'flex', flexDirection: 'column', gap: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<div style={{
width: 36, height: 36, borderRadius: 9, background: '#111315',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 17, fontWeight: 800, color: '#111315' }}>
{showCloseAction ? 'Κλείσιμο Εργάσιμης Ημέρας' : 'Στατιστικά Ημέρας'}
</div>
{data && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>
{data.status === 'open'
? <span style={{ color: '#16a34a', fontWeight: 600 }}> Ημέρα Ενεργή</span>
: <span>Κλειστή</span>
}
{' · '}Άνοιγμα {fmtDateTime(data?.opened_at)}
</div>
)}
</div>
<button
onClick={onClose}
style={{
width: 32, height: 32, borderRadius: 8, border: '1px solid #e5e7eb',
background: 'white', cursor: 'pointer', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b7280" strokeWidth="2.5" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div style={{ display: 'flex', gap: 0, overflowX: 'auto' }}>
{tabs.map(t => (
<TabBtn key={t.id} label={t.label} active={tab === t.id}
onClick={() => setTab(t.id)} badge={t.badge} warn={t.warn} />
))}
</div>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '22px 24px' }}>
{isLoading && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '48px 0' }}>Φόρτωση</div>
)}
{error && (
<div style={{ textAlign: 'center', color: '#dc2626', fontSize: 13, padding: '48px 0' }}>Σφάλμα φόρτωσης δεδομένων</div>
)}
{data && (
<>
{tab === 'overview' && <OverviewTab data={data} />}
{tab === 'waiters' && <WaitersTab data={data} />}
{tab === 'zones' && <ZonesTab data={data} />}
{tab === 'printers' && <PrintersTab data={data} />}
{tab === 'cash' && (
<CashReconciliationTab
data={data}
storeCash={storeCash}
onStoreCashChange={setStoreCash}
/>
)}
{tab === 'checks' && (
<ChecksTab
data={data}
isOpen={isOpen}
onForceClose={() => handleClose(data.checks.open_orders > 0)}
closing={closing}
/>
)}
</>
)}
</div>
{/* Footer — only shown when in close-flow and not already on checks tab */}
{showCloseAction && data && tab !== 'checks' && (
<div style={{
padding: '14px 24px', borderTop: '1px solid #f0f0ef',
display: 'flex', gap: 10, justifyContent: 'flex-end', background: 'white',
}}>
<button onClick={onClose} style={{
height: 40, padding: '0 18px', borderRadius: 9,
border: '1px solid #e5e7eb', background: 'white',
fontSize: 13, fontWeight: 600, cursor: 'pointer', color: '#374151',
}}>Ακύρωση</button>
{tab !== 'cash' ? (
<button onClick={() => setTab('cash')} style={{
height: 40, padding: '0 20px', borderRadius: 9,
background: '#111315', border: 'none', color: 'white',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}>Μέτρηση Ταμείου </button>
) : (
<button onClick={() => setTab('checks')} style={{
height: 40, padding: '0 20px', borderRadius: 9,
background: '#111315', border: 'none', color: 'white',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}>Προχωρήστε στους Ελέγχους </button>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import useAuthStore from '../store/authStore'
/**
* Returns a permissions object derived from the logged-in user.
* Superadmin always has every permission regardless of stored flags.
*/
export default function usePermissions() {
const user = useAuthStore(s => s.user)
if (!user) {
return {
isSuperadmin: false,
accessDashboard: false,
accessWaiterApp: false,
accessKds: false,
cancelOrders: false,
applyDiscounts: false,
modifyPrices: false,
openOrders: false,
closeOrders: false,
viewReports: false,
manageStaff: false,
manageTables: false,
manageMenu: false,
manageSettings: false,
}
}
const superadmin = user.role === 'superadmin'
const p = (flag) => superadmin || !!user[flag]
return {
isSuperadmin: superadmin,
accessDashboard: p('perm_access_dashboard'),
accessWaiterApp: p('perm_access_waiter_app'),
accessKds: p('perm_access_kds'),
cancelOrders: p('perm_cancel_orders'),
applyDiscounts: p('perm_apply_discounts'),
modifyPrices: p('perm_modify_prices'),
openOrders: p('perm_open_orders'),
closeOrders: p('perm_close_orders'),
viewReports: p('perm_view_reports'),
manageStaff: p('perm_manage_staff'),
manageTables: p('perm_manage_tables'),
manageMenu: p('perm_manage_menu'),
manageSettings: p('perm_manage_settings'),
}
}

View File

@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react'
const STORAGE_KEY = 'phase2_features'
// All Phase 2 features with their sidebar metadata.
// enabled: true = shown by default (feature is polished)
// enabled: false = hidden by default (still in development)
export const PHASE2_FEATURE_DEFS = [
{ id: 'online-orders', label: 'Online Orders', route: '/online-orders', enabled: true },
{ id: 'notes', label: 'Σημειώσεις & Εργασίες', route: '/notes', enabled: true },
{ id: 'expenses', label: 'Έξοδα', route: '/expenses', enabled: true },
{ id: 'contacts', label: 'Επαφές / Προμηθευτές', route: '/contacts', enabled: true },
{ id: 'customers', label: 'Πελάτες', route: '/customers', enabled: true },
{ id: 'tabs', label: 'Καρτέλες', route: '/tabs', enabled: true },
{ id: 'waste', label: 'Αποβλήτα / Φθορές', route: '/waste', enabled: true },
{ id: 'kds', label: 'KDS — Κουζίνα', route: '/kds', enabled: true },
{ id: 'schedule', label: 'Πρόγραμμα Βαρδιών', route: '/schedule', enabled: true },
]
function _load() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
export function isFeatureEnabled(id) {
const overrides = _load()
if (id in overrides) return overrides[id]
const def = PHASE2_FEATURE_DEFS.find(f => f.id === id)
return def ? def.enabled : true
}
export function setFeatureEnabled(id, value) {
const overrides = _load()
overrides[id] = value
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides))
// Notify other components in this tab
window.dispatchEvent(new Event('phase2features'))
}
export function usePhase2Features() {
const [, tick] = useState(0)
useEffect(() => {
const handler = () => tick(n => n + 1)
window.addEventListener('phase2features', handler)
window.addEventListener('storage', handler)
return () => {
window.removeEventListener('phase2features', handler)
window.removeEventListener('storage', handler)
}
}, [])
return PHASE2_FEATURE_DEFS.map(f => ({
...f,
enabled: isFeatureEnabled(f.id),
}))
}

View File

@@ -1,3 +1,18 @@
@font-face {
font-family: 'Google Sans';
src: url('/fonts/GoogleSans-Variable.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Google Sans';
src: url('/fonts/GoogleSans-Variable-Italic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -34,3 +49,31 @@
@apply block text-sm font-medium text-gray-700 mb-1;
}
}
/* Sidebar scrollbar — pre-allocates gutter so content never shifts, fades in on hover */
.sidebar-scroll {
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.25s;
}
.sidebar-scroll:hover {
scrollbar-color: rgba(255,255,255,0.22) transparent;
}
/* Webkit */
.sidebar-scroll::-webkit-scrollbar {
width: 4px;
}
.sidebar-scroll::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-scroll::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 99px;
transition: background 0.25s;
}
.sidebar-scroll:hover::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.22);
}

View File

@@ -1,18 +1,84 @@
import { Outlet, useNavigate } from 'react-router-dom'
import { useState, useEffect, useRef, createContext, useContext } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import { useState, useEffect, useRef, createContext } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Lock, AlertTriangle, ShieldAlert } from 'lucide-react'
import { Lock, AlertTriangle, ShieldAlert, Search, Bell, ChevronRight } from 'lucide-react'
import Sidebar from '../components/Sidebar'
import useAuthStore from '../store/authStore'
import client from '../api/client'
import UserMenuButton from '../components/UserMenuButton'
import EditProfileModal from '../components/EditProfileModal'
import useLicenseStatus from '../hooks/useLicenseStatus'
import PhoneListener from '../components/PhoneListener'
export const LicenseContext = createContext(null)
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
// ─── Route → title/breadcrumb map ────────────────────────────────────────────
const ROUTE_META = {
'/dashboard': { title: 'Εικόνα' },
'/tables': { title: 'Τραπέζια' },
'/online-orders': { title: 'Online Παραγγελίες' },
'/crm/contacts': { title: 'Επαφές', crumbs: ['CRM', 'Επαφές'] },
'/crm/customers': { title: 'Πελάτες', crumbs: ['CRM', 'Πελάτες'] },
'/financials/expenses': { title: 'Έξοδα', crumbs: ['Οικονομικά', 'Έξοδα'] },
'/financials/tabs': { title: 'Καρτέλες Πελατών', crumbs: ['Οικονομικά', 'Καρτέλες Πελατών'] },
'/management/products': { title: 'Προϊόντα', crumbs: ['Διαχείριση', 'Προϊόντα'] },
'/management/tables': { title: 'Τραπέζια', crumbs: ['Διαχείριση', 'Τραπέζια'] },
'/management/staff': { title: 'Προσωπικό', crumbs: ['Διαχείριση', 'Προσωπικό'] },
'/management/schedule': { title: 'Πρόγραμμα', crumbs: ['Διαχείριση', 'Πρόγραμμα'] },
'/inventory/throwaways': { title: 'Απορρίμματα', crumbs: ['Αποθήκη', 'Απορρίμματα'] },
'/inventory/stock': { title: 'Απόθεμα', crumbs: ['Αποθήκη', 'Απόθεμα'] },
'/reports/today': { title: 'Σήμερα', crumbs: ['Αναφορές', 'Σήμερα'] },
'/reports/store': { title: 'Κατάστημα', crumbs: ['Αναφορές', 'Κατάστημα'] },
'/reports/staff': { title: 'Προσωπικό', crumbs: ['Αναφορές', 'Προσωπικό'] },
'/reports/products': { title: 'Προϊόντα', crumbs: ['Αναφορές', 'Προϊόντα'] },
'/reports/prep-zones': { title: 'Ζώνες Ετοιμασίας', crumbs: ['Αναφορές', 'Ζώνες Ετοιμασίας'] },
'/reports/expenses': { title: 'Έξοδα', crumbs: ['Αναφορές', 'Έξοδα'] },
'/reports/technical': { title: 'Τεχνικά', crumbs: ['Αναφορές', 'Τεχνικά'] },
'/reports/misc': { title: 'Λοιπά', crumbs: ['Αναφορές', 'Λοιπά'] },
'/apps': { title: 'Εφαρμογές' },
'/apps/phone': { title: 'Τηλέφωνο', crumbs: ['Εφαρμογές', 'Τηλέφωνο'] },
'/settings': { title: 'Ρυθμίσεις' },
'/notes': { title: 'Σημειώσεις' },
}
function PageTitle() {
const { pathname } = useLocation()
// Match exact first, then try prefix for dynamic routes like /orders/:id
let meta = ROUTE_META[pathname]
if (!meta && pathname.startsWith('/orders/')) {
meta = { title: 'Order Detail', crumbs: ['Order Detail'] }
}
if (!meta) return <span className="text-[15px] font-semibold text-slate-800">XeniaPOS</span>
if (!meta.crumbs) {
return <span className="text-[15px] font-semibold text-slate-800">{meta.title}</span>
}
return (
<nav className="flex items-center gap-1.5">
{meta.crumbs.map((crumb, i) => {
const isLast = i === meta.crumbs.length - 1
return (
<span key={i} className="flex items-center gap-1.5">
{i > 0 && <ChevronRight size={13} className="text-slate-300 shrink-0" />}
<span className={isLast
? 'text-[14px] font-semibold text-slate-800'
: 'text-[13px] text-slate-400 font-medium'
}>
{crumb}
</span>
</span>
)
})}
</nav>
)
}
// ─── License Banner ───────────────────────────────────────────────────────────
function LicenseBanner({ license }) {
@@ -26,7 +92,6 @@ function LicenseBanner({ license }) {
} catch { return iso }
}
// Admin lock (deferred or enforced)
if (lock_reason === 'admin') {
return (
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
@@ -38,7 +103,6 @@ function LicenseBanner({ license }) {
)
}
// License fully expired + grace over → blocked
if (isBlocked && lock_reason === 'expired') {
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
return (
@@ -49,7 +113,6 @@ function LicenseBanner({ license }) {
)
}
// In grace period (expired but still allowed to operate)
if (inGracePeriod) {
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
const remaining = grace_days_remaining ?? '?'
@@ -61,7 +124,6 @@ function LicenseBanner({ license }) {
)
}
// Expiry warning (≤5 days remaining)
if (showExpiryWarning) {
const days = days_until_expiry
return (
@@ -75,7 +137,7 @@ function LicenseBanner({ license }) {
return null
}
// ─── Lock Screen — PIN only, always. No password ever. ────────────────────────
// ─── Lock Screen ──────────────────────────────────────────────────────────────
function LockScreen({ username, displayName, onUnlock, onLogout }) {
const [pin, setPin] = useState('')
@@ -96,7 +158,7 @@ function LockScreen({ username, displayName, onUnlock, onLogout }) {
setLoading(true)
try {
const { data } = await client.post('/api/auth/login', { username, pin: usedPin })
if (data.user.role !== 'manager' && data.user.role !== 'sysadmin') {
if (!data.user.perm_access_dashboard) {
setError('Not a manager account.')
setPin('')
return
@@ -182,7 +244,6 @@ function LockScreen({ username, displayName, onUnlock, onLogout }) {
export default function AppLayout() {
const { user, savedUsername, logout, lock, unlock, locked } = useAuthStore()
const [clock, setClock] = useState(new Date())
const [profileOpen, setProfileOpen] = useState(false)
const navigate = useNavigate()
const license = useLicenseStatus()
@@ -193,19 +254,11 @@ export default function AppLayout() {
staleTime: 10_000,
})
// Single ref object — updated every render so the interval always sees fresh values
const stateRef = useRef({})
stateRef.current = { user, locked, securitySettings, logout, lock, navigate }
const lastActivityRef = useRef(Date.now())
// ── Clock ──────────────────────────────────────────────────────────────────
useEffect(() => {
const id = setInterval(() => setClock(new Date()), 1000)
return () => clearInterval(id)
}, [])
// ── Single long-lived interval — never restarts ────────────────────────────
useEffect(() => {
function onActivity() { lastActivityRef.current = Date.now() }
@@ -225,14 +278,12 @@ export default function AppLayout() {
const lockSecs = autoLock ? parseInt(get('security.auto_lock_seconds', '300'), 10) : Infinity
const logoutSecs = autoLogout ? parseInt(get('security.auto_logout_seconds', '1800'), 10) : Infinity
// Auto-logout runs regardless of lock state
if (idle >= logoutSecs) {
logout()
navigate('/login', { replace: true, state: { manualLogout: true } })
return
}
// Auto-lock only fires when not already locked
if (!locked && idle >= lockSecs) {
lock()
}
@@ -242,9 +293,8 @@ export default function AppLayout() {
clearInterval(id)
EVENTS.forEach(ev => window.removeEventListener(ev, onActivity))
}
}, []) // runs once on mount, reads everything from stateRef
}, [])
// ── Handlers ──────────────────────────────────────────────────────────────
function handleLogout() {
logout()
navigate('/login', { replace: true, state: { manualLogout: true } })
@@ -255,7 +305,6 @@ export default function AppLayout() {
lastActivityRef.current = Date.now()
}
const timeStr = clock.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
const loginUsername = user?.username || savedUsername || ''
const displayName = user?.full_name || loginUsername
@@ -272,11 +321,42 @@ export default function AppLayout() {
)}
<Sidebar />
<div className="flex flex-col flex-1 min-w-0">
<LicenseBanner license={license} />
<header className="flex items-center justify-between px-6 py-3 bg-white border-b border-slate-200 shrink-0">
<span className="text-[13px] font-semibold text-slate-600 tabular-nums">{timeStr}</span>
<div className="flex items-center gap-3">
{/* ── Header ── */}
<header className="flex items-center justify-between px-6 py-3 bg-white border-b border-slate-200 shrink-0 gap-4">
{/* Left: page title / breadcrumbs */}
<div className="flex items-center min-w-0">
<PageTitle />
</div>
{/* Right: search + bell + lock + user */}
<div className="flex items-center gap-2 shrink-0">
{/* Search (placeholder) */}
<div className="relative hidden sm:flex items-center">
<Search className="absolute left-2.5 h-3.5 w-3.5 text-slate-400 pointer-events-none" />
<input
type="text"
placeholder="Search…"
className="pl-8 pr-3 py-1.5 text-[13px] bg-slate-50 border border-slate-200 rounded-lg w-44 focus:outline-none focus:ring-2 focus:ring-sky-500/30 focus:border-sky-400 transition placeholder:text-slate-400"
readOnly
/>
</div>
{/* Notifications (placeholder) */}
<button
className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 hover:text-slate-700"
title="Notifications"
>
<Bell className="h-3.5 w-3.5" />
</button>
{/* Divider */}
<div className="h-5 w-px bg-slate-200" />
{/* Lock */}
<button
onClick={lock}
title="Lock"
@@ -284,6 +364,8 @@ export default function AppLayout() {
>
<Lock className="h-3.5 w-3.5" />
</button>
{/* User menu */}
<UserMenuButton
displayName={displayName}
onEditProfile={() => setProfileOpen(true)}
@@ -292,10 +374,13 @@ export default function AppLayout() {
{profileOpen && <EditProfileModal onClose={() => setProfileOpen(false)} />}
</div>
</header>
<main className="flex-1 overflow-hidden flex flex-col min-h-0">
<Outlet />
</main>
</div>
<PhoneListener />
</div>
</LicenseContext.Provider>
)

Some files were not shown because too many files have changed in this diff Show More