Compare commits

...

32 Commits

Author SHA1 Message Date
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
95 changed files with 13729 additions and 1057 deletions

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,8 @@ 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
model_config = {"env_file": str(_HERE / ".env"), "env_file_encoding": "utf-8"}

430
local_backend/demo_seed.py Normal file
View File

@@ -0,0 +1,430 @@
# -*- 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 demo waiters
- 2 table groups with 12 tables total
- A full menu: 3 categories, ~20 products
- 45 days of backdated history (business days, shifts, orders, payments)
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] marker before inserting anything.
"""
import json
import random
import sys
from datetime import datetime, timedelta, 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.order
import models.business_day
import models.shift
import models.settings
import models.flag
import models.message
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
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)
# ── Menu definition ───────────────────────────────────────────────────────────
# (category_name, color, [(product_name, price), ...])
MENU = [
("Ορεκτικά", "#f97316", [
("Τζατζίκι", 4.50),
("Ταραμοσαλάτα", 4.50),
("Χωριάτικη Σαλάτα", 7.50),
("Κεφτεδάκια", 8.00),
("Σαγανάκι", 6.50),
("Χούμους", 5.00),
]),
("Κυρίως Πιάτα", "#ef4444", [
("Μπριζόλα Χοιρινή", 12.00),
("Μπριζόλα Μοσχαρίσια", 16.00),
("Σουβλάκι Κοτόπουλο", 10.00),
("Μουσακάς", 11.00),
("Παστίτσιο", 10.00),
("Σπαγγέτι Μπολονέζ", 9.50),
("Γύρος Χοιρινός", 8.50),
]),
("Ποτά & Αναψυκτικά", "#3b82f6", [
("Νερό 500ml", 1.00),
("Αναψυκτικό", 2.50),
("Μπύρα Φιάλη", 4.00),
("Κρασί (ποτήρι)", 4.50),
("Καφές Ελληνικός", 2.50),
("Φραπέ", 3.00),
("Χυμός Πορτοκάλι", 3.50),
]),
]
# Weighted product picker: drinks ordered ~40% of the time, mains ~35%, starters ~25%
CATEGORY_WEIGHTS = [0.25, 0.35, 0.40]
WAITER_NAMES = [
("Νίκος", "Νίκος Π."),
("Μαρία", "Μαρία Κ."),
("Πέτρος", "Πέτρος Α."),
("Έλενα", "Έλενα Μ."),
]
PAYMENT_METHODS = ["cash", "cash", "cash", "card", "card"] # 60% cash / 40% card
# ── Main ──────────────────────────────────────────────────────────────────────
db = SessionLocal()
try:
# ── Guard: abort if demo data already present ─────────────────────────────
existing = db.query(User).filter(User.full_name.like("[DEMO]%")).first()
if existing:
print("Demo data already present (found [DEMO] 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:
# Ensure password_hash is set on existing manager
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 in WAITER_NAMES:
username = f"demo_{nickname.lower()}"
w = db.query(User).filter(User.username == username).first()
if not w:
w = User(
username=username,
pin_hash=_hash_pin(str(random.randint(1000, 9999))),
role="waiter",
full_name=f"[DEMO] {full_name}",
nickname=nickname,
is_active=True,
)
db.add(w)
db.flush()
waiters.append(w)
print(f" created {len(waiters)} demo 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 (all waiters → all zones) ─────────────────────
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 = [] # flat list for random picks
category_product_map = [] # parallel list of product sub-lists (for weighted pick)
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) 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,
is_available=True,
lifecycle_status="active",
sort_order=prod_sort,
)
db.add(p)
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")
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...")
for days_ago in range(DEMO_DAYS, 0, -1):
day_date = today - timedelta(days=days_ago)
# Business day: opens at ~10:00, closes at ~23:30
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()
# 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 = {} # shift_id → running revenue total, filled during order loop
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 — two rushes: lunch (12-15h) and dinner (19-23h)
n_orders = random.randint(*ORDERS_PER_DAY)
lunch_count = int(n_orders * 0.4)
dinner_count = n_orders - lunch_count
order_times = []
for _ in range(lunch_count):
h = random.randint(12, 14)
m = random.randint(0, 59)
order_times.append(datetime(day_date.year, day_date.month, day_date.day, h, m))
for _ in range(dinner_count):
h = random.randint(19, 22)
m = random.randint(0, 59)
order_times.append(datetime(day_date.year, day_date.month, day_date.day, h, m))
order_times.sort()
# Track which tables are "busy" at each point in time so we don't double-seat
# Format: {table_id: release_time}
table_busy_until: dict[int, datetime] = {}
for opened_at_naive in order_times:
opened_at = _utc(opened_at_naive)
# Pick a free table
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 # all busy → just reuse (edge case)
table = random.choice(free_tables)
# Pick a waiter who is working today
waiter = random.choice(working_waiters)
shift = shifts[waiter.id]
# Order duration: 20-70 min
duration_min = random.randint(20, 70)
closed_at = opened_at + timedelta(minutes=duration_min)
# Cap to close_time
if closed_at > close_time:
closed_at = close_time - timedelta(minutes=2)
table_busy_until[table.id] = closed_at
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",
)
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)
# Items: 2-6 items, weighted by category
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]
payment_method = random.choice(PAYMENT_METHODS)
paid_at = closed_at - timedelta(minutes=random.randint(1, 5))
line_total = product.base_price * qty
order_total += line_total
item = OrderItem(
order_id=order.id,
product_id=product.id,
added_by=waiter.id,
quantity=qty,
unit_price=product.base_price,
status="paid",
printed=True,
paid_by=waiter.id,
paid_at=_utc(paid_at),
payment_method=payment_method,
paid_in_shift_id=shift.id,
)
item.added_at = _utc(items_added_at)
db.add(item)
db.flush()
items_added.append(item.id)
shift_totals[shift.id] = round(shift_totals[shift.id] + line_total, 2)
_audit(db, order.id, "ITEMS_ADDED",
waiter_id=waiter.id, item_ids=items_added,
created_at=items_added_at)
# Payment audit (single payment per order for simplicity)
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
# Write total_collected snapshot onto each ended shift
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")
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}")
finally:
db.close()

View File

@@ -19,6 +19,13 @@ 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
from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router
from routers import business_day as business_day_router
@@ -28,6 +35,15 @@ from routers import flags as flags_router
from routers import messages as messages_router
from routers import sse as sse_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
def _run_migrations():
@@ -218,6 +234,178 @@ 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
)""",
# 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'
)""",
]
for sql in migrations:
try:
@@ -232,12 +420,15 @@ def _run_migrations():
async def lifespan(app: FastAPI):
import asyncio
from services.sse_bus import init_loop
from services.reservation_tasks import start_reservation_tasks
init_loop(asyncio.get_running_loop())
Base.metadata.create_all(bind=engine)
_run_migrations()
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)
@@ -275,3 +466,13 @@ app.include_router(flags_router.router, prefix="/api/flags", tag
app.include_router(messages_router.router, prefix="/api/messages", tags=["messages"])
app.include_router(sse_router.router, prefix="/api/sse", tags=["sse"])
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"])

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,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

@@ -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,10 +20,23 @@ 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)
# 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)
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")
@@ -65,6 +78,9 @@ 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)
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")

View File

@@ -12,6 +12,7 @@ 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)
products = relationship("Product", back_populates="printer_zone")
print_logs = relationship("PrintLog", back_populates="printer")

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,6 +37,19 @@ 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)
# Phase 2A — cost tracking
cost_simple = Column(Float, nullable=True)
cost_breakdown = Column(Text, nullable=True) # JSON: [{"label": str, "amount": float}]
category = relationship("Category", back_populates="products")
printer_zone = relationship("Printer", back_populates="products")
quick_options = relationship("ProductQuickOption", back_populates="product", cascade="all, delete-orphan")

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

@@ -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
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
@@ -21,8 +21,11 @@ class User(Base):
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)
orders_opened = relationship("Order", foreign_keys="Order.opened_by", back_populates="opener")
orders_closed = relationship("Order", foreign_keys="Order.closed_by", back_populates="closer")

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

@@ -6,10 +6,12 @@ from datetime import datetime, timezone
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 +150,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)
@@ -189,6 +200,325 @@ def delete_business_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,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

@@ -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,128 @@
"""
KDS — Kitchen Display System router.
GET /api/kds/items
Returns all active (status='active') order items for open orders,
enriched with zone name and table name. Used by the KDS frontend.
PUT /api/orders/{order_id}/items/{item_id}/status
Mark an item ready (active → ready). Only that transition is allowed here.
Broadcasts item_status_changed SSE event.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from datetime import datetime, timezone
from database import get_db
from models.order import Order, OrderItem
from models.product import Product
from models.printer import Printer
from models.table import Table
from models.user import User
from routers.deps import get_current_user
from services.sse_bus import broadcast_sync
router = APIRouter()
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),
):
"""Return all active order items grouped by zone for the KDS display."""
# Only look at open / partially_paid orders
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()}
# Zone = printer_zone_id (None = no zone)
zones: dict = {}
def _zone_key(zone_id):
if zone_id is None:
return 0
return 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,
})
# Sort zones: named zones first (by zone_id), then no-zone last
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(
order_id: int,
item_id: int,
body: ItemStatusUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
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

@@ -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()

View File

@@ -1,6 +1,16 @@
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from tz import local_strftime
def _iso(dt) -> str | None:
"""Serialize a datetime to ISO 8601 with Z suffix so browsers parse it as UTC."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.isoformat() + "Z"
return dt.isoformat()
from sqlalchemy.orm import Session
from typing import List, Optional
@@ -67,16 +77,21 @@ def _audit(db: Session, order_id: int, event_type: str, waiter_id: int = None,
))
ACTIVE_STATUSES = ["open", "partially_paid", "paid"]
@router.get("/", response_model=List[OrderOut])
def list_orders(
order_status: Optional[str] = None,
waiter_id: Optional[int] = None,
all: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Order)
if order_status:
q = q.filter(Order.status == order_status)
elif not all:
q = q.filter(Order.status.in_(ACTIVE_STATUSES))
if waiter_id:
q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id)
return q.all()
@@ -158,11 +173,11 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
"removed_ingredients": i.removed_ingredients,
"notes": i.notes,
"status": i.status,
"added_at": i.added_at,
"added_at": _iso(i.added_at),
"printed": i.printed,
"paid_by": i.paid_by,
"paid_by_name": name_map.get(i.paid_by) if i.paid_by else None,
"paid_at": i.paid_at,
"paid_at": _iso(i.paid_at),
"payment_method": i.payment_method,
"paid_in_shift_id": i.paid_in_shift_id,
}
@@ -178,21 +193,37 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends
"amount": l.amount,
"payment_method": l.payment_method,
"note": l.note,
"created_at": l.created_at,
"offline_at": l.offline_at,
"created_at": _iso(l.created_at),
"offline_at": _iso(l.offline_at) if isinstance(l.offline_at, datetime) else l.offline_at,
"is_duplicate": l.is_duplicate,
}
table = db.query(Table).filter(Table.id == order.table_id).first() if order.table_id else None
table_name = (table.label or f"T{table.number}") if table else None
# Phase 2F: resolve customer name for display
customer_name = None
customer_phone = None
if order.customer_id and order.customer:
customer_name = order.customer.name
if order.customer.nickname:
customer_name = f"{order.customer.name} «{order.customer.nickname}»"
customer_phone = order.customer.phone
return {
"id": order.id,
"table_id": order.table_id,
"table_name": table_name,
"opened_by": order.opened_by,
"opened_at": order.opened_at,
"opened_at": _iso(order.opened_at),
"status": order.status,
"closed_at": order.closed_at,
"closed_at": _iso(order.closed_at),
"closed_by": order.closed_by,
"notes": order.notes,
"business_day_id": order.business_day_id,
"customer_id": order.customer_id,
"customer_name": customer_name,
"customer_phone": customer_phone,
"items": [fmt_item(i) for i in order.items],
"waiters": [{"waiter_id": w.waiter_id} for w in order.waiters],
"audit_logs": [fmt_log(l) for l in order.audit_logs],
@@ -261,12 +292,25 @@ def add_items(
(o.price_delta or o.extra_cost or 0.0)
for o in (item_in.selected_options or [])
)
# Phase 2A: cost snapshot — breakdown takes priority over simple cost
unit_cost = 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:
unit_cost = total
except Exception:
pass
if unit_cost is None and product.cost_simple:
unit_cost = product.cost_simple
item = OrderItem(
order_id=order_id,
product_id=item_in.product_id,
added_by=user.id,
quantity=item_in.quantity,
unit_price=product.base_price + extra_cost,
unit_cost=unit_cost,
selected_options=json.dumps([o.model_dump() for o in item_in.selected_options]) if item_in.selected_options else None,
removed_ingredients=json.dumps(item_in.removed_ingredients) if item_in.removed_ingredients else None,
notes=item_in.notes,
@@ -340,25 +384,26 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
items = db.query(OrderItem).filter(
OrderItem.id.in_(body.item_ids),
OrderItem.order_id == order_id,
OrderItem.status == "active",
OrderItem.status.in_(["active", "ready"]), # ready items are also payable
).all()
now = datetime.now(timezone.utc)
active_shift = db.query(WaiterShift).filter(
WaiterShift.waiter_id == user.id,
WaiterShift.ended_at == None,
).first()
effective_method = body.payment_method or "cash"
total_paid = 0.0
for item in items:
item.status = "paid"
item.paid_by = user.id
item.paid_at = now
item.payment_method = body.payment_method
item.payment_method = effective_method
item.paid_in_shift_id = active_shift.id if active_shift else None
total_paid += item.unit_price * item.quantity
db.flush() # write item status changes before counting, since autoflush=False
active_remaining = db.query(OrderItem).filter(
OrderItem.order_id == order_id, OrderItem.status == "active"
OrderItem.order_id == order_id, OrderItem.status.in_(["active", "ready"])
).count()
order.status = "paid" if active_remaining == 0 else "partially_paid"
@@ -452,6 +497,7 @@ def pay_items_offline(
WaiterShift.ended_at == None,
).first()
effective_method = body.payment_method or "cash"
total_paid = 0.0
paid_ids = []
if not is_duplicate:
@@ -459,7 +505,7 @@ def pay_items_offline(
item.status = "paid"
item.paid_by = user.id
item.paid_at = paid_at
item.payment_method = body.payment_method
item.payment_method = effective_method
item.paid_in_shift_id = active_shift.id if active_shift else None
total_paid += item.unit_price * item.quantity
paid_ids.append(item.id)
@@ -543,6 +589,30 @@ def assign_waiter(order_id: int, body: AssignWaiterRequest, db: Session = Depend
return {"status": "assigned"}
class AssignCustomerBody(BaseModel):
customer_id: Optional[int] = None # null = unassign
class TabItemBody(BaseModel):
pass # no body needed — tab derived from order's customer
@router.put("/{order_id}/customer")
def assign_customer(order_id: int, body: AssignCustomerBody, db: Session = Depends(get_db), user: User = Depends(require_manager)):
from models.customers import Customer
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if body.customer_id is not None:
customer = db.query(Customer).filter(Customer.id == body.customer_id, Customer.is_active == True).first()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
order.customer_id = body.customer_id
db.commit()
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "customer_assigned"})
return {"status": "ok", "customer_id": order.customer_id}
@router.delete("/{order_id}/waiters/{waiter_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_waiter(order_id: int, waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
assignment = db.query(OrderWaiter).filter(
@@ -597,15 +667,15 @@ def print_order(
"order_id": order.id,
"table_name": table_name,
"waiter_name": waiter_name,
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"),
"closed_at": order.closed_at.strftime("%d/%m/%Y %H:%M") if order.closed_at else None,
"opened_at": local_strftime(order.opened_at, "%d/%m/%Y %H:%M"),
"closed_at": local_strftime(order.closed_at, "%d/%m/%Y %H:%M") if order.closed_at else None,
"status": order.status,
"items": items_data,
"total": grand_total,
"notes": order.notes,
}
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt)
background_tasks.add_task(print_order_receipt, printer.ip_address, printer.port, receipt, printer.line_width)
return {"status": "printing"}
@@ -893,12 +963,64 @@ def print_synopsis(
"order_id": order.id,
"table_name": table_name,
"waiter_name": waiter_name,
"opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"),
"opened_at": local_strftime(order.opened_at, "%d/%m/%Y %H:%M"),
"items": items_data,
"total": total,
"paid_total": paid_total,
"remaining": total - paid_total,
}
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis)
background_tasks.add_task(print_order_synopsis, printer.ip_address, printer.port, synopsis, printer.line_width)
return {"status": "printing"}
# ─── Phase 2G: Put item on customer tab ───────────────────────────────────────
@router.post("/{order_id}/items/{item_id}/tab")
def put_item_on_tab(
order_id: int,
item_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.tabs import Tab, TabEntry
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if not order.customer_id:
raise HTTPException(status_code=400, detail="Order has no customer assigned — assign a customer first")
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 already {item.status} — cannot tab it")
# Find or auto-create the customer's open tab
tab = db.query(Tab).filter(Tab.customer_id == order.customer_id, Tab.status == "open").first()
if not tab:
tab = Tab(customer_id=order.customer_id)
db.add(tab)
db.flush()
# Build description
product_name = item.product.name if item.product else f"#{item.product_id}"
description = f"{product_name} ×{item.quantity} @ €{item.unit_price:.2f}"
entry = TabEntry(
tab_id=tab.id,
order_id=order_id,
order_item_id=item_id,
amount=round(item.unit_price * item.quantity, 2),
description=description,
created_by_id=user.id,
)
db.add(entry)
# Mark item as tabbed — distinct from active/paid/cancelled
item.status = "tabbed"
db.commit()
broadcast_sync("order_updated", {"order_id": order.id, "table_id": order.table_id, "status": order.status, "action": "item_tabbed"})
return {"status": "tabbed", "tab_id": tab.id, "entry_amount": entry.amount}

View File

@@ -235,9 +235,11 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
@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"})
data = body.model_dump(exclude={"quick_options", "options", "ingredients", "preference_sets", "cost_breakdown"})
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])
product = Product(**data)
db.add(product)
db.flush()
@@ -277,8 +279,18 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
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"},
)
for field, value in scalar_fields.items():
setattr(product, field, value)
# 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
if body.quick_options is not None:
_replace_quick_options(db, product, body.quick_options)
if body.options is not None:

View File

@@ -3,6 +3,7 @@ import io
import json
from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks
from tz import local_strftime, to_local
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -20,7 +21,10 @@ from models.business_day import BusinessDay
from schemas.order import OrderOut
from schemas.table import TableOut
from routers.deps import require_manager
from services.printer_service import print_waiter_report, print_printer_report, print_order_receipt
from services.printer_service import (
print_waiter_report, print_printer_report, print_order_receipt, load_divider_style,
print_products_report, print_categories_report, print_tables_report,
)
router = APIRouter()
@@ -84,8 +88,8 @@ def shift_summary(
order = db.query(Order).filter(Order.id == oid).first()
summary[wid]["order_data"][oid] = {
"id": oid,
"time_open": order.opened_at.strftime("%H:%M") if order else "",
"time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "",
"time_open": local_strftime(order.opened_at, "%H:%M") if order else "",
"time_close": local_strftime(order.closed_at, "%H:%M") if order and order.closed_at else "",
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
"total": 0.0,
"items": [],
@@ -157,8 +161,8 @@ def shift_orders_summary(
order = db.query(Order).filter(Order.id == oid).first()
summary[wid]["order_data"][oid] = {
"id": oid,
"time_open": order.opened_at.strftime("%H:%M") if order else "",
"time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "",
"time_open": local_strftime(order.opened_at, "%H:%M") if order else "",
"time_close": local_strftime(order.closed_at, "%H:%M") if order and order.closed_at else "",
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
"total": 0.0,
"items": [],
@@ -379,7 +383,7 @@ def printer_totals(
order = db.query(Order).filter(Order.id == oid).first()
order_map[pid][oid] = {
"order_id": oid,
"time": log.printed_at.strftime("%H:%M"),
"time": local_strftime(log.printed_at, "%H:%M"),
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
"total": 0.0,
"items": [],
@@ -416,17 +420,88 @@ class PrintWaiterReportBody(BaseModel):
class PrintPrinterReportBody(BaseModel):
# printer_target_id: 0 = all printers, >0 = specific printer id
printer_target_id: int
printer_id: int
mode: str # "simple" | "extensive"
from_dt: str
to_dt: str
# mode: "quick" | "orders" | "detailed"
mode: str
from_dt: Optional[str] = None
to_dt: Optional[str] = None
business_day_id: Optional[int] = None
item_breakdown: bool = False
class PrintOrderBody(BaseModel):
printer_id: int
def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, mode: str) -> dict:
"""
Build a report block for a single printer from its logs.
Returns dict with print_jobs, orders, items, total, order_data, item_breakdown.
item_breakdown is always computed (used for product analysis section when requested).
"""
order_map: dict = {}
item_breakdown: dict = {} # name → {"qty": int, "value": float}
items_count = 0
grand_total = 0.0
for log in logs:
oid = log.order_id
if oid not in order_map:
order = db.query(Order).filter(Order.id == oid).first()
if order:
order_map[oid] = {
"id": oid,
"time": local_strftime(log.printed_at, "%H:%M"),
"table": tables.get(order.table_id, f"#{order.table_id}"),
"total": 0.0,
"items": [],
}
try:
item_ids = json.loads(log.item_ids)
except Exception:
item_ids = []
for item_id in item_ids:
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
if item and item.status in ("active", "paid"):
items_count += item.quantity
val = item.unit_price * item.quantity
grand_total += val
product_name = item.product.name if item.product else f"#{item.product_id}"
if oid in order_map:
order_map[oid]["total"] += val
order_map[oid]["items"].append({
"name": product_name,
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"total": round(val, 2),
})
# Accumulate item breakdown (always, regardless of mode)
if product_name not in item_breakdown:
item_breakdown[product_name] = {"qty": 0, "value": 0.0}
item_breakdown[product_name]["qty"] += item.quantity
item_breakdown[product_name]["value"] = round(item_breakdown[product_name]["value"] + val, 2)
# Convert breakdown to sorted list for easy rendering
breakdown_list = sorted(
[{"name": k, "qty": v["qty"], "value": v["value"]} for k, v in item_breakdown.items()],
key=lambda x: x["qty"],
reverse=True,
)
return {
"printer_id": printer_id,
"printer_name": printer_name,
"print_jobs": len(logs),
"orders": len(order_map),
"items": items_count,
"total": round(grand_total, 2),
"order_data": list(order_map.values()) if mode in ("orders", "detailed") else [],
"item_breakdown": breakdown_list,
}
@router.post("/print/waiter")
def print_waiter(
body: PrintWaiterReportBody,
@@ -461,8 +536,8 @@ def print_waiter(
total = sum(i.unit_price * i.quantity for i in active_items)
order_data.append({
"id": o.id,
"time_open": o.opened_at.strftime("%H:%M"),
"time_close": o.closed_at.strftime("%H:%M") if o.closed_at else "",
"time_open": local_strftime(o.opened_at, "%H:%M"),
"time_close": local_strftime(o.closed_at, "%H:%M") if o.closed_at else "",
"table": tables.get(o.table_id, f"#{o.table_id}"),
"total": total,
"items": [
@@ -482,8 +557,8 @@ def print_waiter(
"items": items_count,
"total": grand_total,
"order_data": order_data if body.mode == "extensive" else [],
"from_dt": from_dt.strftime("%d/%m/%Y %H:%M"),
"to_dt": to_dt.strftime("%d/%m/%Y %H:%M"),
"from_dt": local_strftime(from_dt, "%d/%m/%Y %H:%M"),
"to_dt": local_strftime(to_dt, "%d/%m/%Y %H:%M"),
}
background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode)
@@ -497,73 +572,293 @@ def print_printer_totals(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
# The physical printer that will receive the paper
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
target_printer = db.query(Printer).filter(Printer.id == body.printer_target_id).first()
target_name = target_printer.name if target_printer else f"Printer #{body.printer_target_id}"
from_dt = datetime.fromisoformat(body.from_dt)
to_dt = datetime.fromisoformat(body.to_dt)
logs = db.query(PrintLog).filter(
PrintLog.printer_id == body.printer_target_id,
PrintLog.success == True,
PrintLog.printed_at >= from_dt,
PrintLog.printed_at <= to_dt,
).all()
tables = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
# Build per-order entries keyed by order_id; each log may add more items
order_map: dict = {}
items_count = 0
grand_total = 0.0
for log in logs:
oid = log.order_id
if oid not in order_map:
order = db.query(Order).filter(Order.id == oid).first()
if order:
order_map[oid] = {
"id": oid,
"time": log.printed_at.strftime("%H:%M"),
"table": tables.get(order.table_id, f"#{order.table_id}"),
"total": 0.0,
"items": [],
}
try:
item_ids = json.loads(log.item_ids)
except Exception:
item_ids = []
for item_id in item_ids:
item = db.query(OrderItem).filter(OrderItem.id == item_id).first()
if item and item.status in ("active", "paid"):
items_count += item.quantity
val = item.unit_price * item.quantity
grand_total += val
if oid in order_map:
order_map[oid]["total"] += val
product_name = item.product.name if item.product else f"#{item.product_id}"
order_map[oid]["items"].append({"name": product_name, "quantity": item.quantity})
# Build time filter for logs
def _base_log_q():
q = db.query(PrintLog).filter(PrintLog.success == True)
if body.business_day_id:
q = q.join(Order).filter(Order.business_day_id == body.business_day_id)
elif body.from_dt and body.to_dt:
from_dt = datetime.fromisoformat(body.from_dt)
to_dt = datetime.fromisoformat(body.to_dt)
q = q.filter(PrintLog.printed_at >= from_dt, PrintLog.printed_at <= to_dt)
return q
order_data = list(order_map.values())
# Determine period label for the receipt header
if body.business_day_id:
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
period_label = local_strftime(bd.opened_at, "%d/%m/%Y") if bd else f"Workday #{body.business_day_id}"
period_is_workday = True
else:
from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None
to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None
period_label = (
f"{local_strftime(from_dt, '%d/%m/%Y')} - {local_strftime(to_dt, '%d/%m/%Y')}"
if from_dt and to_dt else ""
)
period_is_workday = False
if body.printer_target_id == 0:
# All printers — build one block per active printer that has logs
all_printers = db.query(Printer).filter(Printer.is_active == True).all()
printer_blocks = []
for p in all_printers:
logs = _base_log_q().filter(PrintLog.printer_id == p.id).all()
if not logs:
continue
block = _build_printer_block(p.id, p.name, logs, tables, db, body.mode)
printer_blocks.append(block)
else:
target_printer = db.query(Printer).filter(Printer.id == body.printer_target_id).first()
target_name = target_printer.name if target_printer else f"Printer #{body.printer_target_id}"
logs = _base_log_q().filter(PrintLog.printer_id == body.printer_target_id).all()
printer_blocks = [_build_printer_block(body.printer_target_id, target_name, logs, tables, db, body.mode)]
report = {
"printer_name": target_name,
"print_jobs": len(logs),
"orders": len(order_map),
"items": items_count,
"total": grand_total,
"order_data": order_data if body.mode == "extensive" else [],
"from_dt": from_dt.strftime("%d/%m/%Y %H:%M"),
"to_dt": to_dt.strftime("%d/%m/%Y %H:%M"),
"printer_blocks": printer_blocks,
"period_label": period_label,
"period_is_workday": period_is_workday,
"mode": body.mode,
"item_breakdown": body.item_breakdown,
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
return {"status": "printing"}
# ---------------------------------------------------------------------------
# Print: products / categories / tables
# ---------------------------------------------------------------------------
class PrintAnalyticsBody(BaseModel):
printer_id: int
mode: str # "smart" | "full"
from_dt: Optional[str] = None
to_dt: Optional[str] = None
business_day_id: Optional[int] = None
def _analytics_period(body: PrintAnalyticsBody, db) -> tuple[str, bool]:
"""Return (period_label, period_is_workday)."""
if body.business_day_id:
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
label = local_strftime(bd.opened_at, "%d/%m/%Y") if bd else f"Workday #{body.business_day_id}"
return label, True
from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None
to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None
label = (
f"{local_strftime(from_dt, '%d/%m/%Y')} - {local_strftime(to_dt, '%d/%m/%Y')}"
if from_dt and to_dt else ""
)
return label, False
@router.post("/print/products")
def print_products(
body: PrintAnalyticsBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.product import Product, Category
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
period_label, period_is_workday = _analytics_period(body, db)
# Build sold-item summary
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
if body.business_day_id:
q = q.join(Order).filter(Order.business_day_id == body.business_day_id)
elif body.from_dt and body.to_dt:
q = q.filter(
OrderItem.added_at >= datetime.fromisoformat(body.from_dt),
OrderItem.added_at <= datetime.fromisoformat(body.to_dt),
)
items = q.all()
sold: dict = {}
for item in items:
pid = item.product_id
if pid not in sold:
sold[pid] = {"qty": 0, "revenue": 0.0}
sold[pid]["qty"] += item.quantity
sold[pid]["revenue"] += item.unit_price * item.quantity
if body.mode == "full":
# All active products, 0-sold included
all_products = db.query(Product).filter(Product.lifecycle_status == "active").order_by(Product.name).all()
report_items = sorted(
[{"name": p.name, "qty": sold.get(p.id, {}).get("qty", 0), "revenue": round(sold.get(p.id, {}).get("revenue", 0.0), 2)} for p in all_products],
key=lambda x: x["qty"], reverse=True,
)
else:
# Smart: only sold
products_db = {p.id: p for p in db.query(Product).all()}
report_items = sorted(
[{"name": (products_db[pid].name if pid in products_db else f"#{pid}"), "qty": v["qty"], "revenue": round(v["revenue"], 2)} for pid, v in sold.items()],
key=lambda x: x["qty"], reverse=True,
)
report = {
"mode": body.mode,
"period_label": period_label,
"period_is_workday": period_is_workday,
"items": report_items,
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_products_report, printer.ip_address, printer.port, report)
return {"status": "printing"}
@router.post("/print/categories")
def print_categories(
body: PrintAnalyticsBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.product import Product, Category
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
period_label, period_is_workday = _analytics_period(body, db)
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
if body.business_day_id:
q = q.join(Order).filter(Order.business_day_id == body.business_day_id)
elif body.from_dt and body.to_dt:
q = q.filter(
OrderItem.added_at >= datetime.fromisoformat(body.from_dt),
OrderItem.added_at <= datetime.fromisoformat(body.to_dt),
)
items = q.all()
products_db = {p.id: p for p in db.query(Product).all()}
categories_db = {c.id: c for c in db.query(Category).all()}
# summary[cid] = {name, units_sold, revenue, products: {pid: {name, qty, revenue}}}
summary: dict = {}
for item in items:
product = products_db.get(item.product_id)
if not product:
continue
cid = product.category_id
pid = item.product_id
if cid not in summary:
cat = categories_db.get(cid)
summary[cid] = {"name": cat.name if cat else f"#{cid}", "units_sold": 0, "revenue": 0.0, "products": {}}
summary[cid]["units_sold"] += item.quantity
summary[cid]["revenue"] += item.unit_price * item.quantity
if pid not in summary[cid]["products"]:
summary[cid]["products"][pid] = {"name": product.name, "qty": 0, "revenue": 0.0}
summary[cid]["products"][pid]["qty"] += item.quantity
summary[cid]["products"][pid]["revenue"] += item.unit_price * item.quantity
total_rev = sum(v["revenue"] for v in summary.values())
total_qty = sum(v["units_sold"] for v in summary.values())
cat_list = []
for v in sorted(summary.values(), key=lambda x: x["revenue"], reverse=True):
cat_total_qty = v["units_sold"]
cat_total_rev = v["revenue"]
products_list = sorted(
[{"name": p["name"], "qty": p["qty"], "revenue": round(p["revenue"], 2),
"pct_qty": round(p["qty"] / cat_total_qty * 100, 1) if cat_total_qty else 0,
"pct_rev": round(p["revenue"] / cat_total_rev * 100, 1) if cat_total_rev else 0}
for p in v["products"].values()],
key=lambda x: x["qty"], reverse=True,
)
cat_list.append({
"name": v["name"],
"units_sold": v["units_sold"],
"revenue": round(v["revenue"], 2),
"pct_rev": round(v["revenue"] / total_rev * 100, 1) if total_rev else 0,
"pct_qty": round(v["units_sold"] / total_qty * 100, 1) if total_qty else 0,
"products": products_list,
})
report = {
"mode": body.mode,
"period_label": period_label,
"period_is_workday": period_is_workday,
"categories": cat_list,
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_categories_report, printer.ip_address, printer.port, report)
return {"status": "printing"}
@router.post("/print/tables")
def print_tables(
body: PrintAnalyticsBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found or inactive")
period_label, period_is_workday = _analytics_period(body, db)
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
if body.business_day_id:
q = q.filter(Order.business_day_id == body.business_day_id)
elif body.from_dt and body.to_dt:
q = q.filter(
Order.opened_at >= datetime.fromisoformat(body.from_dt),
Order.opened_at <= datetime.fromisoformat(body.to_dt),
)
orders = q.all()
tables_db = {t.id: t for t in db.query(Table).all()}
summary: dict = {}
for order in orders:
tid = order.table_id
if tid not in summary:
t = tables_db.get(tid)
summary[tid] = {
"name": (t.label or f"T{t.number}") if t else f"#{tid}",
"order_count": 0, "revenue": 0.0, "durations": [],
}
summary[tid]["order_count"] += 1
summary[tid]["revenue"] += sum(i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid"))
if order.closed_at and order.opened_at:
summary[tid]["durations"].append((order.closed_at - order.opened_at).total_seconds() / 60)
table_list = []
for entry in summary.values():
durations = entry.pop("durations")
entry["avg_duration_minutes"] = round(sum(durations) / len(durations), 1) if durations else None
entry["revenue"] = round(entry["revenue"], 2)
table_list.append(entry)
table_list.sort(key=lambda x: x["revenue"], reverse=True)
report = {
"mode": body.mode,
"period_label": period_label,
"period_is_workday": period_is_workday,
"tables": table_list,
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_tables_report, printer.ip_address, printer.port, report)
return {"status": "printing"}
# ---------------------------------------------------------------------------
# Shift history report
# ---------------------------------------------------------------------------
@@ -578,7 +873,7 @@ def shifts_report(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from routers.shifts import compute_shift_total
from routers.shifts import _enrich_shift
q = db.query(WaiterShift)
if waiter_id:
@@ -593,28 +888,7 @@ def shifts_report(
q = q.filter(WaiterShift.ended_at == None)
shifts = q.order_by(WaiterShift.started_at.desc()).all()
waiters_db = {u.id: u for u in db.query(User).all()}
result = []
for shift in shifts:
w = waiters_db.get(shift.waiter_id)
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)
result.append({
"id": shift.id,
"waiter_id": shift.waiter_id,
"waiter_name": wname,
"business_day_id": shift.business_day_id,
"started_at": _dt(shift.started_at),
"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),
"is_active": shift.ended_at is None,
"notes": shift.notes,
})
return {"shifts": result}
return {"shifts": [_enrich_shift(s, db) for s in shifts]}
# ---------------------------------------------------------------------------
@@ -631,6 +905,7 @@ def product_performance(
user: User = Depends(require_manager),
):
from models.product import Product
from models.waste import WasteLog
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
if from_dt:
@@ -643,6 +918,24 @@ def product_performance(
items = q.all()
products_db = {p.id: p for p in db.query(Product).all()}
# Phase 2H: load waste for the same period
wq = db.query(WasteLog)
if business_day_id:
wq = wq.filter(WasteLog.business_day_id == business_day_id)
elif from_dt and to_dt:
wq = wq.filter(
WasteLog.logged_at >= datetime.fromisoformat(from_dt),
WasteLog.logged_at <= datetime.fromisoformat(to_dt),
)
waste_entries = wq.all()
waste_by_product: dict = {}
for w in waste_entries:
pid = w.product_id
if pid not in waste_by_product:
waste_by_product[pid] = {"waste_qty": 0.0, "waste_cost": 0.0}
waste_by_product[pid]["waste_qty"] += w.quantity
waste_by_product[pid]["waste_cost"] += w.total_cost or 0.0
summary: dict = {}
for item in items:
pid = item.product_id
@@ -656,16 +949,51 @@ def product_performance(
"category_id": product.category_id if product else None,
"qty_sold": 0,
"revenue": 0.0,
"total_cost": 0.0,
"trackable_profit": 0.0,
"uncosted_revenue": 0.0,
"uncosted_items": 0,
"order_ids": set(),
}
summary[pid]["qty_sold"] += item.quantity
summary[pid]["revenue"] += item.unit_price * item.quantity
qty = item.quantity
revenue = item.unit_price * qty
summary[pid]["qty_sold"] += qty
summary[pid]["revenue"] += revenue
summary[pid]["order_ids"].add(item.order_id)
if item.unit_cost is not None:
cost = item.unit_cost * qty
summary[pid]["total_cost"] += cost
summary[pid]["trackable_profit"] += revenue - cost
else:
summary[pid]["uncosted_revenue"] += revenue
summary[pid]["uncosted_items"] += qty
# Also include products that only have waste (not sold) in the period
for pid, wdata in waste_by_product.items():
if pid not in summary:
product = products_db.get(pid)
if category_id and (not product or product.category_id != category_id):
continue
summary[pid] = {
"product_id": pid,
"product_name": product.name if product else f"#{pid}",
"category_id": product.category_id if product else None,
"qty_sold": 0, "revenue": 0.0, "total_cost": 0.0, "trackable_profit": 0.0,
"uncosted_revenue": 0.0, "uncosted_items": 0, "order_ids": set(),
}
result = []
for entry in summary.values():
pid = entry["product_id"]
wdata = waste_by_product.get(pid, {"waste_qty": 0.0, "waste_cost": 0.0})
entry["order_count"] = len(entry.pop("order_ids"))
entry["revenue"] = round(entry["revenue"], 2)
entry["total_cost"] = round(entry["total_cost"], 2)
entry["trackable_profit"] = round(entry["trackable_profit"], 2)
entry["uncosted_revenue"] = round(entry["uncosted_revenue"], 2)
entry["has_gap"] = entry["uncosted_items"] > 0
entry["waste_qty"] = round(wdata["waste_qty"], 3)
entry["waste_cost"] = round(wdata["waste_cost"], 2)
result.append(entry)
result.sort(key=lambda x: x["qty_sold"], reverse=True)
@@ -800,10 +1128,22 @@ def business_days_list(
cancelled_orders = [o for o in orders if o.status == "cancelled"]
day_shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()
waiter_ids = {s.waiter_id for s in day_shifts}
revenue = sum(
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
for o in closed_orders
)
revenue = 0.0
total_cost = 0.0
trackable_profit = 0.0
has_gap = False
for o in closed_orders:
for i in o.items:
if i.status not in ("active", "paid"):
continue
rev = i.unit_price * i.quantity
revenue += rev
if i.unit_cost is not None:
cost = i.unit_cost * i.quantity
total_cost += cost
trackable_profit += rev - cost
else:
has_gap = True
opener = waiters_db.get(d.opened_by_id)
closer = waiters_db.get(d.closed_by_id) if d.closed_by_id else None
result.append({
@@ -820,6 +1160,9 @@ def business_days_list(
"waiter_count": len(waiter_ids),
"shift_count": len(day_shifts),
"revenue": round(revenue, 2),
"total_cost": round(total_cost, 2),
"trackable_profit": round(trackable_profit, 2),
"has_gap": has_gap,
})
return {"business_days": result}
@@ -841,10 +1184,22 @@ def current_business_day(
WaiterShift.business_day_id == day.id,
WaiterShift.ended_at == None,
).all()
revenue = sum(
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
for o in closed_orders
)
revenue = 0.0
total_cost = 0.0
trackable_profit = 0.0
has_gap = False
for o in closed_orders:
for i in o.items:
if i.status not in ("active", "paid"):
continue
rev = i.unit_price * i.quantity
revenue += rev
if i.unit_cost is not None:
cost = i.unit_cost * i.quantity
total_cost += cost
trackable_profit += rev - cost
else:
has_gap = True
item_counts: dict = {}
for o in orders:
@@ -866,6 +1221,9 @@ def current_business_day(
"id": day.id,
"opened_at": _dt(day.opened_at),
"revenue": round(revenue, 2),
"total_cost": round(total_cost, 2),
"trackable_profit": round(trackable_profit, 2),
"has_gap": has_gap,
"orders_closed": len(closed_orders),
"orders_open": len(open_orders),
"active_waiters": len(active_shifts),
@@ -896,7 +1254,7 @@ def revenue_trends(
buckets: dict = {}
for order in orders:
dt = order.opened_at
dt = to_local(order.opened_at)
if granularity == "monthly":
key = dt.strftime("%Y-%m")
elif granularity == "weekly":
@@ -906,14 +1264,22 @@ def revenue_trends(
key = dt.strftime("%Y-%m-%d")
if key not in buckets:
buckets[key] = {"date": key, "revenue": 0.0, "orders": 0}
rev = sum(i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid"))
buckets[key]["revenue"] += rev
buckets[key] = {"date": key, "revenue": 0.0, "profit": 0.0, "has_gap": False, "orders": 0}
for i in order.items:
if i.status not in ("active", "paid"):
continue
rev = i.unit_price * i.quantity
buckets[key]["revenue"] += rev
if i.unit_cost is not None:
buckets[key]["profit"] += rev - i.unit_cost * i.quantity
else:
buckets[key]["has_gap"] = True
buckets[key]["orders"] += 1
arr = sorted(buckets.values(), key=lambda x: x["date"])
for d in arr:
d["revenue"] = round(d["revenue"], 2)
d["profit"] = round(d["profit"], 2)
if granularity == "daily":
for i, d in enumerate(arr):
@@ -963,18 +1329,37 @@ def category_performance(
"color": cat.color if cat and hasattr(cat, "color") else None,
"units_sold": 0,
"revenue": 0.0,
"total_cost": 0.0,
"trackable_profit": 0.0,
"uncosted_revenue": 0.0,
"has_gap": False,
"product_ids": set(),
}
summary[cid]["units_sold"] += item.quantity
summary[cid]["revenue"] += item.unit_price * item.quantity
qty = item.quantity
rev = item.unit_price * qty
summary[cid]["units_sold"] += qty
summary[cid]["revenue"] += rev
summary[cid]["product_ids"].add(item.product_id)
if item.unit_cost is not None:
cost = item.unit_cost * qty
summary[cid]["total_cost"] += cost
summary[cid]["trackable_profit"] += rev - cost
else:
summary[cid]["uncosted_revenue"] += rev
summary[cid]["has_gap"] = True
total_rev = sum(v["revenue"] for v in summary.values())
total_profit = sum(v["trackable_profit"] for v in summary.values())
result = []
for entry in summary.values():
entry["product_count"] = len(entry.pop("product_ids"))
entry["revenue"] = round(entry["revenue"], 2)
entry["pct"] = round((entry["revenue"] / total_rev * 100) if total_rev else 0, 1)
entry["total_cost"] = round(entry["total_cost"], 2)
entry["trackable_profit"] = round(entry["trackable_profit"], 2)
entry["uncosted_revenue"] = round(entry["uncosted_revenue"], 2)
entry["pct_rev"] = round((entry["revenue"] / total_rev * 100) if total_rev else 0, 1)
entry["pct_profit"] = round((entry["trackable_profit"] / total_profit * 100) if total_profit > 0 else 0, 1)
entry["pct"] = entry["pct_rev"]
result.append(entry)
result.sort(key=lambda x: x["revenue"], reverse=True)
@@ -1063,9 +1448,18 @@ def printer_history(
printers_db = {p.id: p for p in db.query(Printer).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
# Cache orders to avoid repeated queries
order_cache = {}
def _get_order(oid):
if oid not in order_cache:
order_cache[oid] = db.query(Order).filter(Order.id == oid).first()
return order_cache[oid]
result = []
seen_order_totals = {} # order_id → total, so we don't double-count reprints
for log in logs:
order = db.query(Order).filter(Order.id == log.order_id).first()
order = _get_order(log.order_id)
printer = printers_db.get(log.printer_id)
try:
item_ids = json.loads(log.item_ids)
@@ -1077,6 +1471,18 @@ def printer_history(
if oi:
pname = oi.product.name if oi.product else f"#{oi.product_id}"
items.append({"name": pname, "quantity": oi.quantity})
# Compute full order total (all active+paid items, not just printed slice)
order_total = None
if order:
order_total = sum(
i.unit_price * i.quantity
for i in order.items
if i.status in ("active", "paid")
)
if log.success and log.order_id not in seen_order_totals:
seen_order_totals[log.order_id] = order_total
result.append({
"id": log.id,
"printed_at": _dt(log.printed_at),
@@ -1086,11 +1492,14 @@ def printer_history(
"items": items,
"success": log.success,
"error_message": log.error_message,
"order_total": round(order_total, 2) if order_total is not None else None,
})
total = len(result)
failed = sum(1 for r in result if not r["success"])
return {"logs": result, "total": total, "failed": failed}
# Sum unique order totals (avoid inflating on reprints of the same order)
total_amount = round(sum(seen_order_totals.values()), 2) if seen_order_totals else None
return {"logs": result, "total": total, "failed": failed, "total_amount": total_amount}
# ---------------------------------------------------------------------------
@@ -1124,6 +1533,28 @@ def meta_printers(
return {"printers": [{"id": p.id, "name": p.name} for p in printers]}
@router.get("/meta/products")
def meta_products(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.product import Product, Category
products = db.query(Product).filter(Product.lifecycle_status == "active").order_by(Product.name).all()
categories_db = {c.id: c.name for c in db.query(Category).all()}
return {
"products": [
{
"id": p.id,
"name": p.name,
"category_id": p.category_id,
"category_name": categories_db.get(p.category_id, ""),
"price": float(p.base_price),
}
for p in products
]
}
# ---------------------------------------------------------------------------
# CSV export endpoints
# ---------------------------------------------------------------------------
@@ -1160,6 +1591,11 @@ def shifts_export(
"waiter": s["waiter_name"],
"started_at": s["started_at"],
"ended_at": s["ended_at"] or "",
"duration_hours": s.get("duration_hours", ""),
"hourly_rate": s.get("hourly_rate_snapshot", ""),
"shift_pay": s.get("shift_pay", ""),
"counted_cash_end": s.get("counted_cash_end", ""),
"cash_discrepancy": s.get("cash_discrepancy", ""),
"starting_cash": s["starting_cash"],
"total_collected": s["total_collected"],
"net_to_deliver": s["net_to_deliver"],
@@ -1267,3 +1703,98 @@ def cancellations_export(
} for c in data["cancellations"]]
date_str = (from_dt or "")[:10]
return _csv_response(rows, f"cancellations-{date_str}.csv")
# ---------------------------------------------------------------------------
# Phase 2L — Discount audit report
# ---------------------------------------------------------------------------
@router.get("/discounts")
def discounts_report(
from_dt: Optional[str] = Query(default=None, alias="from"),
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
applied_by: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.order import OrderDiscount, OrderItem
q = db.query(OrderDiscount)
if business_day_id:
q = q.join(Order, Order.id == OrderDiscount.order_id).filter(Order.business_day_id == business_day_id)
elif from_dt and to_dt:
q = q.filter(
OrderDiscount.applied_at >= datetime.fromisoformat(from_dt),
OrderDiscount.applied_at <= datetime.fromisoformat(to_dt),
)
if applied_by:
q = q.filter(OrderDiscount.applied_by == applied_by)
discounts = q.order_by(OrderDiscount.applied_at.desc()).all()
users_map = {u.id: u for u in db.query(User).all()}
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
# Preload orders for table names
order_ids = {d.order_id for d in discounts}
orders_map = {}
if order_ids:
for o in db.query(Order).filter(Order.id.in_(order_ids)).all():
orders_map[o.id] = o
def _compute_discount_amount(d: OrderDiscount) -> float:
"""Compute the actual euro amount discounted."""
order = orders_map.get(d.order_id)
if d.discount_type == "fixed":
return round(d.discount_value, 2)
if d.discount_type == "percent":
if d.item_id:
item = db.query(OrderItem).filter(OrderItem.id == d.item_id).first()
if item:
base = item.unit_price * item.quantity
return round(base * d.discount_value / 100, 2)
elif order:
base = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled")
return round(base * d.discount_value / 100, 2)
return 0.0
result = []
total_discount_value = 0.0
by_waiter: dict = {}
for d in discounts:
order = orders_map.get(d.order_id)
applier = users_map.get(d.applied_by)
applier_name = (applier.full_name or applier.username) if applier else f"#{d.applied_by}"
table_name = tables_map.get(order.table_id) if order and order.table_id else None
order_total = sum(i.unit_price * i.quantity for i in order.items if i.status != "cancelled") if order else None
discount_amount = _compute_discount_amount(d)
total_discount_value += discount_amount
if d.applied_by not in by_waiter:
by_waiter[d.applied_by] = {"waiter_id": d.applied_by, "waiter_name": applier_name, "count": 0, "total_value": 0.0}
by_waiter[d.applied_by]["count"] += 1
by_waiter[d.applied_by]["total_value"] = round(by_waiter[d.applied_by]["total_value"] + discount_amount, 2)
result.append({
"id": d.id,
"order_id": d.order_id,
"table_name": table_name,
"applied_by_id": d.applied_by,
"applied_by_name": applier_name,
"applied_at": _dt(d.applied_at),
"discount_type": d.discount_type,
"discount_value": d.discount_value,
"discount_amount": discount_amount,
"order_total_before": round(order_total, 2) if order_total is not None else None,
"item_id": d.item_id,
"reason": d.reason,
})
return {
"discounts": result,
"total_discount_value": round(total_discount_value, 2),
"order_count": len({d["order_id"] for d in result}),
"by_waiter": sorted(by_waiter.values(), key=lambda x: -x["total_value"]),
}

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.role == "waiter", 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

@@ -27,6 +27,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 +38,9 @@ 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'",
}
DEFAULTS = {
@@ -50,10 +54,11 @@ DEFAULTS = {
"shifts.waiter_self_end": "true",
"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"}},"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"}}}',
"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 +68,8 @@ 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",
}

View File

@@ -35,10 +35,46 @@ 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_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)
pay_data = compute_shift_pay(shift)
return {
"id": shift.id,
"waiter_id": shift.waiter_id,
@@ -51,6 +87,12 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
"net_to_deliver": round(total + (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"],
# Phase 2E
"counted_cash_end": shift.counted_cash_end,
"cash_discrepancy": shift.cash_discrepancy,
"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
@@ -97,10 +139,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()
@@ -125,10 +169,16 @@ 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
if body.counted_cash_end is not None:
shift.counted_cash_end = body.counted_cash_end
expected = (shift.starting_cash or 0.0) + total
shift.cash_discrepancy = round(body.counted_cash_end - expected, 2)
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
@@ -169,6 +219,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 +242,16 @@ 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
if body.counted_cash_end is not None:
shift.counted_cash_end = body.counted_cash_end
expected = (shift.starting_cash or 0.0) + total
shift.cash_discrepancy = round(body.counted_cash_end - expected, 2)
open_break = db.query(ShiftBreak).filter(
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
@@ -356,13 +413,15 @@ def get_shift_summary(
OrderItem.status == "paid",
).all()
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 <= shift.ended_at,
OrderItem.added_at <= upper_bound,
(OrderItem.paid_in_shift_id != shift_id) | (OrderItem.paid_in_shift_id == None),
).all()

View File

@@ -129,7 +129,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)
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)
return {"success": success, "error": error}

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 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()
@@ -36,6 +41,13 @@ 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_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)):
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
@@ -91,10 +103,32 @@ 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

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

@@ -52,10 +52,14 @@ 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,
)
db.add(new_user)
db.commit()
db.refresh(new_user)
db.add(WaiterZone(waiter_id=new_user.id, group_id=None))
db.commit()
return new_user

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

@@ -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,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

@@ -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

@@ -111,7 +111,7 @@ class AuditLogOut(BaseModel):
class OrderOut(BaseModel):
id: int
table_id: int
table_id: Optional[int] = None
opened_by: int
opened_at: UTCDatetime
status: str
@@ -119,6 +119,16 @@ 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
items: List[OrderItemOut] = []
waiters: List[OrderWaiterOut] = []
audit_logs: List[AuditLogOut] = []

View File

@@ -11,6 +11,7 @@ class PrinterBase(BaseModel):
port: int = 9100
is_active: bool = True
protocol: str = "escpos_tcp"
line_width: int = 48
class PrinterCreate(PrinterBase):
@@ -23,6 +24,7 @@ class PrinterUpdate(BaseModel):
port: Optional[int] = None
is_active: Optional[bool] = None
protocol: Optional[str] = None
line_width: Optional[int] = None
class PrinterOut(PrinterBase):

View File

@@ -255,14 +255,31 @@ class PreferenceSetOut(BaseModel):
# ── 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
is_available: bool = True
lifecycle_status: str = "active"
printer_zone_id: Optional[int] = None
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
class ProductCreate(ProductBase):
@@ -274,6 +291,7 @@ class ProductCreate(ProductBase):
class ProductUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
category_id: Optional[int] = None
base_price: Optional[float] = None
is_available: Optional[bool] = None
@@ -284,6 +302,17 @@ class ProductUpdate(BaseModel):
options: Optional[List[ProductOptionCreate]] = None
ingredients: Optional[List[ProductIngredientCreate]] = None
preference_sets: Optional[List[PreferenceSetCreate]] = 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
class ProductReorderItem(BaseModel):
@@ -300,3 +329,42 @@ class ProductOut(ProductBase):
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,
'is_available': data.is_available,
'lifecycle_status': data.lifecycle_status,
'printer_zone_id': data.printer_zone_id,
'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_options': list(data.quick_options),
'options': list(data.options),
'ingredients': list(data.ingredients),
'preference_sets': list(data.preference_sets),
}
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
@@ -73,6 +74,16 @@ 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
@@ -83,5 +94,6 @@ class TableOut(BaseModel):
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,11 @@ 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
class UserCreate(UserBase):
@@ -26,6 +29,10 @@ 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
class WaiterZoneOut(BaseModel):

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

@@ -24,6 +24,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
@@ -145,6 +148,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,
@@ -172,6 +176,175 @@ async def _sync_once():
license_state.update(expiry_fields)
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 = []
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()
)
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,
})
finally:
db.close()
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))
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.role.in_(["sysadmin", "manager"])).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:
@@ -179,6 +352,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

@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
from database import SessionLocal
from models.order import Order, OrderItem, PrintLog
from tz import to_local
from models.printer import Printer
from models.product import Product
from models.settings import PosSettings
@@ -57,6 +58,7 @@ _DIVIDER_CHARS = {
_PRINT_SETTING_KEYS = [
"print.ticket_mode",
"print.divider_style",
"print.dot_style",
"print.font_order_number",
"print.font_meta",
"print.font_item_name",
@@ -66,11 +68,14 @@ _PRINT_SETTING_KEYS = [
"print.font_ingredient",
"print.font_item_note",
"print.font_order_note",
"print.beep_on_ticket",
"print.beep_pattern",
]
_PRINT_SETTING_DEFAULTS = {
"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",
@@ -80,6 +85,27 @@ _PRINT_SETTING_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",
}
# dot_style → (fill_unit, needs_padding)
# fill_unit is repeated to fill available space; needs_padding forces a space
# before and after the dot region so it never touches the name or multiplier.
_DOT_STYLE_UNITS = {
"dot_space": ". ",
"dot": ".",
"dash_space": "- ",
"underscore": "_",
}
# Beep patterns: (n1=on×100ms, n2=off×100ms, n3=count)
# Using ESC BEL n1 n2 n3 (0x1B 0x07 n1 n2 n3)
_BEEP_PATTERNS = {
"single": (2, 2, 1), # one 200ms beep
"double": (1, 1, 2), # two short beeps
"triple": (1, 1, 3), # three short beeps
"long": (5, 2, 1), # one 500ms beep
}
# SIZE byte values (ESC ! base, no bold bit):
@@ -111,26 +137,79 @@ def _load_print_settings(db: Session) -> dict:
return settings
def _divider(p: Network, style: str = "dash"):
def load_divider_style(db: Session) -> str:
"""Public helper — returns the configured divider style string."""
row = db.query(PosSettings).filter(PosSettings.key == "print.divider_style").first()
return row.value if row else _PRINT_SETTING_DEFAULTS["print.divider_style"]
def _divider(p: Network, style: str = "dash", line_width: int = LINE_WIDTH):
char = _DIVIDER_CHARS.get(style, "-")
p._raw(b'\x1b\x61\x00')
if char:
p._raw(_gr(char * LINE_WIDTH + "\n"))
p._raw(_gr(char * line_width + "\n"))
else:
p._raw(b'\n')
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH) -> str:
"""Build a dot-leader line ending with 'xN'.
line_width must reflect the effective width at the chosen font size
(double-width fonts halve the available char count to 24)."""
suffix = f"x{qty}"
available = line_width - len(name) - len(suffix)
if available < 2:
# Name alone is too long — put qty on same line with a single space
return f"{name} {suffix}"
dots = (". " * ((available // 2) + 1))[:available]
return f"{name}{dots}{suffix}"
def _item_line(name: str, qty: int, line_width: int = LINE_WIDTH, dot_style: str = "dot_space") -> str:
"""Simple single-font dot-leader string — used by receipt and synopsis printers."""
suffix = f" x{qty}"
available = line_width - len(name) - 1 - len(suffix)
if available < 1:
return f"{name} x{qty}"
unit = _DOT_STYLE_UNITS.get(dot_style, ". ")
dots = (unit * (available // len(unit) + 1))[:available]
return f"{name} {dots}{suffix}"
def _print_item_line(
p: Network,
name: str, qty: int,
sz_name: int, b_name: bool,
dot_fill: str, dot_sz: int, dot_bold: bool,
line_width: int = LINE_WIDTH,
):
"""Print one item line as three segments with independent fonts:
[name font] name· [dot font] ····· [name font] ·xN\\n
Column widths are computed in physical printer columns (each double-width
char occupies 2 columns) so mixed font sizes stay aligned."""
suffix = f" x{qty}"
# Physical column width of each character under each font
name_char_w = 2 if sz_name in (32, 48) else 1
dot_char_w = 2 if dot_sz in (32, 48) else 1
# Columns consumed by name (with trailing space) and suffix
name_cols = (len(name) + 1) * name_char_w # +1 for the mandatory gap space
suffix_cols = len(suffix) * name_char_w
available_cols = line_width - name_cols - suffix_cols
if available_cols < dot_char_w:
# No room for even one dot — fall back to plain line in name font
_apply_font(p, sz_name, b_name)
_raw_text(p, f"{name} x{qty}\n")
_reset_font(p)
return
# How many dot characters fit in the available columns
n_dots = available_cols // dot_char_w
unit = _DOT_STYLE_UNITS.get(dot_fill, ". ")
dots = (unit * (n_dots // len(unit) + 1))[:n_dots]
# Emit: [name] [space] in name font
_apply_font(p, sz_name, b_name)
_raw_text(p, name + " ")
# Emit: dots in dot font
_apply_font(p, dot_sz, dot_bold)
_raw_text(p, dots)
# Emit: [space][xN]\n back in name font
_apply_font(p, sz_name, b_name)
_raw_text(p, suffix + "\n")
_reset_font(p)
def _apply_font(p: Network, size: int, bold: bool):
@@ -154,8 +233,8 @@ def _print_line(p: Network, text: str, size: int, bold: bool, caps: bool,
def _greek_date(dt: datetime.datetime) -> str:
"""Return date/time string in Greek format: HH:MM DD-MM-YYYY"""
return dt.strftime("%H:%M %d-%m-%Y")
"""Return date/time string in Greek format: HH:MM DD-MM-YYYY, converted to venue local time."""
return to_local(dt).strftime("%H:%M %d-%m-%Y")
def check_printer(ip: str, port: int) -> bool:
@@ -179,6 +258,21 @@ def is_spoof_mode() -> bool:
db.close()
def send_test_beep(ip: str, port: int, n1: int, n2: int, n3: int) -> Tuple[bool, str]:
"""Send a standalone beep-only job. n1=on-time×100ms, n2=off-time×100ms, n3=count."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping test beep")
return True, ""
try:
p = _get_printer(ip, port)
p._raw(bytes([0x1b, 0x07, n1, n2, n3]))
p.close()
return True, ""
except Exception as e:
logger.error("Test beep failed for %s:%s%s", ip, port, e)
return False, str(e)
def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
if is_spoof_mode():
logger.info("Spoof printing ON — dropping test print for %s", name)
@@ -189,7 +283,7 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"TEST — {name}\n")
p._raw(b'\x1b\x21\x00')
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
now = to_local(datetime.datetime.now(datetime.timezone.utc)).strftime("%Y-%m-%d %H:%M:%S")
_raw_text(p, f"{now}\n")
p._raw(b'\n\n\n')
p.cut()
@@ -200,7 +294,7 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]:
return False, str(e)
def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]:
def send_test_order_print(ip: str, port: int, db: Session, line_width: int = LINE_WIDTH) -> Tuple[bool, str]:
"""Print a fake order using the current font/layout settings — for settings preview."""
if _is_spoof_mode(db):
logger.info("Spoof printing ON — dropping test order print")
@@ -315,7 +409,7 @@ def send_test_order_print(ip: str, port: int, db: Session) -> Tuple[bool, str]:
try:
p = _get_printer(ip, port)
_print_kitchen_ticket(p, _Order(), items, _PatchedDB())
_print_kitchen_ticket(p, _Order(), items, _PatchedDB(), line_width)
p.close()
return True, ""
except Exception as e:
@@ -387,7 +481,7 @@ def _parse_options(item: OrderItem) -> dict:
return result
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session):
def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: Session, line_width: int = LINE_WIDTH):
cfg = _load_print_settings(db)
mode = cfg.get("print.ticket_mode", "detailed")
div = cfg.get("print.divider_style", "dash")
@@ -406,7 +500,7 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
# Resolve display names
table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id)
waiter_nick = (order.opener.nickname or order.opener.username) if order.opener else str(order.opened_by)
now_str = _greek_date(datetime.datetime.now())
now_str = _greek_date(datetime.datetime.now(datetime.timezone.utc))
# ── COMPACT header — single line ────────────────────────────────────────
if compact:
@@ -415,24 +509,28 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
header = f"Παρ. #{order.id} | Τρ. {table_name} | {now_str} | {waiter_nick}"
_raw_text(p, (header.upper() if c_ord else header) + "\n")
_reset_font(p)
_divider(p, div)
_divider(p, div, line_width)
# ── DETAILED header ──────────────────────────────────────────────────────
else:
_print_line(p, f"Παραγγελια #{order.id}", sz_ord, b_ord, c_ord,
align=b'\x1b\x61\x01')
_divider(p, div)
_divider(p, div, line_width)
p._raw(b'\x1b\x61\x00')
_apply_font(p, sz_meta, b_meta)
_raw_text(p, ("ΤΡΑΠΕΖΙ:" if c_meta else "Τραπεζι:") + f" Τραπεζι {table_name}\n")
_raw_text(p, ("ΗΜΕΡΟΜΗΝΙΑ:" if c_meta else "Ημερομηνια:") + f" {now_str}\n")
_raw_text(p, ("ΣΕΡΒΙΤΟΡΟΣ:" if c_meta else "Σερβιτορος:") + f" {waiter_nick}\n")
_reset_font(p)
_divider(p, div)
_divider(p, div, line_width)
# ── Items ────────────────────────────────────────────────────────────────
# Double-width fonts halve the effective character width
item_line_width = LINE_WIDTH // 2 if sz_item in (32, 48) else LINE_WIDTH
# Dot-leader style: "STYLE:SIZE:BOLD"
raw_dot = cfg.get("print.dot_style", "dot_space:0:0")
dot_parts = raw_dot.split(":")
dot_fill = dot_parts[0] if dot_parts[0] in _DOT_STYLE_UNITS else "dot_space"
dot_sz = int(dot_parts[1]) if len(dot_parts) > 1 and dot_parts[1].isdigit() else 0
dot_bold = (dot_parts[2] == "1") if len(dot_parts) > 2 else False
for item in items:
product = db.query(Product).filter(Product.id == item.product_id).first()
@@ -440,9 +538,12 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
item_name = raw_name.upper() if c_item else raw_name
p._raw(b'\x1b\x61\x00')
_apply_font(p, sz_item, b_item)
_raw_text(p, _item_line(item_name, item.quantity, item_line_width) + "\n")
_reset_font(p)
_print_item_line(
p, item_name, item.quantity,
sz_item, b_item,
dot_fill, dot_sz, dot_bold,
line_width,
)
opts = _parse_options(item)
@@ -544,7 +645,7 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
if not compact:
p._raw(b'\n')
_divider(p, div)
_divider(p, div, line_width)
# Order-level notes
if order.notes:
@@ -553,7 +654,7 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
_raw_text(p, f"Σημ: {note_text}\n")
_reset_font(p)
if not compact:
_divider(p, div)
_divider(p, div, line_width)
# Footer (detailed only)
if not compact:
@@ -563,6 +664,22 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n')
# Beep before cut so the buzzer fires as the paper advances
beep_enabled = cfg.get("print.beep_on_ticket", "true") == "true"
if beep_enabled:
pattern = cfg.get("print.beep_pattern", "double")
if pattern.startswith("custom:"):
# custom:n1:n2:n3
try:
_, n1s, n2s, n3s = pattern.split(":")
beep_bytes = (int(n1s), int(n2s), int(n3s))
except (ValueError, TypeError):
beep_bytes = _BEEP_PATTERNS["double"]
else:
beep_bytes = _BEEP_PATTERNS.get(pattern, _BEEP_PATTERNS["double"])
p._raw(bytes([0x1b, 0x07, beep_bytes[0], beep_bytes[1], beep_bytes[2]]))
p.cut()
@@ -614,7 +731,7 @@ def print_waiter_report(ip: str, port: int, report: dict, mode: str):
value = f"{od['total']:.2f}e"
times_part = f"{time_open} - {time_close}" if time_close else time_open
prefix = f"{times_part} - {table}"
gap = LINE_WIDTH - len(prefix) - len(value)
gap = LINE_WIDTH - len(prefix) - len(value) # reports always use default width
if gap < 3:
line = f"{prefix} {value}"
else:
@@ -629,61 +746,145 @@ def print_waiter_report(ip: str, port: int, report: dict, mode: str):
logger.error("print_waiter_report failed for %s:%s%s", ip, port, e)
def _dot_leader_line(left: str, right: str, lw: int) -> str:
"""Build a plain dot-leader line: 'left . . . . right' fitting in lw chars."""
gap = lw - len(left) - len(right)
if gap < 2:
return f"{left} {right}"
dots = (". " * (gap // 2 + 1))[:gap]
return f"{left}{dots}{right}"
def _printer_report_block(p, block: dict, mode: str, lw: int, div: str):
"""Print a single printer's block onto an already-open printer connection."""
_divider(p, div, lw)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εκτυπωτης: {block['printer_name']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
# Stats in double-height (no bold)
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εκτυπωσεις: {block['print_jobs']}\n")
_raw_text(p, f"Παραγγελιες: {block['orders']}\n")
_raw_text(p, f"Αντικειμενα: {block['items']}\n")
_raw_text(p, f"Αξια: {block['total']:.2f}e\n")
p._raw(b'\x1b\x21\x00')
if mode in ("orders", "detailed") and block.get("order_data"):
_divider(p, div, lw)
for od in block["order_data"]:
# Order header line: plain font, dot leader
prefix = f"#{od['id']} {od['table']}"
value = f"{od['total']:.2f}e"
header_line = _dot_leader_line(prefix, value, lw)
# No bold — plain normal font
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x45\x00')
_raw_text(p, header_line + "\n")
if mode == "detailed":
for item in od.get("items", []):
# " qty x Name . . . . total_price"
qty = item["quantity"]
name = item["name"]
total_val = item.get("total", 0.0)
left = f" {qty}x {name}"
right = f"{total_val:.2f}e"
_raw_text(p, _dot_leader_line(left, right, lw) + "\n")
def _printer_item_breakdown(p, blocks: list, lw: int, div: str):
"""Print the product analysis section (combined across all blocks)."""
# Merge breakdown across all printer blocks
combined: dict = {}
for block in blocks:
for entry in block.get("item_breakdown", []):
name = entry["name"]
if name not in combined:
combined[name] = {"qty": 0, "value": 0.0}
combined[name]["qty"] += entry["qty"]
combined[name]["value"] = round(combined[name]["value"] + entry["value"], 2)
if not combined:
return
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x10')
_raw_text(p, "ΑΝΑΛΥΣΗ ΠΡΟΙΟΝΤΩΝ\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x61\x00')
_divider(p, div, lw)
sorted_items = sorted(combined.items(), key=lambda x: x[1]["qty"], reverse=True)
for name, data in sorted_items:
line = _dot_leader_line(name, str(data["qty"]), lw)
_raw_text(p, line + "\n")
def print_printer_report(ip: str, port: int, report: dict, mode: str):
"""Print a per-printer totals report. mode='simple'|'extensive'."""
"""Print a printer history report. report contains printer_blocks list."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping printer report print")
return
try:
lw = report.get("line_width", LINE_WIDTH)
div = report.get("div_style", "dash")
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "ΑΝΑΦΟΡΑ ΕΚΤΥΠΩΤΗ\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εκτυπωτης: {report['printer_name']}\n")
_raw_text(p, f"Απο: {report['from_dt']}\n")
_raw_text(p, f"Εως: {report['to_dt']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εργασιες εκτ.: {report['print_jobs']}\n")
_raw_text(p, f"Παραγγελιες: {report['orders']}\n")
_raw_text(p, f"Αντικειμενα: {report['items']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΣΥΝΟΛΟ: {report['total']:.2f}e\n")
period = report.get("period_label", "")
if report.get("period_is_workday"):
_raw_text(p, f"Εργασιμη: {period}\n")
else:
_raw_text(p, f"Περιοδος: {period}\n")
p._raw(b'\x1b\x21\x00')
if mode == "extensive" and report.get("order_data"):
_divider(p)
blocks = report.get("printer_blocks", [])
for block in blocks:
_printer_report_block(p, block, mode, lw, div)
# Grand total footer
if len(blocks) > 1:
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x10')
_raw_text(p, "ΣΥΝΟΛΟ ΟΛΩΝ\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x61\x00')
_raw_text(p, "ΑΝΑΛΥΤΙΚΑ\n")
_divider(p)
for od in report["order_data"]:
# Header line: "HH:MM - TABLE . . . . 9.99e"
prefix = f"{od['time']} - {od['table']}"
value = f"{od['total']:.2f}e"
gap = LINE_WIDTH - len(prefix) - len(value)
if gap < 3:
header_line = f"{prefix} {value}"
else:
dots = (". " * ((gap // 2) + 1))[:gap]
header_line = f"{prefix}{dots}{value}"
p._raw(b'\x1b\x45\x01')
_raw_text(p, header_line + "\n")
p._raw(b'\x1b\x45\x00')
# Indented items
for item in od.get("items", []):
_raw_text(p, f" {item['quantity']} x {item['name']}\n")
total_jobs = sum(b["print_jobs"] for b in blocks)
total_orders = sum(b["orders"] for b in blocks)
total_items = sum(b["items"] for b in blocks)
total_value = sum(b["total"] for b in blocks)
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Εκτυπωσεις: {total_jobs}\n")
_raw_text(p, f"Παραγγελιες: {total_orders}\n")
_raw_text(p, f"Αντικειμενα: {total_items}\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΣΥΝΟΛΟ: {total_value:.2f}e\n")
p._raw(b'\x1b\x21\x00')
elif len(blocks) == 1:
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΣΥΝΟΛΟ: {blocks[0]['total']:.2f}e\n")
p._raw(b'\x1b\x21\x00')
# Optional product breakdown section
if report.get("item_breakdown") and blocks:
_printer_item_breakdown(p, blocks, lw, div)
p._raw(b'\n\n\n')
p.cut()
@@ -692,7 +893,7 @@ def print_printer_report(ip: str, port: int, report: dict, mode: str):
logger.error("print_printer_report failed for %s:%s%s", ip, port, e)
def print_order_receipt(ip: str, port: int, receipt: dict):
def print_order_receipt(ip: str, port: int, receipt: dict, line_width: int = LINE_WIDTH):
"""Print a manager-triggered order receipt."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping order receipt print")
@@ -704,7 +905,7 @@ def print_order_receipt(ip: str, port: int, receipt: dict):
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΠΑΡΑΓΓΕΛΙΑ #{receipt['order_id']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
_divider(p, line_width=line_width)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
@@ -714,21 +915,21 @@ def print_order_receipt(ip: str, port: int, receipt: dict):
if receipt.get("closed_at"):
_raw_text(p, f"Εκλεισε: {receipt['closed_at']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
_divider(p, line_width=line_width)
for item in receipt.get("items", []):
p._raw(b'\x1b\x21\x10')
_raw_text(p, _item_line(item["name"], item["quantity"]) + "\n")
_raw_text(p, _item_line(item["name"], item["quantity"], line_width=line_width) + "\n")
p._raw(b'\x1b\x21\x00')
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
_divider(p)
_divider(p, line_width=line_width)
if receipt.get("notes"):
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Σημ: {receipt['notes']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
_divider(p, line_width=line_width)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
@@ -742,7 +943,7 @@ def print_order_receipt(ip: str, port: int, receipt: dict):
logger.error("print_order_receipt failed for %s:%s%s", ip, port, e)
def print_order_synopsis(ip: str, port: int, synopsis: dict):
def print_order_synopsis(ip: str, port: int, synopsis: dict, line_width: int = LINE_WIDTH):
"""Print a waiter-triggered order synopsis (not a kitchen ticket)."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping order synopsis print")
@@ -754,7 +955,7 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict):
p._raw(b'\x1b\x21\x30')
_raw_text(p, "ΣΥΝΟΨΗ ΠΑΡΑΓΓΕΛΙΑΣ\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
_divider(p, line_width=line_width)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
@@ -762,7 +963,7 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict):
_raw_text(p, f"Σερβιτορος: {synopsis['waiter_name']}\n")
_raw_text(p, f"Ωρα: {synopsis['opened_at']}\n")
p._raw(b'\x1b\x21\x00')
_divider(p)
_divider(p, line_width=line_width)
paid_items = [i for i in synopsis.get("items", []) if i["status"] == "paid"]
active_items = [i for i in synopsis.get("items", []) if i["status"] == "active"]
@@ -772,18 +973,18 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict):
_raw_text(p, "ΕΚΚΡΕΜΗ:\n")
p._raw(b'\x1b\x21\x00')
for item in active_items:
_raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n")
_raw_text(p, f" {_item_line(item['name'], item['quantity'], line_width=line_width - 2)}\n")
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
_divider(p)
_divider(p, line_width=line_width)
if paid_items:
p._raw(b'\x1b\x21\x10')
_raw_text(p, "ΠΛΗΡΩΜΕΝΑ:\n")
p._raw(b'\x1b\x21\x00')
for item in paid_items:
_raw_text(p, f" {_item_line(item['name'], item['quantity'])}\n")
_raw_text(p, f" {_item_line(item['name'], item['quantity'], line_width=line_width - 2)}\n")
_raw_text(p, f" {item['unit_price']:.2f}e x{item['quantity']} = {item['total']:.2f}e\n")
_divider(p)
_divider(p, line_width=line_width)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
@@ -801,6 +1002,189 @@ def print_order_synopsis(ip: str, port: int, synopsis: dict):
logger.error("print_order_synopsis failed for %s:%s%s", ip, port, e)
# ── Analytical report prints (products / categories / tables) ─────────────────
def print_products_report(ip: str, port: int, report: dict):
"""Print a product sales report. mode='smart'(sold only) or 'full'(all products)."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping products report print")
return
try:
lw = report.get("line_width", LINE_WIDTH)
div = report.get("div_style", "dash")
mode = report.get("mode", "smart")
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
title = "ΑΝΑΦΟΡΑ ΠΡΟΙΟΝΤΩΝ" if mode == "smart" else "ΠΛΗΡΗΣ ΑΝΑΛΥΣΗ ΠΡΟΙΟΝΤΩΝ"
_raw_text(p, title + "\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
period = report.get("period_label", "")
if report.get("period_is_workday"):
_raw_text(p, f"Εργασιμη: {period}\n")
else:
_raw_text(p, f"Περιοδος: {period}\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
items = report.get("items", [])
# Only items with sales contribute to pct denominators
sold_items = [x for x in items if x["qty"] > 0]
total_qty = sum(x["qty"] for x in sold_items)
total_rev = sum(x["revenue"] for x in sold_items)
for item in items:
if item["qty"] == 0 and mode == "smart":
continue
left = item["name"]
right = f"{item['qty']} τεμ."
_raw_text(p, _dot_leader_line(left, right, lw) + "\n")
pct_rev = round(item["revenue"] / total_rev * 100, 1) if total_rev else 0
pct_qty = round(item["qty"] / total_qty * 100, 1) if total_qty else 0
_raw_text(p, f" {item['revenue']:.2f}e | {pct_rev}% εσ. {pct_qty}% τεμ.\n")
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Συνολο τεμ.: {total_qty}\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΑΞΙΑ: {total_rev:.2f}e\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n')
p.cut()
p.close()
except Exception as e:
logger.error("print_products_report failed for %s:%s%s", ip, port, e)
def print_categories_report(ip: str, port: int, report: dict):
"""Print a category sales report."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping categories report print")
return
try:
lw = report.get("line_width", LINE_WIDTH)
div = report.get("div_style", "dash")
mode = report.get("mode", "smart")
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "ΑΝΑΦΟΡΑ ΚΑΤΗΓΟΡΙΩΝ\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
period = report.get("period_label", "")
if report.get("period_is_workday"):
_raw_text(p, f"Εργασιμη: {period}\n")
else:
_raw_text(p, f"Περιοδος: {period}\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
categories = report.get("categories", [])
total_rev = sum(c["revenue"] for c in categories)
total_qty = sum(c["units_sold"] for c in categories)
for cat in categories:
_divider(p, div, lw)
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"{cat['name']}\n")
p._raw(b'\x1b\x21\x00')
_raw_text(p, _dot_leader_line("Τεμαχια", str(cat["units_sold"]), lw) + "\n")
_raw_text(p, _dot_leader_line("Εσοδα", f"{cat['revenue']:.2f}e", lw) + "\n")
pct_rev = cat.get("pct_rev", cat.get("pct", 0))
pct_qty = cat.get("pct_qty", 0)
_raw_text(p, _dot_leader_line("% Εσοδων", f"{pct_rev}%", lw) + "\n")
_raw_text(p, _dot_leader_line("% Τεμαχιων", f"{pct_qty}%", lw) + "\n")
if mode == "full" and cat.get("products"):
for prod in cat["products"]:
left = f" {prod['name']}"
right = f"{prod['qty']} | {prod.get('pct_rev', 0)}%E {prod.get('pct_qty', 0)}%T"
_raw_text(p, _dot_leader_line(left, right, lw) + "\n")
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Συνολο τεμ.: {total_qty}\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΑΞΙΑ: {total_rev:.2f}e\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n')
p.cut()
p.close()
except Exception as e:
logger.error("print_categories_report failed for %s:%s%s", ip, port, e)
def print_tables_report(ip: str, port: int, report: dict):
"""Print a table performance report."""
if is_spoof_mode():
logger.info("Spoof printing ON — dropping tables report print")
return
try:
lw = report.get("line_width", LINE_WIDTH)
div = report.get("div_style", "dash")
mode = report.get("mode", "smart")
p = _get_printer(ip, port)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x30')
_raw_text(p, "ΑΝΑΦΟΡΑ ΤΡΑΠΕΖΙΩΝ\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
p._raw(b'\x1b\x61\x00')
p._raw(b'\x1b\x21\x10')
period = report.get("period_label", "")
if report.get("period_is_workday"):
_raw_text(p, f"Εργασιμη: {period}\n")
else:
_raw_text(p, f"Περιοδος: {period}\n")
p._raw(b'\x1b\x21\x00')
_divider(p, div, lw)
tables = report.get("tables", [])
total_rev = sum(t["revenue"] for t in tables)
total_orders = sum(t["order_count"] for t in tables)
for t in tables:
left = t["name"]
right = f"{t['revenue']:.2f}e"
_raw_text(p, _dot_leader_line(left, right, lw) + "\n")
if mode == "full":
avg = t["revenue"] / t["order_count"] if t["order_count"] else 0
_raw_text(p, f" {t['order_count']} παρ. / μ.ο. {avg:.2f}e\n")
if t.get("avg_duration_minutes") is not None:
_raw_text(p, f" Μ. διαρκεια: {t['avg_duration_minutes']:.0f} λεπτα\n")
_divider(p, div, lw)
p._raw(b'\x1b\x61\x01')
p._raw(b'\x1b\x21\x10')
_raw_text(p, f"Παραγγελιες: {total_orders}\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\x1b\x21\x30')
_raw_text(p, f"ΑΞΙΑ: {total_rev:.2f}e\n")
p._raw(b'\x1b\x21\x00')
p._raw(b'\n\n\n')
p.cut()
p.close()
except Exception as e:
logger.error("print_tables_report failed for %s:%s%s", ip, port, e)
# ── Routing logic ────────────────────────────────────────────────────────────
def route_and_print(order_id: int, item_ids: List[int]):
@@ -874,7 +1258,7 @@ def _do_route_and_print(order_id: int, item_ids: List[int], db: Session) -> List
error_msg = None
try:
p = _get_printer(printer.ip_address, printer.port)
_print_kitchen_ticket(p, order, zone_items, db)
_print_kitchen_ticket(p, order, zone_items, db, printer.line_width)
p.close()
success = True
for item in zone_items:

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

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,119 @@
# -*- 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
import models.user # noqa: F401
import models.table # noqa: F401
import models.printer # noqa: F401
import models.product # noqa: F401
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
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

@@ -10,6 +10,15 @@ import OrderDetailPage from './pages/OrderDetailPage'
import ManagementPage from './pages/ManagementPage'
import ReportsPage from './pages/reports/ReportsPage'
import SettingsPage from './pages/Settings/SettingsPage'
import OnlineOrdersPage from './pages/OnlineOrdersPage'
import NotesPage from './pages/NotesPage'
import ContactsPage from './pages/ContactsPage'
import ExpensesPage from './pages/ExpensesPage'
import CustomersPage from './pages/CustomersPage'
import TabsPage from './pages/TabsPage'
import WastePage from './pages/WastePage'
import KdsPage from './pages/KdsPage'
import SchedulePage from './pages/SchedulePage'
import client from './api/client'
function Spinner() {
@@ -80,6 +89,15 @@ export default function App() {
<Route path="tables" element={<TablesPage />} />
<Route path="orders/:orderId" element={<OrderDetailPage />} />
<Route path="management" element={<ManagementPage />} />
<Route path="notes" element={<NotesPage />} />
<Route path="contacts" element={<ContactsPage />} />
<Route path="expenses" element={<ExpensesPage />} />
<Route path="customers" element={<CustomersPage />} />
<Route path="tabs" element={<TabsPage />} />
<Route path="waste" element={<WastePage />} />
<Route path="kds" element={<KdsPage />} />
<Route path="schedule" element={<SchedulePage />} />
<Route path="online-orders" element={<OnlineOrdersPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>

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

@@ -1,17 +1,60 @@
import { NavLink } from 'react-router-dom'
import { useState } from 'react'
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft } from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser, Users, CreditCard, Trash2, ChefHat, CalendarDays } from 'lucide-react'
import { getIncomingOrders } from '../api/client'
import { isFeatureEnabled } from '../hooks/usePhase2Features'
const NAV = [
{ to: '/dashboard', icon: BarChart2, label: 'Dashboard' },
{ to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
{ to: '/management', icon: Package, label: 'Διαχείριση' },
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
]
// Phase 2 feature IDs mapped to their sidebar routes (must match usePhase2Features defs)
const PHASE2_ROUTES = new Set(['/notes', '/expenses', '/contacts', '/customers', '/tabs', '/waste', '/kds', '/schedule'])
export default function Sidebar() {
const [collapsed, setCollapsed] = useState(false)
const [pendingCount, setPendingCount] = useState(0)
const [, featTick] = useState(0)
const pollRef = useRef(null)
useEffect(() => {
async function fetchPending() {
try {
const { data } = await getIncomingOrders()
setPendingCount(data.length)
} catch { /* silently ignore — sidebar badge is 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)
}
}, [])
const ALL_NAV = [
{ to: '/dashboard', icon: BarChart2, label: 'Dashboard' },
{ to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
{ to: '/online-orders', icon: ShoppingBag, label: 'Online Orders', badge: pendingCount, phase2: 'online-orders' },
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
{ to: '/management', icon: Package, label: 'Διαχείριση' },
{ to: '/notes', icon: NotebookPen, label: 'Σημειώσεις', phase2: 'notes' },
{ to: '/expenses', icon: Receipt, label: 'Έξοδα', phase2: 'expenses' },
{ to: '/contacts', icon: BookUser, label: 'Επαφές', phase2: 'contacts' },
{ to: '/customers', icon: Users, label: 'Πελάτες', phase2: 'customers' },
{ to: '/tabs', icon: CreditCard, label: 'Καρτέλες', phase2: 'tabs' },
{ to: '/waste', icon: Trash2, label: 'Αποβλήτα', phase2: 'waste' },
{ to: '/kds', icon: ChefHat, label: 'KDS', phase2: 'kds' },
{ to: '/schedule', icon: CalendarDays, label: 'Πρόγραμμα', phase2: 'schedule' },
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
]
// Filter out disabled Phase 2 entries
const NAV = ALL_NAV.filter(item => !item.phase2 || isFeatureEnabled(item.phase2))
return (
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
@@ -28,7 +71,7 @@ export default function Sidebar() {
</div>
<nav className="flex-1 py-4 space-y-1 px-2">
{NAV.map(({ to, icon: Icon, label }) => (
{NAV.map(({ to, icon: Icon, label, badge }) => (
<NavLink
key={to}
to={to}
@@ -37,7 +80,14 @@ export default function Sidebar() {
(isActive ? 'bg-primary-600 text-white' : 'text-primary-100 hover:bg-primary-700')
}
>
<Icon size={20} className="shrink-0" />
<div className="relative shrink-0">
<Icon size={20} />
{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>
{!collapsed && <span className="text-sm">{label}</span>}
</NavLink>
))}

View File

@@ -0,0 +1,906 @@
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' })
}
function roleBadge(role) {
if (role === 'manager' || role === 'sysadmin') {
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,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

@@ -0,0 +1,218 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
const TYPE_LABELS = {
supplier: 'Προμηθευτής',
staff: 'Προσωπικό',
utility: 'Κοινή Χρεία',
other: 'Άλλο',
}
const TYPE_COLORS = {
supplier: { bg: '#eff6ff', color: '#1d4ed8' },
staff: { bg: '#f0fdf4', color: '#15803d' },
utility: { bg: '#fefce8', color: '#a16207' },
other: { bg: '#f3f4f6', color: '#374151' },
}
const EMPTY_FORM = { name: '', type: 'supplier', phone: '', email: '', address: '', notes: '' }
function ContactModal({ initial, onClose, onSave, isPending }) {
const [form, setForm] = useState(initial ?? EMPTY_FORM)
const f = (k, v) => setForm(p => ({ ...p, [k]: v }))
const isNew = !initial?.id
const canSave = form.name.trim()
const inputStyle = {
width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb',
borderRadius: 8, fontSize: 13.5, outline: 'none', fontFamily: 'inherit',
background: 'white',
}
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 50, padding: 20,
}}>
<div style={{
background: 'white', borderRadius: 16, width: '100%', maxWidth: 520,
boxShadow: '0 20px 60px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column',
}}>
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{isNew ? 'Νέα Επαφή' : 'Επεξεργασία Επαφής'}</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}></button>
</div>
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Όνομα *</label>
<input style={inputStyle} value={form.name} onChange={e => f('name', e.target.value)} autoFocus placeholder="π.χ. Νίκος Γαλακτοπωλείο" />
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τύπος</label>
<select style={inputStyle} value={form.type} onChange={e => f('type', e.target.value)}>
{Object.entries(TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τηλέφωνο</label>
<input style={inputStyle} value={form.phone} onChange={e => f('phone', e.target.value)} placeholder="π.χ. 6912345678" />
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Email</label>
<input style={inputStyle} type="email" value={form.email} onChange={e => f('email', e.target.value)} placeholder="info@example.com" />
</div>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Διεύθυνση</label>
<input style={inputStyle} value={form.address} onChange={e => f('address', e.target.value)} placeholder="Οδός, Πόλη" />
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημειώσεις</label>
<textarea style={{ ...inputStyle, resize: 'none' }} rows={3} value={form.notes} onChange={e => f('notes', e.target.value)} placeholder="Εσωτερικές σημειώσεις…" />
</div>
</div>
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
<button
onClick={() => onSave(form)}
disabled={!canSave || isPending}
style={{
padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600,
cursor: canSave && !isPending ? 'pointer' : 'default',
background: canSave ? '#111827' : '#e5e7eb',
color: canSave ? 'white' : '#9ca3af',
}}
>
{isPending ? 'Αποθήκευση…' : isNew ? 'Δημιουργία' : 'Αποθήκευση'}
</button>
</div>
</div>
</div>
)
}
export default function ContactsPage() {
const qc = useQueryClient()
const [modal, setModal] = useState(null) // null | { contact } | { contact: null } for new
const { data: contacts = [], isLoading } = useQuery({
queryKey: ['contacts'],
queryFn: () => client.get('/api/contacts/').then(r => r.data),
staleTime: 30_000,
})
const save = useMutation({
mutationFn: ({ form, id }) => id
? client.put(`/api/contacts/${id}`, form)
: client.post('/api/contacts/', form),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contacts'] })
setModal(null)
toast.success('Αποθηκεύτηκε')
},
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const deactivate = useMutation({
mutationFn: (id) => client.delete(`/api/contacts/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contacts'] })
toast.success('Η επαφή αποενεργοποιήθηκε')
},
onError: () => toast.error('Σφάλμα'),
})
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, background: 'white' }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Επαφές / Προμηθευτές</h1>
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>{contacts.length} ενεργές επαφές</p>
</div>
<button
onClick={() => setModal({ contact: null })}
style={{ padding: '9px 18px', borderRadius: 9, border: 'none', background: '#111827', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
>
+ Νέα Επαφή
</button>
</div>
{/* Table */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 28px' }}>
{isLoading && <div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0' }}>Φόρτωση</div>}
{!isLoading && contacts.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0', fontSize: 14 }}>
Δεν υπάρχουν επαφές ακόμα. Προσθέστε τον πρώτο προμηθευτή.
</div>
)}
{contacts.length > 0 && (
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
<thead>
<tr style={{ background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
{['Όνομα', 'Τύπος', 'Τηλέφωνο', 'Email', ''].map(h => (
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{contacts.map((c, i) => {
const tc = TYPE_COLORS[c.type] || TYPE_COLORS.other
return (
<tr key={c.id} style={{ borderBottom: i < contacts.length - 1 ? '1px solid #f3f4f6' : 'none', background: 'white' }}>
<td style={{ padding: '12px 16px', fontWeight: 600, color: '#111315' }}>
{c.name}
{c.notes && <div style={{ fontSize: 11.5, color: '#9ca3af', fontWeight: 400, marginTop: 1 }}>{c.notes.slice(0, 60)}{c.notes.length > 60 ? '…' : ''}</div>}
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: tc.bg, color: tc.color }}>
{TYPE_LABELS[c.type] || c.type}
</span>
</td>
<td style={{ padding: '12px 16px', color: c.phone ? '#374151' : '#d1d5db' }}>{c.phone || '—'}</td>
<td style={{ padding: '12px 16px', color: c.email ? '#374151' : '#d1d5db' }}>{c.email || '—'}</td>
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button
onClick={() => setModal({ contact: c })}
style={{ padding: '4px 12px', borderRadius: 6, border: '1px solid #e5e7eb', background: 'white', fontSize: 12, fontWeight: 500, cursor: 'pointer', color: '#374151' }}
>
Επεξεργασία
</button>
<button
onClick={() => { if (window.confirm(`Αποενεργοποίηση "${c.name}";`)) deactivate.mutate(c.id) }}
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #fecaca', background: 'white', fontSize: 12, cursor: 'pointer', color: '#ef4444' }}
>
Απενεργ.
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
{modal && (
<ContactModal
initial={modal.contact ? { ...modal.contact, phone: modal.contact.phone || '', email: modal.contact.email || '', address: modal.contact.address || '', notes: modal.contact.notes || '' } : null}
onClose={() => setModal(null)}
isPending={save.isPending}
onSave={(form) => save.mutate({
form: { name: form.name, type: form.type, phone: form.phone || null, email: form.email || null, address: form.address || null, notes: form.notes || null },
id: modal.contact?.id,
})}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,350 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } 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 fmtDate(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function fmtDateTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
const ORDER_STATUS_LABELS = {
closed: 'Κλειστή', paid: 'Πληρωμένη', open: 'Ανοιχτή',
partially_paid: 'Μερική πλ.', cancelled: 'Ακυρωμένη',
}
const ORDER_STATUS_COLORS = {
closed: '#6b7280', paid: '#16a34a', open: '#3b82f6',
partially_paid: '#d97706', cancelled: '#ef4444',
}
const inputStyle = {
width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb',
borderRadius: 8, fontSize: 13.5, outline: 'none', fontFamily: 'inherit', background: 'white',
}
// ── Customer modal (create / edit) ───────────────────────────────────────────
const EMPTY_FORM = { name: '', nickname: '', phone: '', email: '', notes: '' }
function CustomerModal({ initial, onClose, onSave, isPending }) {
const [form, setForm] = useState(initial ?? EMPTY_FORM)
const f = (k, v) => setForm(p => ({ ...p, [k]: v }))
const isNew = !initial?.id
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, padding: 20 }}>
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 480, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{isNew ? 'Νέος Πελάτης' : 'Επεξεργασία Πελάτη'}</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}></button>
</div>
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Όνομα *</label>
<input style={inputStyle} value={form.name} onChange={e => f('name', e.target.value)} autoFocus placeholder="π.χ. Γιώργος Παπαδόπουλος" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Παρατσούκλι</label>
<input style={inputStyle} value={form.nickname} onChange={e => f('nickname', e.target.value)} placeholder="π.χ. Μεγάλος Γιάννης" />
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τηλέφωνο</label>
<input style={inputStyle} value={form.phone} onChange={e => f('phone', e.target.value)} placeholder="π.χ. 6912345678" />
</div>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Email</label>
<input style={inputStyle} type="email" value={form.email} onChange={e => f('email', e.target.value)} placeholder="email@example.com" />
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημειώσεις <span style={{ fontWeight: 400, color: '#9ca3af' }}>(αλλεργίες, προτιμήσεις)</span></label>
<textarea style={{ ...inputStyle, resize: 'none' }} rows={3} value={form.notes} onChange={e => f('notes', e.target.value)} placeholder="π.χ. Αλλεργία στους ξηρούς καρπούς, VIP πελάτης…" />
</div>
</div>
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
<button
onClick={() => onSave(form)}
disabled={!form.name.trim() || isPending}
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: form.name.trim() ? 'pointer' : 'default', background: form.name.trim() ? '#111827' : '#e5e7eb', color: form.name.trim() ? 'white' : '#9ca3af' }}
>
{isPending ? 'Αποθήκευση…' : isNew ? 'Δημιουργία' : 'Αποθήκευση'}
</button>
</div>
</div>
</div>
)
}
// ── Customer detail panel ─────────────────────────────────────────────────────
function CustomerDetail({ customer, onEdit, onClose }) {
const { data, isLoading } = useQuery({
queryKey: ['customer-orders', customer.id],
queryFn: () => client.get(`/api/customers/${customer.id}/orders`).then(r => r.data),
staleTime: 30_000,
})
const { data: tabsData = [] } = useQuery({
queryKey: ['customer-tabs', customer.id],
queryFn: () => client.get(`/api/tabs/customer/${customer.id}`).then(r => r.data),
staleTime: 30_000,
})
const openTab = tabsData.find(t => t.status === 'open')
const orders = data?.orders || []
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'white', borderLeft: '1px solid #f0f0ef' }}>
{/* Header */}
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<div style={{ fontSize: 18, fontWeight: 800, color: '#111315' }}>{customer.name}</div>
{customer.nickname && <div style={{ fontSize: 13, color: '#9ca3af', marginTop: 2 }}>«{customer.nickname}»</div>}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={onEdit} style={{ padding: '5px 12px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 12, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>Επεξεργασία</button>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af', padding: '0 4px' }}></button>
</div>
</div>
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 8, padding: '14px 22px', flexShrink: 0, borderBottom: '1px solid #f0f0ef' }}>
{[
{ label: 'Επισκέψεις', value: customer.visit_count },
{ label: 'Σύνολο εξόδων', value: fmt(customer.total_spent) },
{ label: 'Μέσος λογ.', value: customer.visit_count > 0 ? fmt(customer.total_spent / customer.visit_count) : '—' },
].map(s => (
<div key={s.label} style={{ background: '#f9fafb', borderRadius: 10, padding: '10px 12px', textAlign: 'center' }}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{s.label}</div>
<div style={{ fontSize: 16, fontWeight: 800, color: '#111315', marginTop: 3 }}>{s.value}</div>
</div>
))}
</div>
{/* Open tab banner */}
{openTab && (
<div style={{ padding: '10px 22px', borderBottom: '1px solid #f0f0ef', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10 }}>
<div>
<div style={{ fontSize: 12.5, fontWeight: 700, color: '#dc2626' }}>Ανοιχτή Καρτέλα</div>
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
{openTab.entries.length} χρεώσεις · πληρωμένο {fmt(openTab.total_paid)}
</div>
</div>
<div style={{ fontSize: 18, fontWeight: 800, color: '#dc2626' }}>{fmt(openTab.balance)}</div>
</div>
</div>
)}
{/* Contact info */}
<div style={{ padding: '12px 22px', borderBottom: '1px solid #f0f0ef', flexShrink: 0 }}>
{(customer.phone || customer.email || customer.notes) ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 13 }}>
{customer.phone && <div><span style={{ color: '#9ca3af', marginRight: 6 }}>📞</span>{customer.phone}</div>}
{customer.email && <div><span style={{ color: '#9ca3af', marginRight: 6 }}></span>{customer.email}</div>}
{customer.notes && (
<div style={{ marginTop: 4, padding: '8px 12px', background: '#fffbeb', borderRadius: 8, border: '1px solid #fef3c7', fontSize: 12.5, color: '#92400e', lineHeight: 1.5 }}>
📝 {customer.notes}
</div>
)}
</div>
) : (
<div style={{ fontSize: 12.5, color: '#d1d5db' }}>Δεν υπάρχουν στοιχεία επικοινωνίας.</div>
)}
</div>
{/* Visit history */}
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 22px' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>Ιστορικό Επισκέψεων</div>
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13 }}>Φόρτωση</div>}
{!isLoading && orders.length === 0 && (
<div style={{ color: '#d1d5db', fontSize: 13 }}>Δεν υπάρχουν επισκέψεις ακόμα.</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{orders.map(o => (
<div key={o.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid #f0f0ef', borderRadius: 10, background: 'white' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#111315' }}>
{o.table_name ? `Τραπέζι ${o.table_name}` : 'Χωρίς τραπέζι'}
{' '}
<span style={{ fontSize: 11, fontWeight: 700, padding: '1px 6px', borderRadius: 99, background: '#f3f4f6', color: ORDER_STATUS_COLORS[o.status] || '#6b7280' }}>
{ORDER_STATUS_LABELS[o.status] || o.status}
</span>
</div>
<div style={{ fontSize: 11.5, color: '#9ca3af', marginTop: 1 }}>
{fmtDateTime(o.opened_at)} · {o.item_count} αντικ.
</div>
</div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315', whiteSpace: 'nowrap' }}>{fmt(o.total)}</div>
</div>
))}
</div>
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function CustomersPage() {
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [selected, setSelected] = useState(null) // customer being viewed
const [modal, setModal] = useState(null) // null | { customer } | { customer: null }
const { data: customers = [], isLoading } = useQuery({
queryKey: ['customers', search],
queryFn: () => client.get('/api/customers/', { params: search ? { search } : {} }).then(r => r.data),
staleTime: 30_000,
})
const save = useMutation({
mutationFn: ({ form, id }) => id
? client.put(`/api/customers/${id}`, form)
: client.post('/api/customers/', form),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ['customers'] })
if (selected && res.data?.id === selected.id) {
setSelected(res.data)
}
setModal(null)
toast.success('Αποθηκεύτηκε')
},
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const deactivate = useMutation({
mutationFn: (id) => client.delete(`/api/customers/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['customers'] })
if (selected?.id) setSelected(null)
toast.success('Αποενεργοποιήθηκε')
},
onError: () => toast.error('Σφάλμα'),
})
const activeCount = customers.length
return (
<div style={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
{/* Left: list */}
<div style={{ display: 'flex', flexDirection: 'column', width: selected ? 360 : '100%', flexShrink: 0, borderRight: selected ? '1px solid #f0f0ef' : 'none', overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '18px 24px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Πελάτες</h1>
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>{activeCount} ενεργοί πελάτες</p>
</div>
<button
onClick={() => setModal({ customer: null })}
style={{ padding: '8px 16px', borderRadius: 9, border: 'none', background: '#111827', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
>
+ Νέος
</button>
</div>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Αναζήτηση με όνομα, παρατσούκλι ή τηλέφωνο…"
style={{ ...inputStyle, fontSize: 13 }}
/>
</div>
{/* List */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '24px 0' }}>Φόρτωση</div>}
{!isLoading && customers.length === 0 && (
<div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '32px 0' }}>
{search ? 'Δεν βρέθηκαν αποτελέσματα.' : 'Δεν υπάρχουν πελάτες ακόμα.'}
</div>
)}
{customers.map(c => (
<button
key={c.id}
onClick={() => setSelected(selected?.id === c.id ? null : c)}
style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px',
border: `1px solid ${selected?.id === c.id ? '#3b82f6' : '#f0f0ef'}`,
borderRadius: 10, background: selected?.id === c.id ? '#eff6ff' : 'white',
cursor: 'pointer', textAlign: 'left', width: '100%',
}}
>
{/* Avatar */}
<div style={{
width: 38, height: 38, borderRadius: '50%', flexShrink: 0,
background: `hsl(${[...c.name].reduce((a, ch) => a + ch.charCodeAt(0), 0) % 360},55%,62%)`,
color: 'white', fontSize: 15, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{c.name.trim()[0].toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315', display: 'flex', alignItems: 'center', gap: 6 }}>
{c.name}
{c.nickname && <span style={{ fontSize: 11, fontWeight: 500, color: '#9ca3af' }}>«{c.nickname}»</span>}
</div>
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
{c.phone || c.email || 'Χωρίς στοιχεία'}
{c.visit_count > 0 && <span style={{ marginLeft: 8, color: '#6b7280' }}>· {c.visit_count} επισκέψεις</span>}
</div>
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#111315', whiteSpace: 'nowrap', flexShrink: 0 }}>
{c.total_spent > 0 ? fmt(c.total_spent) : ''}
</div>
</button>
))}
</div>
</div>
{/* Right: detail panel */}
{selected && (
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<CustomerDetail
customer={selected}
onEdit={() => setModal({ customer: selected })}
onClose={() => setSelected(null)}
/>
</div>
)}
{modal && (
<CustomerModal
initial={modal.customer ? {
...modal.customer,
nickname: modal.customer.nickname || '',
phone: modal.customer.phone || '',
email: modal.customer.email || '',
notes: modal.customer.notes || '',
} : null}
onClose={() => setModal(null)}
isPending={save.isPending}
onSave={(form) => save.mutate({
form: {
name: form.name,
nickname: form.nickname || null,
phone: form.phone || null,
email: form.email || null,
notes: form.notes || null,
},
id: modal.customer?.id,
})}
/>
)}
</div>
)
}

View File

@@ -7,6 +7,7 @@ import Badge from '../ui/Badge'
import { ConfirmModal } from '../ui/Modal'
import { LicenseContext } from '../layouts/AppLayout'
import ShiftDetailModal from './reports/shared/ShiftDetailModal'
import WorkdaySummaryModal from '../components/WorkdaySummaryModal'
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -64,8 +65,8 @@ function Avatar({ name, avatarUrl, size = 36 }) {
// ─── Table color tokens ────────────────────────────────────────────────────────
const TABLE_COLORS = {
open: { bg: '#eef7f0', border: '#d7ecdc', dot: '#2f9e5e', text: '#1f7042', label: 'Ανοιχτό' },
partially_paid: { bg: '#f4eefb', border: '#e3d4f3', dot: '#7a44c9', text: '#57309a', label: 'Μερική πληρ.' },
open: { bg: '#bbf7d0', border: '#4ade80', dot: '#16a34a', text: '#14532d', label: 'Ανοιχτό' },
partially_paid: { bg: '#e9d5ff', border: '#a855f7', dot: '#7c3aed', text: '#4c1d95', label: 'Μερική πληρ.' },
free: { bg: '#f4f4f2', border: '#dfe2e6', dot: '#8a9099', text: '#5a6169', label: 'Ελεύθερο' },
}
@@ -259,7 +260,7 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div style={{
background: 'white', borderRadius: 20, width: '100%', maxWidth: 720,
background: 'white', borderRadius: 20, width: '100%', maxWidth: 800,
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
overflow: 'hidden',
@@ -387,17 +388,17 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
{/* Footer actions */}
{order && (
<div style={{
padding: '14px 22px', borderTop: '1px solid #edeff1',
display: 'flex', gap: 8, flexWrap: 'nowrap', flexShrink: 0,
padding: '12px 22px', borderTop: '1px solid #edeff1',
display: 'flex', gap: 8, flexWrap: 'wrap', flexShrink: 0,
background: '#fafafa', alignItems: 'center',
}}>
{isOpen && activeItems.length > 0 && (
<button
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
style={{
height: 36, padding: '0 16px', borderRadius: 8,
minHeight: 36, padding: '6px 16px', borderRadius: 8,
background: '#3758c9', border: 'none', color: 'white',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
fontSize: 13, fontWeight: 700, cursor: 'pointer', whiteSpace: 'nowrap',
}}
>Πληρωμή όλων</button>
)}
@@ -406,23 +407,23 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
<button
onClick={() => setConfirmAction({ type: 'closeOrder' })}
style={{
height: 36, padding: '0 14px', borderRadius: 8,
minHeight: 36, padding: '6px 14px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#2b2f33', fontSize: 13, fontWeight: 600, cursor: 'pointer',
color: '#2b2f33', fontSize: 13, fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
}}
>Κλείσιμο</button>
<button
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
style={{
height: 36, padding: '0 14px', borderRadius: 8,
minHeight: 36, padding: '6px 14px', borderRadius: 8,
border: '1px solid #fecaca', background: '#fef2f2',
color: '#dc2626', fontSize: 13, fontWeight: 600, cursor: 'pointer',
color: '#dc2626', fontSize: 13, fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap',
}}
>Ακύρωση</button>
</>
)}
{printers.length > 0 && (
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginLeft: 'auto' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexWrap: 'wrap' }}>
<select
value={printerId}
onChange={e => setPrinterId(e.target.value)}
@@ -439,9 +440,9 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
onClick={() => { if (printerId) printOrder.mutate(Number(printerId)) }}
disabled={!printerId}
style={{
height: 36, padding: '0 14px', borderRadius: 8,
minHeight: 36, padding: '6px 14px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#2b2f33', fontSize: 13, fontWeight: 600,
color: '#2b2f33', fontSize: 13, fontWeight: 600, whiteSpace: 'nowrap',
cursor: printerId ? 'pointer' : 'not-allowed', opacity: printerId ? 1 : 0.5,
}}
>🖨 Εκτύπωση</button>
@@ -450,9 +451,9 @@ function OrderQuickModal({ orderId, tableName, onClose, onOpenFull }) {
<button
onClick={onOpenFull}
style={{
height: 36, padding: '0 14px', borderRadius: 8,
minHeight: 36, padding: '6px 14px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#5a6169', fontSize: 12, fontWeight: 500, cursor: 'pointer',
color: '#5a6169', fontSize: 12, fontWeight: 500, cursor: 'pointer', whiteSpace: 'nowrap',
marginLeft: printers.length > 0 ? 0 : 'auto',
}}
>Πλήρης εικόνα </button>
@@ -547,53 +548,122 @@ function TableChip({ name, status, amount, onClick }) {
// ─── End shift confirmation modal ─────────────────────────────────────────────
function EndShiftConfirmModal({ shift, onClose, onConfirm, busy }) {
const [step, setStep] = useState(1) // 1 = notes, 2 = cash handover
const [notes, setNotes] = useState('')
const [countedCash, setCountedCash] = useState('')
const expectedHandover = (shift.starting_cash || 0) + (shift.total_collected || 0)
const counted = countedCash !== '' ? parseFloat(countedCash) : null
const discrepancy = counted != null ? counted - expectedHandover : null
const isShort = discrepancy != null && discrepancy < -0.005
const isOver = discrepancy != null && discrepancy > 0.005
const isClean = discrepancy != null && !isShort && !isOver
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4"
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<div style={{ width: 40, height: 40, borderRadius: '50%', background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 18 }}></span>
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>Τέλος βάρδιας;</div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>Τέλος βάρδιας</div>
<div style={{ fontSize: 13, color: '#5a6169', marginTop: 2 }}>{shift.waiter_name}</div>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
{[1, 2].map(n => (
<div key={n} style={{ width: 8, height: 8, borderRadius: '50%', background: step >= n ? '#3758c9' : '#e5e7eb' }} />
))}
</div>
</div>
<div style={{ background: '#fafafa', borderRadius: 12, padding: '12px 16px', fontSize: 13, color: '#374151' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
{/* Shift summary always visible */}
<div style={{ background: '#fafafa', borderRadius: 12, padding: '10px 14px', fontSize: 13, color: '#374151' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
<span>Έναρξη</span><span style={{ fontWeight: 600 }}>{fmtTime(shift.started_at)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
<span>Διάρκεια</span><span style={{ fontWeight: 600 }}>{fmtDuration(shift.started_at)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>Είσπραξη</span><span style={{ fontWeight: 700, color: '#2f9e5e' }}>{fmtEuro(shift.total_collected)}</span>
</div>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>
Σημειώσεις <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετικό)</span>
</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="π.χ. παρατηρήσεις για τη βάρδια…"
rows={2}
style={{ width: '100%', borderRadius: 8, border: '1px solid #e5e7eb', padding: '8px 10px', fontSize: 13, color: '#111315', resize: 'vertical', outline: 'none', boxSizing: 'border-box' }}
onFocus={e => e.target.style.borderColor = '#6366f1'}
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
<p style={{ fontSize: 12, color: '#8a9099' }}>Μετά την επιβεβαίωση θα εμφανιστεί η αναλυτική σύνοψη βάρδιας.</p>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Άκυρο</button>
<button onClick={() => onConfirm(notes)} disabled={busy}
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60">
{busy ? 'Κλείσιμο…' : 'Τέλος Βάρδιας'}
</button>
</div>
{/* Step 1: notes */}
{step === 1 && (
<>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>
Σημειώσεις <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετικό)</span>
</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="π.χ. παρατηρήσεις για τη βάρδια…"
rows={2}
style={{ width: '100%', borderRadius: 8, border: '1px solid #e5e7eb', padding: '8px 10px', fontSize: 13, color: '#111315', resize: 'vertical', outline: 'none', boxSizing: 'border-box' }}
/>
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50">Άκυρο</button>
<button onClick={() => setStep(2)} className="flex-1 h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700">
Συνέχεια
</button>
</div>
</>
)}
{/* Step 2: cash handover */}
{step === 2 && (
<>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>
Πόσα μετρητά παραδίδει ο σερβιτόρος; <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετικό)</span>
</label>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: '#6b7280', marginBottom: 6 }}>
<span>Αναμενόμενη παράδοση:</span>
<span style={{ fontWeight: 700, color: '#111315' }}>{fmtEuro(expectedHandover)}</span>
</div>
<input
type="number"
step="0.01"
min="0"
value={countedCash}
onChange={e => setCountedCash(e.target.value)}
placeholder={fmtEuro(expectedHandover).replace('€', '')}
autoFocus
style={{ width: '100%', borderRadius: 8, border: '1px solid #e5e7eb', padding: '8px 10px', fontSize: 14, fontWeight: 600, color: '#111315', outline: 'none', boxSizing: 'border-box' }}
/>
{/* Live discrepancy feedback */}
{discrepancy != null && (
<div style={{
marginTop: 8, padding: '8px 12px', borderRadius: 8,
background: isClean ? '#f0fdf4' : '#fef2f2',
border: `1px solid ${isClean ? '#bbf7d0' : '#fecaca'}`,
fontSize: 12.5, fontWeight: 600,
color: isClean ? '#16a34a' : '#dc2626',
}}>
{isClean && '✓ Ταμείο εντάξει — παραδίδει το σωστό ποσό'}
{isShort && `⚠ Ελλείμμα ${fmtEuro(Math.abs(discrepancy))}ο σερβιτόρος παραδίδει λιγότερα`}
{isOver && `⚠ Πλεόνασμα ${fmtEuro(Math.abs(discrepancy))}ο σερβιτόρος παραδίδει περισσότερα`}
</div>
)}
</div>
<div className="flex gap-3">
<button onClick={() => setStep(1)} className="flex-1 h-10 px-4 rounded-lg border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50"> Πίσω</button>
<button
onClick={() => onConfirm(notes, countedCash !== '' ? parseFloat(countedCash) : null)}
disabled={busy}
className="flex-1 h-10 px-4 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-60"
>
{busy ? 'Κλείσιμο…' : 'Τέλος Βάρδιας'}
</button>
</div>
</>
)}
</div>
</div>
)
@@ -792,32 +862,412 @@ function RevenueChart({ orders }) {
)
}
// ─── Reservations stub ────────────────────────────────────────────────────────
// ─── Reservations ─────────────────────────────────────────────────────────────
const STATUS_META = {
pending: { label: 'Αναμονή', bg: '#f0f4ff', color: '#3758c9' },
arrived: { label: 'Έφτασε', bg: '#eef7f0', color: '#1f7042' },
cancelled: { label: 'Ακυρώθηκε', bg: '#fef2f2', color: '#dc2626' },
no_show: { label: 'Δεν ήρθε', bg: '#fff7ed', color: '#c2410c' },
}
function ReservationRow({ res, tables, onStatusChange, onEdit }) {
const meta = STATUS_META[res.status] || STATUS_META.pending
const time = new Date(res.reserved_for).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
const dateStr = new Date(res.reserved_for).toLocaleDateString('el-GR', { day: 'numeric', month: 'short' })
const tableLabel = res.table_label || (res.table_id ? `#${res.table_id}` : null)
function ReservationsCard() {
return (
<div style={{
background: 'white', border: '1px solid #edeff1',
borderRadius: 16, overflow: 'hidden',
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 16px',
borderBottom: '1px solid #f3f4f6',
}}>
{/* Time block */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px', borderBottom: '1px solid #edeff1',
minWidth: 44, textAlign: 'center', flexShrink: 0,
fontFamily: "'ui-monospace','SFMono-Regular','Menlo',monospace",
}}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315' }}>Κρατήσεις σήμερα</div>
<button style={{
height: 28, padding: '0 12px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#5a6169', fontSize: 12, fontWeight: 600, cursor: 'pointer',
}}>+ Νέα</button>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111315', lineHeight: 1 }}>{time}</div>
<div style={{ fontSize: 10, color: '#8a9099', marginTop: 2 }}>{dateStr}</div>
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 700, color: '#111315',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{res.guest_name}</div>
<div style={{ fontSize: 11, color: '#5a6169', marginTop: 2, display: 'flex', gap: 6 }}>
<span>👥 {res.party_size}</span>
{tableLabel && <span>🪑 {tableLabel}</span>}
{res.phone && <span>📞 {res.phone}</span>}
</div>
</div>
{/* Status pill */}
<span style={{
padding: '2px 8px', borderRadius: 999, fontSize: 10, fontWeight: 700, flexShrink: 0,
background: meta.bg, color: meta.color,
}}>{meta.label}</span>
{/* Actions */}
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button onClick={() => onEdit(res)} title="Επεξεργασία" style={{
width: 26, height: 26, borderRadius: 6, border: '1px solid #dfe2e6',
background: 'white', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}></button>
{res.status === 'pending' && (
<button onClick={() => onStatusChange(res.id, 'arrived')} title="Έφτασε" style={{
width: 26, height: 26, borderRadius: 6, border: '1px solid #bbf7d0',
background: '#eef7f0', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}></button>
)}
{(res.status === 'pending' || res.status === 'no_show') && (
<button onClick={() => onStatusChange(res.id, 'cancelled')} title="Ακύρωση" style={{
width: 26, height: 26, borderRadius: 6, border: '1px solid #fecaca',
background: '#fef2f2', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}></button>
)}
</div>
</div>
)
}
function ReservationsCard({ tables, onNew }) {
const [tab, setTab] = useState('upcoming')
const qc = useQueryClient()
const { data: reservations = [], isLoading } = useQuery({
queryKey: ['reservations', tab],
queryFn: () => client.get(`/api/reservations/?tab=${tab}`).then(r => r.data),
refetchInterval: 30_000,
})
const [editTarget, setEditTarget] = useState(null)
const statusMut = useMutation({
mutationFn: ({ id, status }) => client.patch(`/api/reservations/${id}/status`, { status }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['reservations'] })
qc.invalidateQueries({ queryKey: ['tables'] })
},
onError: () => toast.error('Σφάλμα ενημέρωσης'),
})
const tabStyle = (active) => ({
height: 30, padding: '0 14px', borderRadius: 8, border: 'none',
background: active ? '#f0f4ff' : 'transparent',
color: active ? '#3758c9' : '#5a6169',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
})
return (
<>
<div style={{
padding: '32px 20px', textAlign: 'center',
color: '#b8bdc4', fontSize: 13,
background: 'white', border: '1px solid #edeff1',
borderRadius: 16, overflow: 'hidden',
boxShadow: '0 1px 2px rgba(16,20,24,0.04)',
}}>
<div style={{ fontSize: 28, marginBottom: 8 }}>📅</div>
Σύντομα σύστημα κρατήσεων υπό ανάπτυξη
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 16px', borderBottom: '1px solid #edeff1',
}}>
<div style={{ display: 'flex', gap: 4 }}>
<button style={tabStyle(tab === 'upcoming')} onClick={() => setTab('upcoming')}>Επερχόμενες</button>
<button style={tabStyle(tab === 'history')} onClick={() => setTab('history')}>Ιστορικό</button>
</div>
<button onClick={onNew} style={{
height: 28, padding: '0 12px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
color: '#3758c9', fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}>+ Νέα</button>
</div>
{isLoading ? (
<div style={{ padding: '24px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>Φόρτωση</div>
) : reservations.length === 0 ? (
<div style={{ padding: '32px 20px', textAlign: 'center', color: '#b8bdc4', fontSize: 13 }}>
<div style={{ fontSize: 26, marginBottom: 8 }}>📅</div>
{tab === 'upcoming' ? 'Καμία επερχόμενη κράτηση' : 'Κανένα ιστορικό'}
</div>
) : (
<div style={{ maxHeight: 340, overflowY: 'auto' }}>
{reservations.map(r => (
<ReservationRow
key={r.id}
res={r}
tables={tables}
onStatusChange={(id, s) => statusMut.mutate({ id, status: s })}
onEdit={setEditTarget}
/>
))}
</div>
)}
</div>
{editTarget && (
<ReservationModal
tables={tables}
initial={editTarget}
onClose={() => setEditTarget(null)}
onSaved={() => {
setEditTarget(null)
qc.invalidateQueries({ queryKey: ['reservations'] })
qc.invalidateQueries({ queryKey: ['tables'] })
}}
/>
)}
</>
)
}
// ─── New / Edit Reservation Modal ─────────────────────────────────────────────
function toLocalISO(d) {
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`
}
function DateTimePicker({ value, onChange }) {
// value: Date object
const pad = n => String(n).padStart(2, '0')
const toLocal = (d) => {
const y = d.getFullYear()
const mo = pad(d.getMonth() + 1)
const dy = pad(d.getDate())
const h = pad(d.getHours())
const mi = pad(d.getMinutes())
return `${y}-${mo}-${dy}T${h}:${mi}`
}
const fromLocal = (s) => new Date(s)
const quickOffsets = [
{ label: '+30λ', mins: 30 },
{ label: '+1ω', mins: 60 },
{ label: '+2ω', mins: 120 },
{ label: '+3ω', mins: 180 },
{ label: '+5ω', mins: 300 },
{ label: '+1μ', days: 1 },
{ label: '+2μ', days: 2 },
{ label: '+7μ', days: 7 },
]
function applyOffset(offset) {
const base = new Date()
if (offset.mins) base.setMinutes(base.getMinutes() + offset.mins)
else if (offset.days) { base.setDate(base.getDate() + offset.days); base.setSeconds(0, 0) }
onChange(base)
}
return (
<div>
<input
type="datetime-local"
value={toLocal(value)}
onChange={e => onChange(fromLocal(e.target.value))}
style={{
width: '100%', height: 38, borderRadius: 8,
border: '1px solid #dfe2e6', padding: '0 12px',
fontSize: 14, fontFamily: 'inherit', marginBottom: 8,
}}
/>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{quickOffsets.map(o => (
<button key={o.label} type="button" onClick={() => applyOffset(o)} style={{
height: 28, padding: '0 10px', borderRadius: 6,
border: '1px solid #dfe2e6', background: '#f8f9fa',
fontSize: 12, fontWeight: 600, color: '#374151', cursor: 'pointer',
}}>{o.label}</button>
))}
</div>
</div>
)
}
function GroupedTableSelect({ tables, value, onChange }) {
// Group tables by group_id
const groups = {}
for (const t of tables) {
const key = t.group?.name || 'Χωρίς ζώνη'
if (!groups[key]) groups[key] = []
groups[key].push(t)
}
return (
<select
value={value ?? ''}
onChange={e => onChange(e.target.value ? Number(e.target.value) : null)}
style={{
width: '100%', height: 38, borderRadius: 8,
border: '1px solid #dfe2e6', padding: '0 12px',
fontSize: 14, fontFamily: 'inherit', background: 'white',
}}
>
<option value=""> Χωρίς τραπέζι </option>
{Object.entries(groups).map(([groupName, gtables]) => (
<optgroup key={groupName} label={groupName}>
{gtables.map(t => (
<option key={t.id} value={t.id}>
{t.label || `T${t.number}`}
</option>
))}
</optgroup>
))}
</select>
)
}
function ReservationModal({ tables, initial, onClose, onSaved }) {
const isEdit = !!initial?.id
const [guestName, setGuestName] = useState(initial?.guest_name ?? '')
const [partySize, setPartySize] = useState(initial?.party_size ?? 2)
const [phone, setPhone] = useState(initial?.phone ?? '')
const [email, setEmail] = useState(initial?.email ?? '')
const [note, setNote] = useState(initial?.note ?? '')
const [tableId, setTableId] = useState(initial?.table_id ?? null)
const [reservedFor, setReservedFor] = useState(
initial?.reserved_for ? new Date(initial.reserved_for) : new Date()
)
const saveMut = useMutation({
mutationFn: (payload) => isEdit
? client.patch(`/api/reservations/${initial.id}`, payload)
: client.post('/api/reservations/', payload),
onSuccess: () => { toast.success(isEdit ? 'Αποθηκεύτηκε!' : 'Κράτηση δημιουργήθηκε!'); onSaved() },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
function submit(e) {
e.preventDefault()
if (!guestName.trim()) return toast.error('Εισάγετε όνομα')
if (partySize < 1) return toast.error('Αριθμός ατόμων > 0')
saveMut.mutate({
guest_name: guestName.trim(),
party_size: partySize,
phone: phone.trim() || null,
email: email.trim() || null,
note: note.trim() || null,
table_id: tableId,
reserved_for: toLocalISO(reservedFor),
})
}
const labelStyle = { fontSize: 12, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6, display: 'block' }
const inputStyle = { width: '100%', height: 38, borderRadius: 8, border: '1px solid #dfe2e6', padding: '0 12px', fontSize: 14, fontFamily: 'inherit', boxSizing: 'border-box' }
return (
<div
onClick={e => { if (e.target === e.currentTarget) onClose() }}
style={{
position: 'fixed', inset: 0, zIndex: 9000,
background: 'rgba(0,0,0,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 16,
}}
>
<div style={{
background: 'white', borderRadius: 16, width: '100%', maxWidth: 520,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
maxHeight: '90vh', overflowY: 'auto',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '18px 24px', borderBottom: '1px solid #edeff1',
}}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>
{isEdit ? 'Επεξεργασία Κράτησης' : 'Νέα Κράτηση'}
</div>
<button onClick={onClose} style={{
width: 30, height: 30, borderRadius: 8, border: '1px solid #edeff1',
background: 'white', fontSize: 16, cursor: 'pointer', lineHeight: 1,
}}>×</button>
</div>
<form onSubmit={submit} style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Name + Party size on same row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12 }}>
<div>
<label style={labelStyle}>Όνομα</label>
<input
style={inputStyle}
value={guestName}
onChange={e => setGuestName(e.target.value)}
placeholder="Ονοματεπώνυμο πελάτη"
required
/>
</div>
<div>
<label style={labelStyle}>Άτομα</label>
<input
type="number" min={1} max={999}
style={{ ...inputStyle, width: 80 }}
value={partySize}
onChange={e => setPartySize(Number(e.target.value))}
/>
</div>
</div>
{/* Date/time */}
<div>
<label style={labelStyle}>Ημερομηνία & Ώρα</label>
<DateTimePicker value={reservedFor} onChange={setReservedFor} />
</div>
{/* Table */}
<div>
<label style={labelStyle}>Τραπέζι (προαιρετικό)</label>
<GroupedTableSelect tables={tables} value={tableId} onChange={setTableId} />
</div>
{/* Phone + Email */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={labelStyle}>Τηλέφωνο</label>
<input style={inputStyle} value={phone} onChange={e => setPhone(e.target.value)} placeholder="69xxxxxxxx" />
</div>
<div>
<label style={labelStyle}>Email</label>
<input style={inputStyle} type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="email@..." />
</div>
</div>
{/* Note */}
<div>
<label style={labelStyle}>Σημείωση</label>
<textarea
value={note}
onChange={e => setNote(e.target.value)}
placeholder="Εσωτερική σημείωση για το προσωπικό…"
style={{
width: '100%', minHeight: 70, borderRadius: 8,
border: '1px solid #dfe2e6', padding: '8px 12px',
fontSize: 14, fontFamily: 'inherit', resize: 'vertical', boxSizing: 'border-box',
}}
/>
</div>
{/* Footer */}
<div style={{
display: 'flex', justifyContent: 'flex-end', gap: 8,
paddingTop: 8, borderTop: '1px solid #edeff1',
}}>
<button type="button" onClick={onClose} style={{
height: 38, padding: '0 18px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
fontSize: 14, fontWeight: 600, cursor: 'pointer', color: '#374151',
}}>Άκυρο</button>
<button type="submit" disabled={saveMut.isPending} style={{
height: 38, padding: '0 22px', borderRadius: 8,
border: 'none', background: '#3758c9',
color: 'white', fontSize: 14, fontWeight: 700, cursor: 'pointer',
}}>{saveMut.isPending ? 'Αποθήκευση…' : isEdit ? 'Αποθήκευση' : 'Δημιουργία'}</button>
</div>
</form>
</div>
</div>
)
@@ -1133,8 +1583,6 @@ function PendingPrintsPanel({ pendingPrintOrders, onRetryAll, onRetrySingle, onV
export default function DashboardPage() {
const [showStartShift, setShowStartShift] = useState(false)
const [closeDetails, setCloseDetails] = useState(null)
const [forceClosing, setForceClosing] = useState(false)
const [retryingId, setRetryingId] = useState(null)
const [quickView, setQuickView] = useState(null)
const [licenseBlock, setLicenseBlock] = useState(null) // { code, message } when open is blocked
@@ -1145,6 +1593,9 @@ export default function DashboardPage() {
// Quick message to single waiter
const [messageWaiter, setMessageWaiter] = useState(null) // { id, name }
const [privacyMode, setPrivacyMode] = useState(() => localStorage.getItem('privacyMode') === 'true')
// Workday summary modal: 'close' = close-day flow, 'view' = read-only stats
const [workdaySummaryMode, setWorkdaySummaryMode] = useState(null)
const [showNewReservation, setShowNewReservation] = useState(false)
const navigate = useNavigate()
const qc = useQueryClient()
const license = useContext(LicenseContext)
@@ -1211,31 +1662,14 @@ export default function DashboardPage() {
openDayMut.mutate()
}
async function handleCloseDay(force = false) {
setForceClosing(force)
try {
await client.post('/api/business-day/close', { force })
toast.success('Ημέρα έκλεισε!')
setCloseDetails(null)
qc.invalidateQueries({ queryKey: ['business-day'] })
qc.invalidateQueries({ queryKey: ['active-shifts'] })
qc.invalidateQueries({ queryKey: ['orders-active'] })
qc.invalidateQueries({ queryKey: ['tables'] })
qc.invalidateQueries({ queryKey: ['messages-all'] })
} catch (e) {
const detail = e.response?.data?.detail
if (e.response?.status === 409 && detail?.open_orders) setCloseDetails(detail)
else toast.error(typeof detail === 'string' ? detail : 'Σφάλμα κλεισίματος')
} finally {
setForceClosing(false)
}
}
async function handleEndShiftConfirm(notes) {
async function handleEndShiftConfirm(notes, countedCashEnd) {
if (!endShiftTarget) return
setEndShiftBusy(true)
try {
await client.post(`/api/shifts/manager/end/${endShiftTarget.id}`, { notes: notes || null })
await client.post(`/api/shifts/manager/end/${endShiftTarget.id}`, {
notes: notes || null,
counted_cash_end: countedCashEnd ?? null,
})
qc.invalidateQueries({ queryKey: ['active-shifts'] })
const summaryTarget = { id: endShiftTarget.id, waiter_id: endShiftTarget.waiter_id }
setEndShiftTarget(null)
@@ -1375,11 +1809,18 @@ export default function DashboardPage() {
)}
</button>
{isOpen ? (
<button onClick={() => handleCloseDay(false)} style={{
height: 38, padding: '0 18px', borderRadius: 10,
background: '#dc2626', border: 'none', color: 'white',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}>Κλείσιμο Ημέρας</button>
<>
<button onClick={() => setWorkdaySummaryMode('view')} style={{
height: 38, padding: '0 14px', borderRadius: 10,
background: 'white', border: '1px solid #dfe2e6', color: '#374151',
fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}>📊 Στατιστικά Ημέρας</button>
<button onClick={() => setWorkdaySummaryMode('close')} style={{
height: 38, padding: '0 18px', borderRadius: 10,
background: '#dc2626', border: 'none', color: 'white',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}>Κλείσιμο Ημέρας</button>
</>
) : (
<button onClick={handleOpenDay} disabled={openDayMut.isPending} style={{
height: 38, padding: '0 18px', borderRadius: 10,
@@ -1515,7 +1956,7 @@ export default function DashboardPage() {
{/* ── Right column — Reservations + Messages ───────────────────────── */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<ReservationsCard />
<ReservationsCard tables={tables} onNew={() => setShowNewReservation(true)} />
<MessagesCard waiters={allWaiters} tables={tables} />
</div>
</div>
@@ -1555,12 +1996,31 @@ export default function DashboardPage() {
onSent={() => qc.invalidateQueries({ queryKey: ['messages-all'] })}
/>
)}
{closeDetails && (
<CloseConfirmModal
details={closeDetails}
onClose={() => setCloseDetails(null)}
onConfirm={() => handleCloseDay(true)}
busy={forceClosing}
{workdaySummaryMode && (
<WorkdaySummaryModal
showCloseAction={workdaySummaryMode === 'close'}
onClose={() => setWorkdaySummaryMode(null)}
onDayClosed={() => {
toast.success('Ημέρα έκλεισε!')
qc.invalidateQueries({ queryKey: ['business-day'] })
qc.invalidateQueries({ queryKey: ['active-shifts'] })
qc.invalidateQueries({ queryKey: ['orders-active'] })
qc.invalidateQueries({ queryKey: ['tables'] })
qc.invalidateQueries({ queryKey: ['messages-all'] })
setWorkdaySummaryMode(null)
}}
/>
)}
{showNewReservation && (
<ReservationModal
tables={tables}
initial={null}
onClose={() => setShowNewReservation(false)}
onSaved={() => {
setShowNewReservation(false)
qc.invalidateQueries({ queryKey: ['reservations'] })
qc.invalidateQueries({ queryKey: ['tables'] })
}}
/>
)}
{quickView && (

View File

@@ -0,0 +1,437 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
// ── Constants ─────────────────────────────────────────────────────────────────
const CATEGORIES = [
{ id: 'supplier', label: 'Προμηθευτής' },
{ id: 'utilities', label: 'Κοινή Χρεία' },
{ id: 'rent', label: 'Ενοίκιο' },
{ id: 'maintenance',label: 'Συντήρηση' },
{ id: 'staff', label: 'Προσωπικό' },
{ id: 'equipment', label: 'Εξοπλισμός' },
{ id: 'other', label: 'Άλλο' },
]
const STATUS_CONFIG = {
due: { label: 'Οφείλεται', bg: '#fee2e2', color: '#dc2626' },
partial: { label: 'Μερική Πληρ.', bg: '#fef9c3', color: '#a16207' },
paid: { label: 'Πληρώθηκε', bg: '#dcfce7', color: '#16a34a' },
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmt(n) {
if (n == null) return '—'
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
}
function fmtDate(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function fmtDateTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
const inputStyle = {
width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb',
borderRadius: 8, fontSize: 13.5, outline: 'none', fontFamily: 'inherit', background: 'white',
}
// ── Expense form modal ────────────────────────────────────────────────────────
const EMPTY_FORM = { description: '', category: 'supplier', contact_id: '', total_amount: '', due_date: '', notes: '' }
function ExpenseModal({ initial, contacts, onClose, onSave, isPending }) {
const [form, setForm] = useState(initial ?? EMPTY_FORM)
const f = (k, v) => setForm(p => ({ ...p, [k]: v }))
const isNew = !initial?.id
const canSave = form.description.trim() && parseFloat(form.total_amount) > 0
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, padding: 20 }}>
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 540, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>{isNew ? 'Νέο Έξοδο' : 'Επεξεργασία Εξόδου'}</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}></button>
</div>
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Περιγραφή *</label>
<input style={inputStyle} value={form.description} onChange={e => f('description', e.target.value)} autoFocus placeholder="π.χ. Τιμολόγιο Νοεμβρίου — Γαλακτοκομικά" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Κατηγορία</label>
<select style={inputStyle} value={form.category} onChange={e => f('category', e.target.value)}>
{CATEGORIES.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Ποσό () *</label>
<input style={inputStyle} type="number" step="0.01" min="0" value={form.total_amount} onChange={e => f('total_amount', e.target.value)} placeholder="0.00" />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Επαφή / Προμηθευτής</label>
<select style={inputStyle} value={form.contact_id} onChange={e => f('contact_id', e.target.value)}>
<option value=""> Χωρίς επαφή </option>
{contacts.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Ημ/νία πληρωμής</label>
<input style={inputStyle} type="date" value={form.due_date ? form.due_date.slice(0, 10) : ''} onChange={e => f('due_date', e.target.value)} />
</div>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημειώσεις</label>
<textarea style={{ ...inputStyle, resize: 'none' }} rows={2} value={form.notes} onChange={e => f('notes', e.target.value)} placeholder="Προαιρετικές σημειώσεις…" />
</div>
</div>
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
<button
onClick={() => onSave(form)}
disabled={!canSave || isPending}
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: canSave && !isPending ? 'pointer' : 'default', background: canSave ? '#111827' : '#e5e7eb', color: canSave ? 'white' : '#9ca3af' }}
>
{isPending ? 'Αποθήκευση…' : isNew ? 'Δημιουργία' : 'Αποθήκευση'}
</button>
</div>
</div>
</div>
)
}
// ── Pay modal ─────────────────────────────────────────────────────────────────
function PayModal({ expense, onClose, onPay, isPending }) {
const [amount, setAmount] = useState(String(Math.round((expense.due_amount) * 100) / 100))
const [notes, setNotes] = useState('')
const parsed = parseFloat(amount)
const canPay = parsed > 0 && parsed <= expense.due_amount + 0.001
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 20 }}>
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 400, boxShadow: '0 20px 60px rgba(0,0,0,0.18)' }}>
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef' }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 700 }}>Καταγραφή Πληρωμής</h2>
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#6b7280' }}>{expense.description}</p>
</div>
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ background: '#f9fafb', borderRadius: 10, padding: '12px 14px', display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: '#6b7280' }}>Εκκρεμεί</span>
<span style={{ fontSize: 15, fontWeight: 700, color: '#dc2626' }}>{fmt(expense.due_amount)}</span>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Ποσό πληρωμής ()</label>
<input style={inputStyle} type="number" step="0.01" min="0.01" max={expense.due_amount} value={amount} onChange={e => setAmount(e.target.value)} autoFocus />
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημείωση</label>
<input style={inputStyle} value={notes} onChange={e => setNotes(e.target.value)} placeholder="π.χ. Μετρητά στο χέρι" />
</div>
</div>
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
<button
onClick={() => onPay(parsed, notes || null)}
disabled={!canPay || isPending}
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: canPay && !isPending ? 'pointer' : 'default', background: canPay ? '#16a34a' : '#e5e7eb', color: canPay ? 'white' : '#9ca3af' }}
>
{isPending ? 'Αποθήκευση…' : 'Καταγραφή Πληρωμής'}
</button>
</div>
</div>
</div>
)
}
// ── Expense row (expandable) ──────────────────────────────────────────────────
function ExpenseRow({ expense, isExpanded, onToggle, onPay, onEdit, onDelete }) {
const sc = STATUS_CONFIG[expense.status] || STATUS_CONFIG.due
const catLabel = CATEGORIES.find(c => c.id === expense.category)?.label || expense.category
return (
<>
<tr
onClick={onToggle}
style={{ borderBottom: '1px solid #f3f4f6', cursor: 'pointer', background: isExpanded ? '#f9fafb' : 'white' }}
>
<td style={{ padding: '12px 16px' }}>
<div style={{ fontWeight: 600, color: '#111315', fontSize: 13.5 }}>{expense.description}</div>
{expense.contact_name && <div style={{ fontSize: 11.5, color: '#9ca3af', marginTop: 1 }}>{expense.contact_name}</div>}
</td>
<td style={{ padding: '12px 16px', fontSize: 12, color: '#6b7280' }}>{catLabel}</td>
<td style={{ padding: '12px 16px', fontSize: 13, fontWeight: 600, color: '#111315', fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}>{fmt(expense.total_amount)}</td>
<td style={{ padding: '12px 16px', fontSize: 13, color: '#16a34a', fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}>{expense.paid_amount > 0 ? fmt(expense.paid_amount) : '—'}</td>
<td style={{ padding: '12px 16px', fontSize: 13, fontWeight: 700, color: '#dc2626', fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}>{expense.due_amount > 0 ? fmt(expense.due_amount) : '—'}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: sc.bg, color: sc.color }}>{sc.label}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: 12, color: '#9ca3af' }}>{expense.due_date ? fmtDate(expense.due_date) : '—'}</td>
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
<div style={{ display: 'flex', gap: 5, justifyContent: 'flex-end' }}>
{expense.status !== 'paid' && (
<button
onClick={e => { e.stopPropagation(); onPay() }}
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #bbf7d0', background: '#f0fdf4', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#16a34a' }}
>
Πληρωμή
</button>
)}
<button
onClick={e => { e.stopPropagation(); onEdit() }}
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #e5e7eb', background: 'white', fontSize: 12, cursor: 'pointer', color: '#374151' }}
>
Επεξ.
</button>
<button
onClick={e => { e.stopPropagation(); onDelete() }}
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #fecaca', background: 'white', fontSize: 12, cursor: 'pointer', color: '#ef4444' }}
>
</button>
</div>
</td>
</tr>
{isExpanded && (
<tr style={{ background: '#f9fafb' }}>
<td colSpan={8} style={{ padding: '12px 16px 16px', borderBottom: '1px solid #f0f0ef' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{expense.notes && (
<p style={{ margin: 0, fontSize: 12.5, color: '#6b7280', fontStyle: 'italic' }}>📝 {expense.notes}</p>
)}
<div style={{ fontSize: 11.5, color: '#9ca3af' }}>
Καταχωρήθηκε από {expense.created_by_name} · {fmtDate(expense.created_at)}
</div>
{expense.payments.length > 0 && (
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>Ιστορικό Πληρωμών</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{expense.payments.map(p => (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 12px', background: 'white', borderRadius: 8, border: '1px solid #e5e7eb' }}>
<span style={{ fontSize: 12.5, color: '#374151' }}>
{p.paid_by_name} · {fmtDateTime(p.paid_at)}
{p.notes && <span style={{ color: '#9ca3af' }}> {p.notes}</span>}
</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#16a34a' }}>{fmt(p.amount)}</span>
</div>
))}
</div>
</div>
)}
{expense.payments.length === 0 && expense.status === 'due' && (
<div style={{ fontSize: 12.5, color: '#d1d5db' }}>Δεν έχουν καταγραφεί πληρωμές.</div>
)}
</div>
</td>
</tr>
)}
</>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function ExpensesPage() {
const qc = useQueryClient()
const [statusFilter, setStatusFilter] = useState('all')
const [categoryFilter, setCategoryFilter] = useState('all')
const [expanded, setExpanded] = useState(null)
const [modal, setModal] = useState(null) // null | { type: 'expense', expense? } | { type: 'pay', expense }
const params = {}
if (statusFilter !== 'all') params.status = statusFilter
if (categoryFilter !== 'all') params.category = categoryFilter
const { data: expenses = [], isLoading } = useQuery({
queryKey: ['expenses', statusFilter, categoryFilter],
queryFn: () => client.get('/api/expenses/', { params }).then(r => r.data),
staleTime: 15_000,
})
const { data: contacts = [] } = useQuery({
queryKey: ['contacts'],
queryFn: () => client.get('/api/contacts/').then(r => r.data),
staleTime: 60_000,
})
const { data: summary } = useQuery({
queryKey: ['expenses-summary'],
queryFn: () => client.get('/api/expenses/summary').then(r => r.data),
staleTime: 15_000,
})
const saveExpense = useMutation({
mutationFn: ({ form, id }) => id
? client.put(`/api/expenses/${id}`, form)
: client.post('/api/expenses/', form),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['expenses'] })
qc.invalidateQueries({ queryKey: ['expenses-summary'] })
setModal(null)
toast.success('Αποθηκεύτηκε')
},
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const payExpense = useMutation({
mutationFn: ({ id, amount, notes }) => client.post(`/api/expenses/${id}/pay`, { amount, notes }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['expenses'] })
qc.invalidateQueries({ queryKey: ['expenses-summary'] })
setModal(null)
toast.success('Πληρωμή καταγράφηκε')
},
onError: () => toast.error('Σφάλμα καταγραφής πληρωμής'),
})
const deleteExpense = useMutation({
mutationFn: (id) => client.delete(`/api/expenses/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['expenses'] })
qc.invalidateQueries({ queryKey: ['expenses-summary'] })
toast.success('Διαγράφηκε')
},
onError: () => toast.error('Σφάλμα διαγραφής'),
})
const totalDue = summary?.total_due ?? 0
const pendingCount = expenses.filter(e => e.status !== 'paid').length
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Έξοδα</h1>
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
{pendingCount} εκκρεμή · Σύνολο οφειλών: <strong style={{ color: totalDue > 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalDue)}</strong>
</p>
</div>
<button
onClick={() => setModal({ type: 'expense', expense: null })}
style={{ padding: '9px 18px', borderRadius: 9, border: 'none', background: '#111827', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
>
+ Νέο Έξοδο
</button>
</div>
{/* Filters */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{/* Status filter */}
<div style={{ display: 'flex', gap: 4 }}>
{[['all', 'Όλα'], ['due', 'Οφείλεται'], ['partial', 'Μερική'], ['paid', 'Πληρώθηκε']].map(([v, l]) => (
<button key={v} onClick={() => setStatusFilter(v)}
style={{
padding: '5px 12px', borderRadius: 20, fontSize: 12, fontWeight: 600, cursor: 'pointer', border: '1.5px solid',
borderColor: statusFilter === v ? '#111827' : '#e5e7eb',
background: statusFilter === v ? '#111827' : 'white',
color: statusFilter === v ? 'white' : '#6b7280',
}}>
{l}
</button>
))}
</div>
{/* Category filter */}
<select
value={categoryFilter}
onChange={e => setCategoryFilter(e.target.value)}
style={{ padding: '5px 10px', borderRadius: 8, border: '1px solid #e5e7eb', fontSize: 12, outline: 'none', background: 'white', color: '#374151' }}
>
<option value="all">Όλες οι κατηγορίες</option>
{CATEGORIES.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</div>
</div>
{/* Table */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 28px' }}>
{isLoading && <div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0' }}>Φόρτωση</div>}
{!isLoading && expenses.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', padding: '48px 0', fontSize: 14 }}>
Δεν βρέθηκαν έξοδα για τα επιλεγμένα φίλτρα.
</div>
)}
{expenses.length > 0 && (
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
{['Περιγραφή', 'Κατηγορία', 'Σύνολο', 'Πληρωμένο', 'Εκκρεμεί', 'Κατάσταση', 'Ημ/νία', ''].map(h => (
<th key={h} style={{ padding: '10px 16px', textAlign: h === 'Σύνολο' || h === 'Πληρωμένο' || h === 'Εκκρεμεί' ? 'right' : 'left', fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{expenses.map(e => (
<ExpenseRow
key={e.id}
expense={e}
isExpanded={expanded === e.id}
onToggle={() => setExpanded(expanded === e.id ? null : e.id)}
onPay={() => setModal({ type: 'pay', expense: e })}
onEdit={() => setModal({ type: 'expense', expense: e })}
onDelete={() => { if (window.confirm('Διαγραφή εξόδου;')) deleteExpense.mutate(e.id) }}
/>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Expense create/edit modal */}
{modal?.type === 'expense' && (
<ExpenseModal
initial={modal.expense ? {
...modal.expense,
contact_id: modal.expense.contact_id ? String(modal.expense.contact_id) : '',
due_date: modal.expense.due_date || '',
notes: modal.expense.notes || '',
} : null}
contacts={contacts}
onClose={() => setModal(null)}
isPending={saveExpense.isPending}
onSave={(form) => saveExpense.mutate({
form: {
description: form.description,
category: form.category,
contact_id: form.contact_id ? Number(form.contact_id) : null,
total_amount: parseFloat(form.total_amount),
due_date: form.due_date || null,
notes: form.notes || null,
},
id: modal.expense?.id,
})}
/>
)}
{/* Pay modal */}
{modal?.type === 'pay' && (
<PayModal
expense={modal.expense}
onClose={() => setModal(null)}
isPending={payExpense.isPending}
onPay={(amount, notes) => payExpense.mutate({ id: modal.expense.id, amount, notes })}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,278 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
import useAuthStore from '../store/authStore'
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeSince(iso) {
if (!iso) return ''
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
if (mins < 1) return '< 1λ'
if (mins < 60) return `${mins}λ`
const h = Math.floor(mins / 60)
const m = mins % 60
return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
}
function urgencyColor(iso) {
if (!iso) return '#111827'
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
if (mins >= 15) return '#dc2626' // red — very late
if (mins >= 8) return '#d97706' // amber — getting late
return '#111827' // normal
}
// ── Item card ─────────────────────────────────────────────────────────────────
function ItemCard({ item, onMarkReady, isMarking }) {
const [, forceUpdate] = useState(0)
// Rerender every 30s so elapsed time stays fresh
useEffect(() => {
const id = setInterval(() => forceUpdate(n => n + 1), 30_000)
return () => clearInterval(id)
}, [])
const elapsed = timeSince(item.added_at)
const timeColor = urgencyColor(item.added_at)
return (
<div
onClick={() => !isMarking && onMarkReady(item)}
style={{
background: 'white',
border: '1px solid #e5e7eb',
borderRadius: 12,
padding: '14px 16px',
cursor: isMarking ? 'default' : 'pointer',
opacity: isMarking ? 0.6 : 1,
transition: 'opacity 200ms, box-shadow 150ms',
userSelect: 'none',
}}
className="hover:shadow-md active:scale-[0.98]"
>
{/* Table + time */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{
fontSize: 12, fontWeight: 800, color: 'white',
background: '#111827', padding: '2px 9px', borderRadius: 20,
}}>
{item.table_name ?? `#${item.order_id}`}
</span>
<span style={{ fontSize: 12, fontWeight: 700, color: timeColor }}>
{elapsed}
</span>
</div>
{/* Product name + qty */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ fontSize: 17, fontWeight: 800, color: '#111827', lineHeight: 1.2 }}>
{item.product_name}
</span>
{item.quantity > 1 && (
<span style={{ fontSize: 15, fontWeight: 700, color: '#6b7280' }}>
×{item.quantity}
</span>
)}
</div>
{/* Notes */}
{item.notes && (
<div style={{
marginTop: 6, fontSize: 12.5, color: '#d97706',
background: '#fffbeb', borderRadius: 6, padding: '4px 8px',
fontStyle: 'italic',
}}>
{item.notes}
</div>
)}
{/* Tap hint */}
<div style={{ marginTop: 8, fontSize: 10.5, color: '#9ca3af', textAlign: 'right' }}>
Πατήστε για Έτοιμο
</div>
</div>
)
}
// ── Zone column ───────────────────────────────────────────────────────────────
function ZoneColumn({ zone, onMarkReady, markingIds }) {
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: 0,
background: '#f9fafb', borderRadius: 14, overflow: 'hidden',
border: '1px solid #e5e7eb', minWidth: 260, flex: '1 1 260px',
}}>
{/* Zone header */}
<div style={{
padding: '12px 16px', background: '#111827',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
flexShrink: 0,
}}>
<span style={{ fontSize: 14, fontWeight: 800, color: 'white', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
{zone.zone_name}
</span>
<span style={{
fontSize: 12, fontWeight: 700, background: '#374151',
color: 'white', padding: '2px 8px', borderRadius: 99,
}}>
{zone.items.length}
</span>
</div>
{/* Items */}
<div style={{
flex: 1, overflowY: 'auto', padding: 10,
display: 'flex', flexDirection: 'column', gap: 8,
}}>
{zone.items.length === 0 ? (
<div style={{ textAlign: 'center', color: '#d1d5db', fontSize: 13, padding: '24px 0' }}>
Όλα έτοιμα
</div>
) : (
zone.items.map(item => (
<ItemCard
key={item.id}
item={item}
onMarkReady={onMarkReady}
isMarking={markingIds.has(item.id)}
/>
))
)}
</div>
</div>
)
}
// ── Main KDS page ─────────────────────────────────────────────────────────────
export default function KdsPage() {
const qc = useQueryClient()
const token = useAuthStore(s => s.token)
const [markingIds, setMarkingIds] = useState(new Set())
const [clock, setClock] = useState(new Date())
const esRef = useRef(null)
// Clock tick
useEffect(() => {
const id = setInterval(() => setClock(new Date()), 1000)
return () => clearInterval(id)
}, [])
const { data, isLoading, refetch } = useQuery({
queryKey: ['kds-items'],
queryFn: () => client.get('/api/kds/items').then(r => r.data),
staleTime: 0,
refetchInterval: 30_000, // fallback poll if SSE drops
})
// SSE — refetch on any order_updated or item_status_changed event
useEffect(() => {
if (!token) return
const es = new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`)
esRef.current = es
es.onmessage = (e) => {
try {
const { type } = JSON.parse(e.data)
if (['order_updated', 'item_status_changed', 'order_paid', 'order_closed'].includes(type)) {
qc.invalidateQueries({ queryKey: ['kds-items'] })
}
} catch { /* ignore */ }
}
return () => { es.close(); esRef.current = null }
}, [token, qc])
const markReady = useMutation({
mutationFn: ({ order_id, item_id }) =>
client.put(`/api/kds/orders/${order_id}/items/${item_id}/status`, { status: 'ready' }),
onMutate: ({ item_id }) => {
setMarkingIds(s => new Set([...s, item_id]))
},
onSuccess: (_, { item_id }) => {
setMarkingIds(s => { const n = new Set(s); n.delete(item_id); return n })
qc.invalidateQueries({ queryKey: ['kds-items'] })
},
onError: (e, { item_id }) => {
setMarkingIds(s => { const n = new Set(s); n.delete(item_id); return n })
toast.error(e?.response?.data?.detail || 'Σφάλμα')
},
})
const zones = data?.zones ?? []
const totalPending = zones.reduce((s, z) => s + z.items.length, 0)
return (
<div style={{
display: 'flex', flexDirection: 'column', height: '100%',
background: '#111827', overflow: 'hidden',
}}>
{/* Top bar */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 20px', background: '#0f172a', flexShrink: 0,
borderBottom: '1px solid #1e293b',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<span style={{ fontSize: 16, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>
KDS Κουζίνα
</span>
{totalPending > 0 && (
<span style={{
fontSize: 13, fontWeight: 700, padding: '3px 10px', borderRadius: 20,
background: totalPending >= 10 ? '#dc2626' : '#d97706',
color: 'white',
}}>
{totalPending} εκκρεμή
</span>
)}
{totalPending === 0 && !isLoading && (
<span style={{ fontSize: 13, fontWeight: 600, color: '#22c55e' }}> Όλα έτοιμα</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<span style={{ fontSize: 13, color: '#94a3b8', fontVariantNumeric: 'tabular-nums' }}>
{clock.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<button
onClick={() => refetch()}
style={{
fontSize: 12, fontWeight: 600, padding: '5px 12px', borderRadius: 7,
border: '1px solid #334155', background: '#1e293b',
color: '#94a3b8', cursor: 'pointer',
}}
>
Ανανέωση
</button>
</div>
</div>
{/* Zone columns */}
<div style={{
flex: 1, overflowX: 'auto', overflowY: 'hidden',
padding: '14px 16px',
display: 'flex', gap: 12, alignItems: 'stretch',
}}>
{isLoading && (
<div style={{ color: '#64748b', fontSize: 14, margin: 'auto' }}>Φόρτωση</div>
)}
{!isLoading && zones.length === 0 && (
<div style={{ color: '#64748b', fontSize: 14, margin: 'auto', textAlign: 'center' }}>
<div style={{ fontSize: 32, marginBottom: 8 }}></div>
Δεν υπάρχουν εκκρεμείς παραγγελίες.
</div>
)}
{zones.map(zone => (
<ZoneColumn
key={zone.zone_id ?? 'no-zone'}
zone={zone}
onMarkReady={(item) => markReady.mutate({ order_id: item.order_id, item_id: item.id })}
markingIds={markingIds}
/>
))}
</div>
</div>
)
}

View File

@@ -139,6 +139,19 @@ function buildFormFromProduct(product) {
} : null,
is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0,
})) ?? [],
digital_visible: product.digital_visible ?? true,
digital_available: product.digital_available ?? true,
digital_name: product.digital_name ?? '',
digital_description: product.digital_description ?? '',
digital_image_url: product.digital_image_url ?? '',
digital_price: product.digital_price ?? '',
digital_discount: product.digital_discount ?? '',
// Phase 2A — cost tracking
cost_mode: product.cost_breakdown?.length ? 'detailed' : 'simple',
cost_simple: product.cost_simple ?? '',
cost_breakdown: product.cost_breakdown?.length
? product.cost_breakdown.map(e => ({ label: e.label, amount: e.amount }))
: [],
}
}
@@ -179,6 +192,7 @@ function getItemTypeLabel(type) {
export default function ProductFormModal({ product, categories, printers, onSave, onCopy, onClose }) {
const [form, setForm] = useState(() => buildFormFromProduct(product))
const [activeTab, setActiveTab] = useState('favorites')
const [leftTab, setLeftTab] = useState('info')
const [imageFile, setImageFile] = useState(null)
const [uploading, setUploading] = useState(false)
const qc = useQueryClient()
@@ -192,6 +206,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
useEffect(() => {
setForm(buildFormFromProduct(product))
setActiveTab('favorites')
setLeftTab('info')
setImageFile(null)
}, [product.id, product.name])
@@ -425,6 +440,18 @@ export default function ProductFormModal({ product, categories, printers, onSave
})),
is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0,
})),
digital_visible: form.digital_visible,
digital_available: form.digital_available,
digital_name: form.digital_name || null,
digital_description: form.digital_description || null,
digital_image_url: form.digital_image_url || null,
digital_price: form.digital_price !== '' ? parseFloat(form.digital_price) : null,
digital_discount: form.digital_discount !== '' ? parseFloat(form.digital_discount) : null,
// Phase 2A — cost tracking
cost_simple: form.cost_mode === 'simple' && form.cost_simple !== '' ? parseFloat(form.cost_simple) : null,
cost_breakdown: form.cost_mode === 'detailed' && form.cost_breakdown.length > 0
? form.cost_breakdown.map(e => ({ label: e.label, amount: parseFloat(e.amount) || 0 }))
: null,
}
}
@@ -481,83 +508,264 @@ export default function ProductFormModal({ product, categories, printers, onSave
{/* Body */}
<div className="flex-1 flex overflow-hidden">
{/* LEFT: product info */}
<div className="w-80 shrink-0 border-r border-gray-100 bg-gray-50/50 px-5 py-5 flex flex-col gap-3 overflow-y-auto">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest">Στοιχεία προϊόντος</p>
<div>
<label className="label">Όνομα *</label>
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
{/* LEFT: tabbed product info — 30% width */}
<div className="shrink-0 border-r border-gray-100 bg-gray-50/50 flex flex-col overflow-hidden" style={{ width: '25%' }}>
{/* Left tab bar */}
<div className="flex border-b border-gray-200 shrink-0 bg-white">
{[
{ key: 'info', label: 'Πληροφορίες' },
{ key: 'digital', label: 'Digital Menu' },
{ key: 'cost', label: 'Κόστος' },
].map(t => (
<button key={t.key} onClick={() => setLeftTab(t.key)}
className={`flex-1 px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
leftTab === t.key
? 'border-primary-600 text-primary-700 bg-primary-50/50'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}>
{t.label}
</button>
))}
</div>
{/* Description — optional, for digital menus / staff info */}
<div>
<label className="label">Περιγραφή <span className="text-gray-400 font-normal normal-case">(προαιρετική)</span></label>
<textarea
className="input resize-none"
rows={3}
value={form.description}
onChange={e => setField('description', e.target.value)}
placeholder="π.χ. Φρέσκος καφές με γάλα βρώμης, εξαιρετικά αρώματα…"
style={{ lineHeight: 1.5, fontSize: 13 }}
/>
<p className="text-xs text-gray-400 mt-1">Χρήσιμο για ψηφιακό μενού ή ενημέρωση σερβιτόρων.</p>
</div>
{/* Left tab content */}
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-3">
<div>
<label className="label">Τιμή βάσης () *</label>
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
</div>
{/* Tab 1: Main Info */}
{leftTab === 'info' && (<>
<div>
<label className="label">Όνομα *</label>
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
</div>
<div>
<label className="label">Κατηγορία</label>
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>
<option value=""> Χωρίς κατηγορία </option>
{categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order).flatMap(parent => {
const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
return [
<option key={parent.id} value={parent.id}>{parent.name}</option>,
...subs.map(s => <option key={s.id} value={s.id}>&nbsp;&nbsp; {s.name}</option>),
]
})}
</select>
</div>
<div>
<label className="label">Περιγραφή <span className="text-gray-400 font-normal normal-case">(προαιρετική)</span></label>
<textarea
className="input resize-none"
rows={3}
value={form.description}
onChange={e => setField('description', e.target.value)}
placeholder="π.χ. Φρέσκος καφές με γάλα βρώμης, εξαιρετικά αρώματα…"
style={{ lineHeight: 1.5, fontSize: 13 }}
/>
<p className="text-xs text-gray-400 mt-1">Χρήσιμο για ψηφιακό μενού ή ενημέρωση σερβιτόρων.</p>
</div>
<div>
<label className="label">Ζώνη εκτυπωτή</label>
<select className="input" value={form.printer_zone_id} onChange={e => setField('printer_zone_id', e.target.value)}>
<option value=""> Χωρίς εκτυπωτή </option>
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div>
<label className="label">Βασική Τιμή () *</label>
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
</div>
<button type="button" onClick={() => setField('is_available', !form.is_available)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
form.is_available ? 'bg-green-50 border-green-300 text-green-700 hover:bg-green-100'
: 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
}`}>
<span className={`w-2.5 h-2.5 rounded-full ${form.is_available ? 'bg-green-500' : 'bg-gray-400'}`} />
{form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
</button>
<div>
<label className="label">Κατηγορία</label>
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>
<option value=""> Χωρίς κατηγορία </option>
{categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order).flatMap(parent => {
const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
return [
<option key={parent.id} value={parent.id}>{parent.name}</option>,
...subs.map(s => <option key={s.id} value={s.id}>&nbsp;&nbsp; {s.name}</option>),
]
})}
</select>
</div>
{/* Image upload */}
<div>
<label className="label">Εικόνα προϊόντος</label>
{product.image_url && (
<img src={product.image_url}
className="w-16 h-16 rounded-xl object-cover border border-gray-200 mb-2" alt="" />
<div>
<label className="label">Ζώνη εκτυπωτή</label>
<select className="input" value={form.printer_zone_id} onChange={e => setField('printer_zone_id', e.target.value)}>
<option value=""> Χωρίς εκτυπωτή </option>
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<button type="button" onClick={() => setField('is_available', !form.is_available)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
form.is_available ? 'bg-green-50 border-green-300 text-green-700 hover:bg-green-100'
: 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
}`}>
<span className={`w-2.5 h-2.5 rounded-full ${form.is_available ? 'bg-green-500' : 'bg-gray-400'}`} />
{form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
</button>
<div>
<label className="label">Εικόνα προϊόντος</label>
{product.image_url && (
<img src={product.image_url}
className="w-16 h-16 rounded-xl object-cover border border-gray-200 mb-2" alt="" />
)}
{imageFile && <div className="text-xs text-primary-700 font-medium mb-1 break-all">{imageFile.name}</div>}
<label className="cursor-pointer inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-sm text-gray-600 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
{imageFile ? 'Αλλαγή εικόνας' : 'Επιλογή εικόνας'}
<input type="file" accept="image/*" className="sr-only" onChange={e => setImageFile(e.target.files[0] ?? null)} />
</label>
</div>
</>)}
{/* Tab 2: Digital Menu */}
{leftTab === 'digital' && (() => {
const basePrice = parseFloat(form.base_price) || 0
const dp = form.digital_price !== '' ? parseFloat(form.digital_price) : null
const disc = form.digital_discount !== '' ? parseFloat(form.digital_discount) : null
let customerPrice = basePrice
if (dp !== null && !isNaN(dp)) customerPrice = dp
else if (disc !== null && !isNaN(disc) && disc > 0) customerPrice = Math.round(basePrice * (1 - disc / 100) * 100) / 100
return (
<div className="space-y-6">
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Ορατότητα</p>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" checked={form.digital_visible}
onChange={e => setField('digital_visible', e.target.checked)}
className="w-4 h-4 accent-primary-700" />
<span className="text-sm font-medium text-gray-700">Show on digital menu</span>
</label>
<div>
<label className="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" checked={form.digital_available}
onChange={e => setField('digital_available', e.target.checked)}
className="w-4 h-4 accent-primary-700" />
<span className="text-sm font-medium text-gray-700">Available for ordering</span>
</label>
<p className="text-xs text-gray-400 mt-1 ml-7">Uncheck to show as &lsquo;Out of stock&rsquo;</p>
</div>
</div>
</div>
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">Display overrides</p>
<p className="text-xs text-gray-400 mb-3">Leave blank to use the standard product value</p>
<div className="space-y-3">
<div>
<label className="label">Display name (override)</label>
<input className="input" value={form.digital_name}
onChange={e => setField('digital_name', e.target.value)}
placeholder={form.name || 'Leave blank to use product name'} />
</div>
<div>
<label className="label">Description</label>
<textarea className="input resize-none" rows={3}
value={form.digital_description}
onChange={e => setField('digital_description', e.target.value)}
placeholder="Leave blank to use product description"
style={{ lineHeight: 1.5, fontSize: 13 }} />
</div>
<div>
<label className="label">Image URL</label>
<input className="input" value={form.digital_image_url}
onChange={e => setField('digital_image_url', e.target.value)}
placeholder="https://…" />
</div>
</div>
</div>
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-3">Pricing</p>
<div className="space-y-3">
<div>
<label className="label">Online price (override)</label>
<input type="number" step="0.01" min="0" className="input w-40"
value={form.digital_price}
onChange={e => setField('digital_price', e.target.value)}
placeholder="e.g. 9.50" />
</div>
<div>
<label className="label">Discount %</label>
<input type="number" step="1" min="0" max="100" className="input w-40"
value={form.digital_discount}
onChange={e => setField('digital_discount', e.target.value)}
placeholder="e.g. 10 for 10% off" />
</div>
<div className="rounded-lg bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-800">
Customer sees: <span className="font-bold">{customerPrice.toFixed(2)}</span>
</div>
</div>
</div>
</div>
)
})()}
{/* Tab 3: Cost */}
{leftTab === 'cost' && (
<div className="space-y-3">
<p className="text-xs text-gray-400">Το κόστος καταγράφεται στιγμιότυπα σε κάθε παραγγελία για υπολογισμό κέρδους.</p>
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
<div className="flex items-center justify-between px-3 py-2.5 bg-gray-50 border-b border-gray-200">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-widest">Τύπος κόστους</span>
<div className="flex gap-1">
{['simple', 'detailed'].map(mode => (
<button key={mode} type="button"
onClick={() => setField('cost_mode', mode)}
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
form.cost_mode === mode
? 'bg-primary-600 text-white'
: 'text-gray-500 hover:bg-gray-100'
}`}>
{mode === 'simple' ? 'Απλό' : 'Ανάλυση'}
</button>
))}
</div>
</div>
<div className="p-3 space-y-2">
{form.cost_mode === 'simple' ? (
<div>
<label className="label text-xs">Κόστος ανά μονάδα ()</label>
<PriceInput value={form.cost_simple} onChange={v => setField('cost_simple', v)} className="w-full" />
{form.cost_simple !== '' && parseFloat(form.base_price) > 0 && parseFloat(form.cost_simple) > 0 && (() => {
const margin = ((parseFloat(form.base_price) - parseFloat(form.cost_simple)) / parseFloat(form.base_price) * 100)
const isError = parseFloat(form.cost_simple) > parseFloat(form.base_price)
return (
<p className={`text-xs mt-1 font-medium ${isError ? 'text-red-500' : 'text-green-600'}`}>
{isError ? '⚠ Κόστος > Τιμή' : `Περιθώριο: ${margin.toFixed(1)}%`}
</p>
)
})()}
</div>
) : (
<div className="space-y-2">
{form.cost_breakdown.map((entry, bi) => (
<div key={bi} className="flex gap-2 items-center">
<input className="input flex-1 text-sm" placeholder="π.χ. Γάλα"
value={entry.label}
onChange={e => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, label: e.target.value } : x) }))} />
<PriceInput value={entry.amount}
onChange={v => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.map((x, i) => i === bi ? { ...x, amount: v } : x) }))}
className="w-28" />
<button type="button"
onClick={() => setForm(f => ({ ...f, cost_breakdown: f.cost_breakdown.filter((_, i) => i !== bi) }))}
className="text-gray-400 hover:text-red-500 px-1.5 text-sm shrink-0"></button>
</div>
))}
<button type="button"
onClick={() => setForm(f => ({ ...f, cost_breakdown: [...f.cost_breakdown, { label: '', amount: 0 }] }))}
className="text-xs text-primary-600 hover:text-primary-800 font-medium">+ Γραμμή κόστους</button>
{form.cost_breakdown.length > 0 && (() => {
const total = form.cost_breakdown.reduce((s, e) => s + (parseFloat(e.amount) || 0), 0)
const price = parseFloat(form.base_price) || 0
const isError = total > price && price > 0
const margin = price > 0 ? ((price - total) / price * 100) : null
return (
<div className={`text-xs font-medium ${isError ? 'text-red-500' : 'text-gray-600'}`}>
Σύνολο: {total.toFixed(2)}
{margin !== null && !isError && <span className="text-green-600 ml-2">({margin.toFixed(1)}% περιθώριο)</span>}
{isError && <span className="ml-1"> Κόστος &gt; Τιμή!</span>}
</div>
)
})()}
</div>
)}
</div>
</div>
</div>
)}
{imageFile && <div className="text-xs text-primary-700 font-medium mb-1 break-all">{imageFile.name}</div>}
<label className="cursor-pointer inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-sm text-gray-600 transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
{imageFile ? 'Αλλαγή εικόνας' : 'Επιλογή εικόνας'}
<input type="file" accept="image/*" className="sr-only" onChange={e => setImageFile(e.target.files[0] ?? null)} />
</label>
</div>
</div>
{/* RIGHT: tabs */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* RIGHT: ingredient/option tabs — 70% */}
<div className="flex flex-col overflow-hidden" style={{ width: '75%' }}>
<div className="flex border-b border-gray-200 overflow-x-auto shrink-0 bg-white">
{tabs.map(tab => {
if (tab.isAdd) return (

View File

@@ -636,7 +636,7 @@ function SubCatBar({ parentCat, subCats, products, subCatFilter, setSubCatFilter
}
// ── Bulk bar ──────────────────────────────────────────────────────────────────
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive }) {
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrinterZone, onMoveToCategory }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '12px 24px 0', padding: '10px 14px', background: '#111827', color: '#fff', borderRadius: 10, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
@@ -645,7 +645,7 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive }) {
</button>
<span style={{ fontSize: 13, fontWeight: 500 }}>{count} επιλεγμένα</span>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{[
{ label: 'Διαθέσιμα', icon: 'eye', action: onAvail },
{ label: 'Μη διαθέσιμα', icon: 'eyeOff', action: onUnavail },
@@ -659,6 +659,111 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive }) {
{label}
</button>
))}
<div style={{ width: 1, background: 'rgba(255,255,255,0.15)', margin: '4px 2px' }} />
<button onClick={onSetPrinterZone}
style={{ border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: 'rgba(255,255,255,0.85)', padding: '6px 10px', borderRadius: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
className="hover:bg-white/10"
>
<Icon name="folder" size={13} />
Ζώνη εκτύπωσης
</button>
<button onClick={onMoveToCategory}
style={{ border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: 'rgba(255,255,255,0.85)', padding: '6px 10px', borderRadius: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
className="hover:bg-white/10"
>
<Icon name="move" size={13} />
Μετακίνηση
</button>
</div>
</div>
)
}
// ── Bulk: Set Printer Zone modal ───────────────────────────────────────────────
function BulkSetPrinterZoneModal({ count, printers, onClose, onConfirm }) {
const zones = [...new Map(printers.map(p => [p.id, p])).values()]
const [selectedId, setSelectedId] = useState(zones[0]?.id ?? '')
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 16 }}>
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', width: '100%', maxWidth: 380, padding: 24 }}>
<h2 style={{ margin: '0 0 4px', fontWeight: 700, fontSize: 16, color: '#111827' }}>Ζώνη εκτύπωσης</h2>
<p style={{ margin: '0 0 18px', fontSize: 13, color: '#6b7280' }}>
Θα οριστεί σε <strong style={{ color: '#111827' }}>{count}</strong> προϊόντα.
</p>
{zones.length === 0 ? (
<p style={{ fontSize: 13, color: '#ef4444', marginBottom: 18 }}>Δεν βρέθηκαν εκτυπωτές.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
{zones.map(z => (
<label key={z.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', border: `1.5px solid ${selectedId === z.id ? '#3b82f6' : '#e5e7eb'}`, borderRadius: 10, cursor: 'pointer', background: selectedId === z.id ? '#eff6ff' : '#fff' }}>
<input type="radio" name="zone" value={z.id} checked={selectedId === z.id} onChange={() => setSelectedId(z.id)} style={{ display: 'none' }} />
<span style={{ width: 16, height: 16, borderRadius: '50%', border: `2px solid ${selectedId === z.id ? '#3b82f6' : '#d1d5db'}`, background: selectedId === z.id ? '#3b82f6' : '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{selectedId === z.id && <span style={{ width: 6, height: 6, borderRadius: '50%', background: '#fff' }} />}
</span>
<span style={{ fontSize: 14, fontWeight: 500, color: '#111827' }}>{z.name}</span>
</label>
))}
</div>
)}
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={onClose} style={{ flex: 1, padding: '9px 0', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 14, cursor: 'pointer', color: '#374151' }}>Άκυρο</button>
<button
onClick={() => onConfirm(selectedId)}
disabled={!selectedId}
style={{ flex: 1, padding: '9px 0', border: 'none', background: selectedId ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: selectedId ? 'pointer' : 'not-allowed' }}
>
Εφαρμογή
</button>
</div>
</div>
</div>
)
}
// ── Bulk: Move to Category modal ───────────────────────────────────────────────
function BulkMoveToCategoryModal({ count, categories, onClose, onConfirm }) {
const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
const [selectedId, setSelectedId] = useState('')
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 16 }}>
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', width: '100%', maxWidth: 400, padding: 24 }}>
<h2 style={{ margin: '0 0 4px', fontWeight: 700, fontSize: 16, color: '#111827' }}>Μετακίνηση σε κατηγορία</h2>
<p style={{ margin: '0 0 18px', fontSize: 13, color: '#6b7280' }}>
Θα μετακινηθούν <strong style={{ color: '#111827' }}>{count}</strong> προϊόντα.
</p>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 20 }}>
{topLevel.map(cat => {
const subs = categories.filter(c => c.parent_id === cat.id).sort((a, b) => a.sort_order - b.sort_order)
return (
<div key={cat.id}>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', border: `1.5px solid ${selectedId === cat.id ? cat.color || '#3b82f6' : '#e5e7eb'}`, borderRadius: 9, cursor: 'pointer', background: selectedId === cat.id ? '#f8faff' : '#fff' }}>
<input type="radio" name="cat" value={cat.id} checked={selectedId === cat.id} onChange={() => setSelectedId(cat.id)} style={{ display: 'none' }} />
<span style={{ width: 10, height: 10, borderRadius: '50%', background: cat.color || '#94a3b8', flexShrink: 0 }} />
<span style={{ fontSize: 13.5, fontWeight: 600, color: '#111827', flex: 1 }}>{cat.name}</span>
{selectedId === cat.id && <Icon name="check" size={14} className="text-blue-500" />}
</label>
{subs.map(sub => (
<label key={sub.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px 8px 28px', border: `1.5px solid ${selectedId === sub.id ? sub.color || '#3b82f6' : '#e5e7eb'}`, borderRadius: 8, cursor: 'pointer', background: selectedId === sub.id ? '#f8faff' : '#fff', marginTop: 3 }}>
<input type="radio" name="cat" value={sub.id} checked={selectedId === sub.id} onChange={() => setSelectedId(sub.id)} style={{ display: 'none' }} />
<span style={{ width: 7, height: 7, borderRadius: '50%', background: sub.color || '#94a3b8', flexShrink: 0 }} />
<span style={{ fontSize: 13, fontWeight: 500, color: '#374151', flex: 1 }}>{sub.name}</span>
{selectedId === sub.id && <Icon name="check" size={13} className="text-blue-500" />}
</label>
))}
</div>
)
})}
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={onClose} style={{ flex: 1, padding: '9px 0', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 14, cursor: 'pointer', color: '#374151' }}>Άκυρο</button>
<button
onClick={() => onConfirm(selectedId)}
disabled={!selectedId}
style={{ flex: 1, padding: '9px 0', border: 'none', background: selectedId ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: selectedId ? 'pointer' : 'not-allowed' }}
>
Μετακίνηση
</button>
</div>
</div>
</div>
)
@@ -740,10 +845,29 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
</div>
</div>
{/* Price */}
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, fontSize: 14, color: '#111827', marginRight: 4, whiteSpace: 'nowrap' }}>
{parseFloat(p.base_price).toFixed(2)}
</span>
{/* Price + cost/margin */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginRight: 4, flexShrink: 0 }}>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, fontSize: 14, color: '#111827', whiteSpace: 'nowrap' }}>
{parseFloat(p.base_price).toFixed(2)}
</span>
{(() => {
const effectiveCost = p.cost_breakdown?.length
? p.cost_breakdown.reduce((s, e) => s + (e.amount || 0), 0)
: (p.cost_simple ?? null)
if (effectiveCost === null) return null
const price = parseFloat(p.base_price)
const isError = effectiveCost > price
const margin = price > 0 ? ((price - effectiveCost) / price * 100) : null
return (
<span style={{
fontSize: 11, fontWeight: 500, whiteSpace: 'nowrap',
color: isError ? '#ef4444' : '#16a34a',
}}>
{isError ? '⚠ κόστος > τιμή' : margin !== null ? `${margin.toFixed(0)}% margin` : null}
</span>
)
})()}
</div>
{/* Actions */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0 }}>
@@ -779,10 +903,11 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
Επεξεργασία
</button>
{/* Delete — pill style */}
{/* Delete — icon only, same height as Edit pill */}
<button onClick={e => { e.stopPropagation(); onArchive(p) }}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 10px',
height: 27,
background: '#fff5f5', border: '1px solid #fecaca',
borderRadius: 999, fontSize: 11.5, fontWeight: 500, color: '#ef4444',
cursor: 'pointer',
@@ -790,7 +915,6 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
className="hover:bg-red-100"
title="Διαγραφή">
<Icon name="trash" size={13} />
Διαγραφή
</button>
</div>
</div>
@@ -938,6 +1062,8 @@ export default function ProductsTab() {
const [collapsedGroups, setCollapsedGroups] = useState(new Set())
// local override of group display order (array of group keys), reset when scope changes
const [localGroupOrder, setLocalGroupOrder] = useState(null)
const [bulkPrinterZoneModal, setBulkPrinterZoneModal] = useState(false)
const [bulkMoveCatModal, setBulkMoveCatModal] = useState(false)
// drag state for group headers
const groupDragId = useRef(null)
@@ -1163,6 +1289,26 @@ export default function ProductsTab() {
setConfirmDelete(null); invalidate(); clearSelect()
}
async function handleBulkSetPrinterZone(printerId) {
const ids = [...selected]
setBulkPrinterZoneModal(false)
try {
await Promise.all(ids.map(id => client.put(`/api/products/${id}`, { printer_zone_id: printerId })))
toast.success(`Ζώνη εκτύπωσης ορίστηκε σε ${ids.length} προϊόντα`)
invalidate(); clearSelect()
} catch { toast.error('Σφάλμα') }
}
async function handleBulkMoveToCategory(categoryId) {
const ids = [...selected]
setBulkMoveCatModal(false)
try {
await Promise.all(ids.map(id => client.put(`/api/products/${id}`, { category_id: categoryId })))
toast.success(`${ids.length} προϊόντα μετακινήθηκαν`)
invalidate(); clearSelect()
} catch { toast.error('Σφάλμα') }
}
function handleConfirmDelete() {
if (!confirmDelete) return
if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id)
@@ -1345,6 +1491,8 @@ export default function ProductsTab() {
onAvail={() => bulkAction('available')}
onUnavail={() => bulkAction('unavailable')}
onArchive={() => bulkAction('archive')}
onSetPrinterZone={() => setBulkPrinterZoneModal(true)}
onMoveToCategory={() => setBulkMoveCatModal(true)}
/>
)}
@@ -1500,6 +1648,26 @@ export default function ProductsTab() {
onCancel={() => setConfirmDelete(null)}
/>
)}
{/* Bulk: set printer zone modal */}
{bulkPrinterZoneModal && (
<BulkSetPrinterZoneModal
count={selected.size}
printers={printers}
onClose={() => setBulkPrinterZoneModal(false)}
onConfirm={handleBulkSetPrinterZone}
/>
)}
{/* Bulk: move to category modal */}
{bulkMoveCatModal && (
<BulkMoveToCategoryModal
count={selected.size}
categories={categories}
onClose={() => setBulkMoveCatModal(false)}
onConfirm={handleBulkMoveToCategory}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,501 @@
import { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
// ── helpers ───────────────────────────────────────────────────────────────────
function fmtDate(iso) {
if (!iso) return ''
const d = new Date(iso)
const now = new Date()
const diffMs = now - d
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return 'μόλις τώρα'
if (diffMins < 60) return `${diffMins}λ πριν`
const diffH = Math.floor(diffMins / 60)
if (diffH < 24) return `${diffH}ω πριν`
return d.toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit' })
}
// ── Notes column ──────────────────────────────────────────────────────────────
function NoteCard({ note, onSave, onDelete, onTogglePin }) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(note.body)
const taRef = useRef(null)
useEffect(() => {
if (editing && taRef.current) {
taRef.current.focus()
taRef.current.selectionStart = taRef.current.value.length
}
}, [editing])
function handleBlur() {
if (draft.trim() === note.body) { setEditing(false); return }
if (!draft.trim()) { setEditing(false); setDraft(note.body); return }
onSave(note.id, draft.trim())
setEditing(false)
}
function handleKeyDown(e) {
if (e.key === 'Escape') { setEditing(false); setDraft(note.body) }
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleBlur()
}
return (
<div style={{
border: note.is_pinned ? '1.5px solid #fbbf24' : '1px solid #e5e7eb',
borderRadius: 12,
background: note.is_pinned ? '#fffbeb' : 'white',
padding: '14px 16px',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}>
{editing ? (
<textarea
ref={taRef}
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
style={{
width: '100%', minHeight: 80, border: '1.5px solid #3b82f6',
borderRadius: 8, padding: '8px 10px', fontSize: 13.5, lineHeight: 1.55,
resize: 'vertical', outline: 'none', fontFamily: 'inherit',
background: 'white',
}}
/>
) : (
<p
onClick={() => { setEditing(true); setDraft(note.body) }}
style={{
fontSize: 13.5, color: '#111827', lineHeight: 1.6, cursor: 'text',
whiteSpace: 'pre-wrap', margin: 0, wordBreak: 'break-word',
}}
>
{note.body}
</p>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 11, color: '#9ca3af' }}>
{note.created_by_name} · {fmtDate(note.updated_at)}
</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => onTogglePin(note.id, !note.is_pinned)}
title={note.is_pinned ? 'Αποσύνδεση' : 'Καρφίτσωμα'}
style={{
fontSize: 13, padding: '2px 7px', borderRadius: 6, border: '1px solid #e5e7eb',
background: note.is_pinned ? '#fef3c7' : 'white', cursor: 'pointer',
color: note.is_pinned ? '#d97706' : '#6b7280',
}}
>
{note.is_pinned ? '📌' : '📍'}
</button>
<button
onClick={() => { if (window.confirm('Διαγραφή σημείωσης;')) onDelete(note.id) }}
style={{
fontSize: 12, padding: '2px 7px', borderRadius: 6,
border: '1px solid #fecaca', background: 'white',
color: '#ef4444', cursor: 'pointer',
}}
></button>
</div>
</div>
</div>
)
}
function NotesColumn({ notes, onAdd, onSave, onDelete, onTogglePin }) {
const [newBody, setNewBody] = useState('')
const [newPinned, setNewPinned] = useState(false)
function submit() {
if (!newBody.trim()) return
onAdd(newBody.trim(), newPinned)
setNewBody('')
setNewPinned(false)
}
function onKeyDown(e) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) submit()
}
const pinned = notes.filter(n => n.is_pinned)
const rest = notes.filter(n => !n.is_pinned)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, height: '100%' }}>
{/* New note form */}
<div style={{ border: '1.5px dashed #d1d5db', borderRadius: 12, padding: '12px 14px', background: '#fafafa' }}>
<textarea
value={newBody}
onChange={e => setNewBody(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Νέα σημείωση… (Ctrl+Enter για αποθήκευση)"
style={{
width: '100%', minHeight: 72, border: 'none', outline: 'none',
background: 'transparent', fontSize: 13.5, lineHeight: 1.55,
resize: 'none', fontFamily: 'inherit', color: '#374151',
}}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 6 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#6b7280', cursor: 'pointer' }}>
<input type="checkbox" checked={newPinned} onChange={e => setNewPinned(e.target.checked)} />
Καρφίτσωμα
</label>
<button
onClick={submit}
disabled={!newBody.trim()}
style={{
padding: '5px 14px', borderRadius: 7, border: 'none',
background: newBody.trim() ? '#111827' : '#e5e7eb',
color: newBody.trim() ? 'white' : '#9ca3af',
fontSize: 12.5, fontWeight: 600, cursor: newBody.trim() ? 'pointer' : 'default',
}}
>
Αποθήκευση
</button>
</div>
</div>
{/* Notes list */}
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{pinned.length > 0 && (
<>
<div style={{ fontSize: 10, fontWeight: 700, color: '#d97706', textTransform: 'uppercase', letterSpacing: '0.06em' }}>📌 Καρφιτσωμένες</div>
{pinned.map(n => (
<NoteCard key={n.id} note={n} onSave={onSave} onDelete={onDelete} onTogglePin={onTogglePin} />
))}
</>
)}
{rest.length > 0 && (
<>
{pinned.length > 0 && <div style={{ fontSize: 10, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.06em', marginTop: 4 }}>Πρόσφατες</div>}
{rest.map(n => (
<NoteCard key={n.id} note={n} onSave={onSave} onDelete={onDelete} onTogglePin={onTogglePin} />
))}
</>
)}
{notes.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>
Δεν υπάρχουν σημειώσεις ακόμα.
</div>
)}
</div>
</div>
)
}
// ── Todos column ──────────────────────────────────────────────────────────────
function TodoItem({ todo, onToggle, onDelete, onEdit }) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(todo.body)
const inputRef = useRef(null)
useEffect(() => {
if (editing && inputRef.current) inputRef.current.focus()
}, [editing])
function saveEdit() {
if (!draft.trim() || draft.trim() === todo.body) { setEditing(false); setDraft(todo.body); return }
onEdit(todo.id, draft.trim())
setEditing(false)
}
return (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 10,
padding: '10px 12px', borderRadius: 10,
border: `1px solid ${todo.is_done ? '#e5e7eb' : todo.priority === 'high' ? '#fca5a5' : '#e5e7eb'}`,
background: todo.is_done ? '#fafafa' : todo.priority === 'high' ? '#fff5f5' : 'white',
opacity: todo.is_done ? 0.65 : 1,
}}>
{/* Checkbox */}
<button
onClick={() => onToggle(todo.id, !todo.is_done)}
style={{
width: 20, height: 20, borderRadius: 5, flexShrink: 0, marginTop: 1,
border: `2px solid ${todo.is_done ? '#22c55e' : todo.priority === 'high' ? '#ef4444' : '#d1d5db'}`,
background: todo.is_done ? '#22c55e' : 'white',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{todo.is_done && <span style={{ color: 'white', fontSize: 11, lineHeight: 1 }}></span>}
</button>
{/* Body */}
<div style={{ flex: 1, minWidth: 0 }}>
{editing ? (
<input
ref={inputRef}
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={saveEdit}
onKeyDown={e => { if (e.key === 'Enter') saveEdit(); if (e.key === 'Escape') { setEditing(false); setDraft(todo.body) } }}
style={{
width: '100%', border: '1.5px solid #3b82f6', borderRadius: 6,
padding: '3px 7px', fontSize: 13.5, outline: 'none', fontFamily: 'inherit',
}}
/>
) : (
<p
onClick={() => { if (!todo.is_done) { setEditing(true); setDraft(todo.body) } }}
style={{
margin: 0, fontSize: 13.5, lineHeight: 1.5, wordBreak: 'break-word',
color: todo.is_done ? '#9ca3af' : '#111827',
textDecoration: todo.is_done ? 'line-through' : 'none',
cursor: todo.is_done ? 'default' : 'text',
}}
>
{todo.priority === 'high' && !todo.is_done && (
<span style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', marginRight: 5 }}> ΕΠΕΙΓΟΝ</span>
)}
{todo.body}
</p>
)}
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 3 }}>
{todo.created_by_name} · {fmtDate(todo.created_at)}
{todo.is_done && todo.done_by_name && (
<span style={{ color: '#22c55e' }}> · {todo.done_by_name}</span>
)}
</div>
</div>
{/* Delete */}
<button
onClick={() => { if (window.confirm('Διαγραφή εργασίας;')) onDelete(todo.id) }}
style={{
fontSize: 11, padding: '2px 6px', borderRadius: 5, flexShrink: 0,
border: '1px solid #fecaca', background: 'white', color: '#ef4444', cursor: 'pointer',
}}
></button>
</div>
)
}
function TodosColumn({ todos, onAdd, onToggle, onDelete, onEdit }) {
const [newBody, setNewBody] = useState('')
const [newPriority, setNewPriority] = useState('normal')
const [showDone, setShowDone] = useState(false)
function submit() {
if (!newBody.trim()) return
onAdd(newBody.trim(), newPriority)
setNewBody('')
setNewPriority('normal')
}
const undone = todos.filter(t => !t.is_done)
const done = todos.filter(t => t.is_done)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, height: '100%' }}>
{/* New todo form */}
<div style={{ border: '1.5px dashed #d1d5db', borderRadius: 12, padding: '12px 14px', background: '#fafafa' }}>
<input
value={newBody}
onChange={e => setNewBody(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submit() }}
placeholder="Νέα εργασία… (Enter)"
style={{
width: '100%', border: 'none', outline: 'none', background: 'transparent',
fontSize: 13.5, fontFamily: 'inherit', color: '#374151',
}}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }}>
<div style={{ display: 'flex', gap: 6 }}>
{['normal', 'high'].map(p => (
<button
key={p}
onClick={() => setNewPriority(p)}
style={{
padding: '3px 10px', borderRadius: 6, fontSize: 11.5, fontWeight: 600, cursor: 'pointer',
border: `1.5px solid ${newPriority === p ? (p === 'high' ? '#ef4444' : '#6b7280') : '#e5e7eb'}`,
background: newPriority === p ? (p === 'high' ? '#fee2e2' : '#f3f4f6') : 'white',
color: newPriority === p ? (p === 'high' ? '#ef4444' : '#374151') : '#9ca3af',
}}
>
{p === 'high' ? '● Επείγον' : 'Κανονικό'}
</button>
))}
</div>
<button
onClick={submit}
disabled={!newBody.trim()}
style={{
padding: '5px 14px', borderRadius: 7, border: 'none',
background: newBody.trim() ? '#111827' : '#e5e7eb',
color: newBody.trim() ? 'white' : '#9ca3af',
fontSize: 12.5, fontWeight: 600, cursor: newBody.trim() ? 'pointer' : 'default',
}}
>
Προσθήκη
</button>
</div>
</div>
{/* Todo list */}
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
{undone.length === 0 && done.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 13, padding: '32px 0' }}>
Δεν υπάρχουν εργασίες ακόμα.
</div>
)}
{undone.map(t => (
<TodoItem key={t.id} todo={t} onToggle={onToggle} onDelete={onDelete} onEdit={onEdit} />
))}
{done.length > 0 && (
<button
onClick={() => setShowDone(s => !s)}
style={{
marginTop: 4, fontSize: 12, color: '#9ca3af', fontWeight: 600,
background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', padding: '4px 0',
}}
>
{showDone ? '▾' : '▸'} Ολοκληρωμένες ({done.length})
</button>
)}
{showDone && done.map(t => (
<TodoItem key={t.id} todo={t} onToggle={onToggle} onDelete={onDelete} onEdit={onEdit} />
))}
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function NotesPage() {
const qc = useQueryClient()
const { data: notes = [] } = useQuery({
queryKey: ['site-notes'],
queryFn: () => client.get('/api/notes/').then(r => r.data),
staleTime: 30_000,
})
const { data: todos = [] } = useQuery({
queryKey: ['site-todos'],
queryFn: () => client.get('/api/notes/todos').then(r => r.data),
staleTime: 30_000,
})
// ── Note mutations ──────────────────────────────────────────────────────────
const addNote = useMutation({
mutationFn: ({ body, is_pinned }) => client.post('/api/notes/', { body, is_pinned }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-notes'] }),
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const saveNote = useMutation({
mutationFn: ({ id, body }) => client.put(`/api/notes/${id}`, { body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-notes'] }),
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const pinNote = useMutation({
mutationFn: ({ id, is_pinned }) => client.put(`/api/notes/${id}`, { is_pinned }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-notes'] }),
})
const deleteNote = useMutation({
mutationFn: (id) => client.delete(`/api/notes/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-notes'] }),
onError: () => toast.error('Σφάλμα διαγραφής'),
})
// ── Todo mutations ──────────────────────────────────────────────────────────
const addTodo = useMutation({
mutationFn: ({ body, priority }) => client.post('/api/notes/todos', { body, priority }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-todos'] }),
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const toggleTodo = useMutation({
mutationFn: ({ id, is_done }) => client.put(`/api/notes/todos/${id}`, { is_done }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-todos'] }),
})
const editTodo = useMutation({
mutationFn: ({ id, body }) => client.put(`/api/notes/todos/${id}`, { body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-todos'] }),
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const deleteTodo = useMutation({
mutationFn: (id) => client.delete(`/api/notes/todos/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['site-todos'] }),
onError: () => toast.error('Σφάλμα διαγραφής'),
})
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Page header */}
<div style={{
padding: '18px 28px 14px',
borderBottom: '1px solid #f0f0ef',
display: 'flex', alignItems: 'baseline', gap: 16, flexShrink: 0,
background: 'white',
}}>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Σημειώσεις & Εργασίες</h1>
<span style={{ fontSize: 13, color: '#9ca3af' }}>
{notes.length} σημειώσεις · {todos.filter(t => !t.is_done).length} εκκρεμείς εργασίες
</span>
</div>
{/* Two-column body */}
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr',
gap: 0, flex: 1, overflow: 'hidden',
}}>
{/* Left: Notes */}
<div style={{
borderRight: '1px solid #f0f0ef',
padding: '20px 24px',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 12 }}>
Σημειώσεις
</div>
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<NotesColumn
notes={notes}
onAdd={(body, is_pinned) => addNote.mutate({ body, is_pinned })}
onSave={(id, body) => saveNote.mutate({ id, body })}
onDelete={(id) => deleteNote.mutate(id)}
onTogglePin={(id, is_pinned) => pinNote.mutate({ id, is_pinned })}
/>
</div>
</div>
{/* Right: Todos */}
<div style={{
padding: '20px 24px',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 12 }}>
Εργασίες
</div>
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<TodosColumn
todos={todos}
onAdd={(body, priority) => addTodo.mutate({ body, priority })}
onToggle={(id, is_done) => toggleTodo.mutate({ id, is_done })}
onEdit={(id, body) => editTodo.mutate({ id, body })}
onDelete={(id) => deleteTodo.mutate(id)}
/>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,394 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import useAuthStore from '../store/authStore'
import {
getIncomingOrders,
getActiveOrders,
getOrderHistory,
acceptOnlineOrder,
rejectOnlineOrder,
updateOnlineOrderStatus,
} from '../api/client'
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(iso) {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
return `${Math.floor(diff / 3600)}h ago`
}
function calcTotals(items = []) {
return items.reduce((sum, it) => sum + (it.unit_price ?? 0) * (it.quantity ?? 1), 0)
}
const STATUS_LABELS = {
pending_acceptance: { label: 'Αναμονή', cls: 'bg-amber-100 text-amber-700' },
accepted: { label: 'Αποδεκτή', cls: 'bg-blue-100 text-blue-700' },
preparing: { label: 'Προετοιμασία', cls: 'bg-indigo-100 text-indigo-700' },
ready: { label: 'Έτοιμη', cls: 'bg-green-100 text-green-700' },
out_for_delivery: { label: 'Παράδοση', cls: 'bg-purple-100 text-purple-700' },
delivered: { label: 'Παραδόθηκε', cls: 'bg-gray-100 text-gray-500' },
rejected: { label: 'Απορρίφθηκε', cls: 'bg-red-100 text-red-600' },
}
function OnlineStatusBadge({ status }) {
const { label, cls } = STATUS_LABELS[status] ?? { label: status, cls: 'bg-gray-100 text-gray-600' }
return (
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full ${cls}`}>
{label}
</span>
)
}
// ── Order Card ────────────────────────────────────────────────────────────────
function OrderCard({ order, onAction }) {
const [rejectOpen, setRejectOpen] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [busy, setBusy] = useState(false)
const [tick, setTick] = useState(0)
// Refresh the "X min ago" label every 30 s without a full re-render of parent
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 30_000)
return () => clearInterval(id)
}, [])
const subtotal = calcTotals(order.items)
const isDelivery = order.online_order_type === 'delivery'
const st = order.online_status
async function act(fn) {
setBusy(true)
try { await fn() } finally { setBusy(false) }
}
return (
<div className="bg-white border border-gray-200 rounded-2xl shadow-sm overflow-hidden">
{/* Card header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50/60">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-800 text-base">
{order.online_order_ref ?? `#${order.id}`}
</span>
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
isDelivery ? 'bg-orange-100 text-orange-700' : 'bg-teal-100 text-teal-700'
}`}>
{isDelivery ? 'Delivery' : 'Dine-in'}
</span>
<OnlineStatusBadge status={st} />
</div>
<span className="text-xs text-gray-400">{timeAgo(order.opened_at)}</span>
</div>
{/* Customer info */}
<div className="px-4 py-3 space-y-0.5 border-b border-gray-100 text-sm text-gray-700">
{order.online_customer_name && (
<p className="font-medium">{order.online_customer_name}</p>
)}
{order.online_customer_phone && (
<p className="text-gray-500">{order.online_customer_phone}</p>
)}
{isDelivery && order.online_customer_address && (
<p className="text-gray-500">{order.online_customer_address}</p>
)}
{order.online_customer_notes && (
<p className="text-amber-700 bg-amber-50 rounded px-2 py-1 text-xs mt-1">
📝 {order.online_customer_notes}
</p>
)}
</div>
{/* Items */}
<div className="px-4 py-3 border-b border-gray-100">
<div className="space-y-1 text-sm text-gray-700">
{order.items.map((it, i) => (
<div key={i} className="flex justify-between">
<span>{it.quantity} × {it.product?.name ?? `#${it.product_id}`}</span>
<span className="text-gray-500">{(it.unit_price * it.quantity).toFixed(2)}</span>
</div>
))}
</div>
<div className="mt-2 pt-2 border-t border-gray-100 flex justify-between font-semibold text-sm">
<span>Σύνολο</span>
<span>{subtotal.toFixed(2)}</span>
</div>
</div>
{/* Actions */}
<div className="px-4 py-3 flex flex-wrap gap-2">
{st === 'pending_acceptance' && (
<>
<button
onClick={() => act(() => onAction('accept', order.id))}
disabled={busy}
className="btn btn-primary text-sm px-4 min-h-0 h-9 bg-green-600 hover:bg-green-700 border-green-600"
>
Αποδοχή
</button>
<button
onClick={() => setRejectOpen(r => !r)}
disabled={busy}
className="btn btn-danger text-sm px-4 min-h-0 h-9"
>
Απόρριψη
</button>
</>
)}
{st === 'accepted' && (
<button
onClick={() => act(() => onAction('status', order.id, 'preparing'))}
disabled={busy}
className="btn btn-secondary text-sm px-4 min-h-0 h-9"
>
Προετοιμασία
</button>
)}
{st === 'preparing' && (
isDelivery ? (
<button
onClick={() => act(() => onAction('status', order.id, 'out_for_delivery'))}
disabled={busy}
className="btn btn-secondary text-sm px-4 min-h-0 h-9"
>
Εξωτερική Παράδοση
</button>
) : (
<button
onClick={() => act(() => onAction('status', order.id, 'ready'))}
disabled={busy}
className="btn btn-secondary text-sm px-4 min-h-0 h-9"
>
Έτοιμη
</button>
)
)}
{(st === 'ready' || st === 'out_for_delivery') && (
<button
onClick={() => act(() => onAction('status', order.id, 'delivered'))}
disabled={busy}
className="btn btn-primary text-sm px-4 min-h-0 h-9"
>
Παραδόθηκε
</button>
)}
</div>
{/* Reject inline form */}
{rejectOpen && (
<div className="px-4 pb-4 flex gap-2 items-start border-t border-gray-100 pt-3">
<textarea
className="input flex-1 resize-none text-sm"
rows={2}
placeholder="Λόγος απόρριψης (προαιρετικό)"
value={rejectReason}
onChange={e => setRejectReason(e.target.value)}
/>
<div className="flex flex-col gap-2">
<button
onClick={() => act(() => onAction('reject', order.id, rejectReason))}
disabled={busy}
className="btn btn-danger text-sm px-3 min-h-0 h-9"
>
Επιβεβαίωση
</button>
<button
onClick={() => setRejectOpen(false)}
className="btn btn-secondary text-sm px-3 min-h-0 h-9"
>
Ακύρωση
</button>
</div>
</div>
)}
</div>
)
}
// ── Tab Panel ─────────────────────────────────────────────────────────────────
function OrderList({ orders, onAction, emptyText }) {
if (!orders.length) {
return <p className="text-sm text-gray-400 text-center py-12">{emptyText}</p>
}
return (
<div className="space-y-4">
{orders.map(o => (
<OrderCard key={o.id} order={o} onAction={onAction} />
))}
</div>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
const TABS = [
{ key: 'incoming', label: 'Εισερχόμενες' },
{ key: 'active', label: 'Ενεργές' },
{ key: 'history', label: 'Ιστορικό' },
]
export default function OnlineOrdersPage() {
const [activeTab, setActiveTab] = useState('incoming')
const [incoming, setIncoming] = useState([])
const [active, setActive] = useState([])
const [history, setHistory] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const token = useAuthStore(s => s.token)
const pollRef = useRef(null)
const fetchAll = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [inc, act, hist] = await Promise.all([
getIncomingOrders(),
getActiveOrders(),
getOrderHistory(),
])
setIncoming(inc.data)
setActive(act.data)
setHistory(hist.data)
} catch {
setError('Σφάλμα φόρτωσης παραγγελιών.')
} finally {
setLoading(false)
}
}, [])
// Initial fetch + 20 s polling
useEffect(() => {
fetchAll()
pollRef.current = setInterval(fetchAll, 20_000)
return () => clearInterval(pollRef.current)
}, [fetchAll])
// SSE listener — refetch on online_order_received or online_order_updated
useEffect(() => {
if (!token) return
const es = new EventSource(`/api/stream?token=${encodeURIComponent(token)}`)
es.onmessage = (e) => {
try {
const { type } = JSON.parse(e.data)
if (type === 'online_order_received' || type === 'online_order_updated') {
fetchAll()
}
} catch { /* ignore malformed frames */ }
}
return () => es.close()
}, [token, fetchAll])
async function handleAction(action, orderId, extra) {
try {
if (action === 'accept') {
const { data: updated } = await acceptOnlineOrder(orderId)
setIncoming(prev => prev.filter(o => o.id !== orderId))
setActive(prev => [updated, ...prev])
} else if (action === 'reject') {
const { data: updated } = await rejectOnlineOrder(orderId, extra)
setIncoming(prev => prev.filter(o => o.id !== orderId))
setHistory(prev => [updated, ...prev])
} else if (action === 'status') {
const { data: updated } = await updateOnlineOrderStatus(orderId, extra)
const isDone = updated.online_status === 'delivered' || updated.online_status === 'rejected'
if (isDone) {
setActive(prev => prev.filter(o => o.id !== orderId))
setHistory(prev => [updated, ...prev])
} else {
setActive(prev => prev.map(o => o.id === orderId ? updated : o))
}
}
} catch {
// Re-fetch to sync — avoids stale state after a failed optimistic update
fetchAll()
}
}
const pendingCount = incoming.length
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Page header */}
<div className="px-6 py-5 border-b border-gray-100 shrink-0 flex items-center gap-3">
<h1 className="text-xl font-bold text-gray-800">Online Παραγγελίες</h1>
{pendingCount > 0 && (
<span className="bg-red-500 text-white text-xs font-bold px-2 py-0.5 rounded-full">
{pendingCount}
</span>
)}
<button
onClick={fetchAll}
disabled={loading}
className="ml-auto btn btn-secondary text-sm px-3 min-h-0 h-8"
>
{loading ? '…' : '↻ Ανανέωση'}
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200 shrink-0 bg-white px-4">
{TABS.map(tab => {
const count = tab.key === 'incoming' ? incoming.length
: tab.key === 'active' ? active.length
: history.length
const isActive = activeTab === tab.key
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors flex items-center gap-1.5 ${
isActive
? 'border-primary-600 text-primary-700 bg-primary-50/50'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
{tab.label}
{count > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full font-mono ${
tab.key === 'incoming'
? 'bg-red-100 text-red-700'
: isActive ? 'bg-primary-100 text-primary-700' : 'bg-gray-100 text-gray-500'
}`}>
{count}
</span>
)}
</button>
)
})}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{activeTab === 'incoming' && (
<OrderList
orders={incoming}
onAction={handleAction}
emptyText="Δεν υπάρχουν εισερχόμενες παραγγελίες."
/>
)}
{activeTab === 'active' && (
<OrderList
orders={active}
onAction={handleAction}
emptyText="Δεν υπάρχουν ενεργές παραγγελίες."
/>
)}
{activeTab === 'history' && (
<OrderList
orders={history}
onAction={handleAction}
emptyText="Δεν υπάρχει ιστορικό παραγγελιών."
/>
)}
</div>
</div>
)
}

View File

@@ -157,6 +157,8 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
const [confirmAction, setConfirmAction] = useState(null) // { type, payload }
const [showPrintModal, setShowPrintModal] = useState(false)
const [payPending, setPayPending] = useState(null) // item_ids waiting for method selection
const [customerSearch, setCustomerSearch] = useState('')
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
const { data: order, isLoading } = useQuery({
queryKey: ['order', orderId],
@@ -220,12 +222,31 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
onError: () => toast.error('Σφάλμα'),
})
const { data: customersData = [] } = useQuery({
queryKey: ['customers', customerSearch],
queryFn: () => client.get('/api/customers/', { params: customerSearch ? { search: customerSearch } : {} }).then(r => r.data),
staleTime: 30_000,
enabled: showCustomerDropdown,
})
const assignCustomer = useMutation({
mutationFn: (customer_id) => client.put(`/api/orders/${orderId}/customer`, { customer_id }),
onSuccess: (_, customer_id) => { toast.success(customer_id ? 'Πελάτης ανατέθηκε' : 'Πελάτης αφαιρέθηκε'); invalidate(); setShowCustomerDropdown(false); setCustomerSearch('') },
onError: () => toast.error('Σφάλμα'),
})
const payItems = useMutation({
mutationFn: ({ item_ids, payment_method }) => client.post(`/api/orders/${orderId}/pay`, { item_ids, payment_method }),
onSuccess: () => { toast.success('Πληρώθηκε'); invalidate() },
onError: () => toast.error('Σφάλμα πληρωμής'),
})
const tabItem = useMutation({
mutationFn: (itemId) => client.post(`/api/orders/${orderId}/items/${itemId}/tab`),
onSuccess: () => { toast.success('Αντικείμενο μεταφέρθηκε στην καρτέλα'); invalidate() },
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
})
function requestPay(item_ids) {
setPayPending(item_ids)
}
@@ -267,7 +288,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
<div>
<h1 className="text-xl font-bold text-gray-800">Παραγγελία #{order.id}</h1>
<p className="text-sm text-gray-500 mt-1">
Τραπέζι {order.table_id} · Ανοίχτηκε {formatDate(order.opened_at)}
Τραπέζι {order.table_name || order.table_id} · Ανοίχτηκε {formatDate(order.opened_at)}
{order.closed_at && ` · Έκλεισε ${formatDate(order.closed_at)}`}
</p>
</div>
@@ -277,6 +298,73 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
</div>
</div>
{/* Customer assignment */}
<div className="card p-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-gray-700">Πελάτης</h2>
{order.customer_id && isOpen && !readOnly && (
<button
onClick={() => assignCustomer.mutate(null)}
className="text-xs text-gray-400 hover:text-red-500"
>
Αφαίρεση
</button>
)}
</div>
{order.customer_id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', background: '#eff6ff', borderRadius: 8, border: '1px solid #bfdbfe' }}>
<span style={{ fontSize: 16 }}>👤</span>
<div>
<div style={{ fontSize: 13.5, fontWeight: 700, color: '#1d4ed8' }}>{order.customer_name || `Πελάτης #${order.customer_id}`}</div>
{order.customer_phone && <div style={{ fontSize: 11.5, color: '#3b82f6' }}>{order.customer_phone}</div>}
</div>
</div>
) : isOpen && !readOnly ? (
<div style={{ position: 'relative' }}>
{!showCustomerDropdown ? (
<button
onClick={() => setShowCustomerDropdown(true)}
style={{ fontSize: 13, color: '#6b7280', border: '1px dashed #d1d5db', borderRadius: 8, padding: '7px 14px', cursor: 'pointer', background: 'white', width: '100%', textAlign: 'left' }}
>
+ Ανάθεση πελάτη
</button>
) : (
<div>
<input
autoFocus
value={customerSearch}
onChange={e => setCustomerSearch(e.target.value)}
placeholder="Αναζήτηση πελάτη…"
style={{ width: '100%', padding: '7px 11px', border: '1.5px solid #3b82f6', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box' }}
/>
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, background: 'white', border: '1px solid #e5e7eb', borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.10)', zIndex: 20, maxHeight: 220, overflowY: 'auto', marginTop: 4 }}>
{customersData.length === 0 && (
<div style={{ padding: '10px 14px', fontSize: 13, color: '#9ca3af' }}>Δεν βρέθηκαν πελάτες</div>
)}
{customersData.map(c => (
<button key={c.id} onClick={() => assignCustomer.mutate(c.id)}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '9px 14px', border: 'none', background: 'none', cursor: 'pointer', fontSize: 13, borderBottom: '1px solid #f3f4f6' }}
className="hover:bg-blue-50"
>
<span style={{ fontWeight: 600, color: '#111315' }}>{c.name}</span>
{c.nickname && <span style={{ color: '#9ca3af', marginLeft: 5 }}>«{c.nickname}»</span>}
{c.phone && <span style={{ color: '#6b7280', marginLeft: 8, fontSize: 12 }}>{c.phone}</span>}
</button>
))}
<button onClick={() => { setShowCustomerDropdown(false); setCustomerSearch('') }}
style={{ display: 'block', width: '100%', padding: '8px 14px', fontSize: 12, color: '#9ca3af', border: 'none', background: 'none', cursor: 'pointer', borderTop: '1px solid #f0f0ef' }}>
Ακύρωση
</button>
</div>
</div>
)}
</div>
) : (
<div style={{ fontSize: 13, color: '#d1d5db' }}>Χωρίς πελάτη</div>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200">
{[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
@@ -366,6 +454,14 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
>
Πληρωμή
</button>
{order.customer_id && (
<button
onClick={() => tabItem.mutate(item.id)}
style={{ fontSize: 12, padding: '0 8px', height: 32, borderRadius: 6, border: '1px solid #bfdbfe', background: '#eff6ff', color: '#1d4ed8', cursor: 'pointer', fontWeight: 600, whiteSpace: 'nowrap' }}
>
📋 Καρτέλα
</button>
)}
<button
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
@@ -374,6 +470,11 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
</button>
</>
)}
{item.status === 'tabbed' && (
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe' }}>
📋 Καρτέλα
</span>
)}
</div>
</div>
)

View File

@@ -0,0 +1,375 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
// ── Helpers ───────────────────────────────────────────────────────────────────
const DAY_LABELS = ['Δευ', 'Τρί', 'Τετ', 'Πέμ', 'Παρ', 'Σάβ', 'Κυρ']
function fmt(n) {
if (n == null) return '—'
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
}
function weekMonday(d) {
const day = new Date(d)
const dow = day.getDay()
const diff = dow === 0 ? -6 : 1 - dow
day.setDate(day.getDate() + diff)
day.setHours(0, 0, 0, 0)
return day
}
function addDays(d, n) {
const r = new Date(d)
r.setDate(r.getDate() + n)
return r
}
function toLocalDateStr(d) {
return d.toISOString().slice(0, 10)
}
function fmtShortDate(iso) {
if (!iso) return ''
const d = new Date(iso + 'T00:00:00')
return d.toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit' })
}
function fmtTime(t) {
if (!t) return '—'
return t.slice(0, 5)
}
function durationLabel(h) {
if (!h) return ''
const hrs = Math.floor(h)
const mins = Math.round((h - hrs) * 60)
if (hrs === 0) return `${mins}λ`
if (mins === 0) return `${hrs}ω`
return `${hrs}ω${mins}λ`
}
// ── Shift slot in cell ────────────────────────────────────────────────────────
function ShiftSlot({ shift, actual, onDelete }) {
const hasActual = actual && actual.length > 0
const isActive = actual?.some(a => a.is_active)
return (
<div style={{
borderRadius: 7, overflow: 'hidden',
border: `1.5px solid ${hasActual ? '#86efac' : '#bfdbfe'}`,
background: hasActual ? '#f0fdf4' : '#eff6ff',
fontSize: 11.5,
}}>
{/* Scheduled row */}
<div style={{ padding: '5px 8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 4 }}>
<span style={{ fontWeight: 700, color: '#1d4ed8', whiteSpace: 'nowrap' }}>
{fmtTime(shift.start_time)}{fmtTime(shift.end_time)}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{shift.estimated_pay != null && (
<span style={{ color: '#6b7280', fontSize: 10.5 }}>{fmt(shift.estimated_pay)}</span>
)}
<button
onClick={() => onDelete(shift.id)}
style={{ fontSize: 10, color: '#9ca3af', background: 'none', border: 'none', cursor: 'pointer', padding: '0 2px', lineHeight: 1 }}
>
</button>
</div>
</div>
{/* Actual row */}
{hasActual ? (
actual.map(a => (
<div key={a.id} style={{
padding: '3px 8px', borderTop: '1px solid #bbf7d0',
fontSize: 10.5, color: '#15803d', fontWeight: 600,
}}>
{a.started_at ? new Date(a.started_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' }) : '—'}
{a.ended_at
? ` ${new Date(a.ended_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}`
: isActive ? ' (ενεργή)' : ''}
</div>
))
) : (
<div style={{ padding: '3px 8px', borderTop: '1px solid #bfdbfe', fontSize: 10.5, color: '#94a3b8' }}>
Δεν ξεκίνησε
</div>
)}
</div>
)
}
// ── Add shift modal ───────────────────────────────────────────────────────────
function AddShiftModal({ date, waiters, onClose, onAdd, isPending }) {
const [userId, setUserId] = useState(waiters[0]?.id ?? '')
const [start, setStart] = useState('09:00')
const [end, setEnd] = useState('17:00')
const [notes, setNotes] = useState('')
const inputStyle = {
width: '100%', padding: '7px 10px', border: '1px solid #e5e7eb',
borderRadius: 7, fontSize: 13, outline: 'none', fontFamily: 'inherit',
}
const canAdd = userId && start && end
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, padding: 20 }}>
<div style={{ background: 'white', borderRadius: 14, width: '100%', maxWidth: 420, boxShadow: '0 20px 60px rgba(0,0,0,0.18)' }}>
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700 }}>Νέα Βάρδια</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 1 }}>{fmtShortDate(date)}</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}></button>
</div>
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Σερβιτόρος</label>
<select style={inputStyle} value={userId} onChange={e => setUserId(e.target.value)}>
{waiters.map(w => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div>
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Έναρξη</label>
<input type="time" style={inputStyle} value={start} onChange={e => setStart(e.target.value)} />
</div>
<div>
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Λήξη</label>
<input type="time" style={inputStyle} value={end} onChange={e => setEnd(e.target.value)} />
</div>
</div>
<div>
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Σημείωση <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετική)</span></label>
<input style={inputStyle} value={notes} onChange={e => setNotes(e.target.value)} placeholder="π.χ. Μόνο βράδυ" />
</div>
</div>
<div style={{ padding: '12px 20px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} style={{ padding: '7px 16px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
<button
onClick={() => onAdd({ user_id: Number(userId), scheduled_date: date, start_time: start, end_time: end, notes: notes || null })}
disabled={!canAdd || isPending}
style={{ padding: '7px 18px', border: 'none', borderRadius: 7, fontSize: 13, fontWeight: 600, cursor: canAdd ? 'pointer' : 'default', background: canAdd ? '#111827' : '#e5e7eb', color: canAdd ? 'white' : '#9ca3af' }}
>
{isPending ? 'Αποθήκευση…' : 'Προσθήκη'}
</button>
</div>
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function SchedulePage() {
const qc = useQueryClient()
const [weekAnchor, setWeekAnchor] = useState(() => weekMonday(new Date()))
const [addModal, setAddModal] = useState(null) // date string | null
const weekStartStr = toLocalDateStr(weekAnchor)
const weekEndStr = toLocalDateStr(addDays(weekAnchor, 6))
const { data, isLoading } = useQuery({
queryKey: ['schedule-week', weekStartStr],
queryFn: () => client.get('/api/schedule/week', { params: { week_start: weekStartStr } }).then(r => r.data),
staleTime: 30_000,
})
const addShift = useMutation({
mutationFn: (body) => client.post('/api/schedule/', body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['schedule-week'] }); setAddModal(null); toast.success('Βάρδια προστέθηκε') },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const deleteShift = useMutation({
mutationFn: (id) => client.delete(`/api/schedule/${id}`),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['schedule-week'] }); toast.success('Βάρδια διαγράφηκε') },
onError: () => toast.error('Σφάλμα διαγραφής'),
})
const waiters = data?.waiters ?? []
const scheduled = data?.scheduled ?? []
const totalPay = data?.total_estimated_pay ?? 0
// Build lookup: waiter_id → name
const waiterNames = Object.fromEntries(waiters.map(w => [w.id, w.name]))
// Build 7 day columns
const days = Array.from({ length: 7 }, (_, i) => {
const d = addDays(weekAnchor, i)
return { date: d, dateStr: toLocalDateStr(d), label: DAY_LABELS[i] }
})
// Build per-waiter per-day grid
// { waiter_id: { dateStr: { scheduled: [...], actual: [...] } } }
const grid = {}
for (const s of scheduled) {
if (!grid[s.user_id]) grid[s.user_id] = {}
const ds = typeof s.scheduled_date === 'string' ? s.scheduled_date : toLocalDateStr(new Date(s.scheduled_date))
if (!grid[s.user_id][ds]) grid[s.user_id][ds] = { scheduled: [], actual: [] }
grid[s.user_id][ds].scheduled.push(s)
grid[s.user_id][ds].actual.push(...(s.actual_shifts || []))
}
// Unique waiter ids that appear in this week's schedule
const activeWaiterIds = [...new Set(scheduled.map(s => s.user_id))]
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '14px 24px 12px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10 }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Πρόγραμμα</h1>
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
{fmtShortDate(weekStartStr)} {fmtShortDate(weekEndStr)}
{totalPay > 0 && <span style={{ marginLeft: 10, color: '#6b7280' }}>Εκτιμώμενο κόστος εβδομάδας: <strong style={{ color: '#111315' }}>{fmt(totalPay)}</strong></span>}
</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setWeekAnchor(w => addDays(w, -7))}
style={{ padding: '6px 14px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer' }}> Προηγ.</button>
<button onClick={() => setWeekAnchor(weekMonday(new Date()))}
style={{ padding: '6px 14px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer' }}>Αυτή η εβδομάδα</button>
<button onClick={() => setWeekAnchor(w => addDays(w, 7))}
style={{ padding: '6px 14px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer' }}>Επόμ. </button>
</div>
</div>
{/* Grid */}
<div style={{ flex: 1, overflowX: 'auto', overflowY: 'auto', padding: '12px 24px 16px' }}>
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '48px 0' }}>Φόρτωση</div>}
{!isLoading && (
<table style={{ borderCollapse: 'collapse', width: '100%', minWidth: 700 }}>
<thead>
<tr>
<th style={{ padding: '8px 12px', textAlign: 'left', fontSize: 12, fontWeight: 700, color: '#9ca3af', width: 140 }}>Σερβιτόρος</th>
{days.map(d => {
const isToday = toLocalDateStr(new Date()) === d.dateStr
return (
<th key={d.dateStr} style={{
padding: '8px 4px', textAlign: 'center', fontSize: 12,
fontWeight: isToday ? 800 : 600,
color: isToday ? '#3b82f6' : '#374151',
minWidth: 110,
}}>
<div>{d.label}</div>
<div style={{ fontSize: 11, fontWeight: 500, color: isToday ? '#3b82f6' : '#9ca3af' }}>
{fmtShortDate(d.dateStr)}
</div>
</th>
)
})}
</tr>
</thead>
<tbody>
{/* Rows for scheduled waiters */}
{activeWaiterIds.length === 0 && (
<tr>
<td colSpan={8} style={{ textAlign: 'center', color: '#d1d5db', fontSize: 13, padding: '40px 0' }}>
Δεν υπάρχουν προγραμματισμένες βάρδιες αυτή την εβδομάδα.<br />
Κάντε κλικ σε ένα κελί για να προσθέσετε.
</td>
</tr>
)}
{activeWaiterIds.map(uid => {
const w = waiters.find(w => w.id === uid) || { id: uid, name: waiterNames[uid] || `#${uid}`, hourly_rate: null }
return (
<tr key={uid} style={{ borderTop: '1px solid #f3f4f6' }}>
<td style={{ padding: '8px 12px', verticalAlign: 'top' }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#111315' }}>{w.name}</div>
{w.hourly_rate && <div style={{ fontSize: 11, color: '#9ca3af' }}>{fmt(w.hourly_rate)}/ώρα</div>}
</td>
{days.map(d => {
const cell = grid[uid]?.[d.dateStr]
return (
<td key={d.dateStr} style={{ padding: '4px', verticalAlign: 'top', position: 'relative' }}
onClick={() => { if (!cell?.scheduled?.length) setAddModal({ date: d.dateStr, userId: uid }) }}
>
<div style={{ minHeight: 48, cursor: cell?.scheduled?.length ? 'default' : 'pointer' }}>
{cell?.scheduled?.map(s => (
<ShiftSlot
key={s.id} shift={s}
actual={cell.actual}
onDelete={id => { if (window.confirm('Διαγραφή βάρδιας;')) deleteShift.mutate(id) }}
/>
))}
{!cell?.scheduled?.length && (
<div style={{
height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#e5e7eb', fontSize: 18, borderRadius: 7,
border: '1.5px dashed transparent',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = '#bfdbfe'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'transparent'}
>
+
</div>
)}
</div>
</td>
)
})}
</tr>
)
})}
{/* Add row for other waiters not yet scheduled */}
<tr style={{ borderTop: '1px solid #f3f4f6' }}>
<td colSpan={8} style={{ padding: '8px 12px' }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{waiters.filter(w => !activeWaiterIds.includes(w.id)).map(w => (
<button key={w.id}
onClick={() => setAddModal({ date: weekStartStr, userId: w.id })}
style={{
padding: '4px 12px', fontSize: 12, fontWeight: 600, cursor: 'pointer',
border: '1px dashed #d1d5db', borderRadius: 20, background: 'white', color: '#6b7280',
}}>
+ {w.name}
</button>
))}
{waiters.length === 0 && (
<span style={{ fontSize: 12, color: '#d1d5db' }}>Δεν υπάρχουν ενεργοί σερβιτόροι.</span>
)}
</div>
</td>
</tr>
</tbody>
</table>
)}
</div>
{/* Legend */}
<div style={{ padding: '8px 24px', borderTop: '1px solid #f0f0ef', background: '#fafafa', display: 'flex', gap: 16, flexShrink: 0 }}>
{[
{ color: '#bfdbfe', bg: '#eff6ff', label: 'Προγραμματισμένη (δεν ξεκίνησε)' },
{ color: '#86efac', bg: '#f0fdf4', label: 'Προγραμματισμένη + Πραγματική' },
].map(l => (
<div key={l.label} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: '#6b7280' }}>
<div style={{ width: 14, height: 10, borderRadius: 3, background: l.bg, border: `1.5px solid ${l.color}` }} />
{l.label}
</div>
))}
</div>
{addModal && (
<AddShiftModal
date={addModal.date}
waiters={addModal.userId
? waiters.filter(w => w.id === addModal.userId).concat(waiters.filter(w => w.id !== addModal.userId))
: waiters}
onClose={() => setAddModal(null)}
isPending={addShift.isPending}
onAdd={(body) => addShift.mutate(body)}
/>
)}
</div>
)
}

View File

@@ -4,6 +4,8 @@ import ColoursTab from './tabs/ColoursTab'
import OperationTab from './tabs/OperationTab'
import PrintFontsTab from './tabs/PrintFontsTab'
import SecurityTab from './tabs/SecurityTab'
import DevelopmentTab from './tabs/DevelopmentTab'
import Phase2FeaturesTab from './tabs/Phase2FeaturesTab'
import { TabGroup } from '../../ui/Tabs'
const TABS = [
@@ -12,6 +14,8 @@ const TABS = [
{ id: 'operation', label: 'Λειτουργία' },
{ id: 'colours', label: 'Εμφάνιση' },
{ id: 'print-fonts', label: 'Εκτύπωση' },
{ id: 'features', label: 'Λειτουργίες' },
{ id: 'development', label: 'Developer' },
]
export default function SettingsPage() {
@@ -26,6 +30,8 @@ export default function SettingsPage() {
{activeTab === 'operation' && <OperationTab />}
{activeTab === 'colours' && <ColoursTab />}
{activeTab === 'print-fonts' && <PrintFontsTab />}
{activeTab === 'features' && <Phase2FeaturesTab />}
{activeTab === 'development' && <DevelopmentTab />}
</div>
</div>
)

View File

@@ -0,0 +1,135 @@
import { useState } from 'react'
import { PHASE2_FEATURE_DEFS, isFeatureEnabled, setFeatureEnabled, usePhase2Features } from '../../../hooks/usePhase2Features'
const SECRET = '4034'
function PinGate({ onUnlock }) {
const [pin, setPin] = useState('')
const [error, setError] = useState(false)
function submit() {
if (pin === SECRET) {
onUnlock()
} else {
setError(true)
setPin('')
setTimeout(() => setError(false), 1500)
}
}
return (
<div className="flex flex-col items-center justify-center gap-5 py-16 max-w-xs mx-auto">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100 text-2xl">🔒</div>
<div className="text-center">
<p className="font-semibold text-slate-800">Περιορισμένη πρόσβαση</p>
<p className="text-sm text-slate-400 mt-1">Εισάγετε τον κωδικό για να συνεχίσετε</p>
</div>
<div className="flex flex-col items-center gap-3 w-full">
<input
type="password"
value={pin}
onChange={e => setPin(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submit()}
autoFocus
maxLength={8}
placeholder="••••"
className={`w-full text-center text-xl tracking-[0.4em] border rounded-xl px-4 py-3 outline-none transition-colors ${
error ? 'border-red-400 bg-red-50 text-red-600' : 'border-slate-200 focus:border-sky-400'
}`}
/>
{error && <p className="text-xs text-red-500 font-medium">Λάθος κωδικός</p>}
<button
onClick={submit}
className="w-full rounded-xl bg-slate-900 text-white font-semibold py-2.5 text-sm hover:bg-slate-700 transition-colors"
>
Είσοδος
</button>
</div>
</div>
)
}
function Toggle({ checked, onChange }) {
return (
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
style={{
width: 44, height: 24, borderRadius: 999, border: 'none', cursor: 'pointer',
background: checked ? '#16a34a' : '#d1d5db',
position: 'relative', transition: 'background 150ms', flexShrink: 0,
}}
>
<span style={{
position: 'absolute', top: 3, left: checked ? 23 : 3,
width: 18, height: 18, borderRadius: '50%', background: 'white',
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
)
}
export default function Phase2FeaturesTab() {
const [unlocked, setUnlocked] = useState(false)
const features = usePhase2Features()
if (!unlocked) return <PinGate onUnlock={() => setUnlocked(true)} />
const allOn = features.every(f => f.enabled)
const allOff = features.every(f => !f.enabled)
function toggleAll(value) {
PHASE2_FEATURE_DEFS.forEach(f => setFeatureEnabled(f.id, value))
}
return (
<div className="space-y-4 max-w-xl">
<div className="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-xs text-blue-800">
Αυτές οι επιλογές ελέγχουν ποιες σελίδες Phase 2 εμφανίζονται στο πλαϊνό μενού.
Απενεργοποίηση <strong>δεν σβήνει δεδομένα</strong> απλώς κρύβει τον σύνδεσμο.
Χρήσιμο για να κρατάτε σελίδες σε εξέλιξη κρυφές από πελάτες.
</div>
<div className="card divide-y divide-gray-100">
{/* Master toggle row */}
<div className="flex items-center justify-between gap-4 px-5 py-3 bg-gray-50/60">
<span className="text-sm font-semibold text-gray-600">Όλες οι λειτουργίες</span>
<div className="flex gap-3">
<button
onClick={() => toggleAll(true)}
disabled={allOn}
className="text-xs font-semibold px-3 py-1 rounded-full border border-green-300 text-green-700 hover:bg-green-50 disabled:opacity-40 disabled:cursor-default"
>
Ενεργοποίηση όλων
</button>
<button
onClick={() => toggleAll(false)}
disabled={allOff}
className="text-xs font-semibold px-3 py-1 rounded-full border border-red-300 text-red-600 hover:bg-red-50 disabled:opacity-40 disabled:cursor-default"
>
Απενεργοποίηση όλων
</button>
</div>
</div>
{features.map(f => (
<div key={f.id} className="flex items-center justify-between gap-4 px-5 py-3">
<div>
<p className="text-sm font-medium text-gray-700">{f.label}</p>
<p className="text-xs text-gray-400 mt-0.5 font-mono">{f.route}</p>
</div>
<Toggle
checked={f.enabled}
onChange={val => setFeatureEnabled(f.id, val)}
/>
</div>
))}
</div>
<p className="text-xs text-gray-400">
Οι ρυθμίσεις αποθηκεύονται τοπικά στον browser. Ισχύουν αμέσως χωρίς ανανέωση.
</p>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import React, { useState, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../../../api/client'
@@ -30,6 +30,13 @@ const DIVIDER_OPTIONS = [
{ value: 'empty', label: 'Κενή γραμμή', chars: '' },
]
const DOT_STYLE_OPTIONS = [
{ value: 'dot_space', label: 'Τελεία + κενό ( . )', preview: '. . . . .' },
{ value: 'dot', label: 'Τελείες ( . )', preview: '.........' },
{ value: 'dash_space', label: 'Παύλα + κενό ( - )', preview: '- - - - -' },
{ value: 'underscore', label: 'Κάτω παύλα ( _ )', preview: '_________' },
]
const FONT_DEFAULTS = {
'print.font_order_number': '48:1:0',
'print.font_meta': '0:0:0',
@@ -41,7 +48,10 @@ const FONT_DEFAULTS = {
'print.font_item_note': '0:0:0',
'print.font_order_note': '0:1:0',
'print.divider_style': 'dash',
'print.dot_style': 'dot_space:0:0',
'print.ticket_mode': 'detailed',
'print.beep_on_ticket': 'true',
'print.beep_pattern': 'double',
}
// ── Preview ────────────────────────────────────────────────────────────────
@@ -229,6 +239,100 @@ function DividerRow({ value, onChange, isPending }) {
)
}
// ── Dot style row ─────────────────────────────────────────────────────────
function decodeDotStyle(val) {
if (!val) return { style: 'dot_space', size: '0', bold: false }
const [style, size, bold] = val.split(':')
return { style: style ?? 'dot_space', size: size ?? '0', bold: bold === '1' }
}
function encodeDotStyle(style, size, bold) {
return `${style}:${size}:${bold ? '1' : '0'}`
}
function DotStyleRow({ value, onChange, isPending }) {
const { style, size, bold } = decodeDotStyle(value)
const selected = DOT_STYLE_OPTIONS.find(o => o.value === style) ?? DOT_STYLE_OPTIONS[0]
const s = sizeStyle[size] ?? sizeStyle['0']
function handleStyle(e) { onChange('print.dot_style', encodeDotStyle(e.target.value, size, bold)) }
function handleSize(e) { onChange('print.dot_style', encodeDotStyle(style, e.target.value, bold)) }
function handleBold() { onChange('print.dot_style', encodeDotStyle(style, size, !bold)) }
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '10px 20px 10px 36px',
borderBottom: '1px solid #f4f4f2',
background: '#fafafa',
}}>
<span style={{ color: '#d1d5db', fontSize: 13, flexShrink: 0, marginRight: -6 }}></span>
<div style={{ flex: '1 1 160px', minWidth: 140 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#111315', display: 'block', marginBottom: 2 }}>
Στυλ Dots
</span>
<span style={{ fontSize: 11, color: '#9ca3af' }}>Χαρακτήρας · μέγεθος · έντονο για τη γραμμή dot-leader</span>
</div>
{/* Style dropdown */}
<select
value={style}
onChange={handleStyle}
disabled={isPending}
style={{
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', padding: '0 10px', fontSize: 13,
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
}}
>
{DOT_STYLE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{/* Size dropdown */}
<select
value={size}
onChange={handleSize}
disabled={isPending}
style={{
height: 36, borderRadius: 8, border: '1px solid #dfe2e6',
background: 'white', padding: '0 10px', fontSize: 13,
color: '#111315', cursor: 'pointer', width: 160, flexShrink: 0,
}}
>
{FONT_SIZE_OPTIONS.map(o => (
<option key={o.size} value={o.size}>{o.label}</option>
))}
</select>
{/* Bold toggle */}
<ToggleBtn active={bold} onClick={handleBold} disabled={isPending} label="ΕΝΤΟΝΑ" />
{/* Preview */}
<div style={{
background: '#1a1a1a', borderRadius: 8,
width: PREVIEW_W, height: PREVIEW_H, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
<span style={{
color: '#f5f5f5',
fontFamily: 'monospace',
fontSize: s.fontSize,
fontWeight: bold ? 800 : 400,
transform: `scaleX(${s.scaleX}) scaleY(${s.scaleY})`,
transformOrigin: 'center',
whiteSpace: 'nowrap',
display: 'block',
}}>
Esp {selected.preview} x2
</span>
</div>
</div>
)
}
// ── Ticket mode section ────────────────────────────────────────────────────
function TicketModeSection({ value, onChange, isPending, printers }) {
const [selectedPrinter, setSelectedPrinter] = useState(null)
@@ -347,10 +451,193 @@ function TicketModeSection({ value, onChange, isPending, printers }) {
)
}
// ── Beep section ───────────────────────────────────────────────────────────
const BEEP_PRESETS = [
{ id: 'off', label: 'Χωρίς Ήχο', desc: 'Χωρίς beep κατά την εκτύπωση' },
{ id: 'single', label: 'Μονό', desc: '1× παλμός 200ms' },
{ id: 'double', label: 'Διπλό', desc: '2× σύντομοι παλμοί (προτεινόμενο)' },
{ id: 'triple', label: 'Τριπλό', desc: '3× σύντομοι παλμοί — πιο εντονο' },
{ id: 'long', label: 'Μακρύ', desc: '1× παλμός 500ms' },
{ id: 'custom', label: 'Προσαρμοσμένο', desc: 'Ορίστε τη διάρκεια χειροκίνητα' },
]
// Parse beep settings → { preset, n1, n2, n3 }
function decodeBeep(patternVal, enabledVal) {
if (enabledVal === 'false') return { preset: 'off', n1: 2, n2: 2, n3: 1 }
if (!patternVal || patternVal === 'double') return { preset: 'double', n1: 1, n2: 1, n3: 2 }
if (patternVal.startsWith('custom:')) {
const [, n1, n2, n3] = patternVal.split(':')
return { preset: 'custom', n1: parseInt(n1) || 2, n2: parseInt(n2) || 2, n3: parseInt(n3) || 1 }
}
const presetMap = { single: [2,2,1], double: [1,1,2], triple: [1,1,3], long: [5,2,1] }
const [n1, n2, n3] = presetMap[patternVal] ?? [1,1,2]
return { preset: patternVal, n1, n2, n3 }
}
function BeepSection({ beepEnabled, beepPattern, onChange, isPending, printers }) {
const { preset: initPreset, n1: iN1, n2: iN2, n3: iN3 } = decodeBeep(beepPattern, beepEnabled)
const [preset, setPreset] = useState(initPreset)
const [n1, setN1] = useState(iN1)
const [n2, setN2] = useState(iN2)
const [n3, setN3] = useState(iN3)
const [testPrinter, setTestPrinter] = useState(null)
const [testing, setTesting] = useState(false)
useEffect(() => {
if (printers.length > 0 && !testPrinter) {
setTestPrinter((printers.find(p => p.is_active) ?? printers[0]).id)
}
}, [printers])
// Sync local state if parent settings change (e.g. after save)
useEffect(() => {
const decoded = decodeBeep(beepPattern, beepEnabled)
setPreset(decoded.preset)
setN1(decoded.n1); setN2(decoded.n2); setN3(decoded.n3)
}, [beepPattern, beepEnabled])
function handlePresetChange(id) {
setPreset(id)
if (id === 'off') {
onChange('print.beep_on_ticket', 'false')
} else if (id === 'custom') {
onChange('print.beep_on_ticket', 'true')
onChange('print.beep_pattern', `custom:${n1}:${n2}:${n3}`)
} else {
onChange('print.beep_on_ticket', 'true')
onChange('print.beep_pattern', id)
}
}
function handleCustomChange(field, raw) {
const v = Math.max(1, Math.min(30, parseInt(raw) || 1))
const next = { n1, n2, n3, [field]: v }
if (field === 'n1') setN1(v)
if (field === 'n2') setN2(v)
if (field === 'n3') setN3(v)
onChange('print.beep_pattern', `custom:${next.n1}:${next.n2}:${next.n3}`)
}
// Compute beep bytes from current state for test
function getBeepBytes() {
if (preset === 'custom') return { n1, n2, n3 }
const presetMap = { single: [2,2,1], double: [1,1,2], triple: [1,1,3], long: [5,2,1] }
const [bn1, bn2, bn3] = presetMap[preset] ?? [1,1,2]
return { n1: bn1, n2: bn2, n3: bn3 }
}
async function handleTestBeep() {
if (!testPrinter || preset === 'off') return
const { n1: b1, n2: b2, n3: b3 } = getBeepBytes()
setTesting(true)
try {
const res = await client.post(`/api/system/printers/test-beep?printer_id=${testPrinter}&n1=${b1}&n2=${b2}&n3=${b3}`)
if (res.data.success) toast.success('Beep στάλθηκε!')
else toast.error(`Σφάλμα: ${res.data.error}`)
} catch {
toast.error('Σφάλμα επικοινωνίας')
} finally {
setTesting(false)
}
}
return (
<div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Ήχος Εκτυπωτή (Beep)</h2>
<p className="text-xs text-gray-400 mt-0.5">
Ο εκτυπωτής χτυπά για να ειδοποιεί την κουζίνα σε κάθε νέα παραγγελία.
</p>
</div>
{/* Preset grid */}
<div style={{ display: 'flex', gap: 10, padding: '16px 20px', flexWrap: 'wrap' }}>
{BEEP_PRESETS.map(opt => {
const active = preset === opt.id
return (
<button
key={opt.id}
onClick={() => handlePresetChange(opt.id)}
disabled={isPending}
style={{
flex: '1 1 140px', textAlign: 'left', padding: '12px 14px',
borderRadius: 10, cursor: isPending ? 'default' : 'pointer',
border: `2px solid ${active ? '#3758c9' : '#e5e7eb'}`,
background: active ? '#eff3ff' : 'white',
}}
>
<div style={{ fontSize: 13, fontWeight: 700, color: active ? '#3758c9' : '#111315', marginBottom: 3 }}>
{opt.label}
</div>
<div style={{ fontSize: 11, color: '#6b7280', lineHeight: 1.4 }}>{opt.desc}</div>
</button>
)
})}
</div>
{/* Custom fields */}
{preset === 'custom' && (
<div style={{ padding: '14px 20px', display: 'flex', gap: 20, alignItems: 'flex-end', flexWrap: 'wrap', background: '#fafafa' }}>
{[
{ field: 'n1', label: 'Διάρκεια ON (×100ms)', val: n1, hint: 'π.χ. 3 = 300ms' },
{ field: 'n2', label: 'Διάρκεια OFF (×100ms)', val: n2, hint: 'Διάλλειμα μεταξύ παλμών' },
{ field: 'n3', label: 'Επαναλήψεις', val: n3, hint: 'Πόσες φορές' },
].map(({ field, label, val, hint }) => (
<div key={field} style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 140px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>{label}</label>
<input
type="number" min={1} max={30} value={val}
onChange={e => handleCustomChange(field, e.target.value)}
disabled={isPending}
style={{ ...inputStyle, width: '100%' }}
/>
<span style={{ fontSize: 10, color: '#9ca3af' }}>{hint}</span>
</div>
))}
</div>
)}
{/* Test beep */}
<div style={{ padding: '14px 20px', display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: '#374151', fontWeight: 500 }}>Δοκιμαστικό Beep:</span>
{printers.length > 0 ? (
<>
<select
value={testPrinter ?? ''}
onChange={e => setTestPrinter(Number(e.target.value))}
disabled={testing}
style={{ ...inputStyle, width: 200 }}
>
{printers.map(p => <option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>)}
</select>
<button
onClick={handleTestBeep}
disabled={testing || preset === 'off' || !testPrinter}
style={{
...btnSecondary,
opacity: (testing || preset === 'off') ? 0.5 : 1,
cursor: (testing || preset === 'off') ? 'default' : 'pointer',
}}
>
{testing ? 'Αποστολή…' : '🔔 Τεστ Beep'}
</button>
{preset === 'off' && (
<span style={{ fontSize: 12, color: '#9ca3af' }}>Ο ήχος είναι απενεργοποιημένος</span>
)}
</>
) : (
<span style={{ fontSize: 12, color: '#ef4444' }}>Δεν υπάρχουν εκτυπωτές</span>
)}
</div>
</div>
)
}
// ── Printers section ───────────────────────────────────────────────────────
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', is_active: true }
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', line_width: 48, is_active: true }
function PrinterForm({ initial, onSave, onCancel, isPending }) {
const [form, setForm] = useState(initial ?? EMPTY_FORM)
@@ -382,6 +669,11 @@ function PrinterForm({ initial, onSave, onCancel, isPending }) {
{PROTOCOLS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 90px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΧΑΡΑΚΤ./ΓΡΑΜΜΗ</label>
<input value={form.line_width} onChange={e => set('line_width', parseInt(e.target.value) || 48)}
type="number" min={20} max={80} style={inputStyle} />
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', paddingBottom: 2 }}>
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
style={btnPrimary}>Αποθήκευση</button>
@@ -446,6 +738,7 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
{printer.ip_address}:{printer.port}
</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {printer.protocol}</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {printer.line_width ?? 48} χαρ.</span>
</div>
<span style={{
@@ -638,7 +931,16 @@ export default function PrintFontsTab() {
printers={printers}
/>
{/* 3. Font sizes — grouped */}
{/* 3. Beep settings */}
<BeepSection
beepEnabled={val('print.beep_on_ticket')}
beepPattern={val('print.beep_pattern')}
onChange={handleChange}
isPending={updateMut.isPending}
printers={printers}
/>
{/* 4. Font sizes */}
<div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Μεγέθη Γραμματοσειράς</h2>
@@ -650,21 +952,29 @@ export default function PrintFontsTab() {
{FONT_GROUPS.map(group => (
<div key={group.group}>
<SubgroupHeader label={group.group} />
{group.fields.map((field, idx) => (
<FontRow
key={field.key}
field={field}
value={val(field.key)}
onChange={handleChange}
isPending={updateMut.isPending}
nested={group.fields.length > 1}
/>
{group.fields.map((field) => (
<React.Fragment key={field.key}>
<FontRow
field={field}
value={val(field.key)}
onChange={handleChange}
isPending={updateMut.isPending}
nested={group.fields.length > 1}
/>
{field.key === 'print.font_ingredient' && (
<DotStyleRow
value={val('print.dot_style')}
onChange={handleChange}
isPending={updateMut.isPending}
/>
)}
</React.Fragment>
))}
</div>
))}
</div>
{/* 4. Divider style */}
{/* 5. Divider style */}
<div className="card divide-y divide-gray-100">
<div style={{ padding: '16px 20px' }}>
<h2 className="font-semibold text-gray-700">Διαχωριστικές Γραμμές</h2>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
@@ -43,6 +43,8 @@ export default function TablesPage() {
const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
const [selected, setSelected] = useState(new Set())
const [anyHovered, setAnyHovered] = useState(false)
const dragGroupId = useRef(null)
const [dragOverGap, setDragOverGap] = useState(null) // index into groups array (0 = before first)
const { data: tables = [], isLoading } = useQuery({
queryKey: ['tables-all', showInactive],
@@ -102,6 +104,12 @@ export default function TablesPage() {
onError: () => toast.error('Σφάλμα'),
})
const reorderGroups = useMutation({
mutationFn: (ids) => client.put('/api/tables/groups/reorder', ids),
onSuccess: invalidateGroups,
onError: () => toast.error('Σφάλμα αναδιάταξης'),
})
// Filter tables for the active tab
const visibleTables = activeTab === 'all'
? tables
@@ -156,23 +164,74 @@ export default function TablesPage() {
{/* Zone tabs */}
<div className="flex gap-0 flex-wrap border-b border-slate-200 px-4 flex-shrink-0">
{zoneTabs.map(tab => (
<button
key={tab.id}
onClick={() => { setActiveTab(tab.id); clearSelect() }}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.id
? 'border-sky-500 text-sky-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}`}
>
{tab.color && <span className="w-2 h-2 rounded-full shrink-0" style={{ background: tab.color }} />}
{tab.label}
<span className="ml-0.5 text-xs text-slate-400">
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
</span>
</button>
))}
{zoneTabs.map(tab => {
const isGroup = tab.id !== 'all' && tab.id !== 'ungrouped'
const groupIdx = isGroup ? groups.findIndex(g => g.id === tab.id) : -1
function handleDragOver(e) {
e.preventDefault()
// Use mouse position within the tab to decide left-half vs right-half gap
const rect = e.currentTarget.getBoundingClientRect()
const isLeftHalf = e.clientX < rect.left + rect.width / 2
setDragOverGap(isLeftHalf ? groupIdx : groupIdx + 1)
}
function handleDrop(e) {
e.preventDefault()
const fromId = dragGroupId.current
dragGroupId.current = null
setDragOverGap(null)
if (!fromId) return
const groupIds = groups.map(g => g.id)
const fromIdx = groupIds.indexOf(fromId)
if (fromIdx === -1 || dragOverGap === null) return
const reordered = [...groupIds]
reordered.splice(fromIdx, 1)
// After removing the dragged item, adjust target index
const insertAt = dragOverGap > fromIdx ? dragOverGap - 1 : dragOverGap
reordered.splice(insertAt, 0, fromId)
if (reordered.join() !== groupIds.join()) reorderGroups.mutate(reordered)
}
const showLineLeft = isGroup && dragOverGap === groupIdx
const showLineRight = isGroup && dragOverGap === groupIdx + 1 && groupIdx === groups.length - 1
return (
<div key={tab.id} className="relative flex items-stretch">
{/* Drop indicator line — left side of this tab */}
{showLineLeft && (
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-sky-500 rounded-full z-10 -translate-x-px" />
)}
<button
onClick={() => { setActiveTab(tab.id); clearSelect() }}
draggable={isGroup}
onDragStart={isGroup ? () => { dragGroupId.current = tab.id } : undefined}
onDragOver={isGroup ? handleDragOver : undefined}
onDragLeave={isGroup ? () => setDragOverGap(null) : undefined}
onDrop={isGroup ? handleDrop : undefined}
onDragEnd={isGroup ? () => { dragGroupId.current = null; setDragOverGap(null) } : undefined}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.id
? 'border-sky-500 text-sky-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
} ${isGroup ? 'cursor-grab active:cursor-grabbing' : ''}`}
>
{isGroup && <span className="text-slate-300 text-xs mr-0.5 select-none"></span>}
{tab.color && <span className="w-2 h-2 rounded-full shrink-0" style={{ background: tab.color }} />}
{tab.label}
<span className="ml-0.5 text-xs text-slate-400">
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
</span>
</button>
{/* Drop indicator line — right side of the last group tab */}
{showLineRight && (
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-sky-500 rounded-full z-10 translate-x-px" />
)}
</div>
)
})}
</div>
{/* Zone action bar (when viewing a specific zone) */}

View File

@@ -289,7 +289,7 @@ function FlagPills({ flags, displayMode = 'both', onOverflowClick }) {
)
}
function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, flags = [], flagDisplayMode = 'both', onClick, onQuickAction, onFlagsClick }) {
function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingPrint = false, flags = [], flagDisplayMode = 'both', upcomingReservation = null, onClick, onQuickAction, onFlagsClick }) {
const s = COLORS[status] || COLORS.free
const [hover, setHover] = useState(false)
const [pressed, setPressed] = useState(false)
@@ -348,6 +348,15 @@ function TableCardV1({ name, status, amount, openedAt, waiters = [], hasPendingP
display: 'inline-flex', alignItems: 'center', gap: 4,
}}></span>
)}
{upcomingReservation && (
<span title={`${upcomingReservation.guest_name} · ${upcomingReservation.party_size} άτομα · ${new Date(upcomingReservation.reserved_for).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}`} style={{
fontSize: 11, fontWeight: 700, background: '#4f46e5', color: '#e0e7ff',
borderRadius: 999, padding: '2px 8px', flexShrink: 0,
display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
🔒 RESERVED · {new Date(upcomingReservation.reserved_for).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
<FlagPills
flags={flags}
displayMode={flagDisplayMode}
@@ -741,6 +750,7 @@ export default function TablesPage() {
hasPendingPrint={hasPendingPrint}
flags={tableFlags}
flagDisplayMode={flagDisplayMode}
upcomingReservation={table.upcoming_reservation ?? null}
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
onQuickAction={() => setQuickActionTarget({ table, order, currentFlags: tableFlags })}
onFlagsClick={tableFlags.length > 3 ? () => setFlagsDetail({ flags: tableFlags, tableName: table.label || `T${table.number}` }) : undefined}

View File

@@ -0,0 +1,342 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } 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 fmtDateTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function daysSince(iso) {
if (!iso) return null
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000)
if (days === 0) return 'Σήμερα'
if (days === 1) return '1 μέρα'
return `${days} μέρες`
}
const STATUS_CONFIG = {
open: { label: 'Ανοιχτή', bg: '#fee2e2', color: '#dc2626' },
closed: { label: 'Κλειστή', bg: '#dcfce7', color: '#16a34a' },
forgiven: { label: 'Χαρίστηκε', bg: '#f3f4f6', color: '#6b7280' },
}
// ── Pay modal ─────────────────────────────────────────────────────────────────
function PayModal({ tab, onClose, onPay, isPending }) {
const [amount, setAmount] = useState(String(Math.round(tab.balance * 100) / 100))
const [method, setMethod] = useState('cash')
const [notes, setNotes] = useState('')
const parsed = parseFloat(amount)
const canPay = parsed > 0 && parsed <= tab.balance + 0.005
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 20 }}>
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 400, boxShadow: '0 20px 60px rgba(0,0,0,0.18)' }}>
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef' }}>
<div style={{ fontSize: 16, fontWeight: 700 }}>Πληρωμή Καρτέλας</div>
<div style={{ fontSize: 13, color: '#6b7280', marginTop: 2 }}>{tab.customer_name}</div>
</div>
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ background: '#f9fafb', borderRadius: 10, padding: '12px 14px', display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: '#6b7280' }}>Υπόλοιπο καρτέλας</span>
<span style={{ fontSize: 15, fontWeight: 700, color: '#dc2626' }}>{fmt(tab.balance)}</span>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Ποσό πληρωμής ()</label>
<input type="number" step="0.01" min="0.01"
value={amount} onChange={e => setAmount(e.target.value)} autoFocus
style={{ width: '100%', padding: '9px 12px', border: '1.5px solid #e5e7eb', borderRadius: 8, fontSize: 16, fontWeight: 600, outline: 'none', boxSizing: 'border-box' }} />
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Τρόπος πληρωμής</label>
<div style={{ display: 'flex', gap: 6 }}>
{[['cash', 'Μετρητά'], ['card', 'Κάρτα'], ['other', 'Άλλο']].map(([v, l]) => (
<button key={v} onClick={() => setMethod(v)}
style={{
flex: 1, padding: '7px', borderRadius: 7, fontSize: 12.5, fontWeight: 600, cursor: 'pointer',
border: `1.5px solid ${method === v ? '#111827' : '#e5e7eb'}`,
background: method === v ? '#111827' : 'white',
color: method === v ? 'white' : '#6b7280',
}}>{l}</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Σημείωση</label>
<input value={notes} onChange={e => setNotes(e.target.value)} placeholder="προαιρετικό"
style={{ width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box' }} />
</div>
</div>
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
<button onClick={() => onPay(parsed, method, notes || null)} disabled={!canPay || isPending}
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: canPay ? 'pointer' : 'default', background: canPay ? '#16a34a' : '#e5e7eb', color: canPay ? 'white' : '#9ca3af' }}>
{isPending ? 'Αποθήκευση…' : 'Καταγραφή Πληρωμής'}
</button>
</div>
</div>
</div>
)
}
// ── Forgive confirm ───────────────────────────────────────────────────────────
function ForgiveModal({ tab, onClose, onForgive, isPending }) {
const [reason, setReason] = useState('')
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 20 }}>
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 400, boxShadow: '0 20px 60px rgba(0,0,0,0.18)' }}>
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid #f0f0ef' }}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#dc2626' }}>Χάρισμα Καρτέλας</div>
<div style={{ fontSize: 13, color: '#6b7280', marginTop: 2 }}>{tab.customer_name} υπόλοιπο {fmt(tab.balance)}</div>
</div>
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: '12px 14px', background: '#fef2f2', borderRadius: 10, border: '1px solid #fecaca', fontSize: 13, color: '#7f1d1d', lineHeight: 1.5 }}>
Το υπόλοιπο των {fmt(tab.balance)} θα διαγραφεί. Η ενέργεια δεν αναιρείται.
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 5 }}>Αιτιολογία</label>
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="π.χ. Καλή θέληση"
style={{ width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box' }} />
</div>
</div>
<div style={{ padding: '14px 22px', borderTop: '1px solid #f0f0ef', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 8, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
<button onClick={() => onForgive(reason || null)} disabled={isPending}
style={{ padding: '8px 20px', border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: 'pointer', background: '#dc2626', color: 'white' }}>
{isPending ? 'Αποθήκευση…' : 'Χάρισμα Υπολοίπου'}
</button>
</div>
</div>
</div>
)
}
// ── Tab detail card ───────────────────────────────────────────────────────────
function TabDetail({ tab, onPay, onClose, onForgive }) {
const sc = STATUS_CONFIG[tab.status] || STATUS_CONFIG.open
const canClose = tab.status === 'open' && tab.balance <= 0.005 && tab.entries.length > 0
return (
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden', background: 'white' }}>
{/* Header */}
<div style={{ padding: '14px 18px', background: '#f9fafb', borderBottom: '1px solid #e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#111315' }}>{tab.customer_name}</div>
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
Άνοιγμα: {fmtDateTime(tab.opened_at)} · {daysSince(tab.opened_at)} ανοιχτή
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 22, fontWeight: 800, color: tab.balance > 0 ? '#dc2626' : '#16a34a' }}>
{fmt(tab.balance)}
</span>
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: sc.bg, color: sc.color }}>{sc.label}</span>
</div>
</div>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: '#f0f0ef' }}>
{[
{ label: 'Συνολικές χρεώσεις', value: fmt(tab.total_charged) },
{ label: 'Πληρωμένο', value: fmt(tab.total_paid), color: '#16a34a' },
{ label: 'Υπόλοιπο', value: fmt(tab.balance), color: tab.balance > 0 ? '#dc2626' : '#16a34a' },
].map(s => (
<div key={s.label} style={{ background: 'white', padding: '10px 14px', textAlign: 'center' }}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{s.label}</div>
<div style={{ fontSize: 15, fontWeight: 800, color: s.color || '#111315', marginTop: 2 }}>{s.value}</div>
</div>
))}
</div>
{/* Entries */}
{tab.entries.length > 0 && (
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Χρεώσεις</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{tab.entries.map(e => (
<div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
<span style={{ color: '#374151' }}>{e.description || `Χρέωση #${e.id}`}</span>
<span style={{ fontWeight: 600, color: '#dc2626', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(e.amount)}</span>
</div>
))}
</div>
</div>
)}
{/* Payments */}
{tab.payments.length > 0 && (
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Πληρωμές</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{tab.payments.map(p => (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
<span style={{ color: '#374151' }}>
{p.received_by_name} · {fmtDateTime(p.created_at)}
{p.payment_method && <span style={{ color: '#9ca3af', marginLeft: 6 }}>({p.payment_method})</span>}
{p.notes && <span style={{ color: '#9ca3af', marginLeft: 6 }}> {p.notes}</span>}
</span>
<span style={{ fontWeight: 600, color: '#16a34a', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(p.amount)}</span>
</div>
))}
</div>
</div>
)}
{/* Actions */}
{tab.status === 'open' && (
<div style={{ padding: '12px 18px', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{tab.balance > 0.005 && (
<button onClick={() => onPay(tab)}
style={{ padding: '7px 16px', borderRadius: 8, border: 'none', background: '#16a34a', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
Πληρωμή
</button>
)}
{canClose && (
<button onClick={() => onClose(tab)}
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #d1d5db', background: 'white', color: '#374151', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
Κλείσιμο Καρτέλας
</button>
)}
<button onClick={() => onForgive(tab)}
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #fecaca', background: 'white', color: '#dc2626', fontSize: 13, fontWeight: 500, cursor: 'pointer', marginLeft: 'auto' }}>
Χάρισμα Υπολοίπου
</button>
</div>
)}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function TabsPage() {
const qc = useQueryClient()
const [showAll, setShowAll] = useState(false)
const [modal, setModal] = useState(null) // { type: 'pay'|'forgive', tab }
const { data: tabs = [], isLoading } = useQuery({
queryKey: ['tabs', showAll],
queryFn: () => client.get('/api/tabs/', { params: showAll ? { tab_status: 'all' } : {} }).then(r => r.data),
staleTime: 15_000,
})
// Refetch tabs with all statuses when toggled
const { data: allTabs = [] } = useQuery({
queryKey: ['tabs-all'],
queryFn: () => client.get('/api/tabs/', { params: { tab_status: 'closed' } }).then(r => r.data),
staleTime: 30_000,
enabled: showAll,
})
const payTab = useMutation({
mutationFn: ({ id, amount, payment_method, notes }) =>
client.post(`/api/tabs/${id}/pay`, { amount, payment_method, notes }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tabs'] })
qc.invalidateQueries({ queryKey: ['tabs-all'] })
setModal(null)
toast.success('Πληρωμή καταγράφηκε')
},
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
})
const closeTab = useMutation({
mutationFn: (id) => client.post(`/api/tabs/${id}/close`, {}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tabs'] })
toast.success('Καρτέλα έκλεισε')
},
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
})
const forgiveTab = useMutation({
mutationFn: ({ id, reason }) => client.post(`/api/tabs/${id}/forgive`, { reason }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tabs'] })
setModal(null)
toast.success('Το υπόλοιπο χαρίστηκε')
},
onError: () => toast.error('Σφάλμα'),
})
const openTabs = tabs.filter(t => t.status === 'open')
const totalOutstanding = openTabs.reduce((s, t) => s + t.balance, 0)
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Καρτέλες</h1>
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
{openTabs.length} ανοιχτές καρτέλες · Σύνολο οφειλόμενων:{' '}
<strong style={{ color: totalOutstanding > 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalOutstanding)}</strong>
</p>
</div>
<button
onClick={() => setShowAll(s => !s)}
style={{ padding: '7px 14px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', fontSize: 12.5, cursor: 'pointer', color: '#6b7280' }}
>
{showAll ? 'Μόνο ανοιχτές' : 'Εμφάνιση κλειστών'}
</button>
</div>
{/* Tab list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 28px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '48px 0' }}>Φόρτωση</div>}
{!isLoading && openTabs.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 14, padding: '48px 0' }}>
Δεν υπάρχουν ανοιχτές καρτέλες.
</div>
)}
{openTabs.map(tab => (
<TabDetail
key={tab.id}
tab={tab}
onPay={t => setModal({ type: 'pay', tab: t })}
onClose={t => closeTab.mutate(t.id)}
onForgive={t => setModal({ type: 'forgive', tab: t })}
/>
))}
{showAll && allTabs.length > 0 && (
<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 8 }}>Κλειστές / Χαρισμένες</div>
{allTabs.map(tab => (
<TabDetail key={tab.id} tab={tab} onPay={() => {}} onClose={() => {}} onForgive={() => {}} />
))}
</>
)}
</div>
{modal?.type === 'pay' && (
<PayModal
tab={modal.tab}
onClose={() => setModal(null)}
isPending={payTab.isPending}
onPay={(amount, payment_method, notes) => payTab.mutate({ id: modal.tab.id, amount, payment_method, notes })}
/>
)}
{modal?.type === 'forgive' && (
<ForgiveModal
tab={modal.tab}
onClose={() => setModal(null)}
isPending={forgiveTab.isPending}
onForgive={(reason) => forgiveTab.mutate({ id: modal.tab.id, reason })}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,296 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
// ── Constants ─────────────────────────────────────────────────────────────────
const REASONS = [
{ id: 'kitchen_error', label: 'Λάθος κουζίνας', color: '#dc2626' },
{ id: 'dropped', label: 'Έπεσε', color: '#d97706' },
{ id: 'expired', label: 'Έληξε', color: '#7c3aed' },
{ id: 'other', label: 'Άλλο', color: '#6b7280' },
]
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmt(n) {
if (n == null) return '—'
return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
}
function fmtDateTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
// ── Log form ──────────────────────────────────────────────────────────────────
function LogForm({ products, onLog, isPending }) {
const [productId, setProductId] = useState('')
const [qty, setQty] = useState('1')
const [reason, setReason] = useState('kitchen_error')
const [notes, setNotes] = useState('')
const canLog = productId && parseFloat(qty) > 0
function submit() {
if (!canLog) return
onLog({
product_id: Number(productId),
quantity: parseFloat(qty),
reason,
reason_notes: notes.trim() || null,
})
setProductId('')
setQty('1')
setReason('kitchen_error')
setNotes('')
}
const inputStyle = {
width: '100%', padding: '8px 11px', border: '1px solid #e5e7eb',
borderRadius: 8, fontSize: 13.5, outline: 'none', fontFamily: 'inherit', background: 'white',
boxSizing: 'border-box',
}
return (
<div style={{ border: '1.5px dashed #d1d5db', borderRadius: 14, padding: '16px 20px', background: '#fafafa' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 14 }}>
Καταγραφή Απόβλητου
</div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 10, marginBottom: 10 }}>
<div>
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Προϊόν *</label>
<select style={inputStyle} value={productId} onChange={e => setProductId(e.target.value)} autoFocus>
<option value=""> Επιλέξτε προϊόν </option>
{products.map(p => (
<option key={p.id} value={p.id}>{p.name}{p.category_name ? ` (${p.category_name})` : ''}</option>
))}
</select>
</div>
<div>
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Ποσότητα *</label>
<input type="number" step="0.5" min="0.1"
style={inputStyle} value={qty} onChange={e => setQty(e.target.value)} />
</div>
</div>
<div style={{ marginBottom: 10 }}>
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 6 }}>Αιτία</label>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{REASONS.map(r => (
<button key={r.id} type="button" onClick={() => setReason(r.id)}
style={{
padding: '5px 12px', borderRadius: 20, fontSize: 12.5, fontWeight: 600, cursor: 'pointer',
border: `1.5px solid ${reason === r.id ? r.color : '#e5e7eb'}`,
background: reason === r.id ? r.color : 'white',
color: reason === r.id ? 'white' : '#6b7280',
}}>
{r.label}
</button>
))}
</div>
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>
Σημείωση <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετική)</span>
</label>
<input style={inputStyle} value={notes} onChange={e => setNotes(e.target.value)}
placeholder="π.χ. Ο πελάτης επέστρεψε ελαττωματικό…" />
</div>
<button onClick={submit} disabled={!canLog || isPending}
style={{
padding: '9px 22px', borderRadius: 9, border: 'none', fontSize: 13.5, fontWeight: 700, cursor: canLog ? 'pointer' : 'default',
background: canLog ? '#111827' : '#e5e7eb', color: canLog ? 'white' : '#9ca3af',
}}>
{isPending ? 'Καταγραφή…' : '+ Καταγραφή Απόβλητου'}
</button>
</div>
)
}
// ── Waste entry row ───────────────────────────────────────────────────────────
function WasteRow({ entry, canDelete, onDelete }) {
const reasonCfg = REASONS.find(r => r.id === entry.reason) || { label: entry.reason, color: '#6b7280' }
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '11px 16px', border: '1px solid #f0f0ef', borderRadius: 10, background: 'white' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#111315', display: 'flex', alignItems: 'center', gap: 8 }}>
{entry.product_name}
<span style={{ fontSize: 13, fontWeight: 700, color: '#374151' }}>×{entry.quantity}</span>
<span style={{ fontSize: 11, fontWeight: 700, padding: '1px 7px', borderRadius: 99, background: `${reasonCfg.color}18`, color: reasonCfg.color }}>
{reasonCfg.label}
</span>
</div>
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 2 }}>
{fmtDateTime(entry.logged_at)} · {entry.logged_by_name}
{entry.reason_notes && <span style={{ color: '#6b7280', marginLeft: 8 }}> {entry.reason_notes}</span>}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
{entry.total_cost != null ? (
<div style={{ fontSize: 13.5, fontWeight: 700, color: '#dc2626' }}>{fmt(entry.total_cost)}</div>
) : (
<div style={{ fontSize: 12, color: '#d1d5db' }}>χωρίς κόστος</div>
)}
{entry.unit_cost_snapshot != null && (
<div style={{ fontSize: 11, color: '#9ca3af' }}>{fmt(entry.unit_cost_snapshot)}/τεμ.</div>
)}
</div>
{canDelete && (
<button onClick={() => { if (window.confirm('Διαγραφή καταχώρησης;')) onDelete(entry.id) }}
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #fecaca', background: 'white', color: '#ef4444', fontSize: 12, cursor: 'pointer', flexShrink: 0 }}>
</button>
)}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function WastePage() {
const qc = useQueryClient()
const [showHistory, setShowHistory] = useState(false)
const [histFrom, setHistFrom] = useState(() => new Date().toISOString().slice(0, 10))
const [histTo, setHistTo] = useState(() => new Date().toISOString().slice(0, 10))
const { data: productsData = [] } = useQuery({
queryKey: ['meta-products'],
queryFn: () => client.get('/api/reports/meta/products').then(r => r.data.products),
staleTime: 5 * 60_000,
})
const { data: bdData } = useQuery({
queryKey: ['business-day'],
queryFn: () => client.get('/api/business-day/current').then(r => r.data),
staleTime: 30_000,
})
const activeDayId = bdData?.id ?? null
// Today's entries — scoped to active business day (or all if no active day)
const { data: todayData } = useQuery({
queryKey: ['waste-today', activeDayId],
queryFn: () => client.get('/api/waste/', { params: activeDayId ? { business_day_id: activeDayId } : {} }).then(r => r.data),
staleTime: 15_000,
})
// Historical entries
const { data: histData, refetch: refetchHist } = useQuery({
queryKey: ['waste-history', histFrom, histTo],
queryFn: () => client.get('/api/waste/', { params: { from: histFrom + 'T00:00:00', to: histTo + 'T23:59:59' } }).then(r => r.data),
staleTime: 30_000,
enabled: showHistory,
})
const todayEntries = todayData?.entries || []
const histEntries = histData?.entries || []
const todayCost = todayEntries.reduce((s, e) => s + (e.total_cost || 0), 0)
const todayCount = todayEntries.length
const logWaste = useMutation({
mutationFn: (body) => client.post('/api/waste/', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['waste-today'] })
qc.invalidateQueries({ queryKey: ['waste-history'] })
toast.success('Καταγράφηκε')
},
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
})
const deleteWaste = useMutation({
mutationFn: (id) => client.delete(`/api/waste/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['waste-today'] })
qc.invalidateQueries({ queryKey: ['waste-history'] })
toast.success('Διαγράφηκε')
},
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
})
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Αποβλήτα / Φθορές</h1>
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
Σήμερα: {todayCount} καταχωρήσεις
{todayCost > 0 && <span style={{ color: '#dc2626', fontWeight: 600 }}> · εκτιμώμενο κόστος {todayCost.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} </span>}
</p>
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 28px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Log form */}
<LogForm products={productsData} onLog={body => logWaste.mutate(body)} isPending={logWaste.isPending} />
{/* Today's entries */}
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>
{activeDayId ? 'Σήμερα' : 'Τελευταίες καταχωρήσεις'}
</div>
{todayEntries.length === 0 ? (
<div style={{ textAlign: 'center', color: '#d1d5db', fontSize: 13, padding: '20px 0' }}>Καμία καταχώρηση ακόμα.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{todayEntries.map(e => (
<WasteRow key={e.id} entry={e} canDelete={!!activeDayId} onDelete={id => deleteWaste.mutate(id)} />
))}
</div>
)}
</div>
{/* History toggle */}
<div style={{ borderTop: '1px solid #f0f0ef', paddingTop: 12 }}>
<button onClick={() => setShowHistory(s => !s)}
style={{ fontSize: 13, fontWeight: 600, color: '#6b7280', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}>
{showHistory ? '▾ Απόκρυψη ιστορικού' : '▸ Εμφάνιση ιστορικού'}
</button>
{showHistory && (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 12, color: '#6b7280', fontWeight: 600 }}>Από</label>
<input type="date" value={histFrom} onChange={e => setHistFrom(e.target.value)}
style={{ padding: '5px 8px', border: '1px solid #e5e7eb', borderRadius: 7, fontSize: 13, outline: 'none' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ fontSize: 12, color: '#6b7280', fontWeight: 600 }}>Έως</label>
<input type="date" value={histTo} onChange={e => setHistTo(e.target.value)}
style={{ padding: '5px 8px', border: '1px solid #e5e7eb', borderRadius: 7, fontSize: 13, outline: 'none' }} />
</div>
<button onClick={() => refetchHist()}
style={{ padding: '5px 14px', borderRadius: 7, border: '1px solid #e5e7eb', background: 'white', fontSize: 12.5, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
Αναζήτηση
</button>
</div>
{histEntries.length === 0 ? (
<div style={{ color: '#d1d5db', fontSize: 13, padding: '12px 0' }}>Δεν βρέθηκαν καταχωρήσεις.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{histEntries.map(e => (
<WasteRow key={e.id} entry={e} canDelete={false} onDelete={() => {}} />
))}
<div style={{ fontSize: 12.5, color: '#6b7280', fontWeight: 600, paddingLeft: 4 }}>
{histEntries.length} καταχωρήσεις · εκτιμώμενο κόστος{' '}
<span style={{ color: '#dc2626' }}>
{histEntries.reduce((s, e) => s + (e.total_cost || 0), 0).toLocaleString('el-GR', { minimumFractionDigits: 2 })}
</span>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -22,6 +22,7 @@ import TableAnalytics from './restaurant/TableAnalytics'
import PrinterHistory from './operations/PrinterHistory'
import PrinterHealthLog from './operations/PrinterHealthLog'
import CancellationsLog from './operations/CancellationsLog'
import DiscountsLog from './operations/DiscountsLog'
const PARENT_TABS = [
{
@@ -58,6 +59,7 @@ const PARENT_TABS = [
{ id: 'printer-history', label: 'Ιστορικό Εκτυπωτή', Component: PrinterHistory },
{ id: 'printer-health', label: 'Υγεία Εκτυπωτή', Component: PrinterHealthLog },
{ id: 'cancellations', label: 'Ακυρώσεις', Component: CancellationsLog },
{ id: 'discounts', label: 'Εκπτώσεις', Component: DiscountsLog },
],
},
]

View File

@@ -0,0 +1,191 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Tag } from 'lucide-react'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar } from '../shared/TablePrimitives'
import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDateTime, fmtDateStr } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
export default function DiscountsLog() {
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const [waiterF, setWaiterF] = useState('all')
const { data: waitersData } = useQuery({
queryKey: ['meta-waiters'],
queryFn: () => client.get('/api/reports/meta/waiters').then(r => r.data),
staleTime: 5 * 60 * 1000,
})
const { data: bdData } = useQuery({
queryKey: ['business-days-list'],
queryFn: () => client.get('/api/reports/business-days').then(r => r.data),
staleTime: 60 * 1000,
})
const queryParams = {
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
...(waiterF !== 'all' ? { applied_by: waiterF } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['discounts', mode, from, to, businessDayId, waiterF],
queryFn: () => client.get('/api/reports/discounts', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const waiterOptions = [
{ value: 'all', label: 'Όλοι' },
...((waitersData?.waiters || []).map(w => ({ value: String(w.id), label: w.name }))),
]
const bdOptions = [
{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' },
...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` }))),
]
const discounts = data?.discounts || []
const byWaiter = data?.by_waiter || []
const totalValue = data?.total_discount_value ?? 0
const orderCount = data?.order_count ?? 0
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={7} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center">
<button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button>
</div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
<FilterSelect value={waiterF} onChange={setWaiterF} options={waiterOptions} label="Σερβιτόρος" />
</FilterBar>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* Summary cards */}
<div className="grid grid-cols-3 gap-4">
<StatCard label="Σύνολο Εκπτώσεων" value={fmtEUR(totalValue)} icon={Tag} color="amber" />
<StatCard label="Παραγγελίες με Έκπτωση" value={fmtNum(orderCount)} color="slate" />
<StatCard label="Αριθμός Εκπτώσεων" value={fmtNum(discounts.length)} color="slate" />
</div>
{/* By waiter summary */}
{byWaiter.length > 1 && (
<Panel title="Ανά Σερβιτόρο" padded={false}>
<DataTable>
<THead>
<TH>Σερβιτόρος</TH>
<TH align="right">Αριθμός</TH>
<TH align="right">Σύνολο</TH>
<TH align="right">Μέσος / Έκπτωση</TH>
</THead>
<tbody>
{byWaiter.map(w => (
<TR key={w.waiter_id} striped>
<TD><WaiterAvatar name={w.waiter_name} id={w.waiter_id} /></TD>
<TD mono align="right">{fmtNum(w.count)}</TD>
<TD mono align="right" className="font-semibold text-amber-700">{fmtEUR(w.total_value)}</TD>
<TD mono align="right">{fmtEUR(w.count > 0 ? w.total_value / w.count : 0)}</TD>
</TR>
))}
</tbody>
</DataTable>
</Panel>
)}
{/* Full log */}
<Panel
title="Αρχείο Εκπτώσεων"
subtitle={`${discounts.length} εγγραφές — μόνο ανάγνωση`}
padded={false}
>
{discounts.length === 0 ? (
<EmptyState
icon={Tag}
title="Δεν βρέθηκαν εκπτώσεις"
description="Δεν υπάρχουν εκπτώσεις για την επιλεγμένη περίοδο."
/>
) : (
<DataTable>
<THead>
<TH>Ημ/νία</TH>
<TH>Παραγγελία</TH>
<TH>Σερβιτόρος</TH>
<TH align="right">Τύπος</TH>
<TH align="right">Τιμή πριν</TH>
<TH align="right">Ποσό Έκπτωσης</TH>
<TH>Αιτία</TH>
</THead>
<tbody>
{discounts.map(d => {
const typeLabel = d.discount_type === 'percent'
? `${d.discount_value}%`
: fmtEUR(d.discount_value)
const isItemLevel = d.item_id != null
return (
<TR key={d.id} striped>
<TD mono className="text-slate-500">{fmtDateTime(d.applied_at)}</TD>
<TD>
<span className="font-medium text-slate-900">
#{d.order_id}
{d.table_name && <span className="text-slate-400 font-normal ml-1">· {d.table_name}</span>}
</span>
{isItemLevel && (
<span className="ml-2 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-slate-100 text-slate-500">ΑΝΤΙΚ.</span>
)}
</TD>
<TD><WaiterAvatar name={d.applied_by_name} id={d.applied_by_id} /></TD>
<TD mono align="right">
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${d.discount_type === 'percent' ? 'bg-blue-50 text-blue-700' : 'bg-amber-50 text-amber-700'}`}>
{typeLabel}
</span>
</TD>
<TD mono align="right" className="text-slate-500">
{d.order_total_before != null ? fmtEUR(d.order_total_before) : '—'}
</TD>
<TD mono align="right" className="font-semibold text-amber-700">
{fmtEUR(d.discount_amount)}
</TD>
<TD className="text-slate-500 text-[12px]">
{d.reason || <span className="text-slate-300"></span>}
</TD>
</TR>
)
})}
</tbody>
{discounts.length > 1 && (
<tfoot>
<TR>
<TD colSpan={5} className="font-semibold text-slate-700">Σύνολο</TD>
<TD mono align="right" className="font-bold text-amber-700"> {fmtEUR(totalValue)}</TD>
<TD />
</TR>
</tfoot>
)}
</DataTable>
)}
</Panel>
</div>
</div>
)
}

View File

@@ -9,7 +9,7 @@ import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtNum, fmtEUR, fmtDate, fmtTime, fmtDateTime } from '../shared/reportDesignTokens'
import { fmtNum, fmtEUR, fmtDate, fmtTime, fmtDateTime, fmtDateStr } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
@@ -37,6 +37,7 @@ const PRINT_TYPES = [
function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryParams }) {
const [printType, setPrintType] = useState('quick')
const [targetPrinter, setTargetPrinter] = useState('browser')
const [itemBreakdown, setItemBreakdown] = useState(false)
const [printing, setPrinting] = useState(false)
const printerOptions = [
@@ -53,23 +54,25 @@ function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryPara
}
async function handleThermalPrint() {
// Map frontend print types to backend modes
// quick/orders → simple, detailed → extensive
const mode = printType === 'detailed' ? 'extensive' : 'simple'
const printerTargetId = printerF === 'all' ? 0 : parseInt(printerF, 10)
const fromDt = queryParams.from || (new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10) + 'T00:00:00')
const toDt = queryParams.to || (new Date().toISOString().slice(0, 10) + 'T23:59:59')
const body = {
printer_target_id: printerTargetId,
printer_id: parseInt(targetPrinter, 10),
mode: printType,
item_breakdown: itemBreakdown,
}
if (queryParams.business_day_id) {
body.business_day_id = parseInt(queryParams.business_day_id, 10)
} else {
body.from_dt = queryParams.from || (new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10) + 'T00:00:00')
body.to_dt = queryParams.to || (new Date().toISOString().slice(0, 10) + 'T23:59:59')
}
setPrinting(true)
try {
await client.post('/api/reports/print/printer', {
printer_target_id: printerTargetId,
printer_id: parseInt(targetPrinter, 10),
mode,
from_dt: fromDt,
to_dt: toDt,
})
await client.post('/api/reports/print/printer', body)
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
onClose()
} catch {
@@ -80,105 +83,207 @@ function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryPara
}
function handleBrowserPrint() {
const win = window.open('', '_blank', 'width=800,height=700')
const win = window.open('', '_blank', 'width=820,height=750')
if (!win) { onClose(); return }
const styles = `
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Courier New', monospace; font-size: 12px; color: #000; padding: 24px; }
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; }
h2 { font-size: 13px; font-weight: bold; margin: 14px 0 6px; }
.row { display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px dotted #ccc; }
.row:last-child { border-bottom: none; }
.section { margin-bottom: 16px; }
.order-block { border: 1px solid #ccc; border-radius: 4px; padding: 8px; margin-bottom: 8px; }
.order-header { display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 4px; }
.item-row { padding-left: 12px; display: flex; justify-content: space-between; font-size: 11px; color: #333; }
.failed { color: #c00; }
.top-items li { padding: 2px 0; }
@media print { body { padding: 0; } }
</style>
`
// ── Group logs by printer ──────────────────────────────────────────────
// For "all printers" we produce one section per printer.
// For a single selected printer the single section is the same format.
const printerMap = {}
for (const j of logs) {
const key = j.printer_name
if (!printerMap[key]) {
printerMap[key] = { name: key, printJobs: 0, orderIds: new Set(), totalItems: 0, totalValue: 0, orders: [] }
}
const g = printerMap[key]
g.printJobs += 1
g.orderIds.add(j.order_id)
const itemQty = (j.items || []).reduce((s, i) => s + i.quantity, 0)
g.totalItems += itemQty
if (j.order_total != null) g.totalValue += j.order_total
g.orders.push(j)
}
// De-dup order totals per printer group (same order_id → count once)
for (const g of Object.values(printerMap)) {
const seen = {}
g.totalValue = 0
for (const j of g.orders) {
if (j.success && j.order_total != null && !seen[j.order_id]) {
seen[j.order_id] = true
g.totalValue += j.order_total
}
}
}
const groups = Object.values(printerMap)
const filterLabel = printerF === 'all'
? 'Όλοι οι Εκτυπωτές'
: (printers.find(p => String(p.id) === printerF)?.name || printerF)
let body = ''
if (printType === 'quick') {
const topItems = Object.entries(stats.itemCounts).sort((a, b) => b[1] - a[1]).slice(0, 3)
const topWaiters = Object.entries(stats.waiterCounts).sort((a, b) => b[1] - a[1]).slice(0, 3)
const avgItems = stats.total > 0 ? (stats.totalItemQty / stats.total).toFixed(1) : '—'
body = `
<h1>Σύνοψη Εκτυπωτή</h1>
<div class="section">
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
<div class="row"><span>Περίοδος</span><span>${stats.periodLabel}</span></div>
</div>
<h2>Στατιστικά</h2>
<div class="section">
<div class="row"><span>Συνολικές Εκτυπώσεις</span><span>${fmtNum(stats.total)}</span></div>
<div class="row"><span>Αποτυχημένες Εκτυπώσεις</span><span class="${stats.failed > 0 ? 'failed' : ''}">${fmtNum(stats.failed)}</span></div>
${stats.totalAmount != null ? `<div class="row"><span>Συνολικό Ποσό Παραγγελιών</span><span>${fmtEUR(stats.totalAmount)}</span></div>` : ''}
<div class="row"><span>Μέσος Αριθμός Ειδών ανά Παραγγελία</span><span>${avgItems}</span></div>
</div>
<h2>Top 3 Πιο Εκτυπωμένα Είδη</h2>
<div class="section">
<ol class="top-items" style="padding-left:16px">
${topItems.length ? topItems.map(([name, qty], i) => `<li>${i + 1}. ${name}${qty} τεμ.</li>`).join('') : '<li>—</li>'}
</ol>
</div>
${topWaiters.length ? `
<h2>Εκτυπώσεις ανά Σερβιτόρο</h2>
<div class="section">
${topWaiters.map(([name, cnt]) => `<div class="row"><span>${name}</span><span>${cnt}</span></div>`).join('')}
</div>` : ''}
`
} else if (printType === 'orders') {
body = `
<h1>Λίστα Παραγγελιών</h1>
<div class="section">
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
<div class="row"><span>Σύνολο Εγγραφών</span><span>${logs.length}</span></div>
</div>
<h2>Παραγγελίες</h2>
${logs.map(j => `
<div class="order-block ${!j.success ? 'failed' : ''}">
<div class="order-header">
<span>#${j.order_id} · ${j.table || '—'}</span>
<span>${fmtDateTime(j.printed_at)}</span>
</div>
<div class="row"><span>Εκτυπωτής</span><span>${j.printer_name}</span></div>
<div class="row"><span>Αποτέλεσμα</span><span>${j.success ? '✓ Επιτυχία' : '✗ Αποτυχία'}</span></div>
${j.order_total != null ? `<div class="row"><span>Σύνολο</span><span>${fmtEUR(j.order_total)}</span></div>` : ''}
</div>
`).join('')}
`
// ── Period label ───────────────────────────────────────────────────────
let periodRow = ''
if (queryParams.business_day_id) {
periodRow = `<div class="row"><span>Εργάσιμη Μέρα</span><span>${stats.periodLabel}</span></div>`
} else {
body = `
<h1>Πλήρης Ανάλυση Εκτυπώσεων</h1>
<div class="section">
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
<div class="row"><span>Σύνολο Εγγραφών</span><span>${logs.length}</span></div>
</div>
${logs.map(j => `
<div class="order-block ${!j.success ? 'failed' : ''}">
<div class="order-header">
<span>#${j.order_id} · ${j.table || '—'}</span>
<span>${fmtDateTime(j.printed_at)}</span>
</div>
<div class="row"><span>Εκτυπωτής</span><span>${j.printer_name}</span></div>
<div class="row"><span>Αποτέλεσμα</span><span>${j.success ? '✓ Επιτυχία' : '✗ Αποτυχία'}</span></div>
${(j.items || []).map(i => `<div class="item-row"><span>${i.name}</span><span>×${i.quantity}</span></div>`).join('')}
</div>
`).join('')}
`
const fromStr = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
const toStr = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
periodRow = `<div class="row"><span>Από</span><span>${fromStr}</span></div>
<div class="row"><span>Έως</span><span>${toStr}</span></div>`
}
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Ιστορικό Εκτυπωτή</title>${styles}</head><body>${body}</body></html>`)
// ── Titles per mode ────────────────────────────────────────────────────
const titles = {
quick: 'Γρήγορη Ανάλυση Εκτυπώσεων',
orders: 'Ανάλυση Εκτυπώσεων με Παραγγελίες',
detailed: 'Αναλυτικό Ιστορικό Εκτυπώσεων',
}
// ── Render one printer block ───────────────────────────────────────────
function renderPrinterBlock(g) {
const orderCount = g.orderIds.size
const statsBlock = `
<div class="printer-header">
<span>Εκτυπωτής</span><span>${g.name}</span>
</div>
<div class="divider"></div>
<div class="row"><span>Εκτυπώσεις</span><span>${fmtNum(g.printJobs)}</span></div>
<div class="row"><span>Παραγγελίες</span><span>${fmtNum(orderCount)}</span></div>
<div class="row"><span>Είδη</span><span>${fmtNum(g.totalItems)}</span></div>
<div class="row value-row"><span>Αξία Ειδών</span><span>${fmtEUR(g.totalValue)}</span></div>
`
if (printType === 'quick') {
return `<div class="printer-block">${statsBlock}</div>`
}
if (printType === 'orders') {
// Unique orders only
const seenOrders = {}
const orderLines = g.orders
.filter(j => j.success)
.filter(j => { if (seenOrders[j.order_id]) return false; seenOrders[j.order_id] = true; return true })
.map(j => {
const itemQty = (j.items || []).reduce((s, i) => s + i.quantity, 0)
return `<div class="order-line">
<span class="order-id">#${j.order_id} · ${j.table || '—'}</span>
<span class="order-meta">${itemQty} είδη</span>
<span class="order-value">${j.order_total != null ? fmtEUR(j.order_total) : '—'}</span>
</div>`
}).join('')
return `<div class="printer-block">${statsBlock}<div class="divider"></div>${orderLines}</div>`
}
// detailed — full log, each entry is a print job
const jobBlocks = g.orders.map(j => {
const result = j.success ? '✓ Επιτυχία' : '✗ Αποτυχία'
const itemRows = (j.items || []).map(i =>
`<div class="item-row"><span>${i.name}</span><span>×${i.quantity}</span></div>`
).join('')
return `
<div class="job-block ${!j.success ? 'failed' : ''}">
<div class="job-header">
<span>#${j.order_id} · ${j.table || '—'}</span>
<span>${fmtDateTime(j.printed_at)} · ${result}</span>
</div>
${itemRows}
${j.order_total != null ? `<div class="job-total"><span>Σύνολο Παραγγελίας</span><span>${fmtEUR(j.order_total)}</span></div>` : ''}
</div>`
}).join('')
return `<div class="printer-block">${statsBlock}<div class="divider"></div>${jobBlocks}</div>`
}
// ── Summary footer (multi-printer only) ───────────────────────────────
function renderSummary() {
if (groups.length <= 1) return ''
const totJobs = groups.reduce((s, g) => s + g.printJobs, 0)
const totOrders = groups.reduce((s, g) => s + g.orderIds.size, 0)
const totItems = groups.reduce((s, g) => s + g.totalItems, 0)
const totValue = groups.reduce((s, g) => s + g.totalValue, 0)
return `
<div class="summary-block">
<div class="summary-title">Σύνολο Όλων των Εκτυπωτών</div>
<div class="row"><span>Εκτυπώσεις</span><span>${fmtNum(totJobs)}</span></div>
<div class="row"><span>Παραγγελίες</span><span>${fmtNum(totOrders)}</span></div>
<div class="row"><span>Είδη</span><span>${fmtNum(totItems)}</span></div>
<div class="row value-row"><span>Αξία Ειδών</span><span>${fmtEUR(totValue)}</span></div>
</div>`
}
// ── Product breakdown (optional) ───────────────────────────────────────
function renderItemBreakdown() {
if (!itemBreakdown) return ''
// Merge item counts across all printer groups
const combined = {}
for (const g of groups) {
for (const j of g.orders) {
if (!j.success) continue
for (const i of (j.items || [])) {
if (!combined[i.name]) combined[i.name] = { qty: 0 }
combined[i.name].qty += i.quantity
}
}
}
if (!Object.keys(combined).length) return ''
const sorted = Object.entries(combined).sort((a, b) => b[1].qty - a[1].qty)
return `
<div class="breakdown-block">
<div class="breakdown-title">Ανάλυση Προϊόντων</div>
${sorted.map(([name, d]) => `
<div class="breakdown-row">
<span class="breakdown-name">${name}</span>
<span class="breakdown-qty">${d.qty}</span>
</div>`).join('')}
</div>`
}
const styles = `
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Courier New', monospace; font-size: 12px; color: #000; padding: 24px; max-width: 760px; }
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; }
.meta { margin-bottom: 16px; }
.row { display: flex; justify-content: space-between; padding: 3px 0; }
.divider { border-top: 1px dashed #999; margin: 6px 0; }
.printer-block { border: 1px solid #ccc; border-radius: 4px; padding: 10px; margin-bottom: 14px; }
.printer-header { display: flex; justify-content: space-between; font-weight: bold; font-size: 13px; margin-bottom: 4px; }
.value-row { font-weight: bold; }
.order-line { display: flex; gap: 8px; padding: 2px 0; font-size: 11px; }
.order-id { flex: 1; }
.order-meta { color: #666; min-width: 60px; text-align: right; }
.order-value { min-width: 70px; text-align: right; font-weight: bold; }
.job-block { margin-bottom: 8px; padding: 6px 0; border-bottom: 1px dotted #ddd; }
.job-block:last-child { border-bottom: none; }
.job-block.failed { color: #b00; }
.job-header { display: flex; justify-content: space-between; font-weight: bold; font-size: 11px; margin-bottom: 3px; }
.item-row { display: flex; justify-content: space-between; padding-left: 12px; font-size: 11px; color: #333; }
.job-total { display: flex; justify-content: space-between; padding-left: 12px; font-size: 11px; font-weight: bold; margin-top: 3px; }
.summary-block { border: 2px solid #000; border-radius: 4px; padding: 10px; margin-top: 8px; }
.summary-title { font-weight: bold; font-size: 13px; margin-bottom: 6px; text-align: center; letter-spacing: 0.04em; }
.breakdown-block { border: 1px solid #888; border-radius: 4px; padding: 10px; margin-top: 14px; }
.breakdown-title { font-weight: bold; font-size: 13px; margin-bottom: 8px; letter-spacing: 0.04em; }
.breakdown-row { display: flex; justify-content: space-between; padding: 2px 0; border-bottom: 1px dotted #ccc; font-size: 12px; }
.breakdown-row:last-child { border-bottom: none; }
.breakdown-name { flex: 1; }
.breakdown-qty { font-weight: bold; min-width: 40px; text-align: right; }
@media print { body { padding: 0; } }
</style>
`
const htmlBody = `
<h1>${titles[printType] || 'Αναφορά Εκτυπωτή'}</h1>
<div class="meta">
<div class="row"><span>Εκτυπωτής</span><span>${filterLabel}</span></div>
${periodRow}
<div class="row"><span>Σύνολο Εγγραφών</span><span>${logs.length}</span></div>
</div>
${groups.map(renderPrinterBlock).join('')}
${renderSummary()}
${renderItemBreakdown()}
`
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Ιστορικό Εκτυπωτή</title>${styles}</head><body>${htmlBody}</body></html>`)
win.document.close()
win.focus()
win.print()
@@ -221,6 +326,19 @@ function PrintSummaryModal({ onClose, logs, stats, printers, printerF, queryPara
</div>
</div>
<label className="flex cursor-pointer items-start gap-3 rounded-lg border border-slate-200 p-3 transition hover:border-slate-300 hover:bg-slate-50">
<input
type="checkbox"
checked={itemBreakdown}
onChange={e => setItemBreakdown(e.target.checked)}
className="mt-0.5 accent-sky-500"
/>
<div>
<div className="font-medium text-slate-800">Ανάλυση Προϊόντων</div>
<div className="text-[12px] text-slate-500">Προσθέτει σύνολο τεμαχίων ανά προϊόν στο τέλος</div>
</div>
</label>
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
<div className="relative">
@@ -289,16 +407,16 @@ export default function PrinterHistory() {
const logs = data?.logs || []
const total = data?.total || 0
const failed = data?.failed || 0
// total_amount comes pre-computed from the backend (unique orders, no double-counting reprints)
const totalAmount = data?.total_amount ?? null
// Derived stats for StatCards + Print modal
const itemCounts = {}
const waiterCounts = {}
let totalItemQty = 0
let totalAmount = null
logs.forEach(l => {
if (l.waiter) waiterCounts[l.waiter] = (waiterCounts[l.waiter] || 0) + 1
if (l.order_total != null) totalAmount = (totalAmount || 0) + l.order_total
;(l.items || []).forEach(i => {
itemCounts[i.name] = (itemCounts[i.name] || 0) + i.quantity
totalItemQty += i.quantity
@@ -308,7 +426,7 @@ export default function PrinterHistory() {
const topItem = Object.entries(itemCounts).sort((a, b) => b[1] - a[1])[0]
const periodLabel = mode === 'workday'
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
: `${from}${to}`
: `${fmtDateStr(from)}${fmtDateStr(to)}`
const stats = { total, failed, totalAmount, itemCounts, waiterCounts, totalItemQty, periodLabel, topItem }

View File

@@ -1,36 +1,333 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'
import { FileText, X, ChevronDown, Loader2 } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, CHART_PALETTE } from '../shared/reportDesignTokens'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDateStr, CHART_PALETTE } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
// ─── SVG donut chart (inline, for print window) ───────────────────────────────
function svgDonut(categories, size = 200) {
const total = categories.reduce((s, c) => s + c.revenue, 0)
if (!total) return ''
const cx = size / 2, cy = size / 2, R = size * 0.42, r = size * 0.22
let angle = -Math.PI / 2
const slices = categories.map((c, i) => {
const frac = c.revenue / total
const a0 = angle, a1 = angle + frac * 2 * Math.PI
angle = a1
const x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0)
const x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1)
const xi0 = cx + r * Math.cos(a0), yi0 = cy + r * Math.sin(a0)
const xi1 = cx + r * Math.cos(a1), yi1 = cy + r * Math.sin(a1)
const large = frac > 0.5 ? 1 : 0
const color = c.color || CHART_PALETTE[i % CHART_PALETTE.length]
const d = `M${xi0},${yi0} L${x0},${y0} A${R},${R} 0 ${large},1 ${x1},${y1} L${xi1},${yi1} A${r},${r} 0 ${large},0 ${xi0},${yi0} Z`
return `<path d="${d}" fill="${color}" />`
}).join('')
return `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">${slices}</svg>`
}
// ─── Print modal ─────────────────────────────────────────────────────────────
const PRINT_TYPES = [
{ id: 'smart', label: 'Έξυπνη Σύνοψη', desc: 'Κατηγορίες με τεμάχια, έσοδα και ποσοστό' },
{ id: 'full', label: 'Πλήρης Ανάλυση', desc: 'Κατηγορίες με γράφημα πίτας και αναλυτική εικόνα' },
]
function CategoryPrintModal({ onClose, categories, allProducts, soldProducts, printers, queryParams, periodLabel }) {
const [printType, setPrintType] = useState('smart')
const [targetPrinter, setTargetPrinter] = useState('browser')
const [printing, setPrinting] = useState(false)
const printerOptions = [
{ value: 'browser', label: 'Εκτύπωση μέσω browser (PDF)' },
...printers.map(p => ({ value: String(p.id), label: p.name })),
]
function periodRow() {
if (queryParams.business_day_id)
return `<div class="row"><span>Εργάσιμη Μέρα</span><span>${periodLabel}</span></div>`
const f = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
const t = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
return `<div class="row"><span>Από</span><span>${f}</span></div><div class="row"><span>Έως</span><span>${t}</span></div>`
}
function handleBrowserPrint() {
const win = window.open('', '_blank', 'width=820,height=800')
if (!win) { onClose(); return }
const totalRev = categories.reduce((s, c) => s + c.revenue, 0)
const totalQty = categories.reduce((s, c) => s + c.units_sold, 0)
let body = ''
if (printType === 'smart') {
const rows = categories.map(c => {
const pctRev = c.pct_rev ?? c.pct ?? 0
const pctQty = totalQty ? (c.units_sold / totalQty * 100).toFixed(1) : '0'
return `<tr>
<td>${c.category_name}</td>
<td class="num">${fmtNum(c.units_sold)}</td>
<td class="num">${fmtEUR(c.revenue)}</td>
<td class="num">${pctRev}%</td>
<td class="num">${pctQty}%</td>
</tr>`
}).join('')
body = `<table>
<thead><tr><th>Κατηγορία</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><td>Σύνολο</td><td class="num">${fmtNum(totalQty)}</td><td class="num">${fmtEUR(totalRev)}</td><td class="num">100%</td><td class="num">100%</td></tr></tfoot>
</table>`
} else {
// Build sold-product lookup: product_id → {qty_sold, revenue, category_id}
const soldMap = {}
for (const p of soldProducts) soldMap[p.product_id] = p
// Group allProducts by category_id
const productsByCategory = {}
for (const ap of allProducts) {
if (!productsByCategory[ap.category_id]) productsByCategory[ap.category_id] = []
productsByCategory[ap.category_id].push(ap)
}
const donut = svgDonut(categories)
const legend = categories.map((c, i) => {
const color = c.color || CHART_PALETTE[i % CHART_PALETTE.length]
const pctRev = c.pct_rev ?? c.pct ?? 0
const pctQty = totalQty ? (c.units_sold / totalQty * 100).toFixed(1) : 0
return `<div class="legend-row"><span class="legend-dot" style="background:${color}"></span><span class="legend-name">${c.category_name}</span><span class="legend-val">${pctRev}% έσ. / ${pctQty}% τεμ.</span></div>`
}).join('')
// Full category blocks with per-product breakdown
const catBlocks = categories.map((c, i) => {
const color = c.color || CHART_PALETTE[i % CHART_PALETTE.length]
const pctRev = c.pct_rev ?? c.pct ?? 0
const pctQtyOfTotal = totalQty ? (c.units_sold / totalQty * 100).toFixed(1) : 0
const catProducts = (productsByCategory[c.category_id] || [])
.map(ap => {
const sold = soldMap[ap.id]
return { name: ap.name, qty: sold ? sold.qty_sold : 0, revenue: sold ? sold.revenue : 0 }
})
.filter(p => p.qty > 0)
.sort((a, b) => b.qty - a.qty)
const catTotalQty = catProducts.reduce((s, p) => s + p.qty, 0)
const catTotalRev = catProducts.reduce((s, p) => s + p.revenue, 0)
const productRows = catProducts.map(p => {
const pctProdRev = catTotalRev ? (p.revenue / catTotalRev * 100).toFixed(1) : '0'
const pctProdQty = catTotalQty ? (p.qty / catTotalQty * 100).toFixed(1) : '0'
return `<tr>
<td class="prod-name">${p.name}</td>
<td class="num">${fmtNum(p.qty)}</td>
<td class="num">${fmtEUR(p.revenue)}</td>
<td class="num">${pctProdRev}%</td>
<td class="num">${pctProdQty}%</td>
</tr>`
}).join('')
return `<div class="cat-full-block" style="border-left: 4px solid ${color}">
<div class="cat-full-header">
<span class="cat-full-name">${c.category_name}</span>
<span class="cat-full-rev">${fmtEUR(c.revenue)}</span>
</div>
<div class="cat-full-sub">${fmtNum(c.units_sold)} τεμ. &nbsp;·&nbsp; ${c.product_count} προϊόντα &nbsp;·&nbsp; ${pctRev}% εσόδων &nbsp;·&nbsp; ${pctQtyOfTotal}% τεμ.</div>
${catProducts.length ? `
<table class="prod-table">
<colgroup><col/><col style="width:52px"/><col style="width:64px"/><col style="width:48px"/><col style="width:48px"/></colgroup>
<thead><tr><th>Προϊόν</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
<tbody>${productRows}</tbody>
</table>` : '<div class="no-sales">Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο</div>'}
</div>`
}).join('')
body = `
<div class="chart-row">
<div class="donut-wrap">${donut}</div>
<div class="legend">${legend}</div>
</div>
${catBlocks}`
}
const title = printType === 'smart' ? 'Σύνοψη Κατηγοριών' : 'Πλήρης Ανάλυση Κατηγοριών'
const styles = `<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, sans-serif; font-size: 12px; color: #000; padding: 24px; max-width: 760px; }
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; font-family: 'Courier New', monospace; }
.meta { margin-bottom: 16px; font-family: 'Courier New', monospace; }
.row { display: flex; justify-content: space-between; padding: 2px 0; }
table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 12px; font-family: 'Courier New', monospace; }
th { text-align: left; border-bottom: 1px solid #000; padding: 4px 6px; font-size: 10px; text-transform: uppercase; }
td { padding: 3px 6px; border-bottom: 1px dotted #ccc; }
.num { text-align: right; font-weight: bold; }
tfoot td { border-top: 2px solid #000; border-bottom: none; font-weight: bold; padding-top: 6px; }
.chart-row { display: flex; align-items: flex-start; gap: 24px; margin: 16px 0; }
.donut-wrap { flex-shrink: 0; }
.legend { display: flex; flex-direction: column; gap: 6px; justify-content: center; }
.legend-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.legend-dot { display: inline-block; width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
.legend-name { flex: 1; }
.legend-val { font-weight: bold; min-width: 40px; text-align: right; }
.cards-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
.cat-card { padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 4px; }
.cat-name { font-size: 11px; font-weight: bold; text-transform: uppercase; letter-spacing: .05em; color: #475569; margin-bottom: 4px; }
.cat-rev { font-size: 20px; font-weight: bold; font-family: 'Courier New', monospace; margin-bottom: 2px; }
.cat-sub { font-size: 10px; color: #64748b; }
.cat-full-block { padding: 12px 14px; border: 1px solid #e2e8f0; border-radius: 4px; margin-bottom: 14px; }
.cat-full-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; }
.cat-full-name { font-size: 13px; font-weight: bold; text-transform: uppercase; letter-spacing: .05em; }
.cat-full-rev { font-size: 13px; font-weight: bold; font-family: 'Courier New', monospace; color: #334155; }
.cat-full-sub { font-size: 10px; color: #64748b; margin-bottom: 8px; }
.prod-table { width: 100%; border-collapse: collapse; font-size: 10px; margin-top: 6px; table-layout: fixed; }
.prod-table th, .prod-table td { padding: 2px 4px; border-bottom: 1px dotted #e2e8f0; font-size: 10px; }
.prod-table thead th { border-bottom: 1px solid #cbd5e1; font-size: 9px; text-transform: uppercase; color: #64748b; font-weight: bold; }
.prod-table th.num, .prod-table td.num { text-align: right; font-weight: bold; }
.prod-table th:first-child, .prod-table td:first-child { text-align: left; }
.prod-name { color: #1e293b; }
.no-sales { font-size: 10px; color: #94a3b8; font-style: italic; margin-top: 4px; }
@media print { body { padding: 0; } }
</style>`
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title>${styles}</head><body>
<h1>${title}</h1>
<div class="meta">
${periodRow()}
<div class="row"><span>Κατηγορίες</span><span>${categories.length}</span></div>
</div>
${body}
</body></html>`)
win.document.close()
win.focus()
win.print()
onClose()
}
async function handleThermalPrint() {
const body = { printer_id: parseInt(targetPrinter, 10), mode: printType }
if (queryParams.business_day_id) {
body.business_day_id = parseInt(queryParams.business_day_id, 10)
} else {
body.from_dt = queryParams.from
body.to_dt = queryParams.to
}
setPrinting(true)
try {
await client.post('/api/reports/print/categories', body)
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
onClose()
} catch {
toast.error('Αποτυχία αποστολής στον εκτυπωτή')
} finally {
setPrinting(false)
}
}
function handlePrint() {
if (targetPrinter === 'browser') handleBrowserPrint()
else handleThermalPrint()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="relative w-full max-w-md rounded-xl border border-slate-200 bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<h2 className="text-[15px] font-semibold text-slate-900">Εκτύπωση Κατηγοριών</h2>
<button onClick={onClose} className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"><X className="h-4 w-4" /></button>
</div>
<div className="px-5 py-4 space-y-4 text-[13px] text-slate-700">
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Τύπος Εκτύπωσης</div>
<div className="space-y-2">
{PRINT_TYPES.map(pt => (
<label key={pt.id} className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${printType === pt.id ? 'border-sky-400 bg-sky-50' : 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'}`}>
<input type="radio" name="printType" value={pt.id} checked={printType === pt.id} onChange={() => setPrintType(pt.id)} className="mt-0.5 accent-sky-500" />
<div>
<div className="font-medium text-slate-800">{pt.label}</div>
<div className="text-[12px] text-slate-500">{pt.desc}</div>
</div>
</label>
))}
</div>
</div>
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
<div className="relative">
<select value={targetPrinter} onChange={e => setTargetPrinter(e.target.value)} className="w-full appearance-none rounded-md border border-slate-200 bg-white py-2 pl-3 pr-8 text-[13px] text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100">
{printerOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-2">
<button onClick={onClose} className="inline-flex items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-3.5 py-2 text-[13px] font-medium text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:bg-slate-50 hover:border-slate-300">Ακύρωση</button>
<button onClick={handlePrint} disabled={printing} className="inline-flex items-center justify-center gap-2 rounded-md bg-sky-500 px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_1px_0_rgba(15,23,42,0.08)] transition hover:bg-sky-600 active:bg-sky-700 disabled:opacity-60 disabled:pointer-events-none">
{printing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
{printing ? 'Αποστολή...' : 'Εκτύπωση'}
</button>
</div>
</div>
</div>
</div>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function CategoryPerformance() {
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const [showPrintModal, setShowPrintModal] = useState(false)
const [pieMetric, setPieMetric] = useState('revenue')
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data), staleTime: 5 * 60 * 1000 })
const { data: allProductsData } = useQuery({ queryKey: ['meta-products'], queryFn: () => client.get('/api/reports/meta/products').then(r => r.data), staleTime: 5 * 60 * 1000 })
const queryParams = {
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['category-performance', from, to],
queryKey: ['category-performance', mode, from, to, businessDayId],
queryFn: () => client.get('/api/reports/categories/performance', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const { data: productsData } = useQuery({
queryKey: ['product-performance-for-cat', mode, from, to, businessDayId],
queryFn: () => client.get('/api/reports/products/performance', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const printers = printersData?.printers || []
const allProducts = allProductsData?.products || []
const soldProducts = productsData?.products || []
const categories = data?.categories || []
const pieData = categories.map((c, i) => ({
const anyHasCost = categories.some(c => (c.total_cost || 0) > 0)
const pieCategories = pieMetric === 'profit'
? categories.filter(c => (c.trackable_profit || 0) > 0)
: categories
const pieData = pieCategories.map((c, i) => ({
name: c.category_name,
value: c.revenue,
value: pieMetric === 'profit' ? c.trackable_profit : c.revenue,
color: c.color || CHART_PALETTE[i % CHART_PALETTE.length],
}))
const periodLabel = mode === 'workday'
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
: `${fmtDateStr(from)}${fmtDateStr(to)}`
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={6} columns={5} showChart /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
@@ -41,9 +338,20 @@ export default function CategoryPerformance() {
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
<FilterBar right={
<button onClick={() => setShowPrintModal(true)} className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-600 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900">
<FileText className="h-3.5 w-3.5" />Εκτύπωση Σύνοψης
</button>
}>
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
@@ -53,55 +361,109 @@ export default function CategoryPerformance() {
<>
<div className="grid grid-cols-12 gap-4">
<div className="col-span-5">
<Panel title="Κατανομή Εσόδων">
<div style={{ height: 280 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={60} outerRadius={100} paddingAngle={2}>
{pieData.map((d, i) => <Cell key={i} fill={d.color} />)}
</Pie>
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
</PieChart>
</ResponsiveContainer>
</div>
<Panel title={pieMetric === 'profit' ? 'Κατανομή Κέρδους' : 'Κατανομή Εσόδων'} right={
anyHasCost ? (
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
{[['revenue', 'Έσοδα'], ['profit', 'Κέρδος']].map(([m, label]) => (
<button key={m} onClick={() => setPieMetric(m)} className={`rounded px-2 py-1 font-medium transition ${pieMetric === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>{label}</button>
))}
</div>
) : null
}>
{pieData.length === 0 ? (
<div className="flex items-center justify-center py-8 text-[12px] text-slate-400">Δεν υπάρχουν δεδομένα κέρδους</div>
) : (
<div style={{ height: 280 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={60} outerRadius={100} paddingAngle={2}>
{pieData.map((d, i) => <Cell key={i} fill={d.color} />)}
</Pie>
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
</PieChart>
</ResponsiveContainer>
</div>
)}
</Panel>
</div>
<div className="col-span-7 grid grid-cols-2 gap-4">
{categories.map((c, i) => (
<div key={c.category_id} className="rounded-lg border border-slate-200 bg-white p-5 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />
<span className="text-[12px] font-semibold uppercase tracking-wider text-slate-700">{c.category_name}</span>
{categories.map((c, i) => {
const margin = c.revenue > 0 && (c.trackable_profit || 0) > 0
? ((c.trackable_profit / c.revenue) * 100).toFixed(0) + '%'
: null
return (
<div key={c.category_id} className="rounded-lg border border-slate-200 bg-white p-5 shadow-[0_1px_0_rgba(15,23,42,0.04)]">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />
<span className="text-[12px] font-semibold uppercase tracking-wider text-slate-700">{c.category_name}</span>
</div>
<div className="mt-3 font-mono text-[24px] font-medium tabular-nums text-slate-900">{fmtEUR(c.revenue)}</div>
<div className="mt-1 flex justify-between text-[11px] text-slate-500">
<span>{c.units_sold} τεμ. · {c.product_count} προϊόντα</span>
<span className="font-mono">{c.pct_rev ?? c.pct}%</span>
</div>
{(c.trackable_profit || 0) > 0 && (
<div className="mt-2 flex items-center justify-between border-t border-slate-100 pt-2">
<span className="text-[11px] font-medium text-green-700">{fmtEUR(c.trackable_profit)} κέρδος</span>
{margin && <span className="text-[10px] text-slate-400">{margin}{c.has_gap && ' ⚠'}</span>}
</div>
)}
</div>
<div className="mt-3 font-mono text-[24px] font-medium tabular-nums text-slate-900">{fmtEUR(c.revenue)}</div>
<div className="mt-1 flex justify-between text-[11px] text-slate-500">
<span>{c.units_sold} τεμ. · {c.product_count} προϊόντα</span>
<span className="font-mono">{c.pct}%</span>
</div>
</div>
))}
)
})}
</div>
</div>
<div className="mt-4">
<Panel title="Κατηγορίες" padded={false}>
<DataTable>
<THead><TH>Κατηγορία</TH><TH align="right">Προϊόντα</TH><TH align="right">Τεμάχια</TH><TH align="right">Συνολικά Έσοδα</TH><TH align="right">% Συνόλου</TH></THead>
<THead>
<TH>Κατηγορία</TH>
<TH align="right">Προϊόντα</TH>
<TH align="right">Τεμάχια</TH>
<TH align="right">Έσοδα</TH>
{anyHasCost && <TH align="right">Κόστος</TH>}
{anyHasCost && <TH align="right">Κέρδος</TH>}
<TH align="right">% Εσόδων</TH>
{anyHasCost && <TH align="right">% Κέρδους</TH>}
</THead>
<tbody>
{categories.map((c, i) => (
<TR key={c.category_id} striped>
<TD>
<span className="inline-flex items-center gap-2 font-medium text-slate-900">
<span className="h-2 w-2 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />
{c.category_name}
</span>
</TD>
<TD mono align="right">{c.product_count}</TD>
<TD mono align="right">{fmtNum(c.units_sold)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(c.revenue)}</TD>
<TD mono align="right">{c.pct}%</TD>
</TR>
))}
{categories.map((c, i) => {
const margin = c.revenue > 0 && (c.trackable_profit || 0) > 0
? ((c.trackable_profit / c.revenue) * 100).toFixed(0) + '%'
: null
return (
<TR key={c.category_id} striped>
<TD><span className="inline-flex items-center gap-2 font-medium text-slate-900"><span className="h-2 w-2 rounded-sm flex-shrink-0" style={{ backgroundColor: c.color || CHART_PALETTE[i % CHART_PALETTE.length] }} />{c.category_name}</span></TD>
<TD mono align="right">{c.product_count}</TD>
<TD mono align="right">{fmtNum(c.units_sold)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(c.revenue)}</TD>
{anyHasCost && (
<TD mono align="right" className="text-slate-600">
{(c.total_cost || 0) > 0 ? fmtEUR(c.total_cost) : <span className="text-slate-300"></span>}
</TD>
)}
{anyHasCost && (
<TD mono align="right">
{(c.trackable_profit || 0) > 0 ? (
<span className="text-green-700">
{fmtEUR(c.trackable_profit)}
{margin && <span className="ml-1 text-[10px] text-slate-400">({margin}){c.has_gap && ' ⚠'}</span>}
</span>
) : <span className="text-slate-300"></span>}
</TD>
)}
<TD mono align="right">{c.pct_rev ?? c.pct}%</TD>
{anyHasCost && (
<TD mono align="right">
{(c.pct_profit || 0) > 0
? <span className="text-green-700">{c.pct_profit}%</span>
: <span className="text-slate-300"></span>
}
</TD>
)}
</TR>
)
})}
</tbody>
</DataTable>
</Panel>
@@ -109,6 +471,18 @@ export default function CategoryPerformance() {
</>
)}
</div>
{showPrintModal && (
<CategoryPrintModal
onClose={() => setShowPrintModal(false)}
categories={categories}
allProducts={allProducts}
soldProducts={soldProducts}
printers={printers}
queryParams={queryParams}
periodLabel={periodLabel}
/>
)}
</div>
)
}

View File

@@ -1,46 +1,314 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import { AlertTriangle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, Legend } from 'recharts'
import { FileText, X, ChevronDown, Loader2 } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtDate, CHART_PALETTE } from '../shared/reportDesignTokens'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDateStr, CHART_PALETTE } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
// ─── SVG bar chart rendered as inline HTML ────────────────────────────────────
// Used inside the browser print window (no Recharts available there).
function svgBarChart(items, valueKey, labelKey, colorFn, width = 680, barH = 18, gap = 6) {
const top5 = [...items].slice(0, 5)
const maxVal = Math.max(1, ...top5.map(d => d[valueKey]))
const labelW = 160
const numW = 70
const barAreaW = width - labelW - numW - 16
const h = top5.length * (barH + gap) + gap
const bars = top5.map((d, i) => {
const y = gap + i * (barH + gap)
const bw = Math.round((d[valueKey] / maxVal) * barAreaW)
const color = colorFn(i)
return `
<text x="${labelW - 6}" y="${y + barH * 0.72}" text-anchor="end" font-size="11" fill="#475569" font-family="Arial,sans-serif">${d[labelKey]}</text>
<rect x="${labelW}" y="${y}" width="${bw}" height="${barH}" rx="3" fill="${color}" />
<text x="${labelW + bw + 4}" y="${y + barH * 0.72}" font-size="10" fill="#64748b" font-family="Arial,sans-serif">${d[valueKey]}</text>`
}).join('')
return `<svg width="${width}" height="${h}" xmlns="http://www.w3.org/2000/svg">${bars}</svg>`
}
// ─── Print modal ─────────────────────────────────────────────────────────────
const PRINT_TYPES = [
{ id: 'smart', label: 'Έξυπνη Σύνοψη', desc: 'Μόνο προϊόντα με πωλήσεις — σε σειρά ποσότητας' },
{ id: 'full', label: 'Πλήρης Ανάλυση', desc: 'Όλα τα προϊόντα, ακόμα και με 0 πωλήσεις, με αξία + γραφήματα' },
]
function ProductPrintModal({ onClose, products, allProducts, printers, queryParams, periodLabel }) {
const [printType, setPrintType] = useState('smart')
const [targetPrinter, setTargetPrinter] = useState('browser')
const [printing, setPrinting] = useState(false)
const printerOptions = [
{ value: 'browser', label: 'Εκτύπωση μέσω browser (PDF)' },
...printers.map(p => ({ value: String(p.id), label: p.name })),
]
function periodRow() {
if (queryParams.business_day_id)
return `<div class="row"><span>Εργάσιμη Μέρα</span><span>${periodLabel}</span></div>`
const f = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
const t = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
return `<div class="row"><span>Από</span><span>${f}</span></div><div class="row"><span>Έως</span><span>${t}</span></div>`
}
function handleBrowserPrint() {
const win = window.open('', '_blank', 'width=820,height=800')
if (!win) { onClose(); return }
const soldMap = {}
for (const p of products) soldMap[p.product_id] = p
const totalQty = products.reduce((s, p) => s + p.qty_sold, 0)
const totalRev = products.reduce((s, p) => s + p.revenue, 0)
let tableSection = ''
if (printType === 'smart') {
const sorted = [...products].sort((a, b) => b.qty_sold - a.qty_sold)
const rows = sorted.map(p => {
const pctRev = totalRev ? (p.revenue / totalRev * 100).toFixed(1) : '0'
const pctQty = totalQty ? (p.qty_sold / totalQty * 100).toFixed(1) : '0'
return `<tr>
<td>${p.product_name}</td>
<td class="num">${fmtNum(p.qty_sold)}</td>
<td class="num">${fmtEUR(p.revenue)}</td>
<td class="num">${pctRev}%</td>
<td class="num">${pctQty}%</td>
</tr>`
}).join('')
tableSection = `<table>
<thead><tr><th>Προϊόν</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><td>Σύνολο</td><td class="num">${fmtNum(totalQty)}</td><td class="num">${fmtEUR(totalRev)}</td><td class="num">100%</td><td class="num">100%</td></tr></tfoot>
</table>`
} else {
// Full: merge all products, including 0-sold
const merged = allProducts.map(ap => {
const sold = soldMap[ap.id]
return { name: ap.name, category: ap.category_name, qty: sold ? sold.qty_sold : 0, revenue: sold ? sold.revenue : 0 }
}).sort((a, b) => b.qty - a.qty)
// Top-5 bar charts (qty and revenue)
const top5qty = merged.filter(p => p.qty > 0).slice(0, 5)
const top5rev = merged.filter(p => p.revenue > 0).slice(0, 5).sort((a, b) => b.revenue - a.revenue)
const chartQty = svgBarChart(top5qty, 'qty', 'name', i => CHART_PALETTE[i % CHART_PALETTE.length])
const maxRev = Math.max(1, ...top5rev.map(d => d.revenue))
const labelW = 160, barH = 18, gap = 6, barAreaW = 680 - labelW - 90 - 16
const revBars = top5rev.map((d, i) => {
const y = gap + i * (barH + gap)
const bw = Math.round((d.revenue / maxRev) * barAreaW)
return `
<text x="${labelW - 6}" y="${y + barH * 0.72}" text-anchor="end" font-size="11" fill="#475569" font-family="Arial,sans-serif">${d.name}</text>
<rect x="${labelW}" y="${y}" width="${bw}" height="${barH}" rx="3" fill="${CHART_PALETTE[i % CHART_PALETTE.length]}" />
<text x="${labelW + bw + 4}" y="${y + barH * 0.72}" font-size="10" fill="#64748b" font-family="Arial,sans-serif">${fmtEUR(d.revenue)}</text>`
}).join('')
const revH = top5rev.length * (barH + gap) + gap
const chartRevSvg = `<svg width="680" height="${revH}" xmlns="http://www.w3.org/2000/svg">${revBars}</svg>`
const rows = merged.map(p => {
const pctRev = totalRev && p.revenue ? (p.revenue / totalRev * 100).toFixed(1) : '—'
const pctQty = totalQty && p.qty ? (p.qty / totalQty * 100).toFixed(1) : '—'
return `<tr class="${p.qty === 0 ? 'zero' : ''}">
<td>${p.name}</td>
<td class="cat">${p.category}</td>
<td class="num">${fmtNum(p.qty)}</td>
<td class="num">${fmtEUR(p.revenue)}</td>
<td class="num">${pctRev}%</td>
<td class="num">${pctQty}%</td>
</tr>`
}).join('')
tableSection = `
<div class="chart-section">
<div class="chart-title">Top 5 σε Τεμάχια</div>
${chartQty}
</div>
<div class="chart-section">
<div class="chart-title">Top 5 σε Έσοδα</div>
${chartRevSvg}
</div>
<table>
<thead><tr><th>Προϊόν</th><th>Κατηγορία</th><th class="num">Τεμ.</th><th class="num">Έσοδα</th><th class="num">% Έσ.</th><th class="num">% Τεμ.</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><td colspan="2">Σύνολο</td><td class="num">${fmtNum(totalQty)}</td><td class="num">${fmtEUR(totalRev)}</td><td class="num">100%</td><td class="num">100%</td></tr></tfoot>
</table>`
}
const title = printType === 'smart' ? 'Έξυπνη Σύνοψη Προϊόντων' : 'Πλήρης Ανάλυση Προϊόντων'
const styles = `<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, sans-serif; font-size: 12px; color: #000; padding: 24px; max-width: 760px; }
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; font-family: 'Courier New', monospace; }
.meta { margin-bottom: 16px; font-family: 'Courier New', monospace; }
.row { display: flex; justify-content: space-between; padding: 2px 0; }
.chart-section { margin: 16px 0; }
.chart-title { font-size: 11px; font-weight: bold; text-transform: uppercase; letter-spacing: .06em; color: #64748b; margin-bottom: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 16px; font-family: 'Courier New', monospace; }
th { text-align: left; border-bottom: 1px solid #000; padding: 4px 6px; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; }
td { padding: 3px 6px; border-bottom: 1px dotted #ccc; }
.num { text-align: right; font-weight: bold; }
.cat { color: #555; font-size: 10px; }
.zero { color: #aaa; }
tfoot td { border-top: 2px solid #000; border-bottom: none; font-weight: bold; padding-top: 6px; }
@media print { body { padding: 0; } }
</style>`
win.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title>${styles}</head><body>
<h1>${title}</h1>
<div class="meta">
${periodRow()}
<div class="row"><span>Προϊόντα με πωλήσεις</span><span>${products.length}</span></div>
${printType === 'full' ? `<div class="row"><span>Σύνολο ενεργών προϊόντων</span><span>${allProducts.length}</span></div>` : ''}
</div>
${tableSection}
</body></html>`)
win.document.close()
win.focus()
win.print()
onClose()
}
async function handleThermalPrint() {
const body = { printer_id: parseInt(targetPrinter, 10), mode: printType }
if (queryParams.business_day_id) {
body.business_day_id = parseInt(queryParams.business_day_id, 10)
} else {
body.from_dt = queryParams.from
body.to_dt = queryParams.to
}
setPrinting(true)
try {
await client.post('/api/reports/print/products', body)
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
onClose()
} catch {
toast.error('Αποτυχία αποστολής στον εκτυπωτή')
} finally {
setPrinting(false)
}
}
function handlePrint() {
if (targetPrinter === 'browser') handleBrowserPrint()
else handleThermalPrint()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="relative w-full max-w-md rounded-xl border border-slate-200 bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<h2 className="text-[15px] font-semibold text-slate-900">Εκτύπωση Σύνοψης Προϊόντων</h2>
<button onClick={onClose} className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"><X className="h-4 w-4" /></button>
</div>
<div className="px-5 py-4 space-y-4 text-[13px] text-slate-700">
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Τύπος Εκτύπωσης</div>
<div className="space-y-2">
{PRINT_TYPES.map(pt => (
<label key={pt.id} className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${printType === pt.id ? 'border-sky-400 bg-sky-50' : 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'}`}>
<input type="radio" name="printType" value={pt.id} checked={printType === pt.id} onChange={() => setPrintType(pt.id)} className="mt-0.5 accent-sky-500" />
<div>
<div className="font-medium text-slate-800">{pt.label}</div>
<div className="text-[12px] text-slate-500">{pt.desc}</div>
</div>
</label>
))}
</div>
</div>
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
<div className="relative">
<select value={targetPrinter} onChange={e => setTargetPrinter(e.target.value)} className="w-full appearance-none rounded-md border border-slate-200 bg-white py-2 pl-3 pr-8 text-[13px] text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100">
{printerOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
</div>
{targetPrinter !== 'browser' && printType === 'full' && (
<p className="mt-1.5 text-[11px] text-amber-600">Η Πλήρης Ανάλυση σε θερμικό θα εκτυπώσει μόνο τη λίστα χωρίς γραφήματα.</p>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-2">
<button onClick={onClose} className="inline-flex items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-3.5 py-2 text-[13px] font-medium text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:bg-slate-50 hover:border-slate-300">Ακύρωση</button>
<button onClick={handlePrint} disabled={printing} className="inline-flex items-center justify-center gap-2 rounded-md bg-sky-500 px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_1px_0_rgba(15,23,42,0.08)] transition hover:bg-sky-600 active:bg-sky-700 disabled:opacity-60 disabled:pointer-events-none">
{printing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
{printing ? 'Αποστολή...' : 'Εκτύπωση'}
</button>
</div>
</div>
</div>
</div>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function ProductPerformance() {
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const [catF, setCatF] = useState('all')
const [chartMode, setChartMode] = useState('revenue')
const [showPrintModal, setShowPrintModal] = useState(false)
// 'revenue' | 'units' | 'profit' | 'dual'
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const { data: allProductsData } = useQuery({ queryKey: ['meta-products'], queryFn: () => client.get('/api/reports/meta/products').then(r => r.data), staleTime: 5 * 60 * 1000 })
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data), staleTime: 5 * 60 * 1000 })
const queryParams = {
from: from + 'T00:00:00',
to: to + 'T23:59:59',
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
...(catF !== 'all' ? { category_id: catF } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['product-performance', from, to, catF],
queryKey: ['product-performance', mode, from, to, businessDayId, catF],
queryFn: () => client.get('/api/reports/products/performance', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const allProducts = allProductsData?.products || []
const printers = printersData?.printers || []
const products = data?.products || []
const totalRev = products.reduce((s, p) => s + p.revenue, 0)
const totalProfit = products.reduce((s, p) => s + (p.trackable_profit || 0), 0)
const anyHasCost = products.some(p => (p.total_cost || 0) > 0)
const top10 = products.slice(0, 10).map((p, i) => ({
name: p.product_name,
value: chartMode === 'revenue' ? p.revenue : p.qty_sold,
color: CHART_PALETTE[i % CHART_PALETTE.length],
}))
const top10 = [...products]
.sort((a, b) => {
if (chartMode === 'units') return b.qty_sold - a.qty_sold
if (chartMode === 'profit') return (b.trackable_profit || 0) - (a.trackable_profit || 0)
// revenue and dual: sort by revenue
return b.revenue - a.revenue
})
.slice(0, 10)
.map((p, i) => ({
name: p.product_name,
revenue: p.revenue,
profit: p.has_gap ? null : (p.trackable_profit || 0),
units: p.qty_sold,
color: CHART_PALETTE[i % CHART_PALETTE.length],
}))
const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
const periodLabel = mode === 'workday'
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
: `${fmtDateStr(from)}${fmtDateStr(to)}`
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} showChart /></div>
if (isError) return (
@@ -53,74 +321,219 @@ export default function ProductPerformance() {
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton endpoint="/api/reports/products/export" params={queryParams} filename={`products-${from}-to-${to}.csv`} />
<div className="flex items-center gap-2">
<button onClick={() => setShowPrintModal(true)} className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-600 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900">
<FileText className="h-3.5 w-3.5" />Εκτύπωση Σύνοψης
</button>
<ExportButton endpoint="/api/reports/products/export" params={queryParams} filename={`products-${from}-to-${to}.csv`} />
</div>
}>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel
title="Top 10 Προϊόντα"
subtitle={`Ταξινομημένα κατά ${chartMode === 'revenue' ? 'έσοδα' : 'τεμάχια'}`}
right={
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
{[['revenue', 'Έσοδα'], ['units', 'Τεμάχια']].map(([m, label]) => (
<button key={m} onClick={() => setChartMode(m)} className={`rounded px-2 py-1 font-medium transition ${chartMode === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>
{label}
</button>
))}
</div>
}
>
{top10.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα προϊόντων" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
) : (
<div style={{ height: 280 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 24, bottom: 4, left: 4 }}>
<CartesianGrid horizontal={false} stroke="#f1f5f9" />
<XAxis type="number" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => chartMode === 'revenue' ? '€' + v : String(v)} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={140} />
<Tooltip content={<ChartTooltip formatter={v => chartMode === 'revenue' ? fmtEUR(v) : v + ' τεμ.'} />} cursor={{ fill: '#f1f5f9' }} />
<Bar dataKey="value" radius={[0, 3, 3, 0]} barSize={16}>
{top10.map((d, i) => <Cell key={i} fill={d.color} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</Panel>
{(() => {
const chartModes = [
['revenue', 'Έσοδα'],
['units', 'Τεμάχια'],
...(anyHasCost ? [['profit', 'Κέρδος'], ['dual', 'Έσοδα vs Κέρδος']] : []),
]
const subtitleMap = { revenue: 'έσοδα', units: 'τεμάχια', profit: 'κέρδος', dual: 'έσοδα vs κέρδος' }
const isDual = chartMode === 'dual'
return (
<Panel title="Top 10 Προϊόντα" subtitle={`Ταξινομημένα κατά ${subtitleMap[chartMode] || 'έσοδα'}`} right={
<div className="inline-flex rounded-md border border-slate-200 bg-slate-50 p-0.5 text-[11px]">
{chartModes.map(([m, label]) => (
<button key={m} onClick={() => setChartMode(m)} className={`rounded px-2 py-1 font-medium transition ${chartMode === m ? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' : 'text-slate-500'}`}>{label}</button>
))}
</div>
}>
{top10.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα προϊόντων" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
) : (
<div style={{ height: isDual ? 320 : 280 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 24, bottom: isDual ? 16 : 4, left: 4 }}>
<CartesianGrid horizontal={false} stroke="#f1f5f9" />
<XAxis type="number" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false}
tickFormatter={v => chartMode === 'units' ? String(v) : '€' + v} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#475569' }} stroke="#cbd5e1" axisLine={false} tickLine={false} width={140} />
<Tooltip content={<ChartTooltip formatter={(v, name) => {
if (chartMode === 'units') return v + ' τεμ.'
return fmtEUR(v)
}} />} cursor={{ fill: '#f1f5f9' }} />
{isDual && <Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />}
{isDual ? (
<>
<Bar dataKey="revenue" name="Έσοδα" fill="#60a5fa" radius={[0, 0, 0, 0]} barSize={10} />
<Bar dataKey="profit" name="Κέρδος" fill="#34d399" radius={[0, 3, 3, 0]} barSize={10} />
</>
) : (
<Bar dataKey={chartMode === 'units' ? 'units' : chartMode === 'profit' ? 'profit' : 'revenue'}
radius={[0, 3, 3, 0]} barSize={16}>
{top10.map((d, i) => (
<Cell key={i} fill={chartMode === 'profit' ? '#34d399' : d.color} />
))}
</Bar>
)}
</BarChart>
</ResponsiveContainer>
</div>
)}
</Panel>
)
})()}
<div className="mt-4">
<Panel title="Όλα τα Προϊόντα" subtitle={`${products.length} προϊόντα με πωλήσεις`} padded={false}>
{products.length === 0 ? (
<EmptyState title="Δεν βρέθηκαν προϊόντα" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
) : (
<DataTable>
<THead>
<TH>Προϊόν</TH><TH align="right">Τεμάχια</TH><TH align="right">Έσοδα</TH>
<TH align="right">% Συνόλου</TH><TH align="right">Παραγγελίες</TH>
</THead>
<tbody>
{products.map(p => {
const pct = totalRev ? (p.revenue / totalRev * 100) : 0
return (
<TR key={p.product_id} striped>
<TD className="font-medium text-slate-900">{p.product_name}</TD>
<TD mono align="right">{fmtNum(p.qty_sold)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(p.revenue)}</TD>
<TD mono align="right">{pct.toFixed(1)}%</TD>
<TD mono align="right">{fmtNum(p.order_count)}</TD>
</TR>
)
})}
</tbody>
</DataTable>
)}
</Panel>
{(() => {
const totalUncostedRev = products.reduce((s, p) => s + (p.uncosted_revenue || 0), 0)
const totalCost = products.reduce((s, p) => s + (p.total_cost || 0), 0)
const hasGap = products.some(p => p.has_gap)
const overallMargin = totalRev > 0 && !hasGap
? ((totalProfit / totalRev) * 100).toFixed(0) + '%'
: null
return (
<Panel
title="Όλα τα Προϊόντα"
subtitle={`${products.length} προϊόντα με πωλήσεις`}
padded={false}
>
{hasGap && (
<div className="mx-4 mt-3 mb-1 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] text-amber-800">
<span className="shrink-0 font-bold"></span>
<span>
Μερικά προϊόντα δεν έχουν κόστος το κέρδος που φαίνεται είναι μερικό.
Ορισμένα έσοδα <strong>{fmtEUR(totalUncostedRev)}</strong> δεν συμπεριλαμβάνονται στο κέρδος.{' '}
<a href="/management?tab=products" className="underline hover:text-amber-900">Ορισμός κόστους προϊόντων </a>
</span>
</div>
)}
{products.length === 0 ? (
<EmptyState title="Δεν βρέθηκαν προϊόντα" description="Δεν υπάρχουν πωλήσεις σε αυτή την περίοδο." />
) : (
<DataTable>
<THead>
<TH>Προϊόν</TH>
<TH align="right">Τεμάχια</TH>
<TH align="right">Έσοδα</TH>
{anyHasCost && <TH align="right">Κόστος</TH>}
<TH align="right">Κέρδος</TH>
<TH align="right">Απόβλητα</TH>
<TH align="right">% Έσόδων</TH>
{anyHasCost && <TH align="right">% Κέρδους</TH>}
<TH align="right">Παραγγελίες</TH>
</THead>
<tbody>
{products.map(p => {
const pctRev = totalRev ? (p.revenue / totalRev * 100) : 0
const pctProfit = totalProfit > 0 && !p.has_gap
? (p.trackable_profit / totalProfit * 100)
: null
const margin = p.revenue > 0 && !p.has_gap && (p.total_cost || 0) > 0
? ((p.trackable_profit / p.revenue) * 100).toFixed(0) + '%'
: null
return (
<TR key={p.product_id} striped>
<TD className="font-medium text-slate-900">{p.product_name}</TD>
<TD mono align="right">{fmtNum(p.qty_sold)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(p.revenue)}</TD>
{anyHasCost && (
<TD mono align="right">
{(p.total_cost || 0) > 0
? <span className="text-slate-600">{fmtEUR(p.total_cost)}</span>
: <span className="text-slate-300"></span>
}
</TD>
)}
<TD mono align="right">
{p.has_gap ? (
<span className="text-amber-500 text-[11px]"> χωρίς κόστος</span>
) : (p.total_cost || 0) === 0 ? (
<span className="text-slate-300"></span>
) : (
<span className={p.trackable_profit >= 0 ? 'text-green-700' : 'text-red-600'}>
{fmtEUR(p.trackable_profit)}
{margin && <span className="ml-1 text-[10px] text-slate-400">({margin})</span>}
</span>
)}
</TD>
<TD mono align="right">
{p.waste_qty > 0 ? (
<span className="text-amber-600">
{p.waste_qty}
{p.waste_cost > 0 && <span className="text-[10px] text-amber-400 ml-1">({fmtEUR(p.waste_cost)})</span>}
</span>
) : <span className="text-slate-300"></span>}
</TD>
<TD mono align="right">{pctRev.toFixed(1)}%</TD>
{anyHasCost && (
<TD mono align="right">
{pctProfit != null
? <span className="text-green-700">{pctProfit.toFixed(1)}%</span>
: <span className="text-slate-300"></span>
}
</TD>
)}
<TD mono align="right">{fmtNum(p.order_count)}</TD>
</TR>
)
})}
</tbody>
{products.length > 1 && (
<tfoot>
<TR>
<TD className="font-semibold text-slate-700" colSpan={2}>Σύνολο</TD>
<TD mono align="right" className="font-bold">{fmtEUR(totalRev)}</TD>
{anyHasCost && (
<TD mono align="right" className="font-bold text-slate-600">{fmtEUR(totalCost)}</TD>
)}
<TD mono align="right" className="font-bold">
{hasGap ? (
<span className="text-amber-600">{fmtEUR(totalProfit)}{' '}<span className="text-[10px]">+χωρίς κόστος</span></span>
) : totalProfit > 0 ? (
<span className="text-green-700">
{fmtEUR(totalProfit)}
{overallMargin && <span className="ml-1 text-[10px] text-slate-400">({overallMargin})</span>}
</span>
) : <span className="text-slate-300"></span>}
</TD>
<TD />
<TD mono align="right" className="font-bold">100%</TD>
{anyHasCost && (
<TD mono align="right" className="font-bold text-green-700">
{totalProfit > 0 && !hasGap ? '100%' : '—'}
</TD>
)}
<TD />
</TR>
</tfoot>
)}
</DataTable>
)}
</Panel>
)
})()}
</div>
</div>
{showPrintModal && (
<ProductPrintModal
onClose={() => setShowPrintModal(false)}
products={products}
allProducts={allProducts}
printers={printers}
queryParams={queryParams}
periodLabel={periodLabel}
/>
)}
</div>
)
}

View File

@@ -6,7 +6,7 @@ import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import { fmtEUR, fmtNum, fmtDate } from '../shared/reportDesignTokens'
import { fmtEUR, fmtNum, fmtDate, fmtDateStr } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
@@ -27,9 +27,11 @@ export default function RevenueTrends() {
})
const trends = data?.trends || []
const anyHasProfit = trends.some(d => (d.profit || 0) > 0)
const chartData = trends.map(d => ({
label: gran === 'monthly' ? d.date : fmtDate(d.date),
revenue: d.revenue,
profit: (d.profit || 0) > 0 ? d.profit : undefined,
rolling7: d.rolling7,
}))
@@ -56,7 +58,7 @@ export default function RevenueTrends() {
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel title="Τάση Εσόδων" subtitle={`${GRAN_OPTIONS.find(([g]) => g === gran)?.[1] || gran} · ${from}${to}`}>
<Panel title="Τάση Εσόδων" subtitle={`${GRAN_OPTIONS.find(([g]) => g === gran)?.[1] || gran} · ${fmtDateStr(from)}${fmtDateStr(to)}`}>
{trends.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα εσόδων" description="Δεν υπάρχουν κλειστές παραγγελίες σε αυτή την περίοδο." />
) : (
@@ -69,6 +71,7 @@ export default function RevenueTrends() {
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
<Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />
<Line type="monotone" dataKey="revenue" name="Έσοδα" stroke="#60a5fa" strokeWidth={2.5} dot={{ r: 3, fill: '#60a5fa' }} />
{anyHasProfit && <Line type="monotone" dataKey="profit" name="Κέρδος" stroke="#34d399" strokeWidth={2} dot={{ r: 3, fill: '#34d399' }} connectNulls={false} />}
{gran === 'daily' && <Line type="monotone" dataKey="rolling7" name="Μέσος 7 ημ." stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 4" dot={false} />}
</LineChart>
</ResponsiveContainer>
@@ -83,6 +86,7 @@ export default function RevenueTrends() {
<TH>Περίοδος</TH>
<TH align="right">Παραγγελίες</TH>
<TH align="right">Έσοδα</TH>
{anyHasProfit && <TH align="right">Κέρδος</TH>}
{gran === 'daily' && <TH align="right">Μέσος 7 ημ.</TH>}
</THead>
<tbody>
@@ -91,6 +95,17 @@ export default function RevenueTrends() {
<TD className="font-medium">{gran === 'monthly' ? d.date : fmtDate(d.date)}</TD>
<TD mono align="right">{fmtNum(d.orders)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(d.revenue)}</TD>
{anyHasProfit && (
<TD mono align="right">
{(d.profit || 0) > 0
? <span className="text-green-700">
{fmtEUR(d.profit)}
{d.has_gap && <span className="ml-1 text-[10px] text-amber-500"></span>}
</span>
: <span className="text-slate-300"></span>
}
</TD>
)}
{gran === 'daily' && <TD mono align="right" className="text-slate-500">{fmtEUR(d.rolling7)}</TD>}
</TR>
))}

View File

@@ -1,31 +1,222 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { FileText, X, ChevronDown, Loader2 } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtMinutes } from '../shared/reportDesignTokens'
import { fmtEUR, fmtNum, fmtMinutes, fmtDate, fmtTime, fmtDateStr } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
// ─── Print modal ─────────────────────────────────────────────────────────────
const PRINT_TYPES = [
{ id: 'smart', label: 'Έξυπνη Σύνοψη', desc: 'Κατάταξη τραπεζιών με παραγγελίες, μέση διάρκεια και έσοδα' },
{ id: 'full', label: 'Πλήρης Ανάλυση', desc: 'Ίδιο, με επιπλέον μέσο έσοδο ανά επίσκεψη' },
]
function TablePrintModal({ onClose, tables, printers, queryParams, periodLabel }) {
const [printType, setPrintType] = useState('smart')
const [targetPrinter, setTargetPrinter] = useState('browser')
const [printing, setPrinting] = useState(false)
const printerOptions = [
{ value: 'browser', label: 'Εκτύπωση μέσω browser (PDF)' },
...printers.map(p => ({ value: String(p.id), label: p.name })),
]
function handleBrowserPrint() {
const win = window.open('', '_blank', 'width=820,height=750')
if (!win) { onClose(); return }
let periodRow = ''
if (queryParams.business_day_id) {
periodRow = `<div class="row"><span>Εργάσιμη Μέρα</span><span>${periodLabel}</span></div>`
} else {
const fromStr = queryParams.from ? fmtDateStr(queryParams.from.slice(0, 10)) : '—'
const toStr = queryParams.to ? fmtDateStr(queryParams.to.slice(0, 10)) : '—'
periodRow = `<div class="row"><span>Από</span><span>${fromStr}</span></div>
<div class="row"><span>Έως</span><span>${toStr}</span></div>`
}
const totalOrders = tables.reduce((s, t) => s + t.order_count, 0)
const totalRev = tables.reduce((s, t) => s + t.revenue, 0)
const rows = tables.map(t => {
const avgRev = t.order_count ? t.revenue / t.order_count : 0
const dur = t.avg_duration_minutes != null ? fmtMinutes(t.avg_duration_minutes) : '—'
if (printType === 'smart') {
return `<tr>
<td>${t.table_name}</td>
<td class="num">${fmtNum(t.order_count)}</td>
<td class="num">${dur}</td>
<td class="num">${fmtEUR(t.revenue)}</td>
</tr>`
}
return `<tr>
<td>${t.table_name}</td>
<td class="num">${fmtNum(t.order_count)}</td>
<td class="num">${dur}</td>
<td class="num">${fmtEUR(avgRev)}</td>
<td class="num">${fmtEUR(t.revenue)}</td>
</tr>`
}).join('')
const headers = printType === 'smart'
? `<th>Τραπέζι</th><th class="num">Παραγγελίες</th><th class="num">Μ. Διάρκεια</th><th class="num">Έσοδα</th>`
: `<th>Τραπέζι</th><th class="num">Παραγγελίες</th><th class="num">Μ. Διάρκεια</th><th class="num">Μ. / Επίσκεψη</th><th class="num">Έσοδα</th>`
const footCols = printType === 'smart'
? `<td>Σύνολο</td><td class="num">${fmtNum(totalOrders)}</td><td class="num">—</td><td class="num">${fmtEUR(totalRev)}</td>`
: `<td>Σύνολο</td><td class="num">${fmtNum(totalOrders)}</td><td class="num">—</td><td class="num">—</td><td class="num">${fmtEUR(totalRev)}</td>`
const title = printType === 'smart' ? 'Σύνοψη Τραπεζιών' : 'Πλήρης Ανάλυση Τραπεζιών'
const styles = `<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Courier New', monospace; font-size: 12px; color: #000; padding: 24px; max-width: 800px; }
h1 { font-size: 16px; font-weight: bold; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 12px; }
.meta { margin-bottom: 16px; }
.row { display: flex; justify-content: space-between; padding: 2px 0; }
table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 8px; }
th { text-align: left; border-bottom: 1px solid #000; padding: 4px 6px; font-size: 10px; text-transform: uppercase; }
td { padding: 3px 6px; border-bottom: 1px dotted #ccc; }
.num { text-align: right; font-weight: bold; }
tfoot td { border-top: 2px solid #000; border-bottom: none; font-weight: bold; padding-top: 6px; }
@media print { body { padding: 0; } }
</style>`
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title>${styles}</head><body>
<h1>${title}</h1>
<div class="meta">
${periodRow}
<div class="row"><span>Τραπέζια</span><span>${tables.length}</span></div>
</div>
<table>
<thead><tr>${headers}</tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr>${footCols}</tr></tfoot>
</table>
</body></html>`
win.document.write(html)
win.document.close()
win.focus()
win.print()
onClose()
}
async function handleThermalPrint() {
const body = { printer_id: parseInt(targetPrinter, 10), mode: printType }
if (queryParams.business_day_id) {
body.business_day_id = parseInt(queryParams.business_day_id, 10)
} else {
body.from_dt = queryParams.from
body.to_dt = queryParams.to
}
setPrinting(true)
try {
await client.post('/api/reports/print/tables', body)
toast.success('Η εκτύπωση στάλθηκε στον εκτυπωτή')
onClose()
} catch {
toast.error('Αποτυχία αποστολής στον εκτυπωτή')
} finally {
setPrinting(false)
}
}
function handlePrint() {
if (targetPrinter === 'browser') handleBrowserPrint()
else handleThermalPrint()
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}
>
<div className="relative w-full max-w-md rounded-xl border border-slate-200 bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<h2 className="text-[15px] font-semibold text-slate-900">Εκτύπωση Σύνοψης Τραπεζιών</h2>
<button onClick={onClose} className="rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-5 py-4 space-y-4 text-[13px] text-slate-700">
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Τύπος Εκτύπωσης</div>
<div className="space-y-2">
{PRINT_TYPES.map(pt => (
<label key={pt.id} className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${printType === pt.id ? 'border-sky-400 bg-sky-50' : 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'}`}>
<input type="radio" name="printType" value={pt.id} checked={printType === pt.id} onChange={() => setPrintType(pt.id)} className="mt-0.5 accent-sky-500" />
<div>
<div className="font-medium text-slate-800">{pt.label}</div>
<div className="text-[12px] text-slate-500">{pt.desc}</div>
</div>
</label>
))}
</div>
</div>
<div>
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.08em] text-slate-500">Εκτυπωτής Προορισμού</div>
<div className="relative">
<select value={targetPrinter} onChange={e => setTargetPrinter(e.target.value)} className="w-full appearance-none rounded-md border border-slate-200 bg-white py-2 pl-3 pr-8 text-[13px] text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100">
{printerOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-2">
<button onClick={onClose} className="inline-flex items-center justify-center gap-2 rounded-md border border-slate-200 bg-white px-3.5 py-2 text-[13px] font-medium text-slate-700 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:bg-slate-50 hover:border-slate-300">
Ακύρωση
</button>
<button onClick={handlePrint} disabled={printing} className="inline-flex items-center justify-center gap-2 rounded-md bg-sky-500 px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_1px_0_rgba(15,23,42,0.08)] transition hover:bg-sky-600 active:bg-sky-700 disabled:opacity-60 disabled:pointer-events-none">
{printing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
{printing ? 'Αποστολή...' : 'Εκτύπωση'}
</button>
</div>
</div>
</div>
</div>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function TableAnalytics() {
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const [showPrintModal, setShowPrintModal] = useState(false)
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const { data: printersData } = useQuery({ queryKey: ['meta-printers'], queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data), staleTime: 5 * 60 * 1000 })
const queryParams = {
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['table-analytics', from, to],
queryKey: ['table-analytics', mode, from, to, businessDayId],
queryFn: () => client.get('/api/reports/tables/performance', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const printers = printersData?.printers || []
const tables = data?.tables || []
const maxRev = Math.max(1, ...tables.map(t => t.revenue))
const periodLabel = mode === 'workday'
? (bdOptions.find(o => o.value === businessDayId)?.label || 'Εργάσιμη Μέρα')
: `${fmtDateStr(from)}${fmtDateStr(to)}`
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={7} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
@@ -37,10 +228,26 @@ export default function TableAnalytics() {
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar right={
<ExportButton endpoint="/api/reports/tables/performance" params={queryParams} filename={`tables-${from}-to-${to}.csv`} />
<div className="flex items-center gap-2">
<button
onClick={() => setShowPrintModal(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-medium text-slate-600 shadow-[0_1px_0_rgba(15,23,42,0.04)] transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
>
<FileText className="h-3.5 w-3.5" />
Εκτύπωση Σύνοψης
</button>
<ExportButton endpoint="/api/reports/tables/performance" params={queryParams} filename={`tables-${from}-to-${to}.csv`} />
</div>
}>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
@@ -94,6 +301,16 @@ export default function TableAnalytics() {
</>
)}
</div>
{showPrintModal && (
<TablePrintModal
onClose={() => setShowPrintModal(false)}
tables={tables}
printers={printers}
queryParams={queryParams}
periodLabel={periodLabel}
/>
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import { TrendingUp, ReceiptText, Clock, Users, Trophy, XCircle, Package, Sunset } from 'lucide-react'
import { TrendingUp, ReceiptText, Clock, Users, Trophy, XCircle, Package, Sunset, PiggyBank, ShoppingBag } from 'lucide-react'
import client from '../../../api/client'
import { FilterBar } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
@@ -94,6 +94,22 @@ export default function Today() {
<span>·</span>
<span><span className="font-mono font-semibold text-slate-900">{fmtEUR(bd.orders_closed ? bd.revenue / bd.orders_closed : 0)}</span> μέσος όρος</span>
</div>
{bd.trackable_profit > 0 && (
<div className="mt-4 flex items-center gap-3 border-t border-sky-100 pt-4">
<div className="flex-1">
<div className="text-[10px] font-medium uppercase tracking-[0.08em] text-slate-400">Μεικτό Κέρδος</div>
<div className="font-mono text-[22px] font-semibold tabular-nums text-green-700">
{fmtEUR(bd.trackable_profit)}
</div>
</div>
{bd.revenue > 0 && (
<div className="rounded-md bg-green-50 px-2.5 py-1 text-[13px] font-semibold text-green-700 ring-1 ring-inset ring-green-200">
{((bd.trackable_profit / bd.revenue) * 100).toFixed(0)}% margin
{bd.has_gap && <span className="ml-1 text-[10px] text-amber-500"> μερικό</span>}
</div>
)}
</div>
)}
</div>
<div className="col-span-7 grid grid-cols-3 gap-4">
@@ -104,6 +120,17 @@ export default function Today() {
<StatCard label="Κορυφαίο Προϊόν" value={bd.top_product.name} sub={`${bd.top_product.qty} τεμ.`} icon={Trophy} />
)}
<StatCard label="Ακυρώσεις" value={fmtNum(bd.cancellations)} icon={XCircle} />
{bd.total_cost > 0 && (
<StatCard label="Κόστος Πωλήσεων" value={fmtEUR(bd.total_cost)} sub="κόστος ειδών" icon={ShoppingBag} />
)}
{bd.trackable_profit > 0 && (
<StatCard
label="Μεικτό Κέρδος"
value={fmtEUR(bd.trackable_profit)}
sub={bd.has_gap ? '⚠ μερικό κέρδος' : `${((bd.trackable_profit / bd.revenue) * 100).toFixed(0)}% margin`}
icon={PiggyBank}
/>
)}
</div>
</div>
@@ -138,11 +165,16 @@ export default function Today() {
<DataTable>
<THead>
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
<TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH>Κατάσταση</TH>
<TH align="right">Είδη</TH><TH align="right">Σύνολο</TH><TH align="right">Κέρδος</TH><TH>Κατάσταση</TH>
</THead>
<tbody>
{orders.slice(0, 30).map(o => {
const total = (o.items || []).filter(i => ['active', 'paid'].includes(i.status)).reduce((s, i) => s + i.unit_price * i.quantity, 0)
const activeItems = (o.items || []).filter(i => ['active', 'paid'].includes(i.status))
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const costedItems = activeItems.filter(i => i.unit_cost != null)
const orderProfit = costedItems.length > 0
? costedItems.reduce((s, i) => s + (i.unit_price - i.unit_cost) * i.quantity, 0)
: null
const isCancelled = o.status === 'cancelled'
return (
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
@@ -152,6 +184,12 @@ export default function Today() {
<TD mono>{fmtDateTime(o.closed_at)}</TD>
<TD mono align="right">{(o.items || []).length}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
<TD mono align="right">
{orderProfit != null
? <span className={orderProfit >= 0 ? 'text-green-700' : 'text-red-600'}>{fmtEUR(orderProfit)}</span>
: <span className="text-slate-300"></span>
}
</TD>
<TD><StatusBadge status={o.status} pulse /></TD>
</TR>
)

View File

@@ -3,12 +3,12 @@ import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { Flame, CalendarDays, Moon, TrendingUp } from 'lucide-react'
import client from '../../../api/client'
import { FilterBar, FilterSelect, FilterDateInput } from '../shared/FilterBar'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from '../shared/FilterBar'
import { Panel, ChartTooltip } from '../shared/TablePrimitives'
import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import { fmtNum } from '../shared/reportDesignTokens'
import { fmtNum, fmtDate, fmtTime } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
@@ -17,17 +17,26 @@ const DOWS = ['Δευ', 'Τρι', 'Τετ', 'Πεμ', 'Παρ', 'Σαβ', 'Κυ
const HOURS = Array.from({ length: 14 }, (_, i) => 10 + i)
export default function TrafficAnalytics() {
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const queryParams = { from: from + 'T00:00:00', to: to + 'T23:59:59' }
const { data: bdData } = useQuery({ queryKey: ['business-days-list'], queryFn: () => client.get('/api/reports/business-days').then(r => r.data), staleTime: 60 * 1000 })
const queryParams = {
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['traffic', from, to],
queryKey: ['traffic', mode, from, to, businessDayId],
queryFn: () => client.get('/api/reports/traffic', { params: queryParams }).then(r => r.data),
staleTime: 60 * 1000,
})
const bdOptions = [{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' }, ...((bdData?.business_days || []).map(bd => ({ value: String(bd.id), label: `${fmtDate(bd.opened_at)} · ${fmtTime(bd.opened_at)}` })))]
const byHour = useMemo(() => {
const hourMap = {}
;(data?.by_hour || []).forEach(h => { hourMap[h.hour] = h })
@@ -36,12 +45,6 @@ export default function TrafficAnalytics() {
const byWeekday = data?.by_weekday || []
// Build heatmap matrix [dow][hourIndex] = orders
const matrix = useMemo(() => {
// Backend only gives aggregated by_hour, so build a simple row from that
return DOWS.map(() => HOURS.map(() => 0))
}, [data])
const chartData = byHour.map(h => ({
hour: `${String(h.hour).padStart(2, '0')}:00`,
orders: h.orders,
@@ -65,8 +68,15 @@ export default function TrafficAnalytics() {
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} width="w-72" label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
@@ -96,7 +106,7 @@ export default function TrafficAnalytics() {
<div className="mt-4">
<Panel title="Παραγγελίες ανά Ημέρα Εβδομάδας" subtitle="Σωρευτικά σε επιλεγμένη περίοδο">
<div className="grid grid-cols-7 gap-3">
{byWeekday.map((d, i) => {
{byWeekday.map((d) => {
const intensity = d.orders / maxByDow
return (
<div key={d.day} className="flex flex-col items-center gap-2">

View File

@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import client from '../../../api/client'
import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, StatusBadge, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
@@ -13,7 +13,7 @@ import DeleteConfirmModal from '../../../ui/DeleteConfirmModal'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration, fmtDateTime } from '../shared/reportDesignTokens'
import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration, fmtDateTime, fmtDateStr } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
@@ -204,9 +204,11 @@ export default function WorkDaySummary() {
const days = data?.business_days || []
const anyHasCost = days.some(d => (d.total_cost || 0) > 0)
const chartData = [...days].reverse().map(d => ({
date: fmtDate(d.opened_at),
revenue: d.revenue,
profit: (d.trackable_profit || 0) > 0 ? d.trackable_profit : undefined,
}))
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={8} columns={9} showChart /></div>
@@ -228,15 +230,19 @@ export default function WorkDaySummary() {
<div className="flex-1 overflow-y-auto p-6">
{chartData.length > 0 && (
<Panel title="Έσοδα ανά Εργάσιμη Μέρα" subtitle={`${days.length} μέρες · ${from}${to}`}>
<div style={{ height: 220 }}>
<Panel title="Έσοδα ανά Εργάσιμη Μέρα" subtitle={`${days.length} μέρες · ${fmtDateStr(from)}${fmtDateStr(to)}`}>
<div style={{ height: 240 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 4, left: 4 }}>
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: anyHasCost ? 16 : 4, left: 4 }}>
<CartesianGrid vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#cbd5e1" axisLine={false} tickLine={false} tickFormatter={v => '€' + v} />
<Tooltip content={<ChartTooltip formatter={v => fmtEUR(v)} />} />
<Line type="monotone" dataKey="revenue" stroke="#60a5fa" strokeWidth={2} dot={{ r: 3, fill: '#60a5fa' }} activeDot={{ r: 5 }} />
{anyHasCost && <Legend wrapperStyle={{ fontSize: 11 }} iconType="rect" />}
<Line type="monotone" dataKey="revenue" name="Έσοδα" stroke="#60a5fa" strokeWidth={2} dot={{ r: 3, fill: '#60a5fa' }} activeDot={{ r: 5 }} />
{anyHasCost && (
<Line type="monotone" dataKey="profit" name="Κέρδος" stroke="#34d399" strokeWidth={2} dot={{ r: 3, fill: '#34d399' }} activeDot={{ r: 5 }} connectNulls={false} />
)}
</LineChart>
</ResponsiveContainer>
</div>
@@ -256,13 +262,19 @@ export default function WorkDaySummary() {
<TH align="right">Διάρκεια</TH>
<TH align="right">Παραγγελίες</TH>
<TH align="right">Έσοδα</TH>
{anyHasCost && <TH align="right">Κόστος</TH>}
{anyHasCost && <TH align="right">Κέρδος</TH>}
<TH align="right">Ακυρώσεις</TH>
<TH align="right">Σερβιτόροι</TH>
<TH>Κατάσταση</TH>
<TH className="w-10" />
</THead>
<tbody>
{days.map(d => (
{days.map(d => {
const margin = d.revenue > 0 && (d.trackable_profit || 0) > 0
? ((d.trackable_profit / d.revenue) * 100).toFixed(0) + '%'
: null
return (
<TR key={d.id} onClick={() => setDrillDay(d)} striped>
<TD className="font-medium text-slate-900">{fmtDate(d.opened_at)}</TD>
<TD mono>{fmtTime(d.opened_at)}</TD>
@@ -270,6 +282,22 @@ export default function WorkDaySummary() {
<TD mono align="right">{fmtDuration(d.opened_at, d.closed_at)}</TD>
<TD mono align="right">{fmtNum(d.order_count)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(d.revenue)}</TD>
{anyHasCost && (
<TD mono align="right" className="text-slate-600">
{(d.total_cost || 0) > 0 ? fmtEUR(d.total_cost) : <span className="text-slate-300"></span>}
</TD>
)}
{anyHasCost && (
<TD mono align="right">
{(d.trackable_profit || 0) > 0 ? (
<span className="text-green-700">
{fmtEUR(d.trackable_profit)}
{margin && !d.has_gap && <span className="ml-1 text-[10px] text-slate-400">({margin})</span>}
{d.has_gap && <span className="ml-1 text-[10px] text-amber-500"></span>}
</span>
) : <span className="text-slate-300"></span>}
</TD>
)}
<TD mono align="right">{fmtNum(d.cancellation_count)}</TD>
<TD mono align="right">{fmtNum(d.waiter_count)}</TD>
<TD><StatusBadge status={d.status} pulse /></TD>
@@ -286,7 +314,7 @@ export default function WorkDaySummary() {
)}
</TD>
</TR>
))}
)})}
</tbody>
</DataTable>
)}

View File

@@ -155,24 +155,168 @@ export function FilterSelect({ value, onChange, options, label, icon: Icon }) {
// ── Date input ─────────────────────────────────────────────────────────────────
export function FilterDateInput({ value, onChange, label }) {
const GR_MONTHS = ['Ιανουάριος','Φεβρουάριος','Μάρτιος','Απρίλιος','Μάιος','Ιούνιος','Ιούλιος','Αύγουστος','Σεπτέμβριος','Οκτώβριος','Νοέμβριος','Δεκέμβριος']
const GR_DAYS = ['Κυ','Δε','Τρ','Τε','Πε','Πα','Σα']
function fmtGR(value) {
if (!value) return ''
const [y, m, d] = value.split('-')
if (!y || !m || !d) return value
return `${d}/${m}/${y}`
}
function CalendarDropdown({ value, onCommit, onClose }) {
const today = new Date()
const initYear = value ? parseInt(value.slice(0, 4)) : today.getFullYear()
const initMonth = value ? parseInt(value.slice(5, 7)) - 1 : today.getMonth()
const [viewYear, setViewYear] = useState(initYear)
const [viewMonth, setViewMonth] = useState(initMonth)
const selectedY = value ? parseInt(value.slice(0, 4)) : null
const selectedM = value ? parseInt(value.slice(5, 7)) - 1 : null
const selectedD = value ? parseInt(value.slice(8, 10)) : null
function prevMonth(e) {
e.stopPropagation()
if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) }
else setViewMonth(m => m - 1)
}
function nextMonth(e) {
e.stopPropagation()
if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1) }
else setViewMonth(m => m + 1)
}
// Build grid: first cell = weekday of 1st of month (0=Sun)
const firstDow = new Date(viewYear, viewMonth, 1).getDay()
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate()
const cells = []
for (let i = 0; i < firstDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
function selectDay(e, day) {
e.stopPropagation()
const mm = String(viewMonth + 1).padStart(2, '0')
const dd = String(day).padStart(2, '0')
onCommit(`${viewYear}-${mm}-${dd}`)
onClose()
}
const todayY = today.getFullYear()
const todayM = today.getMonth()
const todayD = today.getDate()
return (
<label className={`flex items-center gap-2 ${INPUT_CLASS} cursor-pointer`}>
<svg className="h-3.5 w-3.5 text-slate-400 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
</svg>
{label && (
<span className="text-[11px] font-medium uppercase tracking-wider text-slate-400 flex-shrink-0">
{label}
<div
className="absolute left-0 top-full mt-1 z-50 w-64 rounded-lg border border-slate-200 bg-white shadow-xl ring-1 ring-slate-100 select-none"
onMouseDown={e => e.preventDefault()}
onClick={e => e.stopPropagation()}
>
{/* Month navigation */}
<div className="flex items-center justify-between px-3 py-2 border-b border-slate-100">
<button
type="button"
onClick={prevMonth}
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><polyline points="15 18 9 12 15 6" /></svg>
</button>
<span className="text-[13px] font-semibold text-slate-800">
{GR_MONTHS[viewMonth]} {viewYear}
</span>
<button
type="button"
onClick={nextMonth}
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><polyline points="9 18 15 12 9 6" /></svg>
</button>
</div>
{/* Day-of-week headers */}
<div className="grid grid-cols-7 px-2 pt-2 pb-1">
{GR_DAYS.map(d => (
<div key={d} className="text-center text-[10px] font-semibold text-slate-400 py-0.5">{d}</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 px-2 pb-2 gap-y-0.5">
{cells.map((day, i) => {
if (!day) return <div key={`e${i}`} />
const isSelected = day === selectedD && viewMonth === selectedM && viewYear === selectedY
const isToday = day === todayD && viewMonth === todayM && viewYear === todayY
return (
<button
key={day}
type="button"
onClick={e => selectDay(e, day)}
className={`
h-8 w-8 mx-auto rounded-full text-[12px] font-medium transition-colors
${isSelected
? 'bg-sky-500 text-white'
: isToday
? 'bg-sky-50 text-sky-700 ring-1 ring-sky-200 hover:bg-sky-100'
: 'text-slate-700 hover:bg-slate-100'
}
`}
>
{day}
</button>
)
})}
</div>
</div>
)
}
export function FilterDateInput({ value, onChange, label }) {
const [open, setOpen] = useState(false)
const containerRef = useRef(null)
useEffect(() => {
if (!open) return
function handler(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen(o => !o)}
className={`flex items-center gap-2 ${INPUT_CLASS} cursor-pointer`}
>
<svg className="h-3.5 w-3.5 text-slate-400 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" />
</svg>
{label && (
<span className="text-[11px] font-medium uppercase tracking-wider text-slate-400 flex-shrink-0">
{label}
</span>
)}
<span className="text-[13px] font-normal whitespace-nowrap">
{value
? <span className="text-slate-700">{fmtGR(value)}</span>
: <span className="text-slate-400">ΗΗ/ΜΜ/ΕΕΕΕ</span>
}
</span>
</button>
{open && (
<CalendarDropdown
value={value}
onCommit={onChange}
onClose={() => setOpen(false)}
/>
)}
<input
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-transparent outline-none text-[13px] text-slate-700 font-normal"
/>
</label>
</div>
)
}

View File

@@ -177,20 +177,54 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) {
{summary && (
<>
{/* KPI row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 10, padding: '14px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
{[
{ label: 'Διάρκεια', value: fmtMins(summary.duration_minutes) },
{ label: 'Αρχικά μετρητά', value: summary.starting_cash != null ? fmtEUR(summary.starting_cash) : '—' },
{ label: 'Είσπραξη', value: fmtEUR(summary.total_collected), accent: '#2f9e5e' },
{ label: 'Προς παράδοση', value: fmtEUR(summary.net_to_deliver), accent: '#3758c9' },
].map(k => (
<div key={k.label} style={{ background: '#f9fafb', borderRadius: 10, padding: '10px 12px', textAlign: 'center' }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.5 }}>{k.label}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: k.accent || '#111315', fontFamily: 'ui-monospace,monospace', marginTop: 4 }}>{k.value}</div>
{/* KPI grid — 2 rows × 4 cols */}
{(() => {
const totalOrders = summary.orders?.length ?? 0
const tableSet = new Set(summary.orders?.map(o => o.table_name).filter(Boolean))
const tablesServed = tableSet.size
const durationHours = summary.duration_hours ?? (summary.duration_minutes != null ? summary.duration_minutes / 60 : null)
const durationLabel = durationHours != null
? (() => { const h = Math.floor(durationHours); const m = Math.round((durationHours - h) * 60); return h > 0 ? `${h}ω ${m}λ` : `${m}λ` })()
: fmtMins(summary.duration_minutes)
const row1 = [
{ label: 'Διάρκεια Βάρδιας', value: durationLabel },
{ label: 'Μισθός / Ώρα', value: summary.hourly_rate_snapshot != null ? fmtEUR(summary.hourly_rate_snapshot) : '—' },
{ label: 'Πληρωμή Βάρδιας', value: summary.shift_pay != null ? fmtEUR(summary.shift_pay) : 'Δεν παρακολουθείται', accent: summary.shift_pay != null ? '#059669' : undefined },
{ label: 'Αρχικά Μετρητά', value: summary.starting_cash != null ? fmtEUR(summary.starting_cash) : '—' },
]
const discrepancy = summary.cash_discrepancy
const discLabel = discrepancy == null
? '—'
: Math.abs(discrepancy) < 0.005
? '✓ Εντάξει'
: discrepancy < 0
? `Έλλειμμα ${fmtEUR(Math.abs(discrepancy))}`
: `Πλεόνασμα ${fmtEUR(discrepancy)}`
const discAccent = discrepancy == null
? undefined
: Math.abs(discrepancy) < 0.005 ? '#16a34a' : '#dc2626'
const row2 = [
{ label: 'Σύνολο Παραγγελιών', value: String(totalOrders) },
{ label: 'Εισπράξεις', value: fmtEUR(summary.total_collected), accent: '#2f9e5e' },
{ label: 'Παραδόθηκαν', value: summary.counted_cash_end != null ? fmtEUR(summary.counted_cash_end) : '—', accent: '#3758c9' },
{ label: 'Ταμείο', value: discLabel, accent: discAccent },
]
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: '14px 24px', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
{[row1, row2].map((row, ri) => (
<div key={ri} style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 6 }}>
{row.map(k => (
<div key={k.label} style={{ background: '#f9fafb', borderRadius: 10, padding: '10px 12px', textAlign: 'center' }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.5 }}>{k.label}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: k.accent || '#111315', fontFamily: 'ui-monospace,monospace', marginTop: 4 }}>{k.value}</div>
</div>
))}
</div>
))}
</div>
))}
</div>
)
})()}
{/* Colour legend */}
<div style={{ display: 'flex', gap: 12, padding: '8px 24px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0, flexWrap: 'wrap' }}>

View File

@@ -12,7 +12,7 @@ import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import { fmtEUR, fmtDateTime, fmtDuration, fmtDate, fmtTime } from '../shared/reportDesignTokens'
import { fmtEUR, fmtDateTime, fmtDuration, fmtDate, fmtTime, fmtDateStr } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() {
@@ -103,7 +103,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
<div className="flex-1 overflow-y-auto p-6">
<Panel
title="Όλες οι Βάρδιες"
subtitle={`${shifts.length} βάρδιες · ${from}${to}`}
subtitle={`${shifts.length} βάρδιες · ${fmtDateStr(from)}${fmtDateStr(to)}`}
padded={false}
>
{shifts.length === 0 ? (
@@ -119,9 +119,12 @@ export default function ShiftsOverview({ onNavigate } = {}) {
<TH>Έναρξη</TH>
<TH>Λήξη</TH>
<TH align="right">Διάρκεια</TH>
<TH align="right">Αμοιβή/ώρα</TH>
<TH align="right">Αποδοχές</TH>
<TH align="right">Αρχικά Μετρητά</TH>
<TH align="right">Εισπράχθηκαν</TH>
<TH align="right">Οφείλει</TH>
<TH align="right">Ταμείο</TH>
<TH>Κατάσταση</TH>
<TH className="w-28" />
</THead>
@@ -143,9 +146,37 @@ export default function ShiftsOverview({ onNavigate } = {}) {
: <span className="text-sky-600 font-medium"> ενεργή </span>}
</TD>
<TD mono align="right">{fmtDuration(s.started_at, s.ended_at)}</TD>
<TD mono align="right">
{s.hourly_rate_snapshot != null
? fmtEUR(s.hourly_rate_snapshot)
: <span className="text-slate-400 text-[11px]"></span>}
</TD>
<TD mono align="right">
{s.shift_pay != null
? <span className="font-semibold text-emerald-700">{fmtEUR(s.shift_pay)}</span>
: <span className="text-slate-400 text-[11px]">Δεν παρακολουθείται</span>}
</TD>
<TD mono align="right">{fmtEUR(s.starting_cash)}</TD>
<TD mono align="right">{fmtEUR(s.total_collected)}</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(s.net_to_deliver)}</TD>
<TD mono align="right">
{s.counted_cash_end != null ? (
<span className={
s.cash_discrepancy == null || Math.abs(s.cash_discrepancy) < 0.005
? 'text-emerald-600 font-semibold'
: 'text-red-600 font-semibold'
}>
{fmtEUR(s.counted_cash_end)}
{s.cash_discrepancy != null && Math.abs(s.cash_discrepancy) >= 0.005 && (
<span className="block text-[10px] font-medium">
{s.cash_discrepancy > 0 ? '+' : ''}{fmtEUR(s.cash_discrepancy)}
</span>
)}
</span>
) : (
<span className="text-slate-400 text-[11px]"></span>
)}
</TD>
<TD><StatusBadge status={s.is_active ? 'active' : 'closed'} pulse /></TD>
<TD align="right">
<div className="flex items-center justify-end gap-2">
@@ -170,7 +201,7 @@ export default function ShiftsOverview({ onNavigate } = {}) {
</TR>,
isOpen && (
<tr key={`${s.id}-detail`}>
<td colSpan={9} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
<td colSpan={12} className="border-b border-slate-100 bg-slate-50/60 px-6 py-3">
<div className="flex items-center gap-4 text-[12px] text-slate-500">
<span>
{'Εργάσιμη Μέρα: '}

View File

@@ -6,7 +6,7 @@ import { FilterBar, FilterDateInput } from '../shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD, WaiterAvatar, ChartTooltip } from '../shared/TablePrimitives'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import { fmtEUR, fmtNum, avatarColor } from '../shared/reportDesignTokens'
import { fmtEUR, fmtNum, avatarColor, fmtDateStr } from '../shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
@@ -68,7 +68,7 @@ export default function StaffLeaderboard() {
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel title="Κορυφαίοι" subtitle={`${from}${to}`}>
<Panel title="Κορυφαίοι" subtitle={`${fmtDateStr(from)}${fmtDateStr(to)}`}>
{top3.length === 0 ? (
<EmptyState title="Δεν υπάρχουν δεδομένα" description="Δεν υπάρχουν βάρδιες σε αυτή την περίοδο." />
) : (

View File

@@ -1,71 +1,72 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// Mirrors waiter_pwa/src/store/tableColourStore.js — same localStorage key so both apps share state.
// Must stay in sync with waiter_pwa/src/store/tableColourStore.js DEFAULT_COLOURS.
// The PWA uses these as its hardcoded fallback; the manager uses them for "reset to defaults".
export const DEFAULT_COLOURS = {
light: {
free: {
cardBg: '#d6d6d6',
badgeBg: '#e3e3e3',
nameText: '#3b485e',
badgeText: '#adadad',
cardBg: '#dde5ef',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#3d5270',
badgeText: '#3d5270',
},
mine: {
cardBg: '#e83030',
badgeBg: 'rgba(255,255,255,0.40)',
cardBg: '#e8610a',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#e8610a',
},
open: {
cardBg: '#ffbb29',
badgeBg: 'rgba(255,255,255,0.25)',
cardBg: '#FF8F60',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#FF8F60',
},
partially_paid: {
cardBg: '#e89230',
badgeBg: 'rgba(255,255,255,0.25)',
cardBg: '#FFDC67',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#d4a800',
},
paid: {
cardBg: '#79ad38',
badgeBg: 'rgba(255,255,255,0.25)',
cardBg: '#81D264',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#81D264',
},
},
dark: {
free: {
cardBg: '#243044',
badgeBg: 'rgba(26,35,50,0.50)',
nameText: '#ffffff',
badgeText: '#adadad',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#94b8d4',
badgeText: '#94b8d4',
},
mine: {
cardBg: '#e83030',
badgeBg: 'rgba(255,255,255,0.40)',
cardBg: '#e8610a',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#e8610a',
},
open: {
cardBg: '#ffbb29',
badgeBg: 'rgba(255,255,255,0.25)',
cardBg: '#FF8F60',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#FF8F60',
},
partially_paid: {
cardBg: '#e89230',
badgeBg: 'rgba(255,255,255,0.25)',
cardBg: '#FFDC67',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#d4a800',
},
paid: {
cardBg: '#79ad38',
badgeBg: 'rgba(255,255,255,0.25)',
cardBg: '#81D264',
badgeBg: 'rgba(255,255,255,0.92)',
nameText: '#ffffff',
badgeText: '#ffffff',
badgeText: '#81D264',
},
},
}

View File

@@ -94,7 +94,17 @@ export const fmtTime = (iso) => {
}
export const fmtDate = (iso) => {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' })
const d = new Date(iso)
if (isNaN(d)) return String(iso)
return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
// Format a raw YYYY-MM-DD string (from a date picker) as DD/MM/YYYY without any timezone conversion
export const fmtDateStr = (yyyymmdd) => {
if (!yyyymmdd) return '—'
const [y, m, d] = String(yyyymmdd).split('-')
if (!y || !m || !d) return yyyymmdd
return `${d}/${m}/${y}`
}
export const fmtDateTime = (iso) => {
if (!iso) return '—'

View File

@@ -636,6 +636,11 @@ export default function TableCard({
'4x3': Card4x3,
}[density] || Card2x2
const reservation = table.upcoming_reservation ?? null
const reservedTime = reservation
? new Date(reservation.reserved_for).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
: null
return (
<div style={{ position: 'relative', minWidth: 0, overflow: 'hidden' }}>
<button
@@ -651,6 +656,21 @@ export default function TableCard({
>
<CardComponent {...cardProps} />
</button>
{reservation && (
<div style={{
position: 'absolute', top: 6, left: 6, zIndex: 10,
pointerEvents: 'none',
}}>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
background: '#4f46e5', color: '#e0e7ff',
fontSize: 9, fontWeight: 800, letterSpacing: 0.3,
borderRadius: 4, padding: '2px 6px',
}}>
🔒 {reservedTime}
</span>
</div>
)}
{showTip && flags.length > 0 && (
<div style={{