Compare commits

...

9 Commits

Author SHA1 Message Date
934fda9405 feat: push product images to cloud for the QR menu
local_backend now uploads each product's image file to cloud_backend
during the existing ~5 min menu sync, so the QR menu can show real
photos without needing a manually-set digital_image_url. Only
re-uploads images whose content hash changed since the last push
(Product.cloud_image_hash), to avoid re-sending unchanged binaries
every cycle.

Also adds a manual "sync now" trigger (POST /api/system/sync-menu) and
a matching button in Settings → Operation, for pushing menu/price/image
changes immediately instead of waiting for the next automatic cycle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 20:51:52 +03:00
34ae328b0d feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:00:14 +03:00
02ec1aa28f feat: ShiftDetailModal rework, Today cancellation events, shift details icon button
- Backend: add cancellation_events (row count) to _enrich_shift and shift summary
- Backend: add cancellation_events field to current business day endpoint
- Today tab: hero number now shows cancellation_events (distinct cancel actions),
  sub-label shows 'X παραγγελίες / Y είδη'
- ShiftsOverview: Λεπτομέρειες button is now icon-only (Eye, light blue) matching
  the delete button style
- ShiftDetailModal: modal widened to 1080px; ΠΑΡΑΔΟΘΗΚΑΝ KPI replaced with
  Ακυρώσεις showing 'X events / Y items'; all filter labels renamed (Πλήρης
  Παραγγελία, Μόνο Πληρώθηκε, Μόνο Παρήγγειλε, Ανοιχτό ακόμη); new Ακυρώθηκε
  filter added; cancelled items shown in red, Πληρώθηκε line hidden for cancelled;
  legend updated; classify() handles cancelled status before other checks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:48:30 +03:00
8533a1452b fix: report cancellation bugs + missing OrderItem model columns
Backend:
- OrderItem model: add cancelled_at, cancelled_by, cancel_reason columns
  (migration existed but model was missing them — caused 500 on cancellations_log)
- shifts.py _enrich_shift: count cancellations by quantity sum (not row count),
  add cancellation_value (sum of unit_price * quantity)
- reports.py current business day: add cancelled_items count (per-item quantity sum,
  across all orders in the day, not just fully-cancelled orders)

Manager dashboard:
- PrintFontsTab: fix SSE auth token key (access_token → manager_token, was causing 403)
- Today: show cancelled_items count as sub-label on Ακυρώσεις stat card
- OrderHistory: items/cancellations columns now use quantity sum, not row count
- WorkDaySummary drill-down: same quantity-sum fix
- ShiftsOverview: add Αξία Ακυρ. column next to Ακυρώσεις

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:15:55 +03:00
36e9044972 fix: two bugs in cancellation settings
- settings.py: add orders.waiter_cancellations_allowed to VALID_SETTINGS
  (was missing, causing 400 on every PUT attempt)
- StaffTab.jsx: include can_cancel_orders in edit waiter mutation payload
  (was omitted, so the checkbox change was never sent to the backend)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:55:48 +03:00
8a5a6f8be9 feat: Feature 2 — waiter order cancellation + report updates
Backend:
- Add can_cancel_orders to User model and schema
- Add global orders.waiter_cancellations_allowed setting (migration)
- Cancel endpoints: mark items with cancelled_by/cancelled_at, fire cancellation print
- print_cancellation_ticket: routes to same printer zones, prints ΑΚΥΡΩΣΗ banner
- Fix cancellations_log: date filter, waiter filter, join syntax
- shift/orders: add cancellations count and hours_worked per waiter
- _enrich_shift: add cancellations count to shift data
- Add cancel-permissions endpoint for PWA

Manager dashboard:
- Global cancel setting toggle in Settings > Operation > Shift Settings
- Per-waiter can_cancel_orders checkbox in staff modal
- Manager cancel flow: print confirmation prompt (Ναι/Όχι) in DashboardPage
- ShiftsOverview: Ακυρώσεις column per shift
- Activity: multi-bar chart with ORDERS/ITEMS/CANCELLATIONS/ΕΣΟΔΑ/ΩΡΕΣ checkboxes,
  grouped/stacked switch, right X-axis for hours, full waiter name on hover
- OrderHistory: cancelled items count column per order
- WorkDaySummary drill-down: cancelled items column in orders tab

Waiter PWA:
- Replace 3 pills with CLEAR | ALL | ACTIONS
- ACTIONS opens ItemActionModal for selected items
- ItemActionModal: ORDER AGAIN, MOVE TO OTHER TABLE, SPLIT, CANCEL ORDER
- ActionsSheet: Cancel Παραγγελίας option (greyed if no permission)
- CancelConfirmModal: requires confirmation before cancelling
- TableListPage: cancel order from long-press quick modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:47:02 +03:00
b5b647422a feat: subnet printer discovery (Feature 3)
- New GET /api/system/printers/scan SSE endpoint: parallel async TCP
  scan of a /24 subnet, streams found/progress/done events
- New GET /api/system/printers/scan-hints: returns auto-detected local
  subnets from socket.getaddrinfo, excludes loopback/docker ranges
- FindPrintersModal in Settings > Print: shows subnet+port inputs,
  live progress bar, found printers list; selecting a result pre-fills
  the new printer form (user still names it before saving)
- "Εύρεση εκτυπωτών" button added next to "+ Νέος εκτυπωτής"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 11:14:37 +03:00
f3d03bf85f feat: printer duplicate copies setting (Feature 1)
- Add `duplicates` column (0-9) to printers table via migration
- Print loop repeats job 1+duplicates times per printer zone
- PrinterForm in Settings > Print now has ΑΝΤΙΓΡΑΦΑ (0-9) field
- PrinterRow shows amber badge when duplicates > 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:55:43 +03:00
72b12ddd9c fix: fix timezone handling across backend and frontend (v0.3.1)
- Add tz.py with local_strftime/to_local helpers that read system.timezone
  from DB and convert UTC datetimes to venue local time before formatting
- Fix all strftime() calls in orders.py, reports.py, printer_service.py
  that were formatting UTC datetimes without timezone conversion
- Fix get_order endpoint returning raw dicts without Z suffix on datetimes,
  causing JS new Date() to treat timestamps as local instead of UTC
- Fix fmtDate() in tokens.js that stripped the T separator before parsing,
  breaking UTC-to-local conversion for all report date displays
- Make open/partially_paid table chips more visually distinct on dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:18:46 +03:00
185 changed files with 36273 additions and 3665 deletions

View File

@@ -9,6 +9,10 @@ CLOUD_URL=https://xenia-admin.bonamin.gr
SECRET_KEY=generate-with-openssl-rand-hex-32
LICENSE_GRACE_HOURS=24
# Break-glass support account (leave blank to disable)
MASTER_USERNAME=
MASTER_PASSWORD=
# Volumes — absolute paths on the client machine
DATA_PATH=/opt/xenia/data
LOGO_PATH=/opt/xenia/logo.png

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

198
local_backend/roles.py Normal file
View File

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

View File

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

View File

@@ -3,6 +3,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime, timezone
from pydantic import BaseModel
from database import get_db
from models.business_day import BusinessDay
@@ -173,6 +174,60 @@ def close_business_day(
return day
class PatchBusinessDayRequest(BaseModel):
closed_at: str # ISO-8601 datetime string
@router.patch("/{day_id}", status_code=status.HTTP_200_OK)
def patch_business_day(
day_id: int,
body: "PatchBusinessDayRequest",
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Edit the close-time of a past (closed) business day.
Constraints:
- Day must already be closed.
- new closed_at must be ≥ the latest order closed_at in this day.
- new closed_at must be ≤ now (UTC).
"""
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
if not day:
raise HTTPException(status_code=404, detail="Business day not found")
if day.status != "closed":
raise HTTPException(status_code=400, detail="Can only edit a closed business day")
try:
new_closed = datetime.fromisoformat(body.closed_at.replace("Z", "+00:00"))
if new_closed.tzinfo is None:
new_closed = new_closed.replace(tzinfo=timezone.utc)
except ValueError:
raise HTTPException(status_code=422, detail="Invalid datetime format")
now = datetime.now(timezone.utc)
if new_closed > now:
raise HTTPException(status_code=400, detail="Close time cannot be in the future")
# Must not be earlier than the latest order closed_at in this day
last_order_close = (
db.query(func.max(Order.closed_at))
.filter(Order.business_day_id == day_id, Order.closed_at != None)
.scalar()
)
if last_order_close:
if last_order_close.tzinfo is None:
last_order_close = last_order_close.replace(tzinfo=timezone.utc)
if new_closed < last_order_close:
raise HTTPException(
status_code=400,
detail=f"Close time cannot be earlier than the last order's close time ({_dt(last_order_close)})"
)
day.closed_at = new_closed
db.commit()
return {"id": day.id, "closed_at": _dt(day.closed_at)}
@router.delete("/{day_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_business_day(
day_id: int,

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,15 +3,17 @@ 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
from sqlalchemy import func
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, timezone
from typing import Optional, List
from database import get_db
from models.order import Order, OrderItem, OrderWaiter, PrintLog
from models.prep_zone import PrepZone
from models.user import User
from models.table import Table
from models.printer import Printer
@@ -19,15 +21,21 @@ from models.shift import WaiterShift
from models.business_day import BusinessDay
from schemas.order import OrderOut
from schemas.table import TableOut
from routers.deps import require_manager
from routers.deps import require_reports
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,
print_prep_zone_summary,
)
router = APIRouter()
def _item_val(item) -> float:
"""Effective line value: unit_price + any price adjustment, times quantity."""
return ((item.unit_price or 0.0) + (item.price_adjustment or 0.0)) * item.quantity
def _dt(dt):
if dt is None:
return None
@@ -41,7 +49,7 @@ def shift_summary(
report_date: Optional[date] = Query(default=None, alias="date"),
waiter_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
"""Payments collected per waiter — based on paid_by on order items."""
if from_dt and to_dt:
@@ -79,7 +87,7 @@ def shift_summary(
"order_data": {},
}
summary[wid]["items"] += item.quantity
val = item.unit_price * item.quantity
val = _item_val(item)
summary[wid]["total"] += val
oid = item.order_id
@@ -87,8 +95,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": [],
@@ -116,7 +124,7 @@ def shift_orders_summary(
waiter_id: Optional[int] = None,
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
"""Items sent (added) per waiter — regardless of payment status."""
q = db.query(OrderItem).filter(OrderItem.status.in_(["active", "paid"]))
@@ -135,9 +143,36 @@ def shift_orders_summary(
q = q.filter(OrderItem.added_by == waiter_id)
items = q.all()
# Also fetch cancelled items for the same window/waiter
cq = db.query(OrderItem).filter(OrderItem.status == "cancelled")
if business_day_id:
cq = cq.join(Order, OrderItem.order_id == Order.id).filter(Order.business_day_id == business_day_id)
else:
cq = cq.filter(OrderItem.added_at >= start, OrderItem.added_at < end)
if waiter_id:
cq = cq.filter(OrderItem.added_by == waiter_id)
cancelled_items = cq.all()
waiters_db = {u.id: u for u in db.query(User).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
# Track shifts for duration (hours worked)
shift_q = db.query(WaiterShift)
if business_day_id:
shift_q = shift_q.filter(WaiterShift.business_day_id == business_day_id)
else:
shift_q = shift_q.filter(WaiterShift.started_at >= start, WaiterShift.started_at < end)
if waiter_id:
shift_q = shift_q.filter(WaiterShift.waiter_id == waiter_id)
shifts_list = shift_q.all()
# hours worked per waiter
hours_by_waiter: dict[int, float] = {}
for s in shifts_list:
wid = s.waiter_id
end_time = s.ended_at or datetime.now(timezone.utc).replace(tzinfo=None)
delta = (end_time - s.started_at).total_seconds() / 3600.0
hours_by_waiter[wid] = hours_by_waiter.get(wid, 0.0) + delta
summary: dict[int, dict] = {}
for item in items:
wid = item.added_by
@@ -149,10 +184,11 @@ def shift_orders_summary(
"waiter_name": wname,
"items": 0,
"total": 0.0,
"cancellations": 0,
"order_data": {},
}
summary[wid]["items"] += item.quantity
val = item.unit_price * item.quantity
val = _item_val(item)
summary[wid]["total"] += val
oid = item.order_id
@@ -160,8 +196,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": [],
@@ -172,10 +208,26 @@ def shift_orders_summary(
{"name": product_name, "quantity": item.quantity}
)
for item in cancelled_items:
wid = item.added_by
if wid not in summary:
w = waiters_db.get(wid)
wname = (w.full_name or w.username) if w else f"#{wid}"
summary[wid] = {
"waiter_id": wid,
"waiter_name": wname,
"items": 0,
"total": 0.0,
"cancellations": 0,
"order_data": {},
}
summary[wid]["cancellations"] = summary[wid].get("cancellations", 0) + item.quantity
result = []
for entry in summary.values():
entry["orders"] = len(entry["order_data"])
entry["order_data"] = list(entry["order_data"].values())
entry["hours_worked"] = round(hours_by_waiter.get(entry["waiter_id"], 0.0), 2)
result.append(entry)
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
@@ -192,7 +244,7 @@ def order_history(
page: int = 1,
page_size: int = 50,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from sqlalchemy.orm import joinedload
from models.table import Table as TableModel
@@ -273,6 +325,7 @@ def order_history(
"added_at": _dt_local(item.added_at),
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"price_adjustment": float(item.price_adjustment or 0.0),
"status": item.status,
"paid_by": item.paid_by,
"paid_by_name": _wname(item.paid_by),
@@ -321,7 +374,7 @@ def order_history(
@router.get("/tables/summary")
def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_manager)):
def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_reports)):
tables = db.query(Table).filter(Table.is_active == True).all()
result = []
for table in tables:
@@ -342,7 +395,7 @@ def printer_totals(
from_date: Optional[str] = Query(default=None, alias="from"),
to_date: Optional[str] = Query(default=None, alias="to"),
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
"""Returns totals per printer based on print_log entries in the date range."""
q = db.query(PrintLog).filter(PrintLog.success == True)
@@ -382,7 +435,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": [],
@@ -392,11 +445,13 @@ def printer_totals(
item_ids = json.loads(log.item_ids)
except Exception:
item_ids = []
if item_ids is None:
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"):
summary[pid]["items"] += item.quantity
val = item.unit_price * item.quantity
val = _item_val(item)
summary[pid]["total"] += val
order_map[pid][oid]["total"] += val
product_name = item.product.name if item.product else f"#{item.product_id}"
@@ -452,7 +507,7 @@ def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, m
if order:
order_map[oid] = {
"id": oid,
"time": log.printed_at.strftime("%H:%M"),
"time": local_strftime(log.printed_at, "%H:%M"),
"table": tables.get(order.table_id, f"#{order.table_id}"),
"total": 0.0,
"items": [],
@@ -465,7 +520,7 @@ def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, m
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
val = _item_val(item)
grand_total += val
product_name = item.product.name if item.product else f"#{item.product_id}"
if oid in order_map:
@@ -473,7 +528,7 @@ def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, m
order_map[oid]["items"].append({
"name": product_name,
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"unit_price": float((item.unit_price or 0.0) + (item.price_adjustment or 0.0)),
"total": round(val, 2),
})
# Accumulate item breakdown (always, regardless of mode)
@@ -506,7 +561,7 @@ def print_waiter(
body: PrintWaiterReportBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
@@ -532,11 +587,11 @@ def print_waiter(
order_data = []
for o in orders:
active_items = [i for i in o.items if i.status in ("active", "paid")]
total = sum(i.unit_price * i.quantity for i in active_items)
total = sum(_item_val(i) 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": [
@@ -556,11 +611,11 @@ 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)
background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode, printer.codepage_n)
return {"status": "printing"}
@@ -569,7 +624,7 @@ def print_printer_totals(
body: PrintPrinterReportBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
# The physical printer that will receive the paper
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
@@ -592,13 +647,13 @@ def print_printer_totals(
# 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 = bd.opened_at.strftime("%d/%m/%Y") if bd else f"Workday #{body.business_day_id}"
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"{from_dt.strftime('%d/%m/%Y')} - {to_dt.strftime('%d/%m/%Y')}"
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
@@ -629,7 +684,7 @@ def print_printer_totals(
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode)
background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode, printer.codepage_n)
return {"status": "printing"}
@@ -649,12 +704,12 @@ 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 = bd.opened_at.strftime("%d/%m/%Y") if bd else f"Workday #{body.business_day_id}"
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"{from_dt.strftime('%d/%m/%Y')} - {to_dt.strftime('%d/%m/%Y')}"
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
@@ -665,7 +720,7 @@ def print_products(
body: PrintAnalyticsBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from models.product import Product, Category
@@ -692,7 +747,7 @@ def print_products(
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
sold[pid]["revenue"] += _item_val(item)
if body.mode == "full":
# All active products, 0-sold included
@@ -717,7 +772,7 @@ def print_products(
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_products_report, printer.ip_address, printer.port, report)
background_tasks.add_task(print_products_report, printer.ip_address, printer.port, report, printer.codepage_n)
return {"status": "printing"}
@@ -726,7 +781,7 @@ def print_categories(
body: PrintAnalyticsBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from models.product import Product, Category
@@ -760,11 +815,11 @@ def print_categories(
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
summary[cid]["revenue"] += _item_val(item)
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
summary[cid]["products"][pid]["revenue"] += _item_val(item)
total_rev = sum(v["revenue"] for v in summary.values())
total_qty = sum(v["units_sold"] for v in summary.values())
@@ -796,7 +851,7 @@ def print_categories(
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_categories_report, printer.ip_address, printer.port, report)
background_tasks.add_task(print_categories_report, printer.ip_address, printer.port, report, printer.codepage_n)
return {"status": "printing"}
@@ -805,7 +860,7 @@ def print_tables(
body: PrintAnalyticsBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first()
if not printer:
@@ -834,7 +889,7 @@ def print_tables(
"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"))
summary[tid]["revenue"] += sum(_item_val(i) 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)
@@ -854,7 +909,7 @@ def print_tables(
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_tables_report, printer.ip_address, printer.port, report)
background_tasks.add_task(print_tables_report, printer.ip_address, printer.port, report, printer.codepage_n)
return {"status": "printing"}
@@ -870,7 +925,7 @@ def shifts_report(
to_dt: Optional[str] = Query(default=None, alias="to"),
active_only: bool = False,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from routers.shifts import _enrich_shift
@@ -901,7 +956,7 @@ def product_performance(
business_day_id: Optional[int] = None,
category_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from models.product import Product
from models.waste import WasteLog
@@ -955,7 +1010,7 @@ def product_performance(
"order_ids": set(),
}
qty = item.quantity
revenue = item.unit_price * qty
revenue = _item_val(item)
summary[pid]["qty_sold"] += qty
summary[pid]["revenue"] += revenue
summary[pid]["order_ids"].add(item.order_id)
@@ -1009,7 +1064,7 @@ def table_performance(
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
if from_dt:
@@ -1036,7 +1091,7 @@ def table_performance(
}
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")
_item_val(i) for i in order.items if i.status in ("active", "paid")
)
if order.closed_at and order.opened_at:
summary[tid]["durations"].append(
@@ -1064,7 +1119,7 @@ def traffic_analysis(
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
q = db.query(Order)
if from_dt:
@@ -1081,7 +1136,7 @@ def traffic_analysis(
for order in orders:
revenue = sum(
i.unit_price * i.quantity for i in order.items if i.status in ("active", "paid")
_item_val(i) for i in order.items if i.status in ("active", "paid")
)
h = order.opened_at.hour
d = order.opened_at.weekday()
@@ -1110,7 +1165,7 @@ def business_days_list(
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),
user: User = Depends(require_reports),
):
q = db.query(BusinessDay)
if from_dt:
@@ -1135,7 +1190,7 @@ def business_days_list(
for i in o.items:
if i.status not in ("active", "paid"):
continue
rev = i.unit_price * i.quantity
rev = _item_val(i)
revenue += rev
if i.unit_cost is not None:
cost = i.unit_cost * i.quantity
@@ -1169,7 +1224,7 @@ def business_days_list(
@router.get("/business-days/current")
def current_business_day(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
day = db.query(BusinessDay).filter(BusinessDay.status == "open").order_by(BusinessDay.opened_at.desc()).first()
if not day:
@@ -1191,7 +1246,7 @@ def current_business_day(
for i in o.items:
if i.status not in ("active", "paid"):
continue
rev = i.unit_price * i.quantity
rev = _item_val(i)
revenue += rev
if i.unit_cost is not None:
cost = i.unit_cost * i.quantity
@@ -1215,6 +1270,21 @@ def current_business_day(
p = db.query(Product).filter(Product.id == top_product_id).first()
top_product = {"id": top_product_id, "name": p.name if p else f"#{top_product_id}", "qty": item_counts[top_product_id]}
# Count cancelled items (individual items cancelled, across all orders in this day)
all_order_ids = [o.id for o in orders]
cancelled_items_qty = 0
cancellation_events = 0
if all_order_ids:
from sqlalchemy import func as sqlfunc
cancelled_items_qty = db.query(sqlfunc.sum(OrderItem.quantity)).filter(
OrderItem.order_id.in_(all_order_ids),
OrderItem.status == "cancelled",
).scalar() or 0
cancellation_events = db.query(sqlfunc.count(OrderItem.id)).filter(
OrderItem.order_id.in_(all_order_ids),
OrderItem.status == "cancelled",
).scalar() or 0
return {
"business_day": {
"id": day.id,
@@ -1227,6 +1297,8 @@ def current_business_day(
"orders_open": len(open_orders),
"active_waiters": len(active_shifts),
"cancellations": len(cancelled_orders),
"cancelled_items": int(cancelled_items_qty),
"cancellation_events": int(cancellation_events),
"top_product": top_product,
}
}
@@ -1242,7 +1314,7 @@ def revenue_trends(
to_dt: Optional[str] = Query(default=None, alias="to"),
granularity: str = Query(default="daily"), # daily | weekly | monthly
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
q = db.query(Order).filter(Order.status.in_(["closed", "paid"]))
if from_dt:
@@ -1253,7 +1325,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":
@@ -1267,7 +1339,7 @@ def revenue_trends(
for i in order.items:
if i.status not in ("active", "paid"):
continue
rev = i.unit_price * i.quantity
rev = _item_val(i)
buckets[key]["revenue"] += rev
if i.unit_cost is not None:
buckets[key]["profit"] += rev - i.unit_cost * i.quantity
@@ -1298,7 +1370,7 @@ def category_performance(
to_dt: Optional[str] = Query(default=None, alias="to"),
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from models.product import Product, Category
@@ -1335,7 +1407,7 @@ def category_performance(
"product_ids": set(),
}
qty = item.quantity
rev = item.unit_price * qty
rev = _item_val(item)
summary[cid]["units_sold"] += qty
summary[cid]["revenue"] += rev
summary[cid]["product_ids"].add(item.product_id)
@@ -1376,25 +1448,48 @@ def cancellations_log(
business_day_id: Optional[int] = None,
waiter_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
q = db.query(OrderItem).filter(OrderItem.status == "cancelled")
# Date filter on cancelled_at if available, otherwise fall back to added_at
if from_dt:
q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt))
dt_from = datetime.fromisoformat(from_dt)
from sqlalchemy import or_
q = q.filter(or_(
OrderItem.cancelled_at >= dt_from,
(OrderItem.cancelled_at == None) & (OrderItem.added_at >= dt_from),
))
if to_dt:
q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt))
dt_to = datetime.fromisoformat(to_dt)
from sqlalchemy import or_
q = q.filter(or_(
OrderItem.cancelled_at <= dt_to,
(OrderItem.cancelled_at == None) & (OrderItem.added_at <= dt_to),
))
if business_day_id:
q = q.join(Order).filter(Order.business_day_id == business_day_id)
q = q.join(Order, Order.id == OrderItem.order_id).filter(Order.business_day_id == business_day_id)
if waiter_id:
q = q.filter(OrderItem.cancelled_by == waiter_id)
# Show items cancelled BY this waiter OR items that were on this waiter's order
from sqlalchemy import or_
q = q.filter(or_(
OrderItem.cancelled_by == waiter_id,
OrderItem.added_by == waiter_id,
))
items = q.order_by(OrderItem.added_at.desc()).all()
waiters_db = {u.id: u for u in db.query(User).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
orders_cache = {}
def _get_order(oid):
if oid not in orders_cache:
orders_cache[oid] = db.query(Order).filter(Order.id == oid).first()
return orders_cache[oid]
result = []
for item in items:
order = db.query(Order).filter(Order.id == item.order_id).first()
order = _get_order(item.order_id)
table_name = tables_db.get(order.table_id, f"#{order.table_id}") if order else ""
waiter = waiters_db.get(item.added_by)
waiter_name = (waiter.full_name or waiter.username) if waiter else f"#{item.added_by}"
@@ -1410,7 +1505,7 @@ def cancellations_log(
"product_name": product_name,
"quantity": item.quantity,
"unit_price": item.unit_price,
"value": round(item.unit_price * item.quantity, 2),
"value": round(_item_val(item), 2),
"cancelled_by": cancelled_by_name,
"cancel_reason": getattr(item, "cancel_reason", None),
"cancelled_at": _dt(cancelled_at) if cancelled_at else _dt(item.added_at),
@@ -1431,7 +1526,7 @@ def printer_history(
business_day_id: Optional[int] = None,
printer_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
q = db.query(PrintLog)
if from_dt:
@@ -1464,6 +1559,8 @@ def printer_history(
item_ids = json.loads(log.item_ids)
except Exception:
item_ids = []
if item_ids is None:
item_ids = []
items = []
for iid in item_ids:
oi = db.query(OrderItem).filter(OrderItem.id == iid).first()
@@ -1475,7 +1572,7 @@ def printer_history(
order_total = None
if order:
order_total = sum(
i.unit_price * i.quantity
_item_val(i)
for i in order.items
if i.status in ("active", "paid")
)
@@ -1508,16 +1605,16 @@ def printer_history(
@router.get("/meta/waiters")
def meta_waiters(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
waiters = db.query(User).filter(User.role == "waiter", User.is_active == True).all()
waiters = db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()
return {"waiters": [{"id": w.id, "name": w.full_name or w.username} for w in waiters]}
@router.get("/meta/tables")
def meta_tables(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
tables = db.query(Table).filter(Table.is_active == True).all()
return {"tables": [{"id": t.id, "name": t.label or f"T{t.number}", "group": t.group.name if t.group else None} for t in tables]}
@@ -1526,7 +1623,7 @@ def meta_tables(
@router.get("/meta/printers")
def meta_printers(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
printers = db.query(Printer).filter(Printer.is_active == True).all()
return {"printers": [{"id": p.id, "name": p.name} for p in printers]}
@@ -1535,7 +1632,7 @@ def meta_printers(
@router.get("/meta/products")
def meta_products(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from models.product import Product, Category
products = db.query(Product).filter(Product.lifecycle_status == "active").order_by(Product.name).all()
@@ -1580,7 +1677,7 @@ def shifts_export(
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),
user: User = Depends(require_reports),
):
data = shifts_report(waiter_id=waiter_id, business_day_id=business_day_id, from_dt=from_dt, to_dt=to_dt, active_only=False, db=db, user=user)
rows = []
@@ -1612,7 +1709,7 @@ def orders_export(
order_status: Optional[str] = Query(default=None, alias="status"),
table_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
orders = order_history(from_date=from_date, to_date=to_date, waiter_id=waiter_id, order_status=order_status, table_id=table_id, page=1, page_size=10000, db=db, user=user)
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
@@ -1627,7 +1724,7 @@ def orders_export(
"opened_at": _dt(o.opened_at),
"closed_at": _dt(o.closed_at) if o.closed_at else "",
"status": o.status,
"total": round(sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid")), 2),
"total": round(sum(_item_val(i) for i in o.items if i.status in ("active", "paid")), 2),
})
date_str = (from_date or "")[:10]
return _csv_response(rows, f"orders-{date_str}.csv")
@@ -1640,7 +1737,7 @@ def products_export(
business_day_id: Optional[int] = None,
category_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
data = product_performance(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, category_id=category_id, db=db, user=user)
rows = [{
@@ -1662,7 +1759,7 @@ def printers_export(
printer_id: Optional[int] = None,
business_day_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
data = printer_history(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, printer_id=printer_id, db=db, user=user)
rows = [{
@@ -1685,7 +1782,7 @@ def cancellations_export(
business_day_id: Optional[int] = None,
waiter_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
data = cancellations_log(from_dt=from_dt, to_dt=to_dt, business_day_id=business_day_id, waiter_id=waiter_id, db=db, user=user)
rows = [{
@@ -1704,6 +1801,192 @@ def cancellations_export(
return _csv_response(rows, f"cancellations-{date_str}.csv")
# ── Prep Zones report ──────────────────────────────────────────────────────────
@router.get("/prep-zones")
def prep_zones_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,
db: Session = Depends(get_db),
user: User = Depends(require_reports),
):
"""
Per-prep-zone stats: item totals, full product list, and full order list
for the requested date range / business day.
"""
zones = db.query(PrepZone).order_by(PrepZone.id).all()
# Build product → zones mapping
product_zone_map: dict[int, list[int]] = {}
for zone in zones:
for product in zone.products:
if product.id not in product_zone_map:
product_zone_map[product.id] = []
product_zone_map[product.id].append(zone.id)
# Query order items in the requested range
q = db.query(OrderItem).join(Order)
if from_dt:
q = q.filter(Order.opened_at >= datetime.fromisoformat(from_dt))
if to_dt:
q = q.filter(Order.opened_at <= datetime.fromisoformat(to_dt))
if business_day_id:
q = q.filter(Order.business_day_id == business_day_id)
q = q.filter(OrderItem.status.in_(["active", "paid"]))
items = q.all()
# Aggregate per zone
zone_stats: dict[int, dict] = {
zone.id: {
"id": zone.id,
"name": zone.name,
"notification_name": zone.notification_name,
"printers": [{"id": p.id, "name": p.name} for p in zone.printers],
"item_count": 0,
"total_value": 0.0,
"product_counts": {}, # product_name → {count, value}
"orders_map": {}, # order_id → {order_id, table, opened_at, items_count, value}
}
for zone in zones
}
for item in items:
zids = product_zone_map.get(item.product_id, [])
pname = item.product.name if item.product else f"#{item.product_id}"
ivalue = round(_item_val(item), 2)
order = item.order
table_label = None
if order and order.table:
t = order.table
table_label = t.label or str(t.number)
for zid in zids:
if zid not in zone_stats:
continue
zs = zone_stats[zid]
zs["item_count"] += item.quantity
zs["total_value"] += ivalue
if pname not in zs["product_counts"]:
zs["product_counts"][pname] = {"count": 0, "value": 0.0}
zs["product_counts"][pname]["count"] += item.quantity
zs["product_counts"][pname]["value"] += ivalue
if order:
oid = order.id
if oid not in zs["orders_map"]:
zs["orders_map"][oid] = {
"order_id": oid,
"table": table_label or f"#{oid}",
"opened_at": order.opened_at.isoformat() if order.opened_at else None,
"items_count": 0,
"value": 0.0,
}
zs["orders_map"][oid]["items_count"] += item.quantity
zs["orders_map"][oid]["value"] += ivalue
result = []
for zs in zone_stats.values():
all_products = sorted(
[
{"name": k, "count": v["count"], "value": round(v["value"], 2)}
for k, v in zs["product_counts"].items()
],
key=lambda x: -x["count"],
)
all_orders = sorted(
[
{**o, "value": round(o["value"], 2)}
for o in zs["orders_map"].values()
],
key=lambda x: x["opened_at"] or "",
)
result.append({
"id": zs["id"],
"name": zs["name"],
"notification_name": zs["notification_name"],
"printers": zs["printers"],
"item_count": zs["item_count"],
"total_value": round(zs["total_value"], 2),
"all_products": all_products,
"all_orders": all_orders,
})
return {"zones": result}
class PrintPrepZoneBody(BaseModel):
printer_id: int
zone_id: int
from_dt: Optional[str] = None
to_dt: Optional[str] = None
business_day_id: Optional[int] = None
@router.post("/print/prep-zone")
def print_prep_zone(
body: PrintPrepZoneBody,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_reports),
):
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")
zone = db.query(PrepZone).filter(PrepZone.id == body.zone_id).first()
if not zone:
raise HTTPException(status_code=404, detail="Prep zone not found")
# Resolve period label
if body.business_day_id:
from models.business_day import BusinessDay
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
period_label = bd.opened_at.strftime("%d/%m/%Y") if bd and bd.opened_at else f"#{body.business_day_id}"
elif body.from_dt and body.to_dt:
d1 = datetime.fromisoformat(body.from_dt).strftime("%d/%m/%Y")
d2 = datetime.fromisoformat(body.to_dt).strftime("%d/%m/%Y")
period_label = f"{d1} - {d2}"
else:
period_label = "Όλες"
# Build product→zone mapping for this zone
product_ids = {product.id for product in zone.products}
q = db.query(OrderItem).join(Order)
if body.from_dt:
q = q.filter(Order.opened_at >= datetime.fromisoformat(body.from_dt))
if body.to_dt:
q = q.filter(Order.opened_at <= datetime.fromisoformat(body.to_dt))
if body.business_day_id:
q = q.filter(Order.business_day_id == body.business_day_id)
q = q.filter(OrderItem.status.in_(["active", "paid"]))
items = q.all()
product_counts: dict[str, dict] = {}
for item in items:
if item.product_id not in product_ids:
continue
pname = item.product.name if item.product else f"#{item.product_id}"
if pname not in product_counts:
product_counts[pname] = {"count": 0, "value": 0.0}
product_counts[pname]["count"] += item.quantity
product_counts[pname]["value"] += _item_val(item)
report_items = sorted(
[{"name": k, "count": v["count"], "value": round(v["value"], 2)} for k, v in product_counts.items()],
key=lambda x: -x["count"],
)
report = {
"zone_name": zone.name,
"period_label": period_label,
"items": report_items,
"line_width": printer.line_width,
"div_style": load_divider_style(db),
}
background_tasks.add_task(print_prep_zone_summary, printer.ip_address, printer.port, report, printer.codepage_n)
return {"status": "printing"}
# ---------------------------------------------------------------------------
# Phase 2L — Discount audit report
# ---------------------------------------------------------------------------
@@ -1715,7 +1998,7 @@ def discounts_report(
business_day_id: Optional[int] = None,
applied_by: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
user: User = Depends(require_reports),
):
from models.order import OrderDiscount, OrderItem
@@ -1751,10 +2034,10 @@ def discounts_report(
if d.item_id:
item = db.query(OrderItem).filter(OrderItem.id == d.item_id).first()
if item:
base = item.unit_price * item.quantity
base = _item_val(item)
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")
base = sum(_item_val(i) for i in order.items if i.status != "cancelled")
return round(base * d.discount_value / 100, 2)
return 0.0
@@ -1767,7 +2050,7 @@ def discounts_report(
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
order_total = sum(_item_val(i) for i in order.items if i.status != "cancelled") if order else None
discount_amount = _compute_discount_amount(d)
total_discount_value += discount_amount

View File

@@ -142,7 +142,7 @@ def week_schedule(
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()}
waiters = {u.id: u for u in db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).all()}
def _dt(dt):
if not dt:

View File

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

View File

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

View File

@@ -35,6 +35,24 @@ def compute_shift_total(shift_id: int, db: Session) -> float:
return round(sum(i.unit_price * i.quantity for i in items), 2)
def compute_shift_payment_split(shift_id: int, db: Session) -> dict:
"""Return cash_sales and card_sales for a shift (items paid in this shift)."""
items = db.query(OrderItem).filter(
OrderItem.paid_in_shift_id == shift_id,
OrderItem.status == "paid",
).all()
cash = 0.0
card = 0.0
for i in items:
amount = float(i.unit_price) * i.quantity
method = (i.payment_method or "cash").lower()
if method == "card":
card += amount
else:
cash += amount
return {"cash_sales": round(cash, 2), "card_sales": round(card, 2)}
def compute_shift_pay(shift: WaiterShift) -> dict:
"""Return duration_hours and shift_pay. shift_pay is None if no rate snapshot."""
now = datetime.now(timezone.utc)
@@ -74,7 +92,20 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
w = shift.waiter
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
split = compute_shift_payment_split(shift.id, db)
pay_data = compute_shift_pay(shift)
# Count cancelled items and their value attributed to this waiter during shift window
cancelled_q = db.query(OrderItem).filter(
OrderItem.status == "cancelled",
OrderItem.added_by == shift.waiter_id,
OrderItem.added_at >= shift.started_at,
)
if shift.ended_at:
cancelled_q = cancelled_q.filter(OrderItem.added_at <= shift.ended_at)
cancelled_rows = cancelled_q.all()
cancellation_events = len(cancelled_rows)
cancellations = sum(r.quantity for r in cancelled_rows)
cancellation_value = round(sum(r.unit_price * r.quantity for r in cancelled_rows), 2)
return {
"id": shift.id,
"waiter_id": shift.waiter_id,
@@ -84,15 +115,22 @@ def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
"ended_at": _dt(shift.ended_at),
"starting_cash": shift.starting_cash,
"total_collected": total,
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
"cash_sales": split["cash_sales"],
"card_sales": split["card_sales"],
"net_to_deliver": round(split["cash_sales"] + (shift.starting_cash or 0.0), 2),
"is_active": shift.ended_at is None,
"notes": shift.notes,
"hourly_rate_snapshot": shift.hourly_rate_snapshot,
"duration_hours": pay_data["duration_hours"],
"shift_pay": pay_data["shift_pay"],
# Phase 2E
"cancellation_events": cancellation_events,
"cancellations": cancellations,
"cancellation_value": cancellation_value,
# Phase 2E — discrepancy computed live against cash-only expected (starting_cash + cash_sales)
"counted_cash_end": shift.counted_cash_end,
"cash_discrepancy": shift.cash_discrepancy,
"cash_discrepancy": round(
shift.counted_cash_end - (split["cash_sales"] + (shift.starting_cash or 0.0)), 2
) if shift.counted_cash_end is not None else None,
"breaks": [
{"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
for b in shift.breaks
@@ -118,14 +156,14 @@ def start_shift(
target_id = body.waiter_id
if target_id and target_id != user.id:
if user.role not in ("manager", "sysadmin"):
raise HTTPException(status_code=403, detail="Only managers can start shifts for other waiters")
if not user.perm_access_dashboard and user.role != "superadmin":
raise HTTPException(status_code=403, detail="Only managers can start shifts for other staff")
target = db.query(User).filter(User.id == target_id, User.is_active == True).first()
if not target:
raise HTTPException(status_code=404, detail="Waiter not found")
raise HTTPException(status_code=404, detail="Staff member not found")
else:
target_id = user.id
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true":
if not user.perm_access_dashboard and user.role != "superadmin" and _get_setting(db, "shifts.waiter_self_start") != "true":
raise HTTPException(status_code=403, detail="Shift start requires manager confirmation")
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
@@ -158,7 +196,7 @@ def end_shift(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true":
if not user.perm_access_dashboard and user.role != "superadmin" and _get_setting(db, "shifts.waiter_self_end") != "true":
raise HTTPException(status_code=403, detail="Shift end requires manager confirmation")
shift = db.query(WaiterShift).filter(
@@ -174,11 +212,9 @@ def end_shift(
shift.ended_at = now
if body.notes:
shift.notes = body.notes
# Phase 2E: cash reconciliation
# Phase 2E: cash reconciliation — store counted amount; discrepancy computed live in _enrich_shift
if body.counted_cash_end is not None:
shift.counted_cash_end = body.counted_cash_end
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
@@ -247,11 +283,9 @@ def manager_end_shift(
shift.ended_at = now
if body.notes:
shift.notes = body.notes
# Phase 2E: cash reconciliation
# Phase 2E: cash reconciliation — store counted amount; discrepancy computed live in _enrich_shift
if body.counted_cash_end is not None:
shift.counted_cash_end = body.counted_cash_end
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
@@ -273,7 +307,7 @@ def start_break(
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
if shift.waiter_id != user.id and not user.perm_access_dashboard and user.role != "superadmin":
raise HTTPException(status_code=403, detail="Access denied")
if shift.ended_at:
raise HTTPException(status_code=400, detail="Shift already ended")
@@ -300,7 +334,7 @@ def end_break(
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
if shift.waiter_id != user.id and not user.perm_access_dashboard and user.role != "superadmin":
raise HTTPException(status_code=403, detail="Access denied")
open_break = db.query(ShiftBreak).filter(
@@ -404,7 +438,7 @@ def get_shift_summary(
waiter_id = shift.waiter_id
# Collect all relevant items: paid in this shift OR added by this waiter
# Collect all relevant items: paid in this shift OR added by this waiter (any status)
paid_items = db.query(OrderItem).options(
joinedload(OrderItem.product),
joinedload(OrderItem.order),
@@ -433,6 +467,22 @@ def get_shift_summary(
items_by_id[item.id] = item
all_items = list(items_by_id.values())
# Also include items cancelled within this shift window (may not be in ordered_items
# if they were cancelled after the shift ended or deduped out)
cancelled_items_for_summary = db.query(OrderItem).options(
joinedload(OrderItem.product),
joinedload(OrderItem.order),
).filter(
OrderItem.added_by == waiter_id,
OrderItem.status == "cancelled",
OrderItem.added_at >= shift.started_at,
OrderItem.added_at <= upper_bound,
).all()
for item in cancelled_items_for_summary:
if item.id not in items_by_id:
items_by_id[item.id] = item
all_items = list(items_by_id.values())
# Build lookup maps
all_waiter_ids = set()
for item in all_items:

View File

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

View File

@@ -13,7 +13,7 @@ from schemas.table import (
TableGroupCreate, TableGroupUpdate, TableGroupOut,
TableBatchCreate, MAX_TABLE_NAME_LENGTH,
)
from routers.deps import get_current_user, require_manager
from routers.deps import get_current_user, require_tables_manager
from services.sse_bus import broadcast_sync
# Tables with a pending reservation due within this many hours get the RESERVED badge
@@ -30,7 +30,7 @@ def list_groups(db: Session = Depends(get_db), user: User = Depends(get_current_
@router.post("/groups", response_model=TableGroupOut, status_code=status.HTTP_201_CREATED)
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
if db.query(TableGroup).filter(TableGroup.name == body.name).first():
raise HTTPException(status_code=400, detail="Group name already exists")
sort_order = db.query(TableGroup).count()
@@ -42,14 +42,14 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
@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)):
def reorder_groups(body: List[int] = Body(...), db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
for idx, group_id in enumerate(body):
db.query(TableGroup).filter(TableGroup.id == group_id).update({"sort_order": idx})
db.commit()
@router.put("/groups/{group_id}", response_model=TableGroupOut)
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Group not found")
@@ -61,7 +61,7 @@ def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(ge
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def delete_group(group_id: int, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Group not found")
@@ -84,7 +84,7 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
q = q.filter(Table.is_active == True)
# Zone-based filtering for waiters
if user.role not in ("manager", "sysadmin"):
if user.role != "superadmin" and not user.perm_access_dashboard:
zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all()
# No zone rows → sees nothing
if not zones:
@@ -134,9 +134,9 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u
@router.post("/", response_model=TableOut, status_code=status.HTTP_201_CREATED)
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def create_table(body: TableCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
number = _next_global_number(db)
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True)
table = Table(number=number, label=body.label, group_id=body.group_id, is_active=True, seat_count=body.seat_count)
db.add(table)
db.commit()
db.refresh(table)
@@ -145,7 +145,7 @@ def create_table(body: TableCreate, db: Session = Depends(get_db), user: User =
@router.post("/batch", response_model=List[TableOut], status_code=status.HTTP_201_CREATED)
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
if body.count < 1 or body.count > 200:
raise HTTPException(status_code=400, detail="Count must be between 1 and 200")
@@ -194,7 +194,7 @@ def batch_create_tables(body: TableBatchCreate, db: Session = Depends(get_db), u
@router.put("/{table_id}", response_model=TableOut)
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
table = db.query(Table).filter(Table.id == table_id).first()
if not table:
raise HTTPException(status_code=404, detail="Table not found")
@@ -206,7 +206,7 @@ def update_table(table_id: int, body: TableUpdate, db: Session = Depends(get_db)
@router.delete("/{table_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def delete_table(table_id: int, hard: bool = False, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
table = db.query(Table).filter(Table.id == table_id).first()
if not table:
raise HTTPException(status_code=404, detail="Table not found")
@@ -250,7 +250,7 @@ def table_status(table_id: int, db: Session = Depends(get_db), user: User = Depe
@router.put("/{table_id}/floorplan", response_model=TableOut)
def update_floorplan(table_id: int, body: TableFloorplanUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
def update_floorplan(table_id: int, body: TableFloorplanUpdate, db: Session = Depends(get_db), user: User = Depends(require_tables_manager)):
table = db.query(Table).filter(Table.id == table_id).first()
if not table:
raise HTTPException(status_code=404, detail="Table not found")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,7 @@ class TableBase(BaseModel):
class TableCreate(BaseModel):
label: Optional[str] = None
group_id: Optional[int] = None
seat_count: Optional[int] = None
@field_validator("label")
@classmethod
@@ -58,6 +59,7 @@ class TableUpdate(BaseModel):
label: Optional[str] = None
group_id: Optional[int] = None
is_active: Optional[bool] = None
seat_count: Optional[int] = None
@field_validator("label")
@classmethod
@@ -90,6 +92,7 @@ class TableOut(BaseModel):
label: Optional[str] = None
group_id: Optional[int] = None
is_active: bool = True
seat_count: Optional[int] = None
floor_x: Optional[float] = None
floor_y: Optional[float] = None
group: Optional[TableGroupOut] = None

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ License expiry behaviour:
import asyncio
import json
import logging
import os
import socket
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -176,6 +177,52 @@ async def _sync_once():
license_state.update(expiry_fields)
IMAGE_DIR = Path("/app/data/product_images")
async def _push_product_images(db, products):
"""Upload the local image file for each product whose picture changed since
the last successful push (tracked via Product.cloud_image_hash), so the
public QR menu can show it without needing a manually-set digital_image_url.
Skips products that already have a manual digital_image_url override."""
import hashlib
site_numeric_id = license_state.get("site_numeric_id")
if not site_numeric_id:
return
for p in products:
if p.digital_image_url or not p.image_url:
continue
filename = os.path.basename(p.image_url)
filepath = IMAGE_DIR / filename
if not filepath.exists():
continue
try:
contents = filepath.read_bytes()
image_hash = hashlib.sha256(contents).hexdigest()
if image_hash == p.cloud_image_hash:
continue
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
f"{settings.CLOUD_URL}/api/menu/sync-image",
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
data={"product_id": str(p.id)},
files={"file": (filename, contents)},
)
resp.raise_for_status()
p.cloud_image_hash = image_hash
db.commit()
logger.info("Pushed image for product %d to cloud", p.id)
except Exception as e:
logger.warning("Image push failed for product %d: %s", p.id, e)
async def _push_menu_snapshot():
"""Serialize all digital-visible products+categories and POST to cloud."""
if not settings.SITE_ID or not settings.CLOUD_URL:
@@ -189,6 +236,7 @@ async def _push_menu_snapshot():
try:
categories = db.query(Category).filter(Category.parent_id == None).all()
payload_categories = []
all_products = []
for cat in categories:
products = (
db.query(Product)
@@ -200,6 +248,7 @@ async def _push_menu_snapshot():
.order_by(Product.sort_order)
.all()
)
all_products.extend(products)
product_list = []
for p in products:
product_list.append({
@@ -225,26 +274,28 @@ async def _push_menu_snapshot():
"sort_order": cat.sort_order,
"products": product_list,
})
snapshot_json = json.dumps({"categories": payload_categories})
# Resolve numeric site_id from license state (set by heartbeat response)
site_numeric_id = license_state.get("site_numeric_id")
if not site_numeric_id:
logger.debug("Menu push skipped — site_numeric_id not yet known")
return
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{settings.CLOUD_URL}/api/menu/sync",
headers={"X-Site-ID": settings.SITE_ID, "X-Site-Key": settings.SITE_KEY},
json={"site_id": site_numeric_id, "snapshot_json": snapshot_json},
)
resp.raise_for_status()
logger.info("Menu snapshot pushed (%d categories)", len(payload_categories))
await _push_product_images(db, all_products)
finally:
db.close()
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)
@@ -279,7 +330,7 @@ async def _pull_pending_orders():
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()
system_user = db.query(User).filter(User.perm_access_dashboard == True).first()
opener_id = system_user.id if system_user else 1
for cloud_order in orders:

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

39
local_backend/tz.py Normal file
View File

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

View File

@@ -25,17 +25,25 @@ if answer != "YES":
from sqlalchemy import text, inspect
from database import engine, Base, SessionLocal
# Import all models so Base.metadata knows about every table
# Import all models so Base.metadata knows about every table.
# Order matters: tables with no FK dependencies first, then dependents.
import models.user # noqa: F401
import models.table # noqa: F401
import models.printer # noqa: F401
import models.product # noqa: F401
import models.customers # noqa: F401 must be before models.order (orders.customer_id FK)
import models.order # noqa: F401
import models.business_day # noqa: F401
import models.shift # noqa: F401
import models.settings # noqa: F401
import models.flag # noqa: F401
import models.message # noqa: F401
import models.notes # noqa: F401
import models.expenses # noqa: F401
import models.tabs # noqa: F401
import models.schedule # noqa: F401
import models.waste # noqa: F401
import models.reservation # noqa: F401
print("\nDropping all tables...")

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,8 +21,9 @@ function fmtDateTime(iso) {
return d.toLocaleString('el-GR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
const MANAGER_ROLES = new Set(['superadmin', 'owner', 'store_manager', 'staff_manager'])
function roleBadge(role) {
if (role === 'manager' || role === 'sysadmin') {
if (MANAGER_ROLES.has(role)) {
return { label: 'Διευθυντής', bg: '#ede9fe', color: '#6d28d9' }
}
return { label: 'Σερβιτόρος', bg: '#e0f2fe', color: '#0369a1' }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import { useNavigate } from 'react-router-dom'
import { ChefHat, Phone } from 'lucide-react'
export default function AppsPage() {
const navigate = useNavigate()
return (
<div className="p-8">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 max-w-2xl">
<button
onClick={() => navigate('/kds')}
className="flex flex-col items-center gap-3 p-6 bg-white border border-slate-200 rounded-xl shadow-sm hover:border-sky-300 hover:shadow-md transition-all text-center group"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-sky-50 text-sky-600 group-hover:bg-sky-100 transition-colors">
<ChefHat size={24} />
</div>
<div>
<p className="text-[13px] font-semibold text-slate-800">KDS</p>
<p className="text-[11px] text-slate-400 mt-0.5">Kitchen Display</p>
</div>
</button>
<button
onClick={() => navigate('/apps/phone')}
className="flex flex-col items-center gap-3 p-6 bg-white border border-slate-200 rounded-xl shadow-sm hover:border-emerald-300 hover:shadow-md transition-all text-center group"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 group-hover:bg-emerald-100 transition-colors">
<Phone size={24} />
</div>
<div>
<p className="text-[13px] font-semibold text-slate-800">Τηλέφωνο</p>
<p className="text-[11px] text-slate-400 mt-0.5">Caller ID</p>
</div>
</button>
</div>
</div>
)
}

View File

@@ -132,8 +132,7 @@ export default function ContactsPage() {
{/* 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>
<p style={{ margin: 0, fontSize: 13, color: '#9ca3af' }}>{contacts.length} ενεργές επαφές</p>
</div>
<button
onClick={() => setModal({ contact: null })}

View File

@@ -247,8 +247,7 @@ export default function CustomersPage() {
<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>
<p style={{ margin: 0, fontSize: 13, color: '#9ca3af' }}>{activeCount} ενεργοί πελάτες</p>
</div>
<button
onClick={() => setModal({ customer: null })}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,839 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
import ManagerOrderDrawer from './ManagerOrderDrawer'
// ── Constants ─────────────────────────────────────────────────────────────────
const FAV_CAT_ID = '__favorites__'
const SVC_CAT_ID = '__service__'
const DECIMAL_UNITS = new Set(['kg', 'liter', 'gram', 'ml'])
const DECIMAL_UNIT_LABELS = { kg: 'kg', liter: 'L', gram: 'g', ml: 'mL' }
// ── Helpers ───────────────────────────────────────────────────────────────────
function productAllowsQuickAdd(product) {
if (!product.quick_add_enabled) return false
for (const ps of (product.preference_sets || [])) {
if (ps.default_choice_id == null) return false
}
return true
}
function buildQuickAddItem(product) {
const qty = DECIMAL_UNITS.has(product.unit_type) ? 1.0 : 1
const selectedOptions = []
for (const ps of (product.preference_sets || [])) {
const defaultChoice = ps.choices?.find(c => c.id === ps.default_choice_id)
if (defaultChoice) {
selectedOptions.push({ id: defaultChoice.id, name: defaultChoice.name, price_delta: defaultChoice.extra_cost ?? 0 })
}
}
return {
product_id: product.id,
quantity: qty,
unit_type: product.unit_type || 'piece',
unit_price: product.base_price,
notes: '',
selected_options: selectedOptions,
removed_ingredients: [],
}
}
function fmtQty(qty, unitType) {
const label = DECIMAL_UNIT_LABELS[unitType]
if (!label) return `×${qty}`
if (unitType === 'kg' || unitType === 'liter') return `${Number(qty).toFixed(1)}${label}`
return `${qty}${label}`
}
function hexToRgba(hex, alpha) {
if (!hex) return null
const h = hex.replace('#', '')
const r = parseInt(h.substring(0, 2), 16)
const g = parseInt(h.substring(2, 4), 16)
const b = parseInt(h.substring(4, 6), 16)
return `rgba(${r},${g},${b},${alpha})`
}
function buildSections(parent, subcategories, directProducts) {
const sections = []
if (directProducts.length > 0) {
sections.push({ _isGeneral: true, sort_order: parent.general_sort_order, products: directProducts })
}
for (const sub of subcategories) {
sections.push({ ...sub, _isGeneral: false, sort_order: sub.sort_order })
}
return sections.sort((a, b) => a.sort_order - b.sort_order)
}
// ── Product card for the desktop grid ─────────────────────────────────────────
function DesktopProductCard({ product, onOpen, onQuickAdd }) {
const canQuickAdd = productAllowsQuickAdd(product)
const initials = product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
return (
<div
onClick={() => onOpen(product)}
style={{
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: 12,
overflow: 'hidden',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
transition: 'box-shadow 120ms ease, border-color 120ms ease',
position: 'relative',
}}
onMouseEnter={e => { e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)'; e.currentTarget.style.borderColor = '#cbd5e1' }}
onMouseLeave={e => { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.borderColor = '#e2e8f0' }}
>
{/* Thumbnail */}
<div style={{
width: '100%', aspectRatio: '16/9', maxHeight: 80,
background: '#f1f5f9', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{product.image_url
? <img src={product.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 22, fontWeight: 700, color: '#94a3b8', letterSpacing: -1 }}>{initials}</span>
}
</div>
{/* Info */}
<div style={{ padding: '8px 10px 10px', flex: 1, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#1e293b', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{product.name}
</div>
{product.tags?.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{product.tags.slice(0, 2).map(tag => (
<span key={tag} style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', background: '#f1f5f9', borderRadius: 99, color: '#64748b', border: '1px solid #e2e8f0' }}>{tag}</span>
))}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto' }}>
<span style={{ fontSize: 14, fontWeight: 700, color: '#f59e0b' }}>{Number(product.base_price).toFixed(2)} </span>
{canQuickAdd && (
<button
onClick={e => { e.stopPropagation(); onQuickAdd(product) }}
style={{
width: 30, height: 30, borderRadius: '50%',
background: '#fff7ed', border: '1.5px solid #fed7aa',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#f59e0b',
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.8" strokeLinecap="round"/>
</svg>
</button>
)}
</div>
</div>
</div>
)
}
// ── Service item widget ───────────────────────────────────────────────────────
function ServiceItemWidget({ product, customerCount, onAdd }) {
const [qty, setQty] = useState(0)
const hasPrice = product.base_price != null && product.base_price > 0
return (
<div style={{
background: 'white', border: '1px solid #e2e8f0', borderRadius: 12,
padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#1e293b', flex: 1, minWidth: 0 }}>{product.name}</span>
<span style={{ fontSize: 12, color: hasPrice ? '#f59e0b' : '#94a3b8', fontWeight: 600, flexShrink: 0 }}>
{hasPrice ? `${Number(product.base_price).toFixed(2)}` : 'no charge'}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button onClick={() => setQty(q => Math.max(0, q - 1))}
style={{ width: 32, height: 32, borderRadius: '50%', border: '1px solid #e2e8f0', background: qty === 0 ? '#f8fafc' : 'rgba(245,158,11,0.1)', color: qty === 0 ? '#94a3b8' : '#f59e0b', fontSize: 18, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }}></button>
<button onClick={() => setQty(Math.max(1, customerCount ?? 1))} title={`Ορισμός σε ${customerCount ?? 1}`}
style={{ width: 32, height: 32, borderRadius: '50%', border: '1px solid #e2e8f0', background: 'rgba(245,158,11,0.1)', color: '#f59e0b', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</button>
<button onClick={() => setQty(q => q + 1)}
style={{ width: 32, height: 32, borderRadius: '50%', border: '1px solid #e2e8f0', background: 'rgba(245,158,11,0.1)', color: '#f59e0b', fontSize: 18, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }}>+</button>
<span style={{ fontSize: 20, fontWeight: 800, color: qty === 0 ? '#94a3b8' : '#1e293b', flex: 1, textAlign: 'center' }}>{qty}</span>
<button
disabled={qty === 0}
onClick={() => {
if (qty === 0) return
onAdd({
product_id: product.id,
quantity: qty,
unit_type: 'piece',
unit_price: product.base_price ?? 0,
notes: '',
selected_options: [],
removed_ingredients: [],
is_service_item: true,
})
setQty(0)
}}
style={{
padding: '6px 14px', borderRadius: 8, border: 'none',
background: qty === 0 ? '#f1f5f9' : '#f59e0b',
color: qty === 0 ? '#94a3b8' : '#fff',
fontSize: 12, fontWeight: 700, cursor: qty === 0 ? 'default' : 'pointer',
}}
>ADD</button>
</div>
</div>
)
}
// ── Cart item row ─────────────────────────────────────────────────────────────
function CartItemRow({ item, product, onRemove, onChangeQty, onEdit }) {
const isDecimal = DECIMAL_UNITS.has(product?.unit_type)
const optionSummary = item.selected_options?.length
? item.selected_options.map(o => o.name).join(', ')
: null
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '8px 0', borderBottom: '1px solid #f1f5f9' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#1e293b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{product?.name ?? `#${item.product_id}`}
</div>
{optionSummary && (
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{optionSummary}
</div>
)}
{item.notes && (
<div style={{ fontSize: 11, color: '#f59e0b', marginTop: 1, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.notes}
</div>
)}
</div>
{/* Qty controls */}
{isDecimal ? (
<button onClick={onEdit} style={{ fontSize: 12, fontWeight: 700, color: '#f59e0b', background: '#fff7ed', border: '1px solid #fed7aa', borderRadius: 6, padding: '2px 8px', cursor: 'pointer', whiteSpace: 'nowrap' }}>
{fmtQty(item.quantity, product?.unit_type)}
</button>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
<button onClick={() => onChangeQty(item.quantity - 1)}
style={{ width: 24, height: 24, borderRadius: '50%', border: '1px solid #e2e8f0', background: '#f8fafc', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: item.quantity <= 1 ? '#ef4444' : '#475569', fontSize: 14, fontWeight: 700 }}>
{item.quantity <= 1
? <svg width="10" height="10" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
: ''}
</button>
<span style={{ fontSize: 13, fontWeight: 700, color: '#1e293b', minWidth: 16, textAlign: 'center' }}>{item.quantity}</span>
<button onClick={() => onChangeQty(item.quantity + 1)}
style={{ width: 24, height: 24, borderRadius: '50%', border: '1px solid #e2e8f0', background: '#f8fafc', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#16a34a', fontSize: 14, fontWeight: 700 }}>+</button>
</div>
)}
{/* Price */}
<span style={{ fontSize: 13, fontWeight: 700, color: '#475569', minWidth: 50, textAlign: 'right', flexShrink: 0 }}>
{((item.unit_price ?? 0) * item.quantity + (item.price_adjustment ?? 0) * item.quantity).toFixed(2)}
</span>
{/* Edit + Remove */}
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
<button onClick={onEdit}
style={{ padding: '2px 7px', borderRadius: 6, border: '1px solid #e2e8f0', background: '#f8fafc', color: '#64748b', fontSize: 11, cursor: 'pointer' }}>
Επεξ.
</button>
<button onClick={onRemove}
style={{ width: 24, height: 24, borderRadius: '50%', border: 'none', background: 'none', color: '#94a3b8', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
</button>
</div>
</div>
)
}
// ── Search overlay ────────────────────────────────────────────────────────────
function SearchOverlay({ products, onClose, onOpen }) {
const [query, setQuery] = useState('')
const inputRef = useRef(null)
useEffect(() => {
inputRef.current?.focus()
function onKey(e) { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const activeProducts = products.filter(p => p.lifecycle_status !== 'archived')
const results = query.trim().length === 0
? []
: activeProducts.filter(p => p.name.toLowerCase().includes(query.trim().toLowerCase()))
return (
<>
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 200 }} />
<div style={{
position: 'fixed', top: '15%', left: '50%', transform: 'translateX(-50%)',
zIndex: 201, width: 'min(580px, 90vw)',
background: 'white', borderRadius: 16,
boxShadow: '0 20px 60px rgba(0,0,0,0.18)',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
maxHeight: '60vh',
}}>
{/* Input */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" style={{ color: '#94a3b8', flexShrink: 0 }}>
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2.2"/>
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
<input
ref={inputRef}
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Αναζήτηση προϊόντος…"
style={{ flex: 1, border: 'none', outline: 'none', fontSize: 16, color: '#1e293b', background: 'transparent', fontFamily: 'inherit' }}
/>
<button onClick={onClose} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: '#64748b' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
</button>
</div>
{/* Results */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{query.trim().length === 0 ? (
<p style={{ textAlign: 'center', color: '#94a3b8', padding: '20px', fontSize: 14 }}>Πληκτρολογήστε για αναζήτηση</p>
) : results.length === 0 ? (
<p style={{ textAlign: 'center', color: '#94a3b8', padding: '20px', fontSize: 14 }}>Δεν βρέθηκαν προϊόντα για «{query}»</p>
) : results.map(p => {
const initials = p.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
return (
<button key={p.id} onClick={() => { onOpen(p); onClose() }}
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '10px 16px', background: 'none', border: 'none', cursor: 'pointer', borderBottom: '1px solid #f1f5f9', textAlign: 'left' }}
onMouseEnter={e => { e.currentTarget.style.background = '#f8fafc' }}
onMouseLeave={e => { e.currentTarget.style.background = 'none' }}
>
<div style={{ width: 38, height: 38, borderRadius: 8, flexShrink: 0, background: '#f1f5f9', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{p.image_url
? <img src={p.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 12, fontWeight: 700, color: '#94a3b8' }}>{initials}</span>
}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#1e293b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</div>
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 1 }}>{Number(p.base_price).toFixed(2)} </div>
</div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ color: '#cbd5e1', flexShrink: 0 }}>
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
)
})}
</div>
</div>
</>
)
}
// ── Main component ────────────────────────────────────────────────────────────
export default function DesktopOrderingTab({ orderId, tableId, orderStatus }) {
const qc = useQueryClient()
const { data: productsData, isLoading: productsLoading } = useQuery({
queryKey: ['products-ordering'],
queryFn: async () => {
const [prodRes, catRes] = await Promise.all([
client.get('/api/products/'),
client.get('/api/products/categories'),
])
return { products: prodRes.data, categories: catRes.data }
},
staleTime: 5 * 60 * 1000,
})
const products = productsData?.products ?? []
const categories = productsData?.categories ?? []
const serviceProducts = products.filter(p => p.is_service_item)
const regularProducts = products.filter(p => !p.is_service_item || p.category_id)
const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
const initialCatId = topLevel[0]?.id ?? null
const [activeCat, setActiveCat] = useState(null)
const [viewAllOpen, setViewAllOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [expandedSubs, setExpandedSubs] = useState({})
// Drawer for product customization
const [drawerProduct, setDrawerProduct] = useState(null)
const [editItem, setEditItem] = useState(null) // { cartKey, product, drawerState }
// Cart
const [cart, setCart] = useState([])
const [orderNote, setOrderNote] = useState('')
const [sending, setSending] = useState(false)
// Customer count (loaded from order)
const { data: orderData } = useQuery({
queryKey: ['order', orderId],
queryFn: () => client.get(`/api/orders/${orderId}`).then(r => r.data),
enabled: !!orderId,
staleTime: 30_000,
})
const customerCount = orderData?.customer_count ?? null
// Courses settings
const { data: posSettings } = useQuery({
queryKey: ['pos-settings-courses'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 60_000,
})
const coursesEnabled = posSettings?.['orders.courses_enabled']?.value === 'true'
const courses = coursesEnabled ? (() => { try { return JSON.parse(posSettings?.['orders.courses']?.value || '[]') } catch { return [] } })() : []
// Set initial category once products load
useEffect(() => {
if (activeCat === null && topLevel.length > 0) {
const firstId = serviceProducts.length > 0 ? SVC_CAT_ID : topLevel[0]?.id
setActiveCat(firstId ?? null)
}
}, [topLevel.length, serviceProducts.length])
function buildDefaultExpanded(catId) {
const subs = categories.filter(c => c.parent_id === catId)
const state = {}
subs.forEach(s => { if (s.auto_expanded) state[String(s.id)] = true })
return state
}
function selectCategory(id) {
setActiveCat(id)
setViewAllOpen(false)
setExpandedSubs(id === FAV_CAT_ID || id === SVC_CAT_ID ? {} : buildDefaultExpanded(id))
}
const isSvcActive = activeCat === SVC_CAT_ID
const activeParent = categories.find(c => c.id === activeCat)
const subcategories = activeParent
? categories.filter(c => c.parent_id === activeCat).sort((a, b) => a.sort_order - b.sort_order)
: []
const hasSubcats = subcategories.length > 0
const directProducts = regularProducts.filter(p => p.category_id === activeCat)
const sections = hasSubcats ? buildSections(activeParent, subcategories, directProducts) : []
// Cart management
function addToCart(item) {
setCart(prev => {
const { _key: _k, _drawerState: _ds, quantity: _q, ...newCore } = item
const matchIdx = prev.findIndex(existing => {
const { _key, _drawerState, quantity: _eq, ...existCore } = existing
return JSON.stringify(existCore) === JSON.stringify(newCore)
})
if (matchIdx !== -1) {
const next = [...prev]
next[matchIdx] = { ...next[matchIdx], quantity: next[matchIdx].quantity + (item.quantity ?? 1) }
return next
}
return [...prev, { ...item, _key: Date.now() + Math.random() }]
})
}
function handleQuickAdd(product) {
const base = buildQuickAddItem(product)
const item = product.is_service_item ? { ...base, is_service_item: true } : base
addToCart(item)
toast.success(`${product.name} προστέθηκε`, { duration: 800 })
}
function removeFromCart(key) {
setCart(prev => prev.filter(i => i._key !== key))
}
function changeCartQty(key, newQty) {
if (newQty <= 0) removeFromCart(key)
else setCart(prev => prev.map(i => i._key === key ? { ...i, quantity: newQty } : i))
}
function openEditDrawer(cartItem) {
const product = products.find(p => p.id === cartItem.product_id)
if (!product) return
setEditItem({ cartKey: cartItem._key, product, drawerState: cartItem._drawerState })
setDrawerProduct(null)
}
function handleDrawerAdd(item) {
if (editItem) {
setCart(prev => prev.map(i => i._key === editItem.cartKey ? { ...item, _key: i._key } : i))
setEditItem(null)
} else {
const finalItem = drawerProduct?.is_service_item ? { ...item, is_service_item: true } : item
addToCart(finalItem)
setDrawerProduct(null)
}
}
const cartTotal = cart.reduce((s, i) => s + (i.unit_price ?? 0) * i.quantity + (i.price_adjustment ?? 0) * i.quantity, 0)
const isOpen = ['open', 'partially_paid'].includes(orderStatus)
const addItemsMutation = useMutation({
mutationFn: async () => {
let activeOrderId = orderId
if (!activeOrderId) {
const { data: newOrder } = await client.post('/api/orders/', { table_id: tableId })
activeOrderId = newOrder.id
}
const res = await client.post(`/api/orders/${activeOrderId}/items`, {
items: cart.map(({ _key, _drawerState, ...item }) => item),
order_note: orderNote || null,
})
return res.data
},
onSuccess: (data) => {
const printResults = data.print_results ?? []
const allOk = printResults.length === 0 || printResults.every(r => r.success)
if (!allOk) {
toast.error('Παραγγελία αποθηκεύτηκε αλλά κάποιος εκτυπωτής δεν ανταποκρίθηκε')
} else {
toast.success('Παραγγελία εστάλη επιτυχώς')
}
setCart([])
setOrderNote('')
qc.invalidateQueries({ queryKey: ['order', orderId] })
qc.invalidateQueries({ queryKey: ['orders-active'] })
},
onError: (err) => {
toast.error(err?.response?.data?.detail || 'Σφάλμα αποστολής παραγγελίας')
},
})
// Keyboard shortcut for search
useEffect(() => {
function onKey(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); setSearchOpen(true) }
if (e.key === 'Escape') { setDrawerProduct(null); setEditItem(null); setSearchOpen(false) }
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
if (productsLoading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 200, color: '#94a3b8', fontSize: 14 }}>
Φόρτωση προϊόντων
</div>
)
}
if (!isOpen) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 200, color: '#94a3b8', fontSize: 14 }}>
Η παραγγελία δεν είναι ανοιχτή.
</div>
)
}
const activeDrawerProduct = editItem ? editItem.product : drawerProduct
const drawerOpen = !!activeDrawerProduct
return (
<div style={{ display: 'flex', height: '100%', minHeight: 0, gap: 0 }}>
{/* ── LEFT: Category + Product browser ─────────────────────────────── */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, borderRight: '1px solid #e2e8f0' }}>
{/* Toolbar */}
<div style={{ padding: '10px 12px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0, background: '#f8fafc' }}>
<div style={{ flex: 1, display: 'flex', gap: 6, overflowX: 'auto', scrollbarWidth: 'none' }}>
{/* SERVICE chip */}
{serviceProducts.length > 0 && (
<button onClick={() => selectCategory(SVC_CAT_ID)}
style={{
height: 32, padding: '0 14px', borderRadius: 16, border: isSvcActive ? '2px solid #f59e0b' : '1px solid #e2e8f0',
background: isSvcActive ? '#f59e0b' : '#fff7ed', color: isSvcActive ? '#fff' : '#d97706',
fontSize: 12, fontWeight: 700, cursor: 'pointer', whiteSpace: 'nowrap',
display: 'flex', alignItems: 'center', gap: 4,
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 11l19-9-9 19-2-8-8-2z"/>
</svg>
Service
</button>
)}
{topLevel.map(cat => {
const isActive = activeCat === cat.id
const bg = cat.color
? isActive ? cat.color : hexToRgba(cat.color, 0.15)
: isActive ? '#f59e0b' : '#f8fafc'
const color = cat.color
? isActive ? '#fff' : cat.color
: isActive ? '#fff' : '#64748b'
return (
<button key={cat.id} onClick={() => selectCategory(cat.id)}
style={{
height: 32, padding: '0 14px', borderRadius: 16,
border: isActive && cat.color ? `2px solid ${cat.color}` : isActive ? '2px solid #f59e0b' : '1px solid #e2e8f0',
background: bg, color,
fontSize: 12, fontWeight: 700, cursor: 'pointer', whiteSpace: 'nowrap',
}}
>{cat.name}</button>
)
})}
<button onClick={() => setViewAllOpen(true)}
style={{ height: 32, padding: '0 12px', borderRadius: 16, border: '1px solid #e2e8f0', background: '#f8fafc', color: '#64748b', fontSize: 12, fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0 }}>
Όλες
</button>
</div>
<button onClick={() => setSearchOpen(true)}
style={{ height: 32, padding: '0 12px', borderRadius: 16, border: '1px solid #e2e8f0', background: '#f8fafc', color: '#64748b', fontSize: 12, fontWeight: 600, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2.2"/>
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
Αναζήτηση
</button>
</div>
{/* Product area */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 14px' }}>
{isSvcActive ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 10 }}>
{serviceProducts.map(p => (
<ServiceItemWidget key={p.id} product={p} customerCount={customerCount} onAdd={item => addToCart(item)} />
))}
</div>
) : !hasSubcats ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10 }}>
{directProducts.map(p => (
<DesktopProductCard key={p.id} product={p} onOpen={setDrawerProduct} onQuickAdd={handleQuickAdd} />
))}
</div>
{directProducts.length === 0 && (
<div style={{ textAlign: 'center', color: '#94a3b8', padding: '40px 0', fontSize: 14 }}>Δεν υπάρχουν προϊόντα</div>
)}
</>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{sections.map(section => {
const key = section._isGeneral ? '__general__' : String(section.id)
const isExpanded = !!expandedSubs[key]
const sectionProducts = section._isGeneral
? section.products
: regularProducts.filter(p => p.category_id === section.id)
if (sectionProducts.length === 0) return null
if (section._isGeneral) {
return (
<div key={key}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10 }}>
{sectionProducts.map(p => (
<DesktopProductCard key={p.id} product={p} onOpen={setDrawerProduct} onQuickAdd={handleQuickAdd} />
))}
</div>
</div>
)
}
const accentColor = section.color ?? activeParent?.color ?? null
return (
<div key={key} style={{ border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden' }}>
<button
onClick={() => setExpandedSubs(prev => ({ ...prev, [key]: !prev[key] }))}
style={{
width: '100%', padding: '10px 14px', background: isExpanded ? '#f8fafc' : 'white',
border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8, textAlign: 'left',
}}
>
{accentColor && <span style={{ width: 10, height: 10, borderRadius: '50%', background: accentColor, flexShrink: 0 }} />}
<span style={{ flex: 1, fontSize: 13, fontWeight: 700, color: '#1e293b' }}>{section.name}</span>
<span style={{ fontSize: 11, color: '#94a3b8', marginRight: 4 }}>{sectionProducts.length}</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${isExpanded ? 180 : 0}deg)`, transition: 'transform 180ms', color: '#94a3b8', flexShrink: 0 }}>
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{isExpanded && (
<div style={{ padding: '10px 12px 12px', background: '#fafbfc', borderTop: '1px solid #f1f5f9' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10 }}>
{sectionProducts.map(p => (
<DesktopProductCard key={p.id} product={p} onOpen={setDrawerProduct} onQuickAdd={handleQuickAdd} />
))}
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
{/* ── RIGHT: Cart panel or Drawer panel ────────────────────────────── */}
<div style={{ width: 320, flexShrink: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{drawerOpen ? (
<ManagerOrderDrawer
key={activeDrawerProduct.id}
product={activeDrawerProduct}
initialState={editItem?.drawerState}
onClose={() => { setDrawerProduct(null); setEditItem(null) }}
onAdd={handleDrawerAdd}
courses={courses}
/>
) : (
/* Cart */
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#f8fafc' }}>
{/* Cart header */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#1e293b' }}>Νέα Παραγγελία</div>
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 1 }}>
{cart.length === 0 ? 'Επιλέξτε προϊόντα από τα αριστερά' : `${cart.length} ${cart.length === 1 ? 'προϊόν' : 'προϊόντα'}`}
</div>
</div>
{/* Item list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px' }}>
{cart.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12, padding: '40px 0' }}>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style={{ color: '#e2e8f0' }}>
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4zM3 6h18M16 10a4 4 0 01-8 0" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<p style={{ color: '#cbd5e1', fontSize: 13, textAlign: 'center' }}>Το καλάθι είναι άδειο</p>
</div>
) : (
<div>
{cart.map(item => {
const product = products.find(p => p.id === item.product_id)
return (
<CartItemRow
key={item._key}
item={item}
product={product}
onRemove={() => removeFromCart(item._key)}
onChangeQty={qty => changeCartQty(item._key, qty)}
onEdit={() => openEditDrawer(item)}
/>
)
})}
</div>
)}
</div>
{/* Order note */}
{cart.length > 0 && (
<div style={{ padding: '8px 16px', flexShrink: 0 }}>
<textarea
value={orderNote}
onChange={e => setOrderNote(e.target.value)}
placeholder="Σημείωση παραγγελίας (προαιρετικό)…"
rows={2}
style={{
width: '100%', resize: 'none', padding: '8px 10px',
background: 'white', border: '1px solid #e2e8f0',
borderRadius: 8, fontSize: 12, color: '#1e293b', lineHeight: 1.5,
outline: 'none', boxSizing: 'border-box', fontFamily: 'inherit',
}}
/>
</div>
)}
{/* Cart footer */}
<div style={{ padding: '10px 16px 16px', borderTop: '1px solid #e2e8f0', flexShrink: 0, background: 'white' }}>
{cart.length > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<span style={{ fontSize: 13, color: '#64748b', fontWeight: 600 }}>Σύνολο</span>
<span style={{ fontSize: 18, fontWeight: 800, color: '#1e293b' }}>{cartTotal.toFixed(2)}</span>
</div>
)}
<button
onClick={() => addItemsMutation.mutate()}
disabled={cart.length === 0 || addItemsMutation.isPending}
style={{
width: '100%', height: 44, borderRadius: 22,
background: cart.length === 0 ? '#e2e8f0' : '#f59e0b',
border: 'none', color: cart.length === 0 ? '#94a3b8' : '#fff',
fontSize: 15, fontWeight: 700, fontFamily: 'inherit',
cursor: cart.length === 0 ? 'default' : 'pointer',
transition: 'background 150ms ease',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}
>
{addItemsMutation.isPending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`}
</button>
{cart.length > 0 && (
<button onClick={() => setCart([])}
style={{ width: '100%', marginTop: 6, padding: '6px 0', background: 'none', border: 'none', color: '#94a3b8', fontSize: 12, cursor: 'pointer' }}>
Εκκαθάριση καλαθιού
</button>
)}
</div>
</div>
)}
</div>
{/* View all categories modal */}
{viewAllOpen && (
<>
<div onClick={() => setViewAllOpen(false)} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 200 }} />
<div style={{
position: 'fixed', top: '15%', left: '50%', transform: 'translateX(-50%)',
zIndex: 201, width: 'min(500px, 90vw)',
background: 'white', borderRadius: 16,
boxShadow: '0 20px 60px rgba(0,0,0,0.18)',
overflow: 'hidden',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', borderBottom: '1px solid #e2e8f0' }}>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1e293b' }}>Κατηγορίες</span>
<button onClick={() => setViewAllOpen(false)} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: 18 }}></button>
</div>
<div style={{ padding: '14px 16px', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, maxHeight: '60vh', overflowY: 'auto' }}>
{serviceProducts.length > 0 && (
<button onClick={() => { selectCategory(SVC_CAT_ID); setViewAllOpen(false) }}
style={{ padding: '14px 10px', borderRadius: 12, background: '#fff7ed', border: isSvcActive ? '2px solid #f59e0b' : '1px solid #fed7aa', cursor: 'pointer', fontSize: 13, fontWeight: 700, color: '#d97706', textAlign: 'center' }}>
Service
</button>
)}
{topLevel.map(cat => {
const isActive = activeCat === cat.id
return (
<button key={cat.id} onClick={() => { selectCategory(cat.id); setViewAllOpen(false) }}
style={{
padding: '14px 10px', borderRadius: 12, cursor: 'pointer', fontSize: 13, fontWeight: 700, textAlign: 'center',
background: cat.color || '#f1f5f9',
color: cat.color ? '#fff' : '#475569',
border: isActive ? '2px solid #f59e0b' : '1px solid transparent',
boxShadow: cat.color ? 'inset 0 0 0 100px rgba(0,0,0,0.15)' : 'none',
}}>
{cat.name}
</button>
)
})}
</div>
</div>
</>
)}
{/* Search modal */}
{searchOpen && (
<SearchOverlay
products={products}
onClose={() => setSearchOpen(false)}
onOpen={p => { setDrawerProduct(p); setSearchOpen(false) }}
/>
)}
</div>
)
}

View File

@@ -320,8 +320,7 @@ export default function ExpensesPage() {
<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' }}>
<p style={{ margin: 0, fontSize: 13, color: '#9ca3af' }}>
{pendingCount} εκκρεμή · Σύνολο οφειλών: <strong style={{ color: totalDue > 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalDue)}</strong>
</p>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -89,6 +89,7 @@ export default function LoginPage() {
const [loadingInit, setLoadingInit] = useState(true)
const [selectedManager, setSelectedManager] = useState(null)
const [manualMode, setManualMode] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [pin, setPin] = useState('')
@@ -125,6 +126,10 @@ export default function LoginPage() {
const autofill = settings?.autofill_username !== false
const singleManager = managers.length === 1 ? managers[0] : null
// Suppressed in manual mode so the free-text input is shown instead
const effectiveUsername = manualMode ? null
: (selectedManager?.username || (autofill && singleManager ? singleManager.username : null))
// Auto-login: none method + single manager → attempt exactly once, skip if manual logout
useEffect(() => {
if (!loadingInit && loginMethod === 'none' && singleManager && !manualLogout && !autoLoginAttempted.current) {
@@ -141,11 +146,10 @@ export default function LoginPage() {
setLoading(true)
try {
const { data } = await client.post('/api/auth/login-no-auth', { username: uname })
const role = data.user.role
if (role !== 'manager' && role !== 'sysadmin') { setLoading(false); return }
if (!data.user.perm_access_dashboard) { setLoading(false); return }
clearManualLogoutFlag()
login(data.user, data.access_token)
navigate('/operations', { replace: true })
navigate('/dashboard', { replace: true })
} catch {
setLoading(false)
}
@@ -153,7 +157,7 @@ export default function LoginPage() {
async function handleSubmit(e) {
e?.preventDefault()
const uname = selectedManager?.username || (autofill && singleManager ? singleManager.username : username.trim())
const uname = effectiveUsername || username.trim()
if (!uname) return
if (loginMethod === 'password' && !password) return
if (loginMethod === 'pin' && pin.length < 4) return
@@ -166,8 +170,7 @@ export default function LoginPage() {
else if (loginMethod === 'pin') body.pin = pin
const { data } = await client.post('/api/auth/login', body)
const role = data.user.role
if (role !== 'manager' && role !== 'sysadmin') {
if (!data.user.perm_access_dashboard) {
setError('This account does not have manager access.')
setPin('')
setPassword('')
@@ -175,7 +178,7 @@ export default function LoginPage() {
}
clearManualLogoutFlag()
login(data.user, data.access_token)
navigate('/operations', { replace: true })
navigate('/dashboard', { replace: true })
} catch (err) {
setError(err.response?.data?.detail || 'Invalid credentials')
setPin('')
@@ -191,7 +194,7 @@ export default function LoginPage() {
}, [pin])
// Show spinner while loading OR while auto-login is in progress (none mode, not manual logout)
if (loadingInit || (loginMethod === 'none' && singleManager && !manualLogout)) {
if (loadingInit || (loginMethod === 'none' && singleManager && !manualLogout && !manualMode)) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
@@ -199,10 +202,6 @@ export default function LoginPage() {
)
}
// Determine effective username to show
const effectiveUsername = selectedManager?.username
|| (autofill && singleManager ? singleManager.username : null)
// Multi-manager + none method: show picker first
const needsPicker = loginMethod === 'none' && managers.length > 1 && !selectedManager
@@ -229,56 +228,74 @@ export default function LoginPage() {
</p>
</div>
{/* Username — show if not autofilled */}
{!effectiveUsername && (
<>
{managers.length > 1 ? (
<div className="space-y-2">
{managers.map(m => (
<button
key={m.id}
type="button"
onClick={() => setUsername(m.username)}
className={`w-full flex items-center gap-3 rounded-xl border p-3 text-left transition ${
username === m.username
? 'border-sky-400 bg-sky-50'
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}`}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
{(m.full_name || m.username).charAt(0).toUpperCase()}
</div>
<div>
<p className="text-[13px] font-medium text-slate-800">{m.full_name || m.username}</p>
<p className="text-[11px] text-slate-400">{m.username}</p>
</div>
</button>
))}
{/* Username section */}
{manualMode ? (
<LabelledInput
icon={User}
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="off"
autoFocus
/>
) : effectiveUsername ? (
<div className="space-y-1">
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
{effectiveUsername.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-[13px] font-medium text-slate-700">{effectiveUsername}</p>
<p className="text-[11px] text-slate-400">Manager</p>
</div>
) : (
<LabelledInput
icon={User}
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="off"
autoFocus
/>
)}
</>
)}
{/* Show who we're logging in as when autofilled */}
{effectiveUsername && (
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
{effectiveUsername.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-[13px] font-medium text-slate-700">{effectiveUsername}</p>
<p className="text-[11px] text-slate-400">Manager</p>
</div>
<button
type="button"
onClick={() => { setManualMode(true); setSelectedManager(null); setUsername(''); setPassword(''); setPin(''); setError('') }}
className="text-[11px] text-slate-400 hover:text-sky-600 transition-colors w-full text-right pr-1"
>
Sign in as different account
</button>
</div>
) : managers.length > 0 ? (
<div className="space-y-2">
{managers.map(m => (
<button
key={m.id}
type="button"
onClick={() => setUsername(m.username)}
className={`w-full flex items-center gap-3 rounded-xl border p-3 text-left transition ${
username === m.username
? 'border-sky-400 bg-sky-50'
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}`}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
{(m.full_name || m.username).charAt(0).toUpperCase()}
</div>
<div>
<p className="text-[13px] font-medium text-slate-800">{m.full_name || m.username}</p>
<p className="text-[11px] text-slate-400">{m.username}</p>
</div>
</button>
))}
<button
type="button"
onClick={() => { setManualMode(true); setUsername(''); setPassword(''); setPin(''); setError('') }}
className="text-[11px] text-slate-400 hover:text-sky-600 transition-colors w-full text-right pr-1"
>
Sign in as different account
</button>
</div>
) : (
<LabelledInput
icon={User}
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="off"
autoFocus
/>
)}
{/* Password input */}
@@ -333,6 +350,7 @@ export default function LoginPage() {
{loading && loginMethod === 'pin' && (
<p className="text-center text-[13px] text-slate-400">Verifying</p>
)}
</form>
)}
</div>

View File

@@ -0,0 +1,810 @@
import { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import {
Plus, Pencil, Trash2, Printer, X, Check, ChevronDown,
Zap, Settings2, ArrowUpDown, Layers, CheckSquare, SkipForward,
GripVertical, ChevronUp, AlertTriangle, FlameKindling,
} from 'lucide-react'
import client from '../../api/client'
// ── API ───────────────────────────────────────────────────────────────────────
const fetchZones = () => client.get('/api/prep-zones').then(r => r.data)
const fetchPrinters = () => client.get('/api/system/printers').then(r => r.data)
const fetchCategories = () => client.get('/api/products/categories').then(r => r.data)
// ── Constants ─────────────────────────────────────────────────────────────────
const AUTO_PRINT_OPTIONS = [
{ value: 'none', label: 'Χωρίς εκτύπωση', desc: 'Μη αυτόματη εκτύπωση' },
{ value: 'master', label: 'Μόνο κύριος', desc: 'Εκτύπωση στον κύριο' },
{ value: 'all', label: 'Όλοι εκτυπωτές', desc: 'Master + δευτερεύοντες' },
]
const SORT_OPTIONS = [
{ value: 'order_time', label: 'Σειρά παραγγελίας', desc: 'Όπως προστέθηκαν στο καλάθι' },
{ value: 'item_count', label: 'Πλήθος (φθίνον)', desc: 'Πρώτα τα αντικείμενα με τη μεγαλύτερη ποσότητα' },
{ value: 'alpha', label: 'Αλφαβητικά', desc: 'Ταξινόμηση κατά όνομα' },
]
const EMPTY_ZONE = {
name: '', description: '', notification_name: '',
printer_ids: [], master_printer_id: null,
auto_print: 'none', master_copies: 1, secondary_copies: 1,
sort_items_by: 'order_time', group_by_category: false, category_order: [], print_checkboxes: false,
bypass_pending: false, bypass_kds: false, auto_ready_to_served: false,
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function Toggle({ value, onChange, disabled }) {
return (
<button
type="button"
onClick={() => !disabled && onChange(!value)}
disabled={disabled}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus:outline-none
${value ? 'bg-primary-600' : 'bg-gray-200'} ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform duration-200
${value ? 'translate-x-4' : 'translate-x-0.5'}`} />
</button>
)
}
function CopySpinner({ value, onChange, min = 1, max = 9 }) {
return (
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => onChange(Math.max(min, value - 1))}
className="w-8 h-8 rounded-lg border border-gray-300 flex items-center justify-center text-gray-600 hover:bg-gray-100 hover:border-gray-400 transition-colors text-base font-medium"
></button>
<span className="w-9 text-center text-sm font-bold text-gray-800 tabular-nums">{value}×</span>
<button
type="button"
onClick={() => onChange(Math.min(max, value + 1))}
className="w-8 h-8 rounded-lg border border-gray-300 flex items-center justify-center text-gray-600 hover:bg-gray-100 hover:border-gray-400 transition-colors text-base font-medium"
>+</button>
</div>
)
}
// ── Click-outside hook for dropdowns ─────────────────────────────────────────
function useClickOutside(ref, onClose) {
useEffect(() => {
function handle(e) {
if (ref.current && !ref.current.contains(e.target)) onClose()
}
document.addEventListener('mousedown', handle)
return () => document.removeEventListener('mousedown', handle)
}, [ref, onClose])
}
// ── Master printer single-select ──────────────────────────────────────────────
function MasterPrinterPicker({ printers, value, onChange }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
useClickOutside(ref, () => setOpen(false))
const selected = printers.find(p => p.id === value)
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-gray-300 rounded-lg bg-white text-sm text-left hover:border-gray-400 transition-colors"
>
<span className="flex items-center gap-2 flex-1 min-w-0">
{selected ? (
<>
<span className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
<span className="font-medium text-gray-800 truncate">{selected.name}</span>
<span className="text-xs text-gray-400 font-mono ml-auto shrink-0">{selected.ip_address}:{selected.port}</span>
</>
) : (
<span className="text-gray-400"> Χωρίς κύριο </span>
)}
</span>
<ChevronDown size={13} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
<button
type="button"
onClick={() => { onChange(null); setOpen(false) }}
className={`w-full flex items-center gap-3 px-3 py-2.5 text-sm text-left transition-colors hover:bg-gray-50 ${!value ? 'bg-gray-50' : ''}`}
>
<span className="w-4 h-4 rounded border border-gray-300 flex items-center justify-center shrink-0">
{!value && <Check size={10} className="text-gray-600" />}
</span>
<span className="text-gray-400 italic text-xs">Χωρίς κύριο εκτυπωτή</span>
</button>
{printers.map(p => (
<button
key={p.id}
type="button"
onClick={() => { onChange(p.id); setOpen(false) }}
className={`w-full flex items-center gap-3 px-3 py-2.5 text-sm text-left transition-colors hover:bg-gray-50 ${value === p.id ? 'bg-primary-50' : ''}`}
>
<span className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${value === p.id ? 'bg-primary-600 border-primary-600' : 'border-gray-300'}`}>
{value === p.id && <Check size={10} className="text-white" />}
</span>
<Printer size={13} className="text-gray-400 shrink-0" />
<span className={`flex-1 ${value === p.id ? 'font-semibold text-primary-700' : 'text-gray-700'}`}>{p.name}</span>
<span className="text-xs text-gray-400 font-mono">{p.ip_address}:{p.port}</span>
</button>
))}
</div>
)}
</div>
)
}
// ── Secondary printers multi-select ──────────────────────────────────────────
function SecondaryPrinterPicker({ printers, selected, masterId, onChange }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
useClickOutside(ref, () => setOpen(false))
const selectedSet = new Set(selected)
const eligible = printers.filter(p => p.id !== masterId)
const selectedPrinters = eligible.filter(p => selectedSet.has(p.id))
const toggle = (id) => {
const next = new Set(selectedSet)
if (next.has(id)) next.delete(id); else next.add(id)
onChange([...next])
}
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-gray-300 rounded-lg bg-white text-sm text-left hover:border-gray-400 transition-colors min-h-[38px]"
>
<span className="flex flex-wrap gap-1 flex-1 min-w-0">
{selectedPrinters.length === 0 ? (
<span className="text-gray-400"> Χωρίς δευτερεύοντες </span>
) : (
selectedPrinters.map(p => (
<span key={p.id} className="inline-flex items-center gap-1 bg-gray-100 text-gray-600 rounded px-1.5 py-0.5 text-xs font-medium">
<Printer size={10} />{p.name}
</span>
))
)}
</span>
<ChevronDown size={13} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
{eligible.length === 0 ? (
<div className="px-3 py-4 text-sm text-gray-400 text-center italic">
{printers.length <= 1 ? 'Δεν υπάρχουν άλλοι εκτυπωτές' : 'Ο μόνος εκτυπωτής είναι ο κύριος'}
</div>
) : eligible.map(p => {
const on = selectedSet.has(p.id)
return (
<button
key={p.id}
type="button"
onClick={() => toggle(p.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 text-sm text-left transition-colors hover:bg-gray-50 ${on ? 'bg-gray-50' : ''}`}
>
<span className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${on ? 'bg-gray-700 border-gray-700' : 'border-gray-300'}`}>
{on && <Check size={10} className="text-white" />}
</span>
<Printer size={13} className="text-gray-400 shrink-0" />
<span className={`flex-1 ${on ? 'font-semibold text-gray-800' : 'text-gray-700'}`}>{p.name}</span>
<span className="text-xs text-gray-400 font-mono">{p.ip_address}:{p.port}</span>
</button>
)
})}
<div className="border-t border-gray-100 px-3 py-2 text-xs text-gray-400 italic">
Κλικ έξω για κλείσιμο
</div>
</div>
)}
</div>
)
}
// ── Category order modal ──────────────────────────────────────────────────────
function CategoryOrderModal({ categories, order, onSave, onClose }) {
const ranked = order.map(id => categories.find(c => c.id === id)).filter(Boolean)
const unranked = categories.filter(c => !order.includes(c.id))
const [list, setList] = useState([...ranked, ...unranked])
const dragIdx = useRef(null)
const onDragStart = (i) => { dragIdx.current = i }
const onDragOver = (e, i) => {
e.preventDefault()
if (dragIdx.current === null || dragIdx.current === i) return
const next = [...list]
const [moved] = next.splice(dragIdx.current, 1)
next.splice(i, 0, moved)
dragIdx.current = i
setList(next)
}
const onDragEnd = () => { dragIdx.current = null }
const move = (i, dir) => {
const j = i + dir
if (j < 0 || j >= list.length) return
const next = [...list];
[next[i], next[j]] = [next[j], next[i]]
setList(next)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
<div>
<h2 className="text-sm font-bold text-gray-900">Σειρά Κατηγοριών Εκτύπωσης</h2>
<p className="text-xs text-gray-500 mt-0.5">Σύρετε για αλλαγή σειράς</p>
</div>
<button type="button" onClick={onClose} className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors">
<X size={16} />
</button>
</div>
<div className="px-3 py-3 max-h-80 overflow-y-auto space-y-1">
{list.map((cat, i) => (
<div
key={cat.id}
draggable
onDragStart={() => onDragStart(i)}
onDragOver={e => onDragOver(e, i)}
onDragEnd={onDragEnd}
className="flex items-center gap-2.5 px-3 py-2.5 bg-gray-50 rounded-xl border border-gray-100 cursor-grab active:cursor-grabbing select-none group hover:bg-white hover:border-gray-200 hover:shadow-sm transition-all"
>
<GripVertical size={14} className="text-gray-300 group-hover:text-gray-400 shrink-0" />
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: cat.color || '#94a3b8' }} />
<span className="text-sm text-gray-700 font-medium flex-1 min-w-0 truncate">{cat.name}</span>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button type="button" onClick={() => move(i, -1)} disabled={i === 0}
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-20">
<ChevronUp size={13} />
</button>
<button type="button" onClick={() => move(i, 1)} disabled={i === list.length - 1}
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-20">
<ChevronDown size={13} />
</button>
</div>
<span className="text-xs text-gray-300 font-mono w-5 text-right shrink-0">{i + 1}</span>
</div>
))}
{list.length === 0 && (
<div className="py-8 text-center text-sm text-gray-400">Δεν υπάρχουν κατηγορίες</div>
)}
</div>
<div className="px-5 py-4 border-t border-gray-100 flex gap-2 justify-end">
<button type="button" onClick={onClose} className="btn btn-secondary">Ακύρωση</button>
<button
type="button"
onClick={() => { onSave(list.map(c => c.id)); onClose() }}
className="btn btn-primary flex items-center gap-2"
>
<Check size={14} /> Αποθήκευση σειράς
</button>
</div>
</div>
</div>
)
}
// ── Section header ────────────────────────────────────────────────────────────
function Section({ icon: Icon_, label, children }) {
return (
<div className="space-y-3">
<div className="flex items-center gap-2 pb-1.5 border-b border-gray-100">
<Icon_ size={13} className="text-gray-400" />
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-widest">{label}</span>
</div>
{children}
</div>
)
}
// ── Toggle row ────────────────────────────────────────────────────────────────
function ToggleRow({ label, desc, value, onChange, disabled, children, warning }) {
return (
<div className={`rounded-xl border transition-colors ${value ? 'border-primary-200 bg-primary-50/40' : 'border-gray-200 bg-gray-50/40'} ${disabled ? 'opacity-60' : ''}`}>
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-gray-800">{label}</span>
{warning && <AlertTriangle size={12} className="text-amber-500 shrink-0" />}
</div>
{desc && <p className="text-xs text-gray-500 mt-0.5 leading-relaxed">{desc}</p>}
</div>
<Toggle value={value} onChange={onChange} disabled={disabled} />
</div>
{value && children && (
<div className="px-4 pb-3">{children}</div>
)}
</div>
)
}
// ── Zone Form ─────────────────────────────────────────────────────────────────
function ZoneForm({ initial, printers, categories, onSave, onCancel, saving }) {
const z = initial || EMPTY_ZONE
const [name, setName] = useState(z.name)
const [description, setDesc] = useState(z.description || '')
const [notifName, setNotifName] = useState(z.notification_name || '')
const [allPrinterIds, setAllPrinterIds] = useState(z.printer_ids || [])
const [masterId, setMasterId] = useState(z.master_printer_id ?? null)
const [autoPrint, setAutoPrint] = useState(z.auto_print || 'none')
const [masterCopies, setMasterCopies] = useState(z.master_copies || 1)
const [secondaryCopies, setSecondaryCopies] = useState(z.secondary_copies || 1)
const [sortBy, setSortBy] = useState(z.sort_items_by || 'order_time')
const [groupCat, setGroupCat] = useState(z.group_by_category || false)
const [catOrder, setCatOrder] = useState(z.category_order || [])
const [checkboxes, setCheckboxes] = useState(z.print_checkboxes || false)
const [bypassPending, setBypassPending] = useState(z.bypass_pending || false)
const [bypassKds, setBypassKds] = useState(z.bypass_kds || false)
const [autoReadyServed, setAutoReadyServed] = useState(z.auto_ready_to_served || false)
const [catModal, setCatModal] = useState(false)
const secondaryIds = allPrinterIds.filter(id => id !== masterId)
const handleMasterChange = (id) => {
setMasterId(id)
if (id && !allPrinterIds.includes(id)) setAllPrinterIds(prev => [...prev, id])
}
const handleSecondaryChange = (ids) => {
const next = new Set(ids)
if (masterId) next.add(masterId)
setAllPrinterIds([...next])
}
const handleBypassKds = (v) => { setBypassKds(v); if (v) setBypassPending(true) }
const handleBypassPending = (v) => { setBypassPending(v); if (!v) setBypassKds(false) }
const valid = name.trim().length > 0
const submit = (e) => {
e.preventDefault()
if (!valid) return
onSave({
name: name.trim(),
description: description.trim() || null,
notification_name: notifName.trim() || null,
printer_ids: allPrinterIds,
master_printer_id: masterId || null,
auto_print: autoPrint,
master_copies: masterCopies,
secondary_copies: secondaryCopies,
sort_items_by: sortBy,
group_by_category: groupCat,
category_order: catOrder,
print_checkboxes: checkboxes,
bypass_pending: bypassPending,
bypass_kds: bypassKds,
auto_ready_to_served: autoReadyServed,
})
}
return (
<>
<form onSubmit={submit} className="space-y-5">
{/* Identity */}
<Section icon={Settings2} label="Στοιχεία Ζώνης">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Όνομα ζώνης *</label>
<input className="input" value={name} onChange={e => setName(e.target.value)}
placeholder="π.χ. Kitchen Grill" autoFocus />
</div>
<div>
<label className="label">Όνομα ειδοποίησης</label>
<input className="input" value={notifName} onChange={e => setNotifName(e.target.value)}
placeholder="π.χ. Κουζίνα" />
</div>
</div>
<div>
<label className="label">Περιγραφή</label>
<input className="input" value={description} onChange={e => setDesc(e.target.value)}
placeholder="Προαιρετική περιγραφή" />
</div>
</Section>
{/* Printers */}
<Section icon={Printer} label="Εκτυπωτές">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Κύριος εκτυπωτής</label>
<MasterPrinterPicker printers={printers} value={masterId} onChange={handleMasterChange} />
<p className="mt-1 text-xs text-gray-400">Ένας μόνο, υψηλή προτεραιότητα.</p>
</div>
<div>
<label className="label">Δευτερεύοντες εκτυπωτές</label>
<SecondaryPrinterPicker
printers={printers} selected={secondaryIds}
masterId={masterId} onChange={handleSecondaryChange}
/>
<p className="mt-1 text-xs text-gray-400">Μόνο στη λειτουργία «Όλοι».</p>
</div>
</div>
<div>
<label className="label">Αυτόματη εκτύπωση</label>
<div className="grid grid-cols-3 gap-2">
{AUTO_PRINT_OPTIONS.map(opt => (
<button key={opt.value} type="button" onClick={() => setAutoPrint(opt.value)}
className={`px-3 py-2.5 rounded-xl border text-left transition-all
${autoPrint === opt.value
? 'border-primary-500 bg-primary-50 ring-1 ring-primary-500'
: 'border-gray-200 hover:border-gray-300 bg-white'}`}>
<div className={`text-xs font-bold leading-tight ${autoPrint === opt.value ? 'text-primary-700' : 'text-gray-700'}`}>{opt.label}</div>
<div className="text-[11px] text-gray-400 mt-0.5">{opt.desc}</div>
</button>
))}
</div>
</div>
{autoPrint !== 'none' && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Αντίτυπα κύριου</label>
<CopySpinner value={masterCopies} onChange={setMasterCopies} />
</div>
{autoPrint === 'all' && (
<div>
<label className="label">Αντίτυπα δευτερευόντων</label>
<CopySpinner value={secondaryCopies} onChange={setSecondaryCopies} />
</div>
)}
</div>
)}
</Section>
{/* Ticket formatting */}
<Section icon={ArrowUpDown} label="Μορφοποίηση Δελτίου">
<div>
<label className="label">Ταξινόμηση αντικειμένων</label>
<div className="grid grid-cols-3 gap-2">
{SORT_OPTIONS.map(opt => (
<button key={opt.value} type="button" onClick={() => setSortBy(opt.value)}
className={`px-3 py-2.5 rounded-xl border text-left transition-all
${sortBy === opt.value
? 'border-primary-500 bg-primary-50 ring-1 ring-primary-500'
: 'border-gray-200 hover:border-gray-300 bg-white'}`}>
<div className={`text-xs font-bold leading-tight ${sortBy === opt.value ? 'text-primary-700' : 'text-gray-700'}`}>{opt.label}</div>
<div className="text-[11px] text-gray-400 mt-0.5">{opt.desc}</div>
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<ToggleRow
label="Ομαδοποίηση κατά κατηγορία"
desc="Αντικείμενα ομαδοποιούνται ανά κατηγορία."
value={groupCat} onChange={setGroupCat}
>
<button type="button" onClick={() => setCatModal(true)}
className="mt-1 inline-flex items-center gap-1.5 text-xs font-semibold text-primary-600 hover:text-primary-700 bg-white border border-primary-200 rounded-lg px-3 py-1.5 hover:bg-primary-50 transition-colors">
<Layers size={12} />
Σειρά κατηγοριών
{catOrder.length > 0 && (
<span className="bg-primary-100 text-primary-700 rounded px-1 text-[10px] font-bold">{catOrder.length}</span>
)}
</button>
</ToggleRow>
<ToggleRow
label="Checkboxes"
desc="Προσθέτει [ ] πριν από κάθε αντικείμενο."
value={checkboxes} onChange={setCheckboxes}
/>
</div>
</Section>
{/* KDS Behavior */}
<Section icon={Zap} label="Συμπεριφορά KDS">
<div className="grid grid-cols-1 gap-3">
<ToggleRow
label="Παράκαμψη Pending & Prep"
desc="Αντικείμενα ξεκινούν απευθείας σε READY. Χρήσιμο για ποτά ή μπύρες."
value={bypassPending} onChange={handleBypassPending}
/>
<ToggleRow
label="Παράκαμψη KDS Status"
desc="Αντικείμενα πηγαίνουν κατευθείαν σε SERVED. Ενεργοποιεί αυτόματα και την Παράκαμψη Pending."
value={bypassKds} onChange={handleBypassKds} warning
/>
<ToggleRow
label="Αυτόματο Ready → Served"
desc="Όταν ο σεφ επισημαίνει READY στο KDS, αυτόματα αναβαθμίζεται και σε SERVED."
value={autoReadyServed} onChange={setAutoReadyServed}
/>
</div>
</Section>
{/* Actions */}
<div className="flex gap-3 justify-end pt-3 border-t border-gray-100">
<button type="button" onClick={onCancel} className="btn btn-secondary">
Ακύρωση
</button>
<button type="submit" disabled={!valid || saving} className="btn btn-primary flex items-center gap-2">
<Check size={15} />
{saving ? 'Αποθήκευση…' : 'Αποθήκευση'}
</button>
</div>
</form>
{catModal && (
<CategoryOrderModal
categories={categories} order={catOrder}
onSave={setCatOrder} onClose={() => setCatModal(false)}
/>
)}
</>
)
}
// ── Zone Card ─────────────────────────────────────────────────────────────────
function Badge({ children, variant = 'default' }) {
const cls = {
default: 'bg-gray-100 text-gray-600',
primary: 'bg-primary-100 text-primary-700',
green: 'bg-green-100 text-green-700',
amber: 'bg-amber-100 text-amber-700',
red: 'bg-red-100 text-red-700',
}[variant] || 'bg-gray-100 text-gray-600'
return (
<span className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-semibold ${cls}`}>
{children}
</span>
)
}
function ZoneCard({ zone, printers, onEdit, onDelete }) {
const [confirmDelete, setConfirmDelete] = useState(false)
const printerMap = Object.fromEntries(printers.map(p => [p.id, p]))
const masterPrinter = printerMap[zone.master_printer_id]
const secondaryPrinters = (zone.secondary_printer_ids || []).map(id => printerMap[id]).filter(Boolean)
const printLabel = {
none: { text: 'Χωρίς εκτύπωση', variant: 'default' },
master: { text: 'Κύριος μόνο', variant: 'primary' },
all: { text: 'Όλοι', variant: 'primary' },
}[zone.auto_print] || { text: '—', variant: 'default' }
const sortLabel = {
order_time: 'Σειρά παρ.',
item_count: 'Κατά πλήθος',
alpha: 'Αλφαβητικά',
}[zone.sort_items_by] || zone.sort_items_by
return (
<div className="bg-white border border-gray-200 rounded-2xl shadow-sm overflow-hidden hover:shadow-md hover:border-gray-300 transition-all group">
<div className="px-4 pt-4 pb-3 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-bold text-gray-900 text-sm leading-tight">{zone.name}</h3>
{zone.bypass_kds && <Badge variant="red"><Zap size={10} />Bypass KDS</Badge>}
{!zone.bypass_kds && zone.bypass_pending && <Badge variant="amber"><SkipForward size={10} />Bypass Pending</Badge>}
{zone.auto_ready_to_served && <Badge variant="green"><Check size={10} />Auto Served</Badge>}
</div>
{zone.notification_name && (
<p className="text-xs text-primary-600 font-medium mt-0.5">{zone.notification_name}</p>
)}
{zone.description && <p className="text-xs text-gray-400 mt-0.5">{zone.description}</p>}
</div>
<div className="flex gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => onEdit(zone)}
className="p-1.5 rounded-lg text-gray-400 hover:text-primary-600 hover:bg-primary-50 transition-colors">
<Pencil size={13} />
</button>
{!confirmDelete ? (
<button onClick={() => setConfirmDelete(true)}
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors">
<Trash2 size={13} />
</button>
) : (
<div className="flex gap-1 items-center">
<button onClick={() => setConfirmDelete(false)}
className="p-1.5 rounded-lg text-gray-400 hover:bg-gray-100 transition-colors">
<X size={13} />
</button>
<button onClick={() => onDelete(zone.id)}
className="px-2 py-1 rounded-lg text-xs font-bold bg-red-600 text-white hover:bg-red-700 transition-colors">
Διαγραφή
</button>
</div>
)}
</div>
</div>
<div className="px-4 pb-3 space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wide w-14 shrink-0">Κύριος</span>
{masterPrinter ? (
<>
<span className="inline-flex items-center gap-1 bg-primary-50 text-primary-700 rounded-md px-2 py-0.5 text-xs font-medium">
<Printer size={10} />{masterPrinter.name}
</span>
{zone.auto_print !== 'none' && (
<span className="text-xs text-gray-400 font-mono">{zone.master_copies}×</span>
)}
</>
) : (
<span className="text-xs text-gray-400 italic">Χωρίς</span>
)}
</div>
{secondaryPrinters.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wide w-14 shrink-0">Δευτ.</span>
{secondaryPrinters.map(p => (
<span key={p.id} className="inline-flex items-center gap-1 bg-gray-100 text-gray-600 rounded-md px-2 py-0.5 text-xs font-medium">
<Printer size={10} />{p.name}
</span>
))}
{zone.auto_print === 'all' && (
<span className="text-xs text-gray-400 font-mono">{zone.secondary_copies}×</span>
)}
</div>
)}
</div>
<div className="px-4 py-2.5 border-t border-gray-100 bg-gray-50/60 flex flex-wrap gap-1.5 items-center">
<Badge variant={zone.auto_print !== 'none' ? 'primary' : 'default'}>{printLabel.text}</Badge>
<Badge>{sortLabel}</Badge>
{zone.group_by_category && <Badge><Layers size={10} />Κατά κατηγορία</Badge>}
{zone.print_checkboxes && <Badge><CheckSquare size={10} />Checkboxes</Badge>}
</div>
</div>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function PrepZonesConfigPage() {
const qc = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState(null)
const { data: zones = [], isLoading: lZ } = useQuery({ queryKey: ['prep-zones'], queryFn: fetchZones })
const { data: printers = [], isLoading: lP } = useQuery({ queryKey: ['printers'], queryFn: fetchPrinters })
const { data: categories = [], isLoading: lC } = useQuery({ queryKey: ['categories'], queryFn: fetchCategories })
const invalidate = () => qc.invalidateQueries({ queryKey: ['prep-zones'] })
const create = useMutation({
mutationFn: body => client.post('/api/prep-zones', body).then(r => r.data),
onSuccess: () => { invalidate(); setShowForm(false); toast.success('Ζώνη δημιουργήθηκε') },
onError: () => toast.error('Σφάλμα κατά τη δημιουργία'),
})
const update = useMutation({
mutationFn: ({ id, ...body }) => client.put(`/api/prep-zones/${id}`, body).then(r => r.data),
onSuccess: () => { invalidate(); setEditing(null); toast.success('Ζώνη ενημερώθηκε') },
onError: () => toast.error('Σφάλμα κατά την ενημέρωση'),
})
const remove = useMutation({
mutationFn: id => client.delete(`/api/prep-zones/${id}`),
onSuccess: () => { invalidate(); toast.success('Ζώνη διαγράφηκε') },
onError: () => toast.error('Σφάλμα κατά τη διαγραφή'),
})
if (lZ || lP || lC) {
return (
<div className="p-8 flex items-center gap-3 text-sm text-gray-400">
<div className="w-4 h-4 rounded-full border-2 border-gray-300 border-t-primary-500 animate-spin" />
Φόρτωση
</div>
)
}
const flatCategories = categories.flatMap(c => [c, ...(c.subcategories || [])])
const formOpen = showForm || !!editing
return (
// h-full + overflow-y-auto makes this page scroll within the fixed AppLayout <main>
<div className="h-full overflow-y-auto">
<div className="p-6">
{/* Page header */}
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-orange-100 flex items-center justify-center shrink-0">
<FlameKindling size={16} className="text-orange-600" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900 leading-tight">Ζώνες Προετοιμασίας</h1>
<p className="text-sm text-gray-500">Ζώνες, εκτυπωτές και συμπεριφορά KDS</p>
</div>
</div>
{!formOpen && (
<button onClick={() => setShowForm(true)} className="btn btn-primary flex items-center gap-2 shrink-0">
<Plus size={15} />
Νέα Ζώνη
</button>
)}
</div>
{/* Two-column layout when form is open */}
{formOpen ? (
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-6 items-start xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
{/* Form panel */}
<div className={`bg-white rounded-2xl p-6 shadow-sm border ${showForm ? 'border-primary-200' : 'border-amber-200'}`}>
<div className="flex items-center gap-2 mb-5">
<div className={`w-1 h-5 rounded-full ${showForm ? 'bg-primary-500' : 'bg-amber-400'}`} />
<h2 className="text-sm font-bold text-gray-800">
{showForm ? 'Νέα Ζώνη Προετοιμασίας' : `Επεξεργασία: ${editing?.name}`}
</h2>
</div>
<ZoneForm
initial={editing}
printers={printers}
categories={flatCategories}
onSave={showForm
? (body) => create.mutate(body)
: (body) => update.mutate({ id: editing.id, ...body })}
onCancel={() => { setShowForm(false); setEditing(null) }}
saving={create.isPending || update.isPending}
/>
</div>
{/* Zone list sidebar */}
<div className="space-y-3">
{zones.length === 0 ? (
<div className="text-center py-10 text-gray-400 bg-white rounded-2xl border border-gray-200">
<FlameKindling size={24} className="mx-auto mb-2 text-gray-300" />
<p className="text-sm">Δεν υπάρχουν ζώνες ακόμα</p>
</div>
) : zones.map(zone => (
<ZoneCard
key={zone.id}
zone={zone}
printers={printers}
onEdit={z => { setShowForm(false); setEditing(z) }}
onDelete={id => remove.mutate(id)}
/>
))}
</div>
</div>
) : (
/* Full-width zone grid when no form open */
zones.length === 0 ? (
<div className="text-center py-20 text-gray-400">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gray-100 flex items-center justify-center">
<FlameKindling size={28} className="text-gray-300" />
</div>
<div className="font-semibold text-gray-600 mb-1">Δεν υπάρχουν ζώνες ακόμα</div>
<p className="text-sm">Δημιουργήστε την πρώτη ζώνη για να αρχίσετε.</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{zones.map(zone => (
<ZoneCard
key={zone.id}
zone={zone}
printers={printers}
onEdit={z => { setShowForm(false); setEditing(z) }}
onDelete={id => remove.mutate(id)}
/>
))}
</div>
)
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { useState } from 'react'
import { Percent, Tag, Gift, Users } from 'lucide-react'
import { TabGroup, TabCard } from '../../ui/Tabs'
import PriceGroupsTab from './pricing/PriceGroupsTab'
import ModifiersTab from './pricing/ModifiersTab'
import DealsTab from './pricing/DealsTab'
import DiscountSettingsTab from './pricing/DiscountSettingsTab'
const TABS = [
{ id: 'groups', label: 'Ομάδες Τιμών', icon: Tag },
{ id: 'modifiers', label: 'Τροποποιητές Τιμής', icon: Percent },
{ id: 'deals', label: 'Προσφορές', icon: Gift },
{ id: 'discounts', label: 'Ρυθμίσεις Εκπτώσεων', icon: Users },
]
export default function PricingPage() {
const [tab, setTab] = useState('groups')
return (
<div className="flex flex-col h-full min-h-0 p-6 gap-4">
{/* Page header */}
<div className="flex items-center gap-3 shrink-0">
<div className="w-9 h-9 rounded-xl bg-sky-100 flex items-center justify-center shrink-0">
<Percent className="w-5 h-5 text-sky-600" />
</div>
<div>
<h1 className="text-[15px] font-semibold text-slate-900">Προσφορές &amp; Τιμές</h1>
<p className="text-[12px] text-slate-500">Δυναμικές τιμές, τροποποιητές, προσφορές και εκπτώσεις</p>
</div>
</div>
<TabCard className="flex-1">
<TabGroup tabs={TABS} active={tab} onChange={setTab} />
<div className="flex-1 overflow-y-auto">
{tab === 'groups' && <PriceGroupsTab />}
{tab === 'modifiers' && <ModifiersTab />}
{tab === 'deals' && <DealsTab />}
{tab === 'discounts' && <DiscountSettingsTab />}
</div>
</TabCard>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useState, useEffect, useRef } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../../api/client'
@@ -76,7 +76,7 @@ function PriceInput({ value, onChange, placeholder, className = '', allowNegativ
)
}
function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove, onAdd, parentLabel }) {
function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove, onAdd, parentLabel, showMultiple = false }) {
if (!subChoices || subChoices.length === 0) return null
return (
<div className="border-t border-gray-100 bg-indigo-50/40 px-3 py-2 space-y-2">
@@ -84,14 +84,28 @@ function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove
Υπο-επιλογές του «{parentLabel || '…'}»
</p>
{subChoices.map((sc, sci) => (
<div key={sci} className="flex items-center gap-2 ml-4">
<div key={sci} className="flex items-center gap-2 ml-4 flex-wrap">
<ReorderBtns onUp={() => onMove(sci, -1)} onDown={() => onMove(sci, 1)}
disableUp={sci === 0} disableDown={sci === subChoices.length - 1} />
<DefaultBtn isDefault={sc.is_default} onClick={() => onToggleDefault(sci)} />
<input className="input flex-1 text-sm" placeholder="π.χ. Καραμέλα"
<input className="input flex-1 min-w-28 text-sm" placeholder="π.χ. Καραμέλα"
value={sc.name} onChange={e => onChange(sci, 'name', e.target.value)} />
<PriceInput value={sc.extra_cost} onChange={v => onChange(sci, 'extra_cost', v)}
allowNegative className="w-28 text-sm" />
{showMultiple && (
<label className="flex items-center gap-1 text-xs text-gray-600 cursor-pointer shrink-0 select-none">
<input type="checkbox" checked={sc.allow_multiple ?? false}
onChange={e => onChange(sci, 'allow_multiple', e.target.checked)}
className="accent-primary-700 w-3.5 h-3.5" />
Πολλαπλά
</label>
)}
<label className="flex items-center gap-1 text-xs cursor-pointer shrink-0 select-none" style={{ color: sc.is_compact ? '#7c3aed' : '#6b7280' }}>
<input type="checkbox" checked={sc.is_compact ?? false}
onChange={e => onChange(sci, 'is_compact', e.target.checked)}
className="w-3.5 h-3.5" style={{ accentColor: '#7c3aed' }} />
Compact
</label>
<button onClick={() => onRemove(sci)} className="btn btn-danger px-2 min-h-0 h-9 text-sm shrink-0"></button>
</div>
))}
@@ -102,6 +116,40 @@ function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove
)
}
// ── Group helpers ─────────────────────────────────────────────────────────────
function GroupHeader({ group, onRename, onDelete, collapsed, onToggle }) {
const [editing, setEditing] = useState(false)
const [val, setVal] = useState(group.name)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 8 }}>
<button type="button" onClick={onToggle}
className="flex items-center gap-2 flex-1 px-3 py-2 rounded-lg bg-indigo-50 border border-indigo-200 text-indigo-700 font-semibold text-sm text-left hover:bg-indigo-100 transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 180ms', flexShrink: 0 }}>
<path d="M6 9L12 15L18 9" strokeLinecap="round"/>
</svg>
{editing ? (
<input
className="input flex-1 text-sm py-0 h-6 min-w-0"
value={val}
onChange={e => setVal(e.target.value)}
onBlur={() => { onRename(val); setEditing(false) }}
onKeyDown={e => { if (e.key === 'Enter') e.target.blur() }}
onClick={e => e.stopPropagation()}
autoFocus
/>
) : (
<span className="flex-1 truncate">{group.name || '(αχρησιμοποίητη ομάδα)'}</span>
)}
</button>
<button type="button" onClick={e => { e.stopPropagation(); setVal(group.name); setEditing(true) }}
className="w-7 h-7 rounded text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 flex items-center justify-center text-xs"></button>
<button type="button" onClick={onDelete}
className="w-7 h-7 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 flex items-center justify-center text-xs"></button>
</div>
)
}
// ── Form builder ──────────────────────────────────────────────────────────────
function buildFormFromProduct(product) {
return {
@@ -109,9 +157,10 @@ function buildFormFromProduct(product) {
description: product.description || '',
category_id: product.category_id ?? '',
base_price: product.base_price ?? '',
unit_type: product.unit_type ?? 'piece',
is_available: product.is_available ?? true,
lifecycle_status: product.lifecycle_status ?? 'active',
printer_zone_id: product.printer_zone_id ?? '',
prep_zone_ids: product.prep_zone_ids ?? [],
quick_options: product.quick_options?.map(q => ({
name: q.name, price: q.price ?? 0, allow_multiple: q.allow_multiple ?? false,
sort_order: q.sort_order ?? 0, is_favorite: q.is_favorite ?? false,
@@ -119,25 +168,32 @@ function buildFormFromProduct(product) {
})) ?? [],
options: product.options?.map(o => ({
name: o.name, extra_cost: o.extra_cost ?? 0, allow_multiple: o.allow_multiple ?? false,
sub_choices: o.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [],
multi_select: o.multi_select ?? false, is_compact: o.is_compact ?? false,
sub_choices: o.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false, allow_multiple: s.allow_multiple ?? false, is_compact: s.is_compact ?? false })) ?? [],
is_favorite: o.is_favorite ?? false, favorite_sort_order: o.favorite_sort_order ?? 0,
group_id: o.group_id ?? null,
})) ?? [],
ingredients: product.ingredients?.map(i => ({
name: i.name, extra_cost: i.extra_cost ?? 0,
is_favorite: i.is_favorite ?? false, favorite_sort_order: i.favorite_sort_order ?? 0,
is_compact: i.is_compact ?? false, group_id: i.group_id ?? null,
})) ?? [],
preference_sets: product.preference_sets?.map(ps => ({
name: ps.name,
default_choice_index: ps.choices ? ps.choices.findIndex(c => c.id === ps.default_choice_id) : -1,
choices: ps.choices?.map(c => ({
name: c.name, extra_cost: c.extra_cost ?? 0, disables_subset: c.disables_subset ?? false,
sub_choices: c.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [],
is_compact: c.is_compact ?? false,
sub_choices: c.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false, is_compact: s.is_compact ?? false })) ?? [],
})) ?? [],
group_id: ps.group_id ?? null,
shared_subset: ps.shared_subset ? {
name: ps.shared_subset.name,
choices: ps.shared_subset.choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [],
} : null,
is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0,
allow_multi_select: ps.allow_multi_select ?? false,
allow_choice_quantity: ps.allow_choice_quantity ?? false,
})) ?? [],
digital_visible: product.digital_visible ?? true,
digital_available: product.digital_available ?? true,
@@ -152,6 +208,18 @@ function buildFormFromProduct(product) {
cost_breakdown: product.cost_breakdown?.length
? product.cost_breakdown.map(e => ({ label: e.label, amount: e.amount }))
: [],
// Quick Add
quick_add_enabled: product.quick_add_enabled ?? true,
// Tags
tags: product.tags ?? [],
tagsInput: '',
// Service items
is_service_item: product.is_service_item ?? false,
// Fiscal printer (ΦΗΜ)
fiscal_name: product.fiscal_name ?? '',
fiscal_vat_group_id: product.fiscal_vat_group_id ?? null,
// Modifier groups (folders) — keyed by _tempId in form, id from server
modifier_groups: (product.modifier_groups || []).map(g => ({ _tempId: g.id, id: g.id, modifier_type: g.modifier_type, name: g.name, sort_order: g.sort_order })),
}
}
@@ -189,7 +257,7 @@ function getItemTypeLabel(type) {
}
// ── Main modal ────────────────────────────────────────────────────────────────
export default function ProductFormModal({ product, categories, printers, onSave, onCopy, onClose }) {
export default function ProductFormModal({ product, categories, printers, prepZones = [], onSave, onCopy, onClose }) {
const [form, setForm] = useState(() => buildFormFromProduct(product))
const [activeTab, setActiveTab] = useState('favorites')
const [leftTab, setLeftTab] = useState('info')
@@ -197,6 +265,17 @@ export default function ProductFormModal({ product, categories, printers, onSave
const [uploading, setUploading] = useState(false)
const qc = useQueryClient()
// Fetch fiscal VAT groups from settings (needed for the Ταμειακή tab dropdown)
const { data: settingsData } = useQuery({
queryKey: ['pos-settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 60_000,
})
const fiscalEnabled = settingsData?.['fiscal.enabled']?.value === 'true'
const vatGroups = (() => {
try { return JSON.parse(settingsData?.['fiscal.vat_groups']?.value || '[]') } catch { return [] }
})()
useEffect(() => {
function onKey(e) { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
@@ -251,14 +330,14 @@ export default function ProductFormModal({ product, categories, printers, onSave
function moveQuickOption(i, dir) { setForm(f => ({ ...f, quick_options: moveItem(f.quick_options, i, dir) })) }
// Options
function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0, allow_multiple: false, sub_choices: [], is_favorite: false, favorite_sort_order: 0 }] })) }
function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0, allow_multiple: false, multi_select: false, is_compact: false, sub_choices: [], is_favorite: false, favorite_sort_order: 0 }] })) }
function removeOption(i) { setForm(f => ({ ...f, options: f.options.filter((_, idx) => idx !== i) })) }
function setOption(i, k, v) { setForm(f => ({ ...f, options: f.options.map((o, idx) => idx === i ? { ...o, [k]: v } : o) })) }
function moveOption(i, dir) { setForm(f => ({ ...f, options: moveItem(f.options, i, dir) })) }
function addOptionSubChoice(oi) {
setForm(f => ({ ...f, options: f.options.map((o, idx) =>
idx !== oi ? o : { ...o, sub_choices: [...(o.sub_choices || []), { name: '', extra_cost: 0, is_default: false }] }
idx !== oi ? o : { ...o, sub_choices: [...(o.sub_choices || []), { name: '', extra_cost: 0, is_default: false, allow_multiple: false, is_compact: false }] }
)}))
}
function removeOptionSubChoice(oi, sci) {
@@ -287,14 +366,14 @@ export default function ProductFormModal({ product, categories, printers, onSave
}
// Ingredients
function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '', extra_cost: 0, is_favorite: false, favorite_sort_order: 0 }] })) }
function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '', extra_cost: 0, is_favorite: false, favorite_sort_order: 0, is_compact: false }] })) }
function removeIngredient(i) { setForm(f => ({ ...f, ingredients: f.ingredients.filter((_, idx) => idx !== i) })) }
function setIngredient(i, k, v) { setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { ...ing, [k]: v } : ing) })) }
function moveIngredient(i, dir) { setForm(f => ({ ...f, ingredients: moveItem(f.ingredients, i, dir) })) }
// Preference sets
function addPrefSet() {
setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', default_choice_index: -1, choices: [], shared_subset: null, is_favorite: false, favorite_sort_order: 0 }] }))
setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', default_choice_index: -1, choices: [], shared_subset: null, is_favorite: false, favorite_sort_order: 0, allow_multi_select: false, allow_choice_quantity: false }] }))
setActiveTab(form.preference_sets.length)
}
function removePrefSet(si) {
@@ -306,7 +385,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
}
function addChoice(si) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) =>
idx === si ? { ...ps, choices: [...ps.choices, { name: '', extra_cost: 0, disables_subset: false, sub_choices: [] }] } : ps
idx === si ? { ...ps, choices: [...ps.choices, { name: '', extra_cost: 0, disables_subset: false, is_compact: false, sub_choices: [] }] } : ps
)}))
}
function removeChoice(si, ci) {
@@ -341,7 +420,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
function addSubChoice(si, ci) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) =>
pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) =>
cidx !== ci ? ch : { ...ch, sub_choices: [...(ch.sub_choices || []), { name: '', extra_cost: 0, is_default: false }] }
cidx !== ci ? ch : { ...ch, sub_choices: [...(ch.sub_choices || []), { name: '', extra_cost: 0, is_default: false, is_compact: false }] }
)}
)}))
}
@@ -405,27 +484,60 @@ export default function ProductFormModal({ product, categories, printers, onSave
)}))
}
// Modifier groups
const _nextTempId = useRef(-1)
function addModifierGroup(modifier_type) {
const _tempId = _nextTempId.current--
setForm(f => ({ ...f, modifier_groups: [...f.modifier_groups, { _tempId, id: null, modifier_type, name: 'Νέα ομάδα', sort_order: f.modifier_groups.length }] }))
}
function renameModifierGroup(_tempId, name) {
setForm(f => ({ ...f, modifier_groups: f.modifier_groups.map(g => g._tempId === _tempId ? { ...g, name } : g) }))
}
function deleteModifierGroup(_tempId) {
setForm(f => {
const next = { ...f, modifier_groups: f.modifier_groups.filter(g => g._tempId !== _tempId) }
// Un-assign all items that belonged to this group
next.options = f.options.map(o => o.group_id === _tempId ? { ...o, group_id: null } : o)
next.ingredients = f.ingredients.map(i => i.group_id === _tempId ? { ...i, group_id: null } : i)
next.preference_sets = f.preference_sets.map(ps => ps.group_id === _tempId ? { ...ps, group_id: null } : ps)
return next
})
}
function setItemGroup(listKey, idx, _tempId) {
setForm(f => ({ ...f, [listKey]: f[listKey].map((item, i) => i === idx ? { ...item, group_id: _tempId } : item) }))
}
const [collapsedGroups, setCollapsedGroups] = useState({})
function toggleGroupCollapsed(_tempId) {
setCollapsedGroups(s => ({ ...s, [_tempId]: !s[_tempId] }))
}
function buildBody() {
return {
name: form.name,
description: form.description || null,
category_id: form.category_id ? Number(form.category_id) : null,
base_price: parseFloat(form.base_price),
base_price: form.base_price !== '' && form.base_price !== null && form.base_price !== undefined ? parseFloat(form.base_price) : 0,
unit_type: form.unit_type || 'piece',
is_available: form.is_available,
lifecycle_status: form.lifecycle_status,
printer_zone_id: form.printer_zone_id ? Number(form.printer_zone_id) : null,
prep_zone_ids: form.prep_zone_ids,
quick_options: form.quick_options.map((q, i) => ({
name: q.name, price: parseFloat(q.price) || 0, allow_multiple: q.allow_multiple ?? false,
sort_order: i, is_favorite: q.is_favorite ?? false, favorite_sort_order: q.favorite_sort_order ?? 0, is_compact: q.is_compact ?? false,
})),
modifier_groups: form.modifier_groups.map((g, gi) => ({ modifier_type: g.modifier_type, name: g.name, sort_order: gi })),
options: form.options.map(o => ({
name: o.name, extra_cost: parseFloat(o.extra_cost) || 0, allow_multiple: o.allow_multiple ?? false,
sub_choices: (o.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })),
multi_select: o.multi_select ?? false, is_compact: o.is_compact ?? false,
sub_choices: (o.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false, allow_multiple: s.allow_multiple ?? false, is_compact: s.is_compact ?? false })),
is_favorite: o.is_favorite ?? false, favorite_sort_order: o.favorite_sort_order ?? 0,
group_id: o.group_id != null ? form.modifier_groups.findIndex(g => g._tempId === o.group_id) : null,
})),
ingredients: form.ingredients.map(i => ({
name: i.name, extra_cost: parseFloat(i.extra_cost) || 0,
is_favorite: i.is_favorite ?? false, favorite_sort_order: i.favorite_sort_order ?? 0,
is_compact: i.is_compact ?? false,
group_id: i.group_id != null ? form.modifier_groups.findIndex(g => g._tempId === i.group_id) : null,
})),
preference_sets: form.preference_sets.map(ps => ({
name: ps.name,
@@ -436,9 +548,13 @@ export default function ProductFormModal({ product, categories, printers, onSave
} : null,
choices: ps.choices.map(c => ({
name: c.name, extra_cost: parseFloat(c.extra_cost) || 0, disables_subset: c.disables_subset ?? false,
sub_choices: (c.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })),
is_compact: c.is_compact ?? false,
sub_choices: (c.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false, is_compact: s.is_compact ?? false })),
})),
is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0,
group_id: ps.group_id != null ? form.modifier_groups.findIndex(g => g._tempId === ps.group_id) : null,
allow_multi_select: ps.allow_multi_select ?? false,
allow_choice_quantity: ps.allow_choice_quantity ?? false,
})),
digital_visible: form.digital_visible,
digital_available: form.digital_available,
@@ -452,6 +568,15 @@ export default function ProductFormModal({ product, categories, printers, onSave
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,
// Quick Add
quick_add_enabled: form.quick_add_enabled,
// Tags
tags: form.tags || [],
// Service items
is_service_item: form.is_service_item ?? false,
// Fiscal printer (ΦΗΜ)
fiscal_name: form.fiscal_name?.trim() || null,
fiscal_vat_group_id: form.fiscal_vat_group_id ? parseInt(form.fiscal_vat_group_id, 10) : null,
}
}
@@ -481,8 +606,9 @@ export default function ProductFormModal({ product, categories, printers, onSave
}
const isNew = !product.id
const canSave = form.name.trim() && form.base_price
const canSave = form.name.trim() && (form.is_service_item || (form.base_price !== '' && form.base_price !== null && form.base_price !== undefined))
const favCount = buildFavoritesList(form).length
const prefGroups = form.modifier_groups.filter(g => g.modifier_type === 'preference')
const tabs = [
{ key: 'favorites', label: 'Αγαπημένα', count: favCount, isFavTab: true },
{ key: 'quick', label: 'Γρήγορες', count: form.quick_options.length },
@@ -490,6 +616,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
{ key: 'options', label: 'Έξτρα', count: form.options.length },
...form.preference_sets.map((ps, i) => ({ key: i, label: ps.name || `Προτ. ${i + 1}`, count: ps.choices.length })),
{ key: '__add_pref__', label: ' Προτίμηση', isAdd: true },
{ key: '__add_pref_group__', label: ' Ομάδα', isAddGroup: true },
]
const favList = buildFavoritesList(form)
@@ -508,14 +635,17 @@ export default function ProductFormModal({ product, categories, printers, onSave
{/* Body */}
<div className="flex-1 flex overflow-hidden">
{/* 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: tabbed product info — 25% normally, 100% for service items */}
<div className="shrink-0 border-r border-gray-100 bg-gray-50/50 flex flex-col overflow-hidden" style={{ width: form.is_service_item ? '100%' : '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: 'Κόστος' },
...(!form.is_service_item ? [
{ key: 'digital', label: 'Digital Menu' },
{ key: 'cost', label: 'Κόστος' },
...(fiscalEnabled ? [{ key: 'fiscal', 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 ${
@@ -533,12 +663,36 @@ export default function ProductFormModal({ product, categories, printers, onSave
{/* 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>
{/* Service Item toggle */}
<button
type="button"
onClick={() => setField('is_service_item', !form.is_service_item)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors w-full ${
form.is_service_item
? 'bg-amber-50 border-amber-400 text-amber-800'
: 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
}`}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 11l19-9-9 19-2-8-8-2z"/>
</svg>
{form.is_service_item ? 'Service Item' : 'Κανονικό προϊόν'}
</button>
{form.is_service_item && (
<p className="text-xs text-amber-700 -mt-1 px-1">
{form.base_price && parseFloat(form.base_price) > 0
? 'Τυπώνεται σε ξεχωριστή ενότητα SERVICE. Καταγράφεται στα έσοδα.'
: 'Τυπώνεται μία φορά — δεν αποθηκεύεται στην παραγγελία, δεν καταγράφεται στα έσοδα.'}
</p>
)}
<div>
<label className="label">Όνομα *</label>
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder={form.is_service_item ? 'π.χ. Σερβίτσιο' : 'π.χ. Espresso'} />
</div>
{!form.is_service_item && <div>
<label className="label">Περιγραφή <span className="text-gray-400 font-normal normal-case">(προαιρετική)</span></label>
<textarea
className="input resize-none"
@@ -549,15 +703,68 @@ export default function ProductFormModal({ product, categories, printers, onSave
style={{ lineHeight: 1.5, fontSize: 13 }}
/>
<p className="text-xs text-gray-400 mt-1">Χρήσιμο για ψηφιακό μενού ή ενημέρωση σερβιτόρων.</p>
</div>
</div>}
{!form.is_service_item && <div>
<label className="label">Tags <span className="text-gray-400 font-normal normal-case">(προαιρετικά)</span></label>
<div className="flex flex-wrap gap-1.5 mb-1.5">
{(form.tags || []).map((tag, ti) => (
<span key={ti} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-xs font-medium text-indigo-700">
{tag}
<button type="button" onClick={() => setField('tags', form.tags.filter((_, i) => i !== ti))}
className="hover:text-indigo-900 leading-none">×</button>
</span>
))}
</div>
<input
className="input text-sm"
value={form.tagsInput || ''}
onChange={e => setField('tagsInput', e.target.value)}
onKeyDown={e => {
if ((e.key === 'Enter' || e.key === ',') && form.tagsInput?.trim()) {
e.preventDefault()
const tag = form.tagsInput.trim().toLowerCase().replace(/,/g, '')
if (tag && !(form.tags || []).includes(tag)) setField('tags', [...(form.tags || []), tag])
setField('tagsInput', '')
}
}}
onBlur={() => {
if (form.tagsInput?.trim()) {
const tag = form.tagsInput.trim().toLowerCase()
if (!(form.tags || []).includes(tag)) setField('tags', [...(form.tags || []), tag])
setField('tagsInput', '')
}
}}
placeholder="π.χ. vegan, gluten-free — Enter ή κόμμα"
/>
<p className="text-xs text-gray-400 mt-1">Πάτα Enter ή κόμμα για να προσθέσεις tag.</p>
</div>}
{!form.is_service_item && <div>
<label className="label">Μονάδα μέτρησης</label>
<select className="input" value={form.unit_type} onChange={e => setField('unit_type', e.target.value)}>
<option value="piece">Τεμάχιο (pc)</option>
<option value="portion">Μερίδα (portion)</option>
<option value="kg">Κιλό (kg)</option>
<option value="liter">Λίτρο (L)</option>
<option value="gram">Γραμμάριο (g)</option>
<option value="ml">Χιλιοστόλιτρο (mL)</option>
</select>
{(form.unit_type === 'kg' || form.unit_type === 'liter' || form.unit_type === 'gram' || form.unit_type === 'ml') && (
<p className="text-xs text-indigo-600 mt-1">Η ποσότητα θα εισάγεται ως δεκαδικός αριθμός στην εφαρμογή σερβιτόρων.</p>
)}
</div>}
<div>
<label className="label">Βασική Τιμή () *</label>
<label className="label">{form.is_service_item ? 'Τιμή (€) — προαιρετική' : 'Βασική Τιμή (€) *'}</label>
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
</div>
<div>
<label className="label">Κατηγορία</label>
<label className="label">
Κατηγορία
{form.is_service_item && <span className="text-gray-400 font-normal normal-case ml-1">(προαιρετική εμφανίζεται και εκεί)</span>}
</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 => {
@@ -571,23 +778,56 @@ export default function ProductFormModal({ product, categories, printers, onSave
</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>
<label className="label">Ζώνη Προετοιμασίας</label>
<div className="flex flex-col gap-1.5 mt-1">
{prepZones.length === 0 ? (
<p className="text-xs text-gray-400 italic">Δεν υπάρχουν ζώνες. <a href="/management/prep-zones" className="underline text-primary-600">Δημιουργήστε μια ζώνη</a>.</p>
) : (
prepZones.map(z => {
const selected = form.prep_zone_ids.includes(z.id)
return (
<label key={z.id} className={`flex items-center gap-2.5 px-3 py-2 rounded-lg border cursor-pointer transition-colors select-none text-sm ${selected ? 'border-primary-300 bg-primary-50 text-primary-700' : 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'}`}>
<input
type="checkbox"
checked={selected}
onChange={() => {
const ids = form.prep_zone_ids.includes(z.id)
? form.prep_zone_ids.filter(id => id !== z.id)
: [...form.prep_zone_ids, z.id]
setField('prep_zone_ids', ids)
}}
className="w-3.5 h-3.5 accent-primary-600 shrink-0"
/>
<span className="font-medium">{z.name}</span>
{z.description && <span className="text-xs text-gray-400 ml-1"> {z.description}</span>}
</label>
)
})
)}
</div>
</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>
{!form.is_service_item && <div className="flex items-center gap-3 flex-wrap">
<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>
<button type="button" onClick={() => setField('quick_add_enabled', !form.quick_add_enabled)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
form.quick_add_enabled ? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-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.quick_add_enabled ? 'bg-blue-500' : 'bg-gray-400'}`} />
{form.quick_add_enabled ? 'Quick Add: Ενεργό' : 'Quick Add: Ανενεργό'}
</button>
</div>}
{!form.is_service_item && <div>
<label className="label">Εικόνα προϊόντος</label>
{product.image_url && (
<img src={product.image_url}
@@ -599,7 +839,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
{imageFile ? 'Αλλαγή εικόνας' : 'Επιλογή εικόνας'}
<input type="file" accept="image/*" className="sr-only" onChange={e => setImageFile(e.target.files[0] ?? null)} />
</label>
</div>
</div>}
</>)}
{/* Tab 2: Digital Menu */}
@@ -761,13 +1001,69 @@ export default function ProductFormModal({ product, categories, printers, onSave
</div>
)}
{/* Tab 4: Fiscal (ΦΗΜ) — only shown when fiscal.enabled = true */}
{leftTab === 'fiscal' && (
<div className="space-y-4">
<div className="rounded-lg bg-blue-50 border border-blue-200 px-3 py-2 text-xs text-blue-800">
Τα πεδία αυτά χρησιμοποιούνται κατά την αυτόματη αποστολή αποδείξεων στη ΦΗΜ.
</div>
<div>
<label className="label">Όνομα ΦΗΜ <span className="text-gray-400 font-normal normal-case">(προαιρετικό)</span></label>
<input
className="input font-mono uppercase"
placeholder={form.name?.toUpperCase() || 'ΧΡΗΣΙΜΟΠΟΙΕΊ ΤΟ ΚΑΝΟΝΙΚΟ ΟΝΟΜΑ'}
value={form.fiscal_name}
maxLength={30}
onChange={e => setField('fiscal_name', e.target.value.toUpperCase())}
/>
<p className="text-xs text-gray-400 mt-1">
Σύντομο όνομα για την απόδειξη. Αν αφεθεί κενό, χρησιμοποιείται το κανονικό όνομα.
</p>
</div>
<div>
<label className="label">Ομάδα ΦΠΑ ΦΗΜ *</label>
{vatGroups.length === 0 ? (
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
Δεν έχουν οριστεί ομάδες ΦΠΑ. Πηγαίνετε στις <strong>Ρυθμίσεις Ταμειακή</strong> για να τις ρυθμίσετε.
</p>
) : (
<select
className="input"
value={form.fiscal_vat_group_id ?? ''}
onChange={e => setField('fiscal_vat_group_id', e.target.value ? parseInt(e.target.value, 10) : null)}
>
<option value=""> Χωρίς ανάθεση </option>
{vatGroups.map(g => (
<option key={g.machine_id} value={g.machine_id}>
{g.machine_id} {g.friendly_name}
</option>
))}
</select>
)}
{!form.fiscal_vat_group_id && (
<p className="text-xs text-red-500 mt-1">
Απαιτείται για πληρωμή με ΦΗΜ ενεργή.
</p>
)}
</div>
</div>
)}
</div>
</div>
{/* RIGHT: ingredient/option tabs — 70% */}
<div className="flex flex-col overflow-hidden" style={{ width: '75%' }}>
{/* RIGHT: ingredient/option tabs — 70% (hidden for service items) */}
<div className="flex flex-col overflow-hidden" style={{ width: '75%', display: form.is_service_item ? 'none' : undefined }}>
<div className="flex border-b border-gray-200 overflow-x-auto shrink-0 bg-white">
{tabs.map(tab => {
if (tab.isAddGroup) return (
<button key="__add_pref_group__" onClick={() => addModifierGroup('preference')}
className="px-4 py-3 text-sm font-medium text-indigo-600 hover:bg-indigo-50 whitespace-nowrap border-b-2 border-transparent transition-colors">
{tab.label}
</button>
)
if (tab.isAdd) return (
<button key="__add_pref__" onClick={addPrefSet}
className="px-4 py-3 text-sm font-medium text-primary-600 hover:bg-primary-50 whitespace-nowrap border-b-2 border-transparent transition-colors">
@@ -849,63 +1145,142 @@ export default function ProductFormModal({ product, categories, printers, onSave
)}
{/* Ingredients */}
{activeTab === 'ingredients' && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-gray-500">Υλικά που ο πελάτης μπορεί να αφαιρέσει.</p>
<button onClick={addIngredient} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Υλικό</button>
{activeTab === 'ingredients' && (() => {
const ingGroups = form.modifier_groups.filter(g => g.modifier_type === 'ingredient')
const ungrouped = form.ingredients.map((ing, i) => ({ ing, i })).filter(({ ing }) => ing.group_id == null)
const renderIngRow = (ing, i) => (
<div key={i} className={`flex gap-2 items-center border rounded-xl p-3 bg-white flex-wrap ${ing.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
<ReorderBtns onUp={() => moveIngredient(i, -1)} onDown={() => moveIngredient(i, 1)}
disableUp={i === 0} disableDown={i === form.ingredients.length - 1} />
<FavoriteBtn isFavorite={ing.is_favorite} onClick={() => toggleFavorite('ingredient', i)} />
<input className="input flex-1 min-w-40" placeholder="Όνομα υλικού" value={ing.name} onChange={e => setIngredient(i, 'name', e.target.value)} />
<PriceInput value={ing.extra_cost} onChange={v => setIngredient(i, 'extra_cost', v)} allowNegative className="w-32" />
<label className="flex items-center gap-1.5 text-sm cursor-pointer shrink-0 select-none" style={{ color: ing.is_compact ? '#7c3aed' : '#6b7280' }}>
<input type="checkbox" checked={ing.is_compact ?? false} onChange={e => setIngredient(i, 'is_compact', e.target.checked)} className="w-4 h-4" style={{ accentColor: '#7c3aed' }} />
Compact
</label>
{ingGroups.length > 0 && (
<select className="input text-xs px-2 h-9 shrink-0" style={{ width: 120 }}
value={form.ingredients[i].group_id ?? ''}
onChange={e => setItemGroup('ingredients', i, e.target.value === '' ? null : Number(e.target.value))}>
<option value=""> χωρίς ομάδα </option>
{ingGroups.map(g => <option key={g._tempId} value={g._tempId}>{g.name}</option>)}
</select>
)}
<button onClick={() => removeIngredient(i)} className="btn btn-danger px-3 min-h-0 h-10"></button>
</div>
{!form.ingredients.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν υλικά.</p>}
<div className="space-y-2">
{form.ingredients.map((ing, i) => (
<div key={i} className={`flex gap-2 items-center border rounded-xl p-3 bg-white ${ing.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
<ReorderBtns onUp={() => moveIngredient(i, -1)} onDown={() => moveIngredient(i, 1)}
disableUp={i === 0} disableDown={i === form.ingredients.length - 1} />
<FavoriteBtn isFavorite={ing.is_favorite} onClick={() => toggleFavorite('ingredient', i)} />
<input className="input flex-1" placeholder="Όνομα υλικού" value={ing.name} onChange={e => setIngredient(i, 'name', e.target.value)} />
<PriceInput value={ing.extra_cost} onChange={v => setIngredient(i, 'extra_cost', v)} allowNegative className="w-32" />
<button onClick={() => removeIngredient(i)} className="btn btn-danger px-3 min-h-0 h-10"></button>
)
return (
<div>
<div className="flex items-center justify-between mb-3 gap-2 flex-wrap">
<p className="text-sm text-gray-500">Υλικά που ο πελάτης μπορεί να αφαιρέσει.</p>
<div className="flex gap-2">
<button onClick={() => addModifierGroup('ingredient')} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Ομάδα</button>
<button onClick={addIngredient} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Υλικό</button>
</div>
))}
</div>
{!form.ingredients.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν υλικά.</p>}
<div className="space-y-2">
{ungrouped.map(({ ing, i }) => renderIngRow(ing, i))}
{ingGroups.map(g => {
const members = form.ingredients.map((ing, i) => ({ ing, i })).filter(({ ing }) => ing.group_id === g._tempId)
const collapsed = collapsedGroups[g._tempId]
return (
<div key={g._tempId}>
<GroupHeader group={g} collapsed={collapsed} onToggle={() => toggleGroupCollapsed(g._tempId)}
onRename={name => renameModifierGroup(g._tempId, name)}
onDelete={() => deleteModifierGroup(g._tempId)} />
{!collapsed && (
<div className="ml-4 border-l-2 border-indigo-100 pl-3 mt-1 space-y-2">
{members.length === 0 && <p className="text-xs text-gray-400 py-2">Χωρίς υλικά αλλάξτε ομάδα από κάθε υλικό.</p>}
{members.map(({ ing, i }) => renderIngRow(ing, i))}
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)}
)
})()}
{/* Options/Extras */}
{activeTab === 'options' && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-gray-500">Έξτρα (checkbox). Κάθε extra μπορεί να έχει υπο-επιλογές.</p>
<button onClick={addOption} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Έξτρα</button>
{activeTab === 'options' && (() => {
const optGroups = form.modifier_groups.filter(g => g.modifier_type === 'option')
const ungrouped = form.options.map((opt, i) => ({ opt, i })).filter(({ opt }) => opt.group_id == null)
const renderOptRow = (opt, i) => (
<div key={i} className={`border rounded-xl overflow-hidden ${opt.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
<div className="flex gap-2 items-center p-3 bg-white flex-wrap">
<ReorderBtns onUp={() => moveOption(i, -1)} onDown={() => moveOption(i, 1)}
disableUp={i === 0} disableDown={i === form.options.length - 1} />
<FavoriteBtn isFavorite={opt.is_favorite} onClick={() => toggleFavorite('option', i)} />
<input className="input flex-1 min-w-40" placeholder="π.χ. Κανέλα" value={opt.name} onChange={e => setOption(i, 'name', e.target.value)} />
<PriceInput value={opt.extra_cost} onChange={v => setOption(i, 'extra_cost', v)} allowNegative className="w-32" />
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer shrink-0 select-none">
<input type="checkbox" checked={opt.allow_multiple} onChange={e => setOption(i, 'allow_multiple', e.target.checked)} className="accent-primary-700 w-4 h-4" />
Πολλαπλά
</label>
<label className="flex items-center gap-1.5 text-sm cursor-pointer shrink-0 select-none" style={{ color: opt.multi_select ? '#0891b2' : '#6b7280' }}>
<input type="checkbox" checked={opt.multi_select ?? false} onChange={e => setOption(i, 'multi_select', e.target.checked)} className="w-4 h-4" style={{ accentColor: '#0891b2' }} />
Multi-select
</label>
<label className="flex items-center gap-1.5 text-sm cursor-pointer shrink-0 select-none" style={{ color: opt.is_compact ? '#7c3aed' : '#6b7280' }}>
<input type="checkbox" checked={opt.is_compact ?? false} onChange={e => setOption(i, 'is_compact', e.target.checked)} className="w-4 h-4" style={{ accentColor: '#7c3aed' }} />
Compact
</label>
{optGroups.length > 0 && (
<select className="input text-xs px-2 h-9 shrink-0" style={{ width: 120 }}
value={form.options[i].group_id ?? ''}
onChange={e => setItemGroup('options', i, e.target.value === '' ? null : Number(e.target.value))}>
<option value=""> χωρίς ομάδα </option>
{optGroups.map(g => <option key={g._tempId} value={g._tempId}>{g.name}</option>)}
</select>
)}
<button onClick={() => addOptionSubChoice(i)} className="btn btn-secondary text-xs px-2 min-h-0 h-9 shrink-0 whitespace-nowrap">+ Υπο-επιλογές</button>
<button onClick={() => removeOption(i)} className="btn btn-danger px-3 min-h-0 h-10"></button>
</div>
<SubChoiceRows subChoices={opt.sub_choices} parentLabel={opt.name}
showMultiple={opt.multi_select}
onMove={(sci, dir) => moveOptionSubChoice(i, sci, dir)}
onToggleDefault={sci => toggleOptionSubDefault(i, sci)}
onChange={(sci, k, v) => setOptionSubChoice(i, sci, k, v)}
onRemove={sci => removeOptionSubChoice(i, sci)}
onAdd={() => addOptionSubChoice(i)} />
</div>
{!form.options.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν extras.</p>}
<div className="space-y-3">
{form.options.map((opt, i) => (
<div key={i} className={`border rounded-xl overflow-hidden ${opt.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
<div className="flex gap-2 items-center p-3 bg-white flex-wrap">
<ReorderBtns onUp={() => moveOption(i, -1)} onDown={() => moveOption(i, 1)}
disableUp={i === 0} disableDown={i === form.options.length - 1} />
<FavoriteBtn isFavorite={opt.is_favorite} onClick={() => toggleFavorite('option', i)} />
<input className="input flex-1 min-w-40" placeholder="π.χ. Κανέλα" value={opt.name} onChange={e => setOption(i, 'name', e.target.value)} />
<PriceInput value={opt.extra_cost} onChange={v => setOption(i, 'extra_cost', v)} allowNegative className="w-32" />
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer shrink-0 select-none">
<input type="checkbox" checked={opt.allow_multiple} onChange={e => setOption(i, 'allow_multiple', e.target.checked)} className="accent-primary-700 w-4 h-4" />
Πολλαπλά
</label>
<button onClick={() => addOptionSubChoice(i)} className="btn btn-secondary text-xs px-2 min-h-0 h-9 shrink-0 whitespace-nowrap">+ Υπο-επιλογές</button>
<button onClick={() => removeOption(i)} className="btn btn-danger px-3 min-h-0 h-10"></button>
</div>
<SubChoiceRows subChoices={opt.sub_choices} parentLabel={opt.name}
onMove={(sci, dir) => moveOptionSubChoice(i, sci, dir)}
onToggleDefault={sci => toggleOptionSubDefault(i, sci)}
onChange={(sci, k, v) => setOptionSubChoice(i, sci, k, v)}
onRemove={sci => removeOptionSubChoice(i, sci)}
onAdd={() => addOptionSubChoice(i)} />
)
return (
<div>
<div className="flex items-center justify-between mb-3 gap-2 flex-wrap">
<p className="text-sm text-gray-500">Έξτρα (checkbox). Κάθε extra μπορεί να έχει υπο-επιλογές.</p>
<div className="flex gap-2">
<button onClick={() => addModifierGroup('option')} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Ομάδα</button>
<button onClick={addOption} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Έξτρα</button>
</div>
))}
</div>
{!form.options.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν extras.</p>}
<div className="space-y-3">
{ungrouped.map(({ opt, i }) => renderOptRow(opt, i))}
{optGroups.map(g => {
const members = form.options.map((opt, i) => ({ opt, i })).filter(({ opt }) => opt.group_id === g._tempId)
const collapsed = collapsedGroups[g._tempId]
return (
<div key={g._tempId}>
<GroupHeader group={g} collapsed={collapsed} onToggle={() => toggleGroupCollapsed(g._tempId)}
onRename={name => renameModifierGroup(g._tempId, name)}
onDelete={() => deleteModifierGroup(g._tempId)} />
{!collapsed && (
<div className="ml-4 border-l-2 border-indigo-100 pl-3 mt-1 space-y-3">
{members.length === 0 && <p className="text-xs text-gray-400 py-2">Χωρίς extras αλλάξτε ομάδα από κάθε extra.</p>}
{members.map(({ opt, i }) => renderOptRow(opt, i))}
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)}
)
})()}
{/* Preference set tab */}
{typeof activeTab === 'number' && form.preference_sets[activeTab] && (() => {
@@ -914,22 +1289,65 @@ export default function ProductFormModal({ product, categories, printers, onSave
const hasSharedSubset = !!ps.shared_subset
return (
<div>
<div className="flex items-center gap-3 mb-4">
<input className="input flex-1 font-semibold text-base" placeholder="π.χ. Ζάχαρη" value={ps.name}
<div className="flex items-center gap-3 mb-4 flex-wrap">
<input className="input flex-1 font-semibold text-base min-w-32" placeholder="π.χ. Ζάχαρη" value={ps.name}
onChange={e => setPrefSetField(si, 'name', e.target.value)} autoFocus />
{prefGroups.length > 0 && (
<select className="input text-xs px-2 h-9 shrink-0" style={{ width: 140 }}
value={ps.group_id ?? ''}
onChange={e => setPrefSetField(si, 'group_id', e.target.value === '' ? null : Number(e.target.value))}>
<option value=""> χωρίς ομάδα </option>
{prefGroups.map(g => <option key={g._tempId} value={g._tempId}>{g.name}</option>)}
</select>
)}
<FavoriteBtn isFavorite={ps.is_favorite} onClick={() => toggleFavorite('pref', si)} />
<button onClick={() => removePrefSet(si)} className="btn btn-danger px-3 min-h-0 h-10 shrink-0">Διαγραφή</button>
</div>
{/* Pref groups management (only show if any exist) */}
{prefGroups.length > 0 && (
<div className="mb-4 p-3 bg-indigo-50/50 border border-indigo-100 rounded-xl space-y-1">
<p className="text-xs font-semibold text-indigo-600 mb-2">Ομάδες προτιμήσεων</p>
{prefGroups.map(g => (
<GroupHeader key={g._tempId} group={g}
collapsed={collapsedGroups[g._tempId]}
onToggle={() => toggleGroupCollapsed(g._tempId)}
onRename={name => renameModifierGroup(g._tempId, name)}
onDelete={() => deleteModifierGroup(g._tempId)} />
))}
</div>
)}
<div className="flex items-center gap-5 mb-3 flex-wrap">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" checked={ps.allow_multi_select ?? false}
onChange={e => setPrefSetField(si, 'allow_multi_select', e.target.checked)}
className="w-4 h-4" style={{ accentColor: '#6366f1' }} />
<span className="text-sm font-medium" style={{ color: ps.allow_multi_select ? '#6366f1' : '#6b7280' }}>
Πολλαπλή επιλογή
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" checked={ps.allow_choice_quantity ?? false}
onChange={e => setPrefSetField(si, 'allow_choice_quantity', e.target.checked)}
className="w-4 h-4" style={{ accentColor: '#6366f1' }} />
<span className="text-sm font-medium" style={{ color: ps.allow_choice_quantity ? '#6366f1' : '#6b7280' }}>
Ποσότητα ανά επιλογή
</span>
</label>
</div>
<p className="text-xs text-gray-400 mb-3"> = προεπιλογή · = απενεργοποιεί κοινό υπο-σύνολο</p>
<div className="space-y-3 mb-5">
{ps.choices.map((ch, ci) => (
<div key={ci} className="border border-gray-200 rounded-xl overflow-hidden">
<div className="flex items-center gap-2 p-3 bg-white">
<div className="flex items-center gap-2 p-3 bg-white flex-wrap">
<ReorderBtns onUp={() => moveChoice(si, ci, -1)} onDown={() => moveChoice(si, ci, 1)}
disableUp={ci === 0} disableDown={ci === ps.choices.length - 1} />
<DefaultBtn isDefault={ps.default_choice_index === ci} onClick={() => toggleDefaultChoice(si, ci)} />
<input className="input flex-1" placeholder="π.χ. Σκέτος" value={ch.name} onChange={e => setChoice(si, ci, 'name', e.target.value)} />
<input className="input flex-1 min-w-32" placeholder="π.χ. Σκέτος" value={ch.name} onChange={e => setChoice(si, ci, 'name', e.target.value)} />
<PriceInput value={ch.extra_cost} onChange={v => setChoice(si, ci, 'extra_cost', v)} allowNegative className="w-32" />
<label className="flex items-center gap-1 text-xs cursor-pointer shrink-0 select-none" style={{ color: ch.is_compact ? '#7c3aed' : '#6b7280' }}>
<input type="checkbox" checked={ch.is_compact ?? false} onChange={e => setChoice(si, ci, 'is_compact', e.target.checked)} className="w-3.5 h-3.5" style={{ accentColor: '#7c3aed' }} />
Compact
</label>
{hasSharedSubset && (
<button type="button" onClick={() => setChoice(si, ci, 'disables_subset', !ch.disables_subset)}
className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 text-sm ${ch.disables_subset ? 'bg-red-100 text-red-500' : 'text-gray-300 hover:text-red-400'}`}></button>

View File

@@ -0,0 +1,9 @@
import ProductsTab from '../Management/ProductsTab'
export default function ProductsPage() {
return (
<div className="flex flex-col h-full min-h-0">
<ProductsTab />
</div>
)
}

View File

@@ -40,7 +40,8 @@ function Icon({ name, size = 14, className = '' }) {
// ── Helpers ───────────────────────────────────────────────────────────────────
const EMPTY_PRODUCT = {
name: '', category_id: '', base_price: '', is_available: true, lifecycle_status: 'active',
printer_zone_id: '', description: '', quick_options: [], options: [], ingredients: [], preference_sets: [],
description: '', quick_options: [], options: [], ingredients: [], preference_sets: [],
is_service_item: false,
}
function catColor(cat) { return cat?.color || '#94a3b8' }
@@ -175,6 +176,15 @@ function CategoryPanel({ categories, products, selectedCat, onSelect, onEdit, on
{/* Tree */}
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 24px' }}>
{/* Service items — permanent, always first */}
<CatRow
label="Service"
count={products.filter(p => p.is_service_item && p.lifecycle_status !== 'archived').length}
isSelected={selectedCat === '__service__'}
onClick={() => onSelect('__service__')}
dotColor="#f59e0b"
isService
/>
{/* All */}
<CatRow
label="Όλα τα προϊόντα" count={totalActive}
@@ -277,7 +287,7 @@ function CategoryPanel({ categories, products, selectedCat, onSelect, onEdit, on
)
}
function CatRow({ label, count, isSelected, onClick, dotColor, dotIsGradient, isParent, isOpen, onToggle, onEdit, onDelete, onAddChild, onReparent, depth = 0, showDrag }) {
function CatRow({ label, count, isSelected, onClick, dotColor, dotIsGradient, isParent, isOpen, onToggle, onEdit, onDelete, onAddChild, onReparent, depth = 0, showDrag, isService }) {
const bg = isSelected ? '#eff6ff' : 'transparent'
const accent = isSelected ? '#3b82f6' : 'transparent'
return (
@@ -313,14 +323,20 @@ function CatRow({ label, count, isSelected, onClick, dotColor, dotIsGradient, is
<span style={{ width: 15, flexShrink: 0 }} />
)}
{/* Color dot */}
<span style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
...(dotIsGradient ? { background: dotColor } : { backgroundColor: dotColor }),
}} />
{/* Color dot or service icon */}
{isService ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<path d="M3 11l19-9-9 19-2-8-8-2z"/>
</svg>
) : (
<span style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
...(dotIsGradient ? { background: dotColor } : { backgroundColor: dotColor }),
}} />
)}
{/* Name */}
<span style={{ flex: 1, fontSize: 13.5, fontWeight: isSelected ? 600 : 500, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
<span style={{ flex: 1, fontSize: 13.5, fontWeight: isSelected ? 600 : 500, color: isService ? '#b45309' : '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
{label}
</span>
@@ -439,9 +455,9 @@ function CatFooterRow({ icon, label, count, isSelected, onClick }) {
}
// ── Products Panel Header ─────────────────────────────────────────────────────
function ProductsHeader({ title, visibleCount, totalCount, search, setSearch, view, setView, zoneFilter, setZoneFilter, priceMin, setPriceMin, priceMax, setPriceMax, dataQualityFilter, setDataQualityFilter, showUnavailable, setShowUnavailable, showArchived, setShowArchived, zones, onNew }) {
function ProductsHeader({ title, visibleCount, totalCount, search, setSearch, view, setView, priceMin, setPriceMin, priceMax, setPriceMax, dataQualityFilter, setDataQualityFilter, showUnavailable, setShowUnavailable, showArchived, setShowArchived, onNew }) {
const [filtersOpen, setFiltersOpen] = useState(false)
const hasFilter = zoneFilter !== 'all' || priceMin !== '' || priceMax !== '' || dataQualityFilter !== 'all' || showUnavailable || showArchived
const hasFilter = priceMin !== '' || priceMax !== '' || dataQualityFilter !== 'all' || showUnavailable || showArchived
function clearFilters() {
setZoneFilter('all')
@@ -526,10 +542,6 @@ function ProductsHeader({ title, visibleCount, totalCount, search, setSearch, vi
borderRadius: 10, boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end',
}}>
<FilterGroup label="Ζώνη εκτύπωσης">
<SegControl value={zoneFilter} onChange={setZoneFilter}
options={[{ v: 'all', l: 'Όλες' }, ...zones.map(z => ({ v: z, l: z }))]} />
</FilterGroup>
<FilterGroup label="Τιμή €">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input type="number" placeholder="από" value={priceMin} onChange={e => setPriceMin(e.target.value)}
@@ -636,7 +648,7 @@ function SubCatBar({ parentCat, subCats, products, subCatFilter, setSubCatFilter
}
// ── Bulk bar ──────────────────────────────────────────────────────────────────
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrinterZone, onMoveToCategory }) {
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrepZone, 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 }}>
@@ -660,12 +672,12 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrinterZo
</button>
))}
<div style={{ width: 1, background: 'rgba(255,255,255,0.15)', margin: '4px 2px' }} />
<button onClick={onSetPrinterZone}
<button onClick={onSetPrepZone}
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 }}
@@ -679,38 +691,43 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrinterZo
)
}
// ── 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 ?? '')
// ── Bulk: Set Prep Zone modal ───────────────────────────────────────────────
function BulkSetPrepZoneModal({ count, prepZones, onClose, onConfirm }) {
const [selectedIds, setSelectedIds] = useState([])
const toggle = (id) => setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, 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>
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', width: '100%', maxWidth: 420, 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>
{prepZones.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>
))}
{prepZones.map(z => {
const on = selectedIds.includes(z.id)
return (
<label key={z.id} onClick={() => toggle(z.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', border: `1.5px solid ${on ? '#3b82f6' : '#e5e7eb'}`, borderRadius: 10, cursor: 'pointer', background: on ? '#eff6ff' : '#fff' }}>
<span style={{ width: 16, height: 16, borderRadius: 4, border: `2px solid ${on ? '#3b82f6' : '#d1d5db'}`, background: on ? '#3b82f6' : '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{on && <span style={{ width: 8, height: 8, background: '#fff', borderRadius: 2, display: 'block' }} />}
</span>
<div>
<span style={{ fontSize: 14, fontWeight: 500, color: '#111827' }}>{z.name}</span>
{z.description && <span style={{ fontSize: 12, color: '#6b7280', marginLeft: 8 }}>{z.description}</span>}
</div>
</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' }}
onClick={() => onConfirm(selectedIds)}
disabled={!selectedIds.length}
style={{ flex: 1, padding: '9px 0', border: 'none', background: selectedIds.length ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: selectedIds.length ? 'pointer' : 'not-allowed' }}
>
Εφαρμογή
</button>
@@ -829,6 +846,11 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
Αρχείο
</span>
)}
{p.is_service_item && !isArchived && (
<span style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', background: '#fef3c7', color: '#b45309', padding: '1px 6px', borderRadius: 4, flexShrink: 0 }}>
Service
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: '#6b7280' }}>
{displayCat && (
@@ -837,11 +859,11 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
{displayCat.name}
</span>
)}
{p.printer_zone_name && (
<span style={{ background: '#eff6ff', color: '#1d4ed8', padding: '1px 6px', borderRadius: 4, fontSize: 11 }}>
{p.printer_zone_name}
{p.prep_zone_names?.map(name => (
<span key={name} style={{ background: '#eff6ff', color: '#1d4ed8', padding: '1px 6px', borderRadius: 4, fontSize: 11 }}>
{name}
</span>
)}
))}
</div>
</div>
@@ -997,11 +1019,13 @@ function ProductCard({ p, cat, parentCat, selected, onToggleSelect, onEdit, onAr
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: 15, color: '#111827' }}>
{parseFloat(p.base_price).toFixed(2)}
</span>
{p.printer_zone_name && (
<span style={{ background: '#eff6ff', color: '#1d4ed8', padding: '1px 6px', borderRadius: 4, fontSize: 11 }}>
{p.printer_zone_name}
</span>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{p.prep_zone_names?.map(name => (
<span key={name} style={{ background: '#eff6ff', color: '#1d4ed8', padding: '1px 6px', borderRadius: 4, fontSize: 11 }}>
{name}
</span>
))}
</div>
</div>
</div>
</div>
@@ -1052,7 +1076,6 @@ export default function ProductsTab() {
const [confirmDelete, setConfirmDelete] = useState(null)
const [search, setSearch] = useState('')
const [view, setView] = useState('list')
const [zoneFilter, setZoneFilter] = useState('all')
const [priceMin, setPriceMin] = useState('')
const [priceMax, setPriceMax] = useState('')
const [dataQualityFilter, setDataQualityFilter] = useState('all')
@@ -1062,7 +1085,7 @@ 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 [bulkPrepZoneModal, setBulkPrepZoneModal] = useState(false)
const [bulkMoveCatModal, setBulkMoveCatModal] = useState(false)
// drag state for group headers
@@ -1087,11 +1110,15 @@ export default function ProductsTab() {
staleTime: 60_000,
})
const printers = statusData?.printers ?? []
const zones = [...new Set(printers.map(p => p.name))]
const { data: prepZones = [] } = useQuery({
queryKey: ['prep-zones'],
queryFn: () => client.get('/api/prep-zones').then(r => r.data),
})
const products = allProducts.map(p => ({
...p,
printer_zone_name: p.printer_zone_id ? printers.find(pr => pr.id === p.printer_zone_id)?.name : null,
prep_zone_names: (p.prep_zone_ids || []).map(id => prepZones.find(z => z.id === id)?.name).filter(Boolean),
}))
const invalidate = () => {
@@ -1191,8 +1218,10 @@ export default function ProductsTab() {
if (selectedCat === '__archived__') {
list = list.filter(p => p.lifecycle_status === 'archived')
} else if (selectedCat === '__service__') {
list = list.filter(p => p.is_service_item && p.lifecycle_status !== 'archived')
} else if (selectedCat === '__uncategorized__') {
list = list.filter(p => !p.category_id && p.lifecycle_status !== 'archived')
list = list.filter(p => !p.category_id && !p.is_service_item && p.lifecycle_status !== 'archived')
} else if (selectedCat) {
if (isParentScope) {
const ids = new Set([selectedCat, ...subCats.map(s => s.id)])
@@ -1202,6 +1231,8 @@ export default function ProductsTab() {
}
if (!showArchived) list = list.filter(p => p.lifecycle_status !== 'archived')
} else {
// "All products" — exclude service items (they have their own section)
list = list.filter(p => !p.is_service_item)
if (!showArchived) list = list.filter(p => p.lifecycle_status !== 'archived')
}
@@ -1215,8 +1246,6 @@ export default function ProductsTab() {
list = list.filter(p => p.name.toLowerCase().includes(q))
}
if (zoneFilter !== 'all') list = list.filter(p => p.printer_zone_name === zoneFilter)
const pmin = priceMin === '' ? -Infinity : Number(priceMin)
const pmax = priceMax === '' ? Infinity : Number(priceMax)
list = list.filter(p => p.base_price >= pmin && p.base_price <= pmax)
@@ -1289,12 +1318,12 @@ export default function ProductsTab() {
setConfirmDelete(null); invalidate(); clearSelect()
}
async function handleBulkSetPrinterZone(printerId) {
async function handleBulkSetPrepZone(prepZoneIds) {
const ids = [...selected]
setBulkPrinterZoneModal(false)
setBulkPrepZoneModal(false)
try {
await Promise.all(ids.map(id => client.put(`/api/products/${id}`, { printer_zone_id: printerId })))
toast.success(`Ζώνη εκτύπωσης ορίστηκε σε ${ids.length} προϊόντα`)
await client.post('/api/products/bulk-prep-zones', { product_ids: ids, prep_zone_ids: prepZoneIds })
toast.success(`Ζώνες προετοιμασίας ορίστηκαν σε ${ids.length} προϊόντα`)
invalidate(); clearSelect()
} catch { toast.error('Σφάλμα') }
}
@@ -1420,6 +1449,7 @@ export default function ProductsTab() {
function getTitle() {
if (selectedCat === null) return 'Όλα τα προϊόντα'
if (selectedCat === '__archived__') return 'Αρχειοθετημένα'
if (selectedCat === '__service__') return 'Service'
if (selectedCat === '__uncategorized__') return 'Χωρίς κατηγορία'
const cat = categories.find(c => c.id === selectedCat)
if (!cat) return 'Προϊόντα'
@@ -1456,15 +1486,17 @@ export default function ProductsTab() {
totalCount={totalActive}
search={search} setSearch={setSearch}
view={view} setView={setView}
zoneFilter={zoneFilter} setZoneFilter={setZoneFilter}
priceMin={priceMin} setPriceMin={setPriceMin}
priceMax={priceMax} setPriceMax={setPriceMax}
dataQualityFilter={dataQualityFilter} setDataQualityFilter={setDataQualityFilter}
showUnavailable={showUnavailable} setShowUnavailable={setShowUnavailable}
showArchived={showArchived} setShowArchived={setShowArchived}
zones={zones}
onNew={() => {
const SPECIAL = [null, '__uncategorized__', '__archived__']
if (selectedCat === '__service__') {
setEditProduct({ ...EMPTY_PRODUCT, is_service_item: true, base_price: 0 })
return
}
const SPECIAL = [null, '__uncategorized__', '__archived__', '__service__']
const presetCat = (!SPECIAL.includes(subCatFilter) && subCatFilter)
|| (!SPECIAL.includes(selectedCat) && selectedCat)
|| ''
@@ -1491,7 +1523,7 @@ export default function ProductsTab() {
onAvail={() => bulkAction('available')}
onUnavail={() => bulkAction('unavailable')}
onArchive={() => bulkAction('archive')}
onSetPrinterZone={() => setBulkPrinterZoneModal(true)}
onSetPrepZone={() => setBulkPrepZoneModal(true)}
onMoveToCategory={() => setBulkMoveCatModal(true)}
/>
)}
@@ -1626,6 +1658,7 @@ export default function ProductsTab() {
product={editProduct}
categories={categories}
printers={printers}
prepZones={prepZones}
onSave={b => saveProduct.mutate(b)}
onCopy={formData => setEditProduct({ ...EMPTY_PRODUCT, ...formData, id: undefined, image_url: undefined, name: formData.name + ' (αντίγραφο)' })}
onClose={() => setEditProduct(null)}
@@ -1649,13 +1682,13 @@ export default function ProductsTab() {
/>
)}
{/* Bulk: set printer zone modal */}
{bulkPrinterZoneModal && (
<BulkSetPrinterZoneModal
{/* Bulk: set prep zone modal */}
{bulkPrepZoneModal && (
<BulkSetPrepZoneModal
count={selected.size}
printers={printers}
onClose={() => setBulkPrinterZoneModal(false)}
onConfirm={handleBulkSetPrinterZone}
prepZones={prepZones}
onClose={() => setBulkPrepZoneModal(false)}
onConfirm={handleBulkSetPrepZone}
/>
)}

View File

@@ -0,0 +1 @@
export { default } from '../SchedulePage'

View File

@@ -0,0 +1,9 @@
import StaffTab from '../StaffTab'
export default function StaffPage() {
return (
<div className="flex flex-col h-full min-h-0">
<StaffTab />
</div>
)
}

View File

@@ -0,0 +1,9 @@
import TablesConfigTab from '../TablesConfigTab'
export default function TablesConfigPage() {
return (
<div className="flex flex-col h-full min-h-0">
<TablesConfigTab />
</div>
)
}

View File

@@ -0,0 +1,687 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Gift, Power, Pencil, Trash2, X, Info } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import Button from '../../../ui/Button'
import Modal from '../../../ui/Modal'
import { ConfirmModal } from '../../../ui/Modal'
// ─── API ─────────────────────────────────────────────────────────────────────
const api = {
list: () => client.get('/api/pricing/deals').then(r => r.data),
create: body => client.post('/api/pricing/deals', body).then(r => r.data),
update: (id,b) => client.put(`/api/pricing/deals/${id}`, b).then(r => r.data),
toggle: id => client.patch(`/api/pricing/deals/${id}/toggle`).then(r => r.data),
remove: id => client.delete(`/api/pricing/deals/${id}`),
modifiers: () => client.get('/api/pricing/modifiers').then(r => r.data),
products: () => client.get('/api/products', { params: { all: true } }).then(r => r.data),
categories:() => client.get('/api/products/categories').then(r => r.data),
}
const COLORS = [
'#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316',
'#eab308','#22c55e','#14b8a6','#0ea5e9','#64748b',
]
const CONDITION_TYPES = [
{ value: 'min_item_quantity', label: 'Ελάχιστη ποσότητα αντικειμένου' },
{ value: 'min_order_value', label: 'Ελάχιστη αξία παραγγελίας' },
{ value: 'time_range', label: 'Ώρα ημέρας' },
{ value: 'day_of_week', label: 'Ημέρα εβδομάδας' },
{ value: 'date_range', label: 'Εύρος ημερομηνιών' },
{ value: 'order_channel', label: 'Κανάλι παραγγελίας' },
{ value: 'price_group_active',label: 'Ομάδα τιμής ενεργή' },
]
const DAYS = ['Δευτέρα','Τρίτη','Τετάρτη','Πέμπτη','Παρασκευή','Σάββατο','Κυριακή']
const CHANNELS = ['pos','online','qr','takeaway']
const ACTION_TYPES = [
{ value: 'apply_modifier', label: 'Εφαρμογή τροποποιητή' },
{ value: 'set_price', label: 'Ορισμός τιμής' },
{ value: 'add_amount', label: '± Ποσό €' },
{ value: 'add_percent', label: '± Ποσοστό %' },
{ value: 'free_item', label: 'Δωρεάν προϊόν' },
{ value: 'free_choice', label: 'Επιλογή δωρεάν προϊόντος' },
]
// ─── Reuse condition editor (simplified for deals) ────────────────────────────
function ConditionRow({ cond, onChange, onRemove, groups }) {
const set = (k, v) => onChange({ ...cond, params: { ...cond.params, [k]: v } })
const setType = t => onChange({ condition_type: t, params: {} })
return (
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
<div className="flex items-center gap-2">
<select className="input flex-1 text-[12px]" value={cond.condition_type}
onChange={e => setType(e.target.value)}>
{CONDITION_TYPES.map(ct => <option key={ct.value} value={ct.value}>{ct.label}</option>)}
</select>
<button type="button" onClick={onRemove}
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
<X className="w-3.5 h-3.5" />
</button>
</div>
{cond.condition_type === 'min_item_quantity' && (
<div>
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη ποσότητα</label>
<input type="number" min={1} className="input w-28 text-[12px]"
value={cond.params.min ?? 1} onChange={e => set('min', Number(e.target.value))} />
</div>
)}
{cond.condition_type === 'min_order_value' && (
<div>
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη αξία ()</label>
<input type="number" min={0} step={0.5} className="input w-28 text-[12px]"
value={cond.params.min ?? 0} onChange={e => set('min', Number(e.target.value))} />
</div>
)}
{cond.condition_type === 'time_range' && (
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
<input type="time" className="input w-full text-[12px]" value={cond.params.from ?? ''}
onChange={e => set('from', e.target.value)} />
</div>
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
<input type="time" className="input w-full text-[12px]" value={cond.params.to ?? ''}
onChange={e => set('to', e.target.value)} />
</div>
</div>
)}
{cond.condition_type === 'day_of_week' && (
<div className="flex flex-wrap gap-1.5">
{DAYS.map((d, i) => (
<button type="button" key={i}
onClick={() => { const days = cond.params.days ?? []; set('days', days.includes(i) ? days.filter(x => x !== i) : [...days, i]) }}
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
(cond.params.days ?? []).includes(i) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{d.slice(0, 3)}</button>
))}
</div>
)}
{cond.condition_type === 'date_range' && (
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
<input type="date" className="input w-full text-[12px]" value={cond.params.from ?? ''}
onChange={e => set('from', e.target.value)} />
</div>
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
<input type="date" className="input w-full text-[12px]" value={cond.params.to ?? ''}
onChange={e => set('to', e.target.value)} />
</div>
</div>
)}
{cond.condition_type === 'order_channel' && (
<div className="flex flex-wrap gap-1.5">
{CHANNELS.map(ch => (
<button type="button" key={ch}
onClick={() => { const channels = cond.params.channels ?? []; set('channels', channels.includes(ch) ? channels.filter(x => x !== ch) : [...channels, ch]) }}
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
(cond.params.channels ?? []).includes(ch) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{ch}</button>
))}
</div>
)}
{cond.condition_type === 'price_group_active' && (
<div>
<select className="input w-full text-[12px]" value={cond.params.price_group_id ?? ''}
onChange={e => set('price_group_id', Number(e.target.value))}>
<option value=""> Επιλογή ομάδας </option>
{(groups ?? []).map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
)}
</div>
)
}
// ─── Shared multi-picker (same logic as ModifiersTab) ─────────────────────────
function MultiPicker({ items, selected, onToggle, labelKey = 'name', valueKey = 'id' }) {
return (
<div className="border border-slate-200 rounded-lg overflow-hidden">
{items.length === 0 && (
<p className="text-[11px] text-slate-400 px-3 py-2">Δεν βρέθηκαν αποτελέσματα</p>
)}
{items.map(item => {
const val = item[valueKey]
const checked = selected.includes(val)
return (
<div key={val} onClick={() => onToggle(val)}
className={`flex items-center gap-2.5 px-3 py-1.5 cursor-pointer transition-colors text-[12px] border-b border-slate-100 last:border-0 ${
checked ? 'bg-sky-50 text-sky-700' : 'hover:bg-slate-50 text-slate-700'
}`}>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
checked ? 'bg-sky-500 border-sky-500' : 'border-slate-300 bg-white'
}`}>
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l3 3 5-6" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
{item[labelKey]}
</div>
)
})}
</div>
)
}
function TagPicker({ selected, onToggle, onAdd, allTags }) {
const [draft, setDraft] = useState('')
const handleAdd = () => {
const tag = draft.trim()
if (tag && !selected.includes(tag)) onAdd(tag)
setDraft('')
}
return (
<div className="space-y-2">
{allTags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{allTags.map(tag => {
const active = selected.includes(tag)
return (
<button type="button" key={tag} onClick={() => onToggle(tag)}
className={`px-2 py-0.5 rounded-full text-[11px] font-medium border transition-colors ${
active ? 'bg-sky-500 border-sky-500 text-white' : 'border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-600'
}`}>{tag}</button>
)
})}
</div>
)}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selected.map(tag => (
<span key={tag} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-100 text-sky-700 text-[11px] font-medium">
{tag}
<button type="button" onClick={() => onToggle(tag)} className="hover:text-sky-900">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<input className="input flex-1 text-[12px]" placeholder="Νέο tag..." value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd() } }} />
<button type="button" onClick={handleAdd}
className="px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 text-[12px] hover:bg-slate-200 transition-colors">
+
</button>
</div>
</div>
)
}
// ─── Deal target row ──────────────────────────────────────────────────────────
function DealTargetRow({ target, onChange, onRemove, products, categories, allTags }) {
const ids = target.target_ids ?? []
const tags = target.target_tags ?? []
const setIds = v => onChange({ ...target, target_ids: v, target_id: v[0] ?? null })
const setTags = v => onChange({ ...target, target_tags: v, target_tag: v[0] ?? null })
const toggleId = id => setIds(ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
const toggleTag = tag => setTags(tags.includes(tag) ? tags.filter(x => x !== tag) : [...tags, tag])
return (
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
<div className="flex items-center gap-2">
<select className="input flex-1 text-[12px]" value={target.target_type}
onChange={e => onChange({ target_type: e.target.value, target_id: null, target_tag: null, target_ids: [], target_tags: [] })}>
<option value="any">Οποιοδήποτε προϊόν</option>
<option value="item">Προϊόντα</option>
<option value="category">Κατηγορίες</option>
<option value="tag">Ετικέτες (tags)</option>
</select>
<button type="button" onClick={onRemove}
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
<X className="w-3.5 h-3.5" />
</button>
</div>
{target.target_type === 'item' && (
<MultiPicker items={products} selected={ids} onToggle={toggleId} />
)}
{target.target_type === 'category' && (
<MultiPicker items={categories} selected={ids} onToggle={toggleId} />
)}
{target.target_type === 'tag' && (
<TagPicker selected={tags} onToggle={toggleTag} onAdd={tag => setTags([...tags, tag])} allTags={allTags} />
)}
</div>
)
}
// ─── Deal form ────────────────────────────────────────────────────────────────
const DEAL_TABS = [
{ key: 'info', label: 'Πληροφορίες' },
{ key: 'trigger', label: 'Trigger & Συνθήκες' },
{ key: 'action', label: 'Ενέργεια' },
]
function DealForm({ initial, onSave, onCancel, saving, modifiers, groups, activeTab, onTabChange }) {
const { data: products = [] } = useQuery({
queryKey: ['products-all-pricing'],
queryFn: () => client.get('/api/products/', { params: { all: true } }).then(r => r.data),
})
const { data: categories = [] } = useQuery({
queryKey: ['categories'],
queryFn: () => client.get('/api/products/categories').then(r => r.data),
staleTime: 60_000,
})
const { data: allTags = [] } = useQuery({
queryKey: ['product-tags'],
queryFn: () => client.get('/api/products/tags').then(r => r.data),
staleTime: 60_000,
})
const normalizeTarget = t => ({
...t,
target_ids: t.target_ids ?? (t.target_id != null ? [t.target_id] : []),
target_tags: t.target_tags ?? (t.target_tag ? [t.target_tag] : []),
})
const blank = {
name: '', description: '', color: COLORS[6], is_active: true, sort_order: 0,
action_type: 'free_item',
action_modifier_id: null, action_value: null,
action_free_item_id: null,
action_free_target_type: 'item', action_free_target_ids: [],
action_free_quantity: 1,
conditions: [], targets: [{ target_type: 'any', target_id: null, target_tag: null, target_ids: [], target_tags: [] }],
}
const [f, setF] = useState(initial ? {
...blank, ...initial,
conditions: initial.conditions ?? [],
targets: (initial.targets ?? [{ target_type: 'any', target_id: null, target_tag: null, target_ids: [], target_tags: [] }]).map(normalizeTarget),
action_free_target_ids: initial.action_free_target_ids ?? [],
} : blank)
const set = (k, v) => setF(p => ({ ...p, [k]: v }))
const addCond = () => set('conditions', [...f.conditions, { condition_type: 'min_item_quantity', params: { min: 1 } }])
const updCond = (i, c) => set('conditions', f.conditions.map((x, j) => j === i ? c : x))
const remCond = i => set('conditions', f.conditions.filter((_, j) => j !== i))
const addTarget = () => set('targets', [...f.targets, { target_type: 'any', target_id: null, target_tag: null, target_ids: [], target_tags: [] }])
const updTarget = (i, t) => set('targets', f.targets.map((x, j) => j === i ? t : x))
const remTarget = i => set('targets', f.targets.filter((_, j) => j !== i))
const toggleFreeId = id => {
const ids = f.action_free_target_ids ?? []
set('action_free_target_ids', ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
}
function submit(e) {
e.preventDefault()
if (!f.name.trim()) return toast.error('Απαιτείται όνομα')
onSave({
...f,
action_free_target_ids: f.action_free_target_ids?.length ? f.action_free_target_ids : null,
})
}
return (
<form id="deal-form" onSubmit={submit} className="space-y-5">
{/* ── Info tab ── */}
{activeTab === 'info' && (
<>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-[12px] font-medium text-slate-600 mb-1">Όνομα προσφοράς</label>
<input className="input w-full" value={f.name} onChange={e => set('name', e.target.value)}
placeholder="π.χ. 3+1 Μπύρες" autoFocus />
</div>
<div className="col-span-2">
<label className="block text-[12px] font-medium text-slate-600 mb-1">Περιγραφή</label>
<input className="input w-full" value={f.description ?? ''}
onChange={e => set('description', e.target.value)} />
</div>
</div>
<div className="flex items-center justify-between">
<div>
<label className="block text-[12px] font-medium text-slate-600 mb-1.5">Χρώμα</label>
<div className="flex flex-wrap gap-2">
{COLORS.map(c => (
<button type="button" key={c} onClick={() => set('color', c)}
className={`w-6 h-6 rounded-full transition-transform ${f.color === c ? 'ring-2 ring-offset-1 ring-slate-400 scale-110' : 'hover:scale-105'}`}
style={{ background: c }} />
))}
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<div onClick={() => set('is_active', !f.is_active)}
className={`relative rounded-full transition-colors ${f.is_active ? 'bg-sky-500' : 'bg-slate-200'}`}
style={{ width: 32, height: 18 }}>
<span className={`absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-white shadow transition-transform ${f.is_active ? 'translate-x-3.5' : ''}`} />
</div>
<span className="text-[12px] text-slate-700">Ενεργή</span>
</label>
</div>
</>
)}
{/* ── Trigger & Conditions tab ── */}
{activeTab === 'trigger' && (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">Trigger</span>
<span className="text-[11px] text-slate-400 ml-1.5">Αγορά αυτών</span>
</div>
<button type="button" onClick={addTarget}
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
<Plus className="w-3.5 h-3.5" />Προσθήκη
</button>
</div>
<p className="text-[11px] text-slate-400">
Ποια προϊόντα πρέπει να υπάρχουν στην παραγγελία για να ενεργοποιηθεί η προσφορά;
</p>
{f.targets.map((t, i) => (
<DealTargetRow key={i} target={t} onChange={v => updTarget(i, v)} onRemove={() => remTarget(i)}
products={products} categories={categories} allTags={allTags} />
))}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">Επιπλέον Συνθήκες</span>
<span className="text-[11px] text-slate-400 ml-1.5">(προαιρετικά)</span>
</div>
<button type="button" onClick={addCond}
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
<Plus className="w-3.5 h-3.5" />Προσθήκη
</button>
</div>
{f.conditions.length === 0 && (
<p className="text-[12px] text-slate-400 bg-slate-50 rounded-lg px-3 py-2.5">
Χωρίς επιπλέον συνθήκες αρκεί μόνο το trigger.
</p>
)}
{f.conditions.map((c, i) => (
<ConditionRow key={i} cond={c} onChange={v => updCond(i, v)} onRemove={() => remCond(i)} groups={groups} />
))}
</div>
</>
)}
{/* ── Action tab ── */}
{activeTab === 'action' && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{ACTION_TYPES.map(({ value, label }) => (
<button type="button" key={value} onClick={() => set('action_type', value)}
className={`py-2 rounded-lg border text-[12px] font-medium transition-colors text-center ${
f.action_type === value ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{label}</button>
))}
</div>
{f.action_type === 'apply_modifier' && (
<div>
<label className="block text-[11px] text-slate-500 mb-1">Τροποποιητής (χωρίς συνθήκες)</label>
<select className="input w-full text-[12px]" value={f.action_modifier_id ?? ''}
onChange={e => set('action_modifier_id', Number(e.target.value))}>
<option value=""> Επιλογή </option>
{(modifiers ?? []).filter(m => !m.conditions?.length).map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
<p className="text-[11px] text-slate-400 mt-1">Εμφανίζονται μόνο τροποποιητές χωρίς συνθήκες (χειροκίνητοι).</p>
</div>
)}
{(f.action_type === 'set_price' || f.action_type === 'add_amount' || f.action_type === 'add_percent') && (
<div>
<label className="block text-[11px] text-slate-500 mb-1">
{f.action_type === 'set_price' ? 'Νέα τιμή (€)' :
f.action_type === 'add_amount' ? 'Ποσό (€, αρνητικό = έκπτωση)' :
'Ποσοστό (%, αρνητικό = έκπτωση)'}
</label>
<input type="number" step={0.1} className="input w-36 text-[12px]"
value={f.action_value ?? 0} onChange={e => set('action_value', Number(e.target.value))} />
</div>
)}
{f.action_type === 'free_item' && (
<div className="space-y-3">
<div>
<label className="block text-[11px] text-slate-500 mb-1">Δωρεάν προϊόν</label>
<select className="input w-full text-[12px]" value={f.action_free_item_id ?? ''}
onChange={e => set('action_free_item_id', Number(e.target.value))}>
<option value=""> Επιλογή </option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div>
<label className="block text-[11px] text-slate-500 mb-1">Ποσότητα</label>
<input type="number" min={1} className="input w-20 text-[12px]"
value={f.action_free_quantity} onChange={e => set('action_free_quantity', Number(e.target.value))} />
</div>
</div>
)}
{f.action_type === 'free_choice' && (
<div className="space-y-2">
<div className="flex gap-2">
{[['item','Προϊόντα'],['category','Κατηγορία'],['tag','Ετικέτα']].map(([v,l]) => (
<button type="button" key={v} onClick={() => { set('action_free_target_type', v); set('action_free_target_ids', []) }}
className={`px-3 py-1 rounded text-[12px] font-medium border transition-colors ${
f.action_free_target_type === v ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{l}</button>
))}
</div>
{f.action_free_target_type === 'item' && (
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
{products.map(p => (
<label key={p.id} className="flex items-center gap-2 cursor-pointer py-0.5">
<input type="checkbox" className="rounded"
checked={(f.action_free_target_ids ?? []).includes(p.id)}
onChange={() => toggleFreeId(p.id)} />
<span className="text-[12px] text-slate-700">{p.name}</span>
</label>
))}
</div>
)}
{f.action_free_target_type === 'category' && (
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
{categories.map(c => (
<label key={c.id} className="flex items-center gap-2 cursor-pointer py-0.5">
<input type="checkbox" className="rounded"
checked={(f.action_free_target_ids ?? []).includes(c.id)}
onChange={() => toggleFreeId(c.id)} />
<span className="text-[12px] text-slate-700">{c.name}</span>
</label>
))}
</div>
)}
{f.action_free_target_type === 'tag' && (
<div>
<label className="block text-[11px] text-slate-500 mb-1">Ετικέτες (μία ανά γραμμή)</label>
<textarea className="input w-full text-[12px] font-mono" rows={3}
value={(f.action_free_target_ids ?? []).join('\n')}
onChange={e => set('action_free_target_ids', e.target.value.split('\n').map(s => s.trim()).filter(Boolean))} />
</div>
)}
<div>
<label className="block text-[11px] text-slate-500 mb-1">Ποσότητα δωρεάν</label>
<input type="number" min={1} className="input w-20 text-[12px]"
value={f.action_free_quantity} onChange={e => set('action_free_quantity', Number(e.target.value))} />
</div>
</div>
)}
</div>
)}
</form>
)
}
// ─── Deal card ────────────────────────────────────────────────────────────────
function DealCard({ deal, onEdit, onDelete }) {
const qc = useQueryClient()
const toggle = useMutation({
mutationFn: () => api.toggle(deal.id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['pricing-deals'] }),
onError: () => toast.error('Σφάλμα'),
})
const actionLabel = ACTION_TYPES.find(a => a.value === deal.action_type)?.label ?? deal.action_type
return (
<div className={`bg-white rounded-xl border shadow-sm p-4 flex items-start gap-3 transition-opacity ${deal.is_active ? 'border-slate-200' : 'border-slate-100 opacity-60'}`}>
<div className="w-3 h-3 rounded-full mt-1 shrink-0" style={{ background: deal.color ?? '#22c55e' }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-semibold text-slate-800">{deal.name}</span>
<span className={`text-[11px] font-medium px-1.5 py-0.5 rounded-full ${
deal.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'
}`}>{deal.is_active ? 'Ενεργή' : 'Ανενεργή'}</span>
<span className="text-[11px] text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded-full">{actionLabel}</span>
</div>
{deal.description && <p className="text-[12px] text-slate-500 mt-0.5">{deal.description}</p>}
<p className="text-[11px] text-slate-400 mt-1">
{deal.conditions?.length ?? 0} συνθήκες · {deal.targets?.length ?? 0} targets
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => toggle.mutate()}
className={`p-1.5 rounded-lg transition-colors ${
deal.is_active ? 'text-emerald-600 hover:bg-emerald-50' : 'text-slate-400 hover:bg-slate-100'
}`}>
<Power className="w-4 h-4" />
</button>
<button onClick={() => onEdit(deal)}
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
<Pencil className="w-4 h-4" />
</button>
<button onClick={() => onDelete(deal)}
className="p-1.5 rounded-lg text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)
}
// ─── Main tab ─────────────────────────────────────────────────────────────────
export default function DealsTab() {
const qc = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState(null)
const [deleting, setDeleting] = useState(null)
const [formTab, setFormTab] = useState('info')
const { data: deals = [], isLoading } = useQuery({ queryKey: ['pricing-deals'], queryFn: api.list })
const { data: modifiers = [] } = useQuery({ queryKey: ['pricing-modifiers'], queryFn: api.modifiers })
const { data: groups = [] } = useQuery({ queryKey: ['pricing-groups'], queryFn: () => client.get('/api/pricing/groups').then(r => r.data) })
const create = useMutation({
mutationFn: api.create,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-deals'] }); setShowForm(false); toast.success('Δημιουργήθηκε') },
onError: () => toast.error('Σφάλμα'),
})
const update = useMutation({
mutationFn: ({ id, body }) => api.update(id, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-deals'] }); setEditing(null); toast.success('Αποθηκεύτηκε') },
onError: () => toast.error('Σφάλμα'),
})
const remove = useMutation({
mutationFn: api.remove,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-deals'] }); setDeleting(null); toast.success('Διαγράφηκε') },
onError: () => toast.error('Σφάλμα'),
})
const formProps = { modifiers, groups }
return (
<div className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-[14px] font-semibold text-slate-800">Προσφορές</h2>
<p className="text-[12px] text-slate-500 mt-0.5">
Αυτόματες δράσεις που ενεργοποιούνται όταν ο σερβιτόρος προσθέτει συγκεκριμένα προϊόντα στην παραγγελία.
</p>
</div>
<Button variant="primary" onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-1.5" />Νέα Προσφορά
</Button>
</div>
<div className="flex items-start gap-3 bg-sky-50 border border-sky-200 rounded-lg px-4 py-3">
<Info className="w-4 h-4 text-sky-500 shrink-0 mt-0.5" />
<p className="text-[12px] text-sky-700">
Όταν μια προσφορά ενεργοποιηθεί, ο σερβιτόρος λαμβάνει prompt στην εφαρμογή και επιλέγει αν θα την εφαρμόσει.
Τίποτα δεν εφαρμόζεται αυτόματα χωρίς επιβεβαίωση.
</p>
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<div className="w-5 h-5 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
</div>
) : deals.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
<Gift className="w-10 h-10 mb-3 opacity-30" />
<p className="text-[13px]">Δεν υπάρχουν προσφορές</p>
<p className="text-[12px] mt-1">π.χ. «Αγόρασε 3 μπύρες, πάρε 1 δωρεάν»</p>
</div>
) : (
<div className="space-y-3">
{deals.map(d => (
<DealCard key={d.id} deal={d} onEdit={d => { setEditing(d); setFormTab('info') }} onDelete={setDeleting} />
))}
</div>
)}
{showForm && (
<Modal title="Νέα Προσφορά" onClose={() => { setShowForm(false); setFormTab('info') }}
maxWidth="max-w-xl" tabs={DEAL_TABS} activeTab={formTab} onTabChange={setFormTab}
footer={<>
<Button type="button" variant="secondary" onClick={() => { setShowForm(false); setFormTab('info') }}>Ακύρωση</Button>
<Button type="submit" form="deal-form" variant="primary" disabled={create.isPending}>
{create.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</>}>
<DealForm onSave={body => create.mutate(body)} onCancel={() => { setShowForm(false); setFormTab('info') }}
saving={create.isPending} {...formProps} activeTab={formTab} onTabChange={setFormTab} />
</Modal>
)}
{editing && (
<Modal title="Επεξεργασία Προσφοράς" onClose={() => { setEditing(null); setFormTab('info') }}
maxWidth="max-w-xl" tabs={DEAL_TABS} activeTab={formTab} onTabChange={setFormTab}
footer={<>
<Button type="button" variant="secondary" onClick={() => { setEditing(null); setFormTab('info') }}>Ακύρωση</Button>
<Button type="submit" form="deal-form" variant="primary" disabled={update.isPending}>
{update.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</>}>
<DealForm initial={editing} onSave={body => update.mutate({ id: editing.id, body })}
onCancel={() => { setEditing(null); setFormTab('info') }} saving={update.isPending}
{...formProps} activeTab={formTab} onTabChange={setFormTab} />
</Modal>
)}
{deleting && (
<ConfirmModal title="Διαγραφή Προσφοράς"
message={`Θέλετε να διαγράψετε την προσφορά "${deleting.name}";`}
confirmLabel="Διαγραφή"
onConfirm={() => remove.mutate(deleting.id)}
onCancel={() => setDeleting(null)} />
)}
</div>
)
}

View File

@@ -0,0 +1,265 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Users, ShieldCheck, Save } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import Button from '../../../ui/Button'
// ─── API ─────────────────────────────────────────────────────────────────────
const api = {
globalGet: () => client.get('/api/pricing/discount-settings/global').then(r => r.data),
globalPut: body => client.put('/api/pricing/discount-settings/global', body).then(r => r.data),
waiterGet: id => client.get(`/api/pricing/discount-settings/${id}`).then(r => r.data),
waiterPut: (id,b) => client.put(`/api/pricing/discount-settings/${id}`, b).then(r => r.data),
staff: () => client.get('/api/waiters/').then(r => r.data),
}
// ─── Toggle helpers ───────────────────────────────────────────────────────────
function Toggle({ value, onChange }) {
return (
<div onClick={() => onChange(!value)}
className={`relative rounded-full cursor-pointer transition-colors shrink-0 ${value ? 'bg-sky-500' : 'bg-slate-200'}`}
style={{ width: 36, height: 20 }}>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${value ? 'translate-x-4' : ''}`} />
</div>
)
}
// ─── Numeric field ────────────────────────────────────────────────────────────
function NumField({ label, hint, value, onChange, step = 1, min = 0, unit = '' }) {
return (
<div className="space-y-1">
<label className="block text-[12px] font-medium text-slate-600">{label}</label>
{hint && <p className="text-[11px] text-slate-400">{hint}</p>}
<div className="flex items-center gap-1.5">
<input
type="number" min={min} step={step}
className="input w-28 text-[12px]"
placeholder="—"
value={value ?? ''}
onChange={e => onChange(e.target.value === '' ? null : Number(e.target.value))}
/>
{unit && <span className="text-[12px] text-slate-500">{unit}</span>}
{value !== null && value !== undefined && (
<button type="button" onClick={() => onChange(null)}
className="text-[11px] text-slate-400 hover:text-slate-600 underline ml-1">
Αφαίρεση
</button>
)}
</div>
</div>
)
}
// ─── Global settings panel ────────────────────────────────────────────────────
function GlobalPanel() {
const qc = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['discount-global'], queryFn: api.globalGet })
const blankGlobal = {
enabled: false,
max_total_value_workday: null,
max_total_value_shift: null,
max_items_per_shift: null,
}
const [f, setF] = useState(null)
const current = f ?? (data ? { ...blankGlobal, ...data } : blankGlobal)
const set = (k, v) => setF(p => ({ ...(p ?? current), [k]: v }))
const save = useMutation({
mutationFn: () => api.globalPut(current),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['discount-global'] }); setF(null); toast.success('Αποθηκεύτηκε') },
onError: () => toast.error('Σφάλμα'),
})
if (isLoading) return <div className="h-24 flex items-center justify-center"><div className="w-4 h-4 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" /></div>
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 space-y-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-sky-100 flex items-center justify-center shrink-0">
<ShieldCheck className="w-4 h-4 text-sky-600" />
</div>
<div>
<h3 className="text-[13px] font-semibold text-slate-800">Καθολικές Ρυθμίσεις</h3>
<p className="text-[11px] text-slate-500">Εφαρμόζονται σε όλους τους σερβιτόρους ανεξαρτήτως ατομικών ρυθμίσεων</p>
</div>
</div>
{/* Master enable */}
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<div>
<span className="text-[13px] font-medium text-slate-700">Εκπτώσεις Ενεργές</span>
<p className="text-[11px] text-slate-400 mt-0.5">Αν είναι ανενεργό, κανένας σερβιτόρος δεν μπορεί να εφαρμόσει εκπτώσεις</p>
</div>
<Toggle value={current.enabled ?? false} onChange={v => set('enabled', v)} />
</div>
{/* Limits */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<NumField label="Μέγιστο ανά εργάσιμη ημέρα" hint="Ολόκληρο το κατάστημα"
value={current.max_total_value_workday} onChange={v => set('max_total_value_workday', v)} step={0.5} unit="€" />
<NumField label="Μέγιστο ανά βάρδια" hint="Όλες οι βάρδιες μαζί"
value={current.max_total_value_shift} onChange={v => set('max_total_value_shift', v)} step={0.5} unit="€" />
<NumField label="Μέγιστα αντικείμενα/βάρδια" hint="Σύνολο αντικειμένων με έκπτωση"
value={current.max_items_per_shift} onChange={v => set('max_items_per_shift', v)} unit="τεμ." />
</div>
<div className="flex justify-end">
<Button variant="primary" onClick={() => save.mutate()} disabled={save.isPending}>
<Save className="w-3.5 h-3.5 mr-1.5" />{save.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</div>
</div>
)
}
// ─── Per-waiter settings panel ────────────────────────────────────────────────
const WAITER_BLANK = {
can_apply_discounts: false,
max_discount_percent: null,
max_discount_amount: null,
max_total_value_shift: null,
max_total_value_workday: null,
max_items_per_shift: null,
max_items_per_workday: null,
max_items_per_order: null,
}
function WaiterPanel({ waiters }) {
const qc = useQueryClient()
const [selectedId, setSelectedId] = useState(waiters[0]?.id ?? null)
const [dirty, setDirty] = useState(null)
const { data, isLoading } = useQuery({
queryKey: ['discount-waiter', selectedId],
queryFn: () => api.waiterGet(selectedId),
enabled: !!selectedId,
})
const current = dirty ?? (data ? { ...WAITER_BLANK, ...data } : WAITER_BLANK)
const set = (k, v) => setDirty(p => ({ ...(p ?? current), [k]: v }))
const save = useMutation({
mutationFn: () => api.waiterPut(selectedId, current),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['discount-waiter', selectedId] }); setDirty(null); toast.success('Αποθηκεύτηκε') },
onError: () => toast.error('Σφάλμα'),
})
const selectedWaiter = waiters.find(w => w.id === selectedId)
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 space-y-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-indigo-100 flex items-center justify-center shrink-0">
<Users className="w-4 h-4 text-indigo-600" />
</div>
<div>
<h3 className="text-[13px] font-semibold text-slate-800">Ανά Σερβιτόρο</h3>
<p className="text-[11px] text-slate-500">Ατομικά όρια ισχύουν παράλληλα με τα καθολικά</p>
</div>
</div>
{/* Waiter picker */}
<div className="flex flex-wrap gap-2">
{waiters.map(w => (
<button key={w.id} type="button"
onClick={() => { setSelectedId(w.id); setDirty(null) }}
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium border transition-colors ${
w.id === selectedId ? 'border-indigo-500 bg-indigo-50 text-indigo-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'
}`}>
{w.nickname || w.full_name || w.username}
</button>
))}
</div>
{!selectedId ? (
<p className="text-[12px] text-slate-400 text-center py-8">Επιλέξτε σερβιτόρο</p>
) : isLoading ? (
<div className="h-24 flex items-center justify-center"><div className="w-4 h-4 rounded-full border-2 border-indigo-400 border-t-transparent animate-spin" /></div>
) : (
<div className="space-y-5">
{/* Can apply toggle */}
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<div>
<span className="text-[13px] font-medium text-slate-700">
{selectedWaiter?.nickname || selectedWaiter?.full_name || selectedWaiter?.username} Δικαίωμα εκπτώσεων
</span>
<p className="text-[11px] text-slate-400 mt-0.5">Απενεργοποίηση κλειδώνει τον σερβιτόρο εντελώς</p>
</div>
<Toggle value={current.can_apply_discounts} onChange={v => set('can_apply_discounts', v)} />
</div>
{/* Per-discount limits */}
<div>
<p className="text-[11px] font-semibold text-slate-500 uppercase tracking-wide mb-3">Ανά Έκπτωση</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<NumField label="Μέγιστο ποσοστό %" hint="Μέγιστη έκπτωση σε % ανά εφαρμογή"
value={current.max_discount_percent} onChange={v => set('max_discount_percent', v)} step={5} unit="%" />
<NumField label="Μέγιστο ποσό €" hint="Μέγιστο € που αφαιρείται ανά εφαρμογή"
value={current.max_discount_amount} onChange={v => set('max_discount_amount', v)} step={0.5} unit="€" />
</div>
</div>
{/* Scope limits */}
<div>
<p className="text-[11px] font-semibold text-slate-500 uppercase tracking-wide mb-3">Συνολικά Όρια</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<NumField label="Αξία/Βάρδια" hint="Συνολική αξία εκπτώσεων ανά βάρδια"
value={current.max_total_value_shift} onChange={v => set('max_total_value_shift', v)} step={0.5} unit="€" />
<NumField label="Αξία/Εργάσιμη ημέρα" hint="Συνολική αξία εκπτώσεων ανά ημέρα"
value={current.max_total_value_workday} onChange={v => set('max_total_value_workday', v)} step={0.5} unit="€" />
<NumField label="Αντικείμενα/Βάρδια"
value={current.max_items_per_shift} onChange={v => set('max_items_per_shift', v)} unit="τεμ." />
<NumField label="Αντικείμενα/Εργάσιμη ημέρα"
value={current.max_items_per_workday} onChange={v => set('max_items_per_workday', v)} unit="τεμ." />
<NumField label="Αντικείμενα/Παραγγελία" hint="Μέγιστος αριθμός αντικειμένων με έκπτωση ανά παραγγελία"
value={current.max_items_per_order} onChange={v => set('max_items_per_order', v)} unit="τεμ." />
</div>
</div>
<div className="flex justify-end">
<Button variant="primary" onClick={() => save.mutate()} disabled={save.isPending}>
<Save className="w-3.5 h-3.5 mr-1.5" />{save.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</div>
</div>
)}
</div>
)
}
// ─── Main tab ─────────────────────────────────────────────────────────────────
export default function DiscountSettingsTab() {
const { data: staff = [] } = useQuery({ queryKey: ['waiters'], queryFn: api.staff })
const waiters = staff.filter(u => u.is_active !== false)
return (
<div className="p-6 space-y-5">
<div>
<h2 className="text-[14px] font-semibold text-slate-800">Ρυθμίσεις Εκπτώσεων</h2>
<p className="text-[12px] text-slate-500 mt-0.5">
Ελέγξτε ποιοι σερβιτόροι μπορούν να εφαρμόζουν εκπτώσεις και ποια είναι τα όρια ανά βάρδια ή εργάσιμη ημέρα.
</p>
</div>
<GlobalPanel />
{waiters.length > 0
? <WaiterPanel waiters={waiters} />
: (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
<Users className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p className="text-[13px]">Δεν βρέθηκαν ενεργοί σερβιτόροι</p>
</div>
)
}
</div>
)
}

View File

@@ -0,0 +1,810 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Plus, Percent, Power, Pencil, Trash2, Star, Layers,
GripVertical, ChevronDown, ChevronUp, X, Info,
} from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import Button from '../../../ui/Button'
import Modal from '../../../ui/Modal'
import { ConfirmModal } from '../../../ui/Modal'
// ─── API ─────────────────────────────────────────────────────────────────────
const api = {
list: (params) => client.get('/api/pricing/modifiers', { params }).then(r => r.data),
create: body => client.post('/api/pricing/modifiers', body).then(r => r.data),
update: (id, b) => client.put(`/api/pricing/modifiers/${id}`, b).then(r => r.data),
toggle: id => client.patch(`/api/pricing/modifiers/${id}/toggle`).then(r => r.data),
reorder: items => client.patch('/api/pricing/modifiers/reorder', { items }),
remove: id => client.delete(`/api/pricing/modifiers/${id}`),
groups: () => client.get('/api/pricing/groups').then(r => r.data),
products: () => client.get('/api/products', { params: { all: true } }).then(r => r.data),
categories: () => client.get('/api/products/categories').then(r => r.data),
prepZones: () => client.get('/api/prep-zones').then(r => r.data),
}
// ─── Constants ───────────────────────────────────────────────────────────────
const COLORS = [
'#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316',
'#eab308','#22c55e','#14b8a6','#0ea5e9','#64748b',
]
const CONDITION_TYPES = [
{ value: 'time_range', label: 'Ώρα ημέρας (εύρος)' },
{ value: 'date_range', label: 'Εύρος ημερομηνιών' },
{ value: 'specific_date', label: 'Συγκεκριμένη ημερομηνία' },
{ value: 'day_of_week', label: 'Ημέρα εβδομάδας' },
{ value: 'min_item_quantity', label: 'Ελάχιστη ποσότητα προϊόντος' },
{ value: 'min_category_qty', label: 'Ελάχιστη ποσότητα κατηγορίας' },
{ value: 'min_order_value', label: 'Ελάχιστη αξία παραγγελίας' },
{ value: 'order_channel', label: 'Κανάλι παραγγελίας' },
{ value: 'price_group_active', label: 'Ομάδα τιμής ενεργή' },
{ value: 'user_tier', label: 'Επίπεδο πελάτη (placeholder)' },
{ value: 'low_stock', label: 'Χαμηλό απόθεμα (placeholder)' },
]
const DAYS = ['Δευτέρα','Τρίτη','Τετάρτη','Πέμπτη','Παρασκευή','Σάββατο','Κυριακή']
const CHANNELS = ['pos','online','qr','takeaway']
const ROUND_OPTIONS = [
{ value: '', label: 'Χωρίς στρογγυλοποίηση' },
{ value: '0.05', label: 'Στο κοντινότερο €0,05' },
{ value: '0.10', label: 'Στο κοντινότερο €0,10' },
{ value: '0.20', label: 'Στο κοντινότερο €0,20' },
{ value: '0.50', label: 'Στο κοντινότερο €0,50' },
{ value: 'x.99', label: 'Σε x,99 (π.χ. 4,99)' },
{ value: 'x.00', label: 'Σε x,00 (στρογγυλό)' },
]
// ─── Condition editor ─────────────────────────────────────────────────────────
function ConditionEditor({ cond, onChange, onRemove, groups, categories, prepZones }) {
const set = (k, v) => onChange({ ...cond, params: { ...cond.params, [k]: v } })
const setType = t => onChange({ condition_type: t, params: {} })
return (
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
<div className="flex items-center gap-2">
<select className="input flex-1 text-[12px]" value={cond.condition_type}
onChange={e => setType(e.target.value)}>
{CONDITION_TYPES.map(ct => (
<option key={ct.value} value={ct.value}>{ct.label}</option>
))}
</select>
<button type="button" onClick={onRemove}
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
<X className="w-3.5 h-3.5" />
</button>
</div>
{/* Params per type */}
{cond.condition_type === 'time_range' && (
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
<input type="time" className="input w-full text-[12px]" value={cond.params.from ?? ''}
onChange={e => set('from', e.target.value)} />
</div>
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
<input type="time" className="input w-full text-[12px]" value={cond.params.to ?? ''}
onChange={e => set('to', e.target.value)} />
</div>
</div>
)}
{cond.condition_type === 'date_range' && (
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
<input type="date" className="input w-full text-[12px]" value={cond.params.from ?? ''}
onChange={e => set('from', e.target.value)} />
</div>
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
<input type="date" className="input w-full text-[12px]" value={cond.params.to ?? ''}
onChange={e => set('to', e.target.value)} />
</div>
</div>
)}
{cond.condition_type === 'specific_date' && (
<div>
<label className="block text-[11px] text-slate-500 mb-0.5">Ημερομηνίες (μία ανά γραμμή)</label>
<textarea className="input w-full text-[12px] font-mono" rows={3}
placeholder="YYYY-MM-DD"
value={(cond.params.dates ?? []).join('\n')}
onChange={e => set('dates', e.target.value.split('\n').map(s => s.trim()).filter(Boolean))} />
</div>
)}
{cond.condition_type === 'day_of_week' && (
<div className="flex flex-wrap gap-1.5">
{DAYS.map((d, i) => (
<button type="button" key={i}
onClick={() => {
const days = cond.params.days ?? []
set('days', days.includes(i) ? days.filter(x => x !== i) : [...days, i])
}}
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
(cond.params.days ?? []).includes(i) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{d.slice(0, 3)}</button>
))}
</div>
)}
{cond.condition_type === 'min_item_quantity' && (
<div>
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη ποσότητα</label>
<input type="number" min={1} className="input w-32 text-[12px]"
value={cond.params.min ?? 1} onChange={e => set('min', Number(e.target.value))} />
</div>
)}
{cond.condition_type === 'min_category_qty' && (
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Κατηγορία</label>
<select className="input w-full text-[12px]" value={cond.params.category_id ?? ''}
onChange={e => set('category_id', Number(e.target.value))}>
<option value=""> Επιλογή </option>
{(categories ?? []).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="w-24">
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχ.</label>
<input type="number" min={1} className="input w-full text-[12px]"
value={cond.params.min ?? 1} onChange={e => set('min', Number(e.target.value))} />
</div>
</div>
)}
{cond.condition_type === 'min_order_value' && (
<div>
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη αξία παραγγελίας ()</label>
<input type="number" min={0} step={0.5} className="input w-32 text-[12px]"
value={cond.params.min ?? 0} onChange={e => set('min', Number(e.target.value))} />
</div>
)}
{cond.condition_type === 'order_channel' && (
<div className="flex flex-wrap gap-1.5">
{CHANNELS.map(ch => (
<button type="button" key={ch}
onClick={() => {
const channels = cond.params.channels ?? []
set('channels', channels.includes(ch) ? channels.filter(x => x !== ch) : [...channels, ch])
}}
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
(cond.params.channels ?? []).includes(ch) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{ch}</button>
))}
</div>
)}
{cond.condition_type === 'price_group_active' && (
<div>
<label className="block text-[11px] text-slate-500 mb-0.5">Ομάδα τιμής</label>
<select className="input w-full text-[12px]" value={cond.params.price_group_id ?? ''}
onChange={e => set('price_group_id', Number(e.target.value))}>
<option value=""> Επιλογή </option>
{(groups ?? []).map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
)}
{(cond.condition_type === 'user_tier' || cond.condition_type === 'low_stock') && (
<p className="text-[11px] text-amber-600 bg-amber-50 rounded px-2 py-1 flex items-center gap-1.5">
<Info className="w-3.5 h-3.5 shrink-0" />
Placeholder θα ενεργοποιηθεί σε μελλοντική έκδοση
</p>
)}
</div>
)
}
// ─── Shared multi-picker ──────────────────────────────────────────────────────
function MultiPicker({ items, selected, onToggle, labelKey = 'name', valueKey = 'id', placeholder = 'Επιλέξτε...' }) {
return (
<div className="border border-slate-200 rounded-lg overflow-hidden">
{items.length === 0 && (
<p className="text-[11px] text-slate-400 px-3 py-2">{placeholder}</p>
)}
{items.map(item => {
const val = item[valueKey]
const checked = selected.includes(val)
return (
<div key={val} onClick={() => onToggle(val)}
className={`flex items-center gap-2.5 px-3 py-1.5 cursor-pointer transition-colors text-[12px] border-b border-slate-100 last:border-0 ${
checked ? 'bg-sky-50 text-sky-700' : 'hover:bg-slate-50 text-slate-700'
}`}>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
checked ? 'bg-sky-500 border-sky-500' : 'border-slate-300 bg-white'
}`}>
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l3 3 5-6" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
{item[labelKey]}
</div>
)
})}
</div>
)
}
function TagPicker({ selected, onToggle, onAdd, allTags }) {
const [draft, setDraft] = useState('')
const handleAdd = () => {
const tag = draft.trim()
if (tag && !selected.includes(tag)) onAdd(tag)
setDraft('')
}
return (
<div className="space-y-2">
{/* Quick-select existing tags */}
{allTags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{allTags.map(tag => {
const active = selected.includes(tag)
return (
<button type="button" key={tag} onClick={() => onToggle(tag)}
className={`px-2 py-0.5 rounded-full text-[11px] font-medium border transition-colors ${
active ? 'bg-sky-500 border-sky-500 text-white' : 'border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-600'
}`}>{tag}</button>
)
})}
</div>
)}
{/* Selected chips */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selected.map(tag => (
<span key={tag} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-100 text-sky-700 text-[11px] font-medium">
{tag}
<button type="button" onClick={() => onToggle(tag)} className="hover:text-sky-900">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{/* Manual entry */}
<div className="flex gap-2">
<input className="input flex-1 text-[12px]" placeholder="Νέο tag..." value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd() } }} />
<button type="button" onClick={handleAdd}
className="px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 text-[12px] hover:bg-slate-200 transition-colors">
+
</button>
</div>
</div>
)
}
// ─── Target editor ────────────────────────────────────────────────────────────
function TargetEditor({ target, onChange, onRemove, products, categories, prepZones, allTags }) {
const ids = target.target_ids ?? []
const tags = target.target_tags ?? []
const setIds = v => onChange({ ...target, target_ids: v, target_id: v[0] ?? null })
const setTags = v => onChange({ ...target, target_tags: v, target_tag: v[0] ?? null })
const toggleId = id => setIds(ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
const toggleTag = tag => setTags(tags.includes(tag) ? tags.filter(x => x !== tag) : [...tags, tag])
return (
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
<div className="flex items-center gap-2">
<select className="input flex-1 text-[12px]" value={target.target_type}
onChange={e => onChange({ target_type: e.target.value, target_id: null, target_tag: null, target_ids: [], target_tags: [] })}>
<option value="all">Όλα τα προϊόντα</option>
<option value="item">Προϊόντα</option>
<option value="category">Κατηγορίες</option>
<option value="prep_zone">Ζώνες ετοιμασίας</option>
<option value="tag">Ετικέτες (tags)</option>
</select>
<button type="button" onClick={onRemove}
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
<X className="w-3.5 h-3.5" />
</button>
</div>
{target.target_type === 'item' && (
<MultiPicker items={products} selected={ids} onToggle={toggleId} />
)}
{target.target_type === 'category' && (
<MultiPicker items={categories} selected={ids} onToggle={toggleId} />
)}
{target.target_type === 'prep_zone' && (
<MultiPicker items={prepZones} selected={ids} onToggle={toggleId} />
)}
{target.target_type === 'tag' && (
<TagPicker selected={tags} onToggle={toggleTag} onAdd={tag => setTags([...tags, tag])} allTags={allTags} />
)}
</div>
)
}
// ─── Modifier form ────────────────────────────────────────────────────────────
const MODIFIER_TABS = [
{ key: 'info', label: 'Πληροφορίες' },
{ key: 'target', label: 'Στόχος' },
{ key: 'conditions', label: 'Συνθήκες' },
{ key: 'price', label: 'Τιμή' },
]
function ModifierForm({ initial, onSave, onCancel, saving, groups, activeTab, onTabChange }) {
const { data: products = [] } = useQuery({
queryKey: ['products-all-pricing'],
queryFn: () => client.get('/api/products/', { params: { all: true } }).then(r => r.data),
})
const { data: categories = [] } = useQuery({
queryKey: ['categories'],
queryFn: () => client.get('/api/products/categories').then(r => r.data),
staleTime: 60_000,
})
const { data: prepZones = [] } = useQuery({
queryKey: ['prep-zones'],
queryFn: () => client.get('/api/prep-zones').then(r => r.data),
staleTime: 60_000,
})
const { data: allTags = [] } = useQuery({
queryKey: ['product-tags'],
queryFn: () => client.get('/api/products/tags').then(r => r.data),
staleTime: 60_000,
})
const normalizeTarget = t => ({
...t,
target_ids: t.target_ids ?? (t.target_id != null ? [t.target_id] : []),
target_tags: t.target_tags ?? (t.target_tag ? [t.target_tag] : []),
})
const blank = {
name: '', description: '', color: COLORS[0], is_active: true, is_favorite: false,
allow_stack: false, sort_order: 0, scope: 'global', item_id: null,
action_type: 'add_percent', action_value: -10, round_to: '',
conditions: [], targets: [{ target_type: 'all', target_id: null, target_tag: null, target_ids: [], target_tags: [] }],
}
const [f, setF] = useState(initial ? {
...initial,
action_value: initial.action_value ?? 0,
round_to: initial.round_to ?? '',
conditions: initial.conditions ?? [],
targets: (initial.targets ?? []).map(normalizeTarget),
} : blank)
const set = (k, v) => setF(p => ({ ...p, [k]: v }))
const addCondition = () => set('conditions', [...f.conditions, { condition_type: 'time_range', params: {} }])
const updateCondition = (i, c) => set('conditions', f.conditions.map((x, j) => j === i ? c : x))
const removeCondition = i => set('conditions', f.conditions.filter((_, j) => j !== i))
const addTarget = () => set('targets', [...f.targets, { target_type: 'all', target_id: null, target_tag: null, target_ids: [], target_tags: [] }])
const updateTarget = (i, t) => set('targets', f.targets.map((x, j) => j === i ? t : x))
const removeTarget = i => set('targets', f.targets.filter((_, j) => j !== i))
function submit(e) {
e.preventDefault()
if (!f.name.trim()) return toast.error('Απαιτείται όνομα')
if (f.scope === 'item' && !f.item_id) return toast.error('Επιλέξτε προϊόν για item-scope')
onSave({ ...f, round_to: f.round_to || null })
}
const actionLabel = f.action_type === 'set' ? 'Νέα τιμή (€)' :
f.action_type === 'add_amount' ? 'Ποσό (€, αρνητικό = έκπτωση)' :
'Ποσοστό (%, αρνητικό = έκπτωση)'
return (
<form id="modifier-form" onSubmit={submit} className="space-y-5">
{/* ── Info tab ── */}
{activeTab === 'info' && (
<>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-[12px] font-medium text-slate-600 mb-1">Όνομα</label>
<input className="input w-full" value={f.name} onChange={e => set('name', e.target.value)}
placeholder="π.χ. Happy Hour -10%" autoFocus />
</div>
<div className="col-span-2">
<label className="block text-[12px] font-medium text-slate-600 mb-1">Περιγραφή (προαιρετική)</label>
<input className="input w-full" value={f.description ?? ''} onChange={e => set('description', e.target.value)} />
</div>
</div>
<div>
<label className="block text-[12px] font-medium text-slate-600 mb-1.5">Χρώμα</label>
<div className="flex flex-wrap gap-2">
{COLORS.map(c => (
<button type="button" key={c} onClick={() => set('color', c)}
className={`w-6 h-6 rounded-full transition-transform ${f.color === c ? 'ring-2 ring-offset-1 ring-slate-400 scale-110' : 'hover:scale-105'}`}
style={{ background: c }} />
))}
</div>
</div>
<div className="flex flex-wrap gap-4">
{[
{ key: 'is_active', label: 'Ενεργός' },
{ key: 'is_favorite', label: 'Αγαπημένος (dashboard)' },
{ key: 'allow_stack', label: 'Επιτρέπει συσσώρευση' },
].map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 cursor-pointer select-none">
<div onClick={() => set(key, !f[key])}
className={`relative rounded-full transition-colors ${f[key] ? 'bg-sky-500' : 'bg-slate-200'}`}
style={{ height: '18px', width: '32px' }}>
<span className={`absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-white shadow transition-transform ${f[key] ? 'translate-x-3.5' : ''}`} />
</div>
<span className="text-[12px] text-slate-700">{label}</span>
</label>
))}
</div>
</>
)}
{/* ── Target tab ── */}
{activeTab === 'target' && (
<>
<div>
<label className="block text-[12px] font-medium text-slate-600 mb-1">Εμβέλεια</label>
<div className="flex gap-2">
{[['global','Καθολικός (με στόχους)'],['item','Συγκεκριμένο προϊόν']].map(([v, l]) => (
<button type="button" key={v} onClick={() => set('scope', v)}
className={`flex-1 py-2 rounded-lg border text-[12px] font-medium transition-colors ${
f.scope === v ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{l}</button>
))}
</div>
{f.scope === 'item' && (
<select className="input w-full mt-2 text-[13px]" value={f.item_id ?? ''}
onChange={e => set('item_id', Number(e.target.value))}>
<option value=""> Επιλογή προϊόντος </option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
)}
</div>
{f.scope === 'global' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">
Στόχοι <span className="font-normal text-slate-400 normal-case ml-1">(OR αρκεί ένας)</span>
</span>
<button type="button" onClick={addTarget}
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
<Plus className="w-3.5 h-3.5" />Προσθήκη
</button>
</div>
{f.targets.length === 0 && (
<p className="text-[12px] text-amber-600 bg-amber-50 rounded-lg px-3 py-2.5">
Χωρίς στόχο ο τροποποιητής δεν θα εφαρμοστεί σε κανένα προϊόν.
</p>
)}
{f.targets.map((t, i) => (
<TargetEditor key={i} target={t}
onChange={v => updateTarget(i, v)}
onRemove={() => removeTarget(i)}
products={products} categories={categories} prepZones={prepZones} allTags={allTags} />
))}
</div>
)}
</>
)}
{/* ── Conditions tab ── */}
{activeTab === 'conditions' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">Συνθήκες</span>
<span className="text-[11px] text-slate-400 ml-1.5">AND όλες πρέπει να ισχύουν</span>
</div>
<button type="button" onClick={addCondition}
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
<Plus className="w-3.5 h-3.5" />Προσθήκη
</button>
</div>
{f.conditions.length === 0 && (
<p className="text-[12px] text-slate-400 bg-slate-50 rounded-lg px-3 py-2.5">
Χωρίς συνθήκες ο τροποποιητής εφαρμόζεται χειροκίνητα μέσω ενεργοποίησης.
</p>
)}
{f.conditions.map((c, i) => (
<ConditionEditor key={i} cond={c}
onChange={v => updateCondition(i, v)}
onRemove={() => removeCondition(i)}
groups={groups} categories={categories} prepZones={prepZones} />
))}
</div>
)}
{/* ── Price Modifier tab ── */}
{activeTab === 'price' && (
<div className="space-y-3">
<div className="flex gap-2">
{[['set','Ορισμός τιμής'],['add_amount','± Ποσό €'],['add_percent','± Ποσοστό %']].map(([v,l]) => (
<button type="button" key={v} onClick={() => set('action_type', v)}
className={`flex-1 py-1.5 rounded-lg border text-[12px] font-medium transition-colors ${
f.action_type === v ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{l}</button>
))}
</div>
<div className="flex gap-3 items-end">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">{actionLabel}</label>
<input type="number" step={f.action_type === 'set' ? 0.01 : f.action_type === 'add_amount' ? 0.1 : 1}
className="input w-full" value={f.action_value}
onChange={e => set('action_value', Number(e.target.value))} />
</div>
{f.action_type === 'add_percent' && (
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Στρογγυλοποίηση (προαιρ.)</label>
<select className="input w-full text-[12px]" value={f.round_to ?? ''}
onChange={e => set('round_to', e.target.value)}>
{ROUND_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
)}
</div>
<p className="text-[11px] text-slate-400">
Το σύστημα στρογγυλοποιεί πάντα την τελική τιμή στο κοντινότερο 0,10 ανεξάρτητα από την παραπάνω επιλογή.
</p>
</div>
)}
</form>
)
}
// ─── Modifier card ────────────────────────────────────────────────────────────
function ModifierCard({ modifier, onEdit, onDelete, dragHandleProps }) {
const qc = useQueryClient()
const [expanded, setExpanded] = useState(false)
const toggle = useMutation({
mutationFn: () => api.toggle(modifier.id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }),
onError: () => toast.error('Σφάλμα'),
})
const actionText = modifier.action_type === 'set'
? `= €${modifier.action_value}`
: modifier.action_type === 'add_amount'
? `${modifier.action_value >= 0 ? '+' : ''}${modifier.action_value}`
: `${modifier.action_value >= 0 ? '+' : ''}${modifier.action_value}%`
const condCount = modifier.conditions?.length ?? 0
const targetSummary = modifier.scope === 'item'
? 'item-scope'
: modifier.targets?.length === 1 && modifier.targets[0]?.target_type === 'all'
? 'Όλα τα προϊόντα'
: `${modifier.targets?.length ?? 0} στόχοι`
return (
<div className={`bg-white rounded-xl border shadow-sm transition-opacity ${modifier.is_active ? 'border-slate-200' : 'border-slate-100 opacity-60'}`}>
<div className="flex items-center gap-2 px-3 py-3">
{/* Drag handle */}
<div {...dragHandleProps} className="cursor-grab text-slate-300 hover:text-slate-400 shrink-0">
<GripVertical className="w-4 h-4" />
</div>
{/* Color bar */}
<div className="w-1 h-8 rounded-full shrink-0" style={{ background: modifier.color ?? '#64748b' }} />
{/* Name + badges */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[13px] font-semibold text-slate-800 truncate">{modifier.name}</span>
{modifier.is_favorite && <Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" />}
{modifier.allow_stack && <Layers className="w-3 h-3 text-sky-400 shrink-0" title="Stackable" />}
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full ${
modifier.action_value < 0 ? 'bg-emerald-100 text-emerald-700' : 'bg-sky-100 text-sky-700'
}`}>{actionText}</span>
<span className="text-[10px] text-slate-400">{condCount === 0 ? 'χειροκίνητος' : `${condCount} συνθ.`} · {targetSummary}</span>
</div>
{modifier.description && <p className="text-[11px] text-slate-400 truncate mt-0.5">{modifier.description}</p>}
</div>
{/* Actions */}
<div className="flex items-center gap-0.5 shrink-0">
<button onClick={() => setExpanded(e => !e)}
className="p-1.5 rounded text-slate-400 hover:bg-slate-100 transition-colors">
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
<button onClick={() => toggle.mutate()}
className={`p-1.5 rounded transition-colors ${
modifier.is_active ? 'text-emerald-600 hover:bg-emerald-50' : 'text-slate-400 hover:bg-slate-100'
}`} title={modifier.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}>
<Power className="w-3.5 h-3.5" />
</button>
<button onClick={() => onEdit(modifier)}
className="p-1.5 rounded text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => onDelete(modifier)}
className="p-1.5 rounded text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Expanded detail */}
{expanded && (
<div className="border-t border-slate-100 px-4 py-3 text-[12px] text-slate-500 space-y-1.5">
{modifier.conditions?.length === 0 && <p className="italic">Χωρίς αυτόματες συνθήκες μόνο χειροκίνητη ενεργοποίηση</p>}
{modifier.conditions?.map((c, i) => (
<div key={i} className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-sky-400 shrink-0" />
<span>{CONDITION_TYPES.find(x => x.value === c.condition_type)?.label ?? c.condition_type}</span>
<span className="text-slate-400 font-mono text-[11px]">{JSON.stringify(c.params)}</span>
</div>
))}
</div>
)}
</div>
)
}
// ─── Main tab ─────────────────────────────────────────────────────────────────
export default function ModifiersTab() {
const qc = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState(null)
const [deleting, setDeleting] = useState(null)
const [formTab, setFormTab] = useState('info')
const [scopeFilter, setScopeFilter] = useState('all')
const [items, setItems] = useState(null)
const [dragging, setDragging] = useState(null)
const { data: rawModifiers = [], isLoading } = useQuery({
queryKey: ['pricing-modifiers'],
queryFn: () => api.list(),
})
const modifiers = items ?? rawModifiers
const { data: groups = [] } = useQuery({ queryKey: ['pricing-groups'], queryFn: api.groups, staleTime: 30_000 })
const create = useMutation({
mutationFn: api.create,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }); setItems(null); setShowForm(false); toast.success('Δημιουργήθηκε') },
onError: () => toast.error('Σφάλμα'),
})
const update = useMutation({
mutationFn: ({ id, body }) => api.update(id, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }); setItems(null); setEditing(null); toast.success('Αποθηκεύτηκε') },
onError: () => toast.error('Σφάλμα'),
})
const remove = useMutation({
mutationFn: api.remove,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }); setItems(null); setDeleting(null); toast.success('Διαγράφηκε') },
onError: () => toast.error('Σφάλμα'),
})
const reorder = useMutation({
mutationFn: api.reorder,
onError: () => { setItems(null); toast.error('Σφάλμα αναδιάταξης') },
})
// Simple drag-to-reorder (no extra library — HTML5 DnD)
function handleDragStart(e, id) {
setDragging(id)
e.dataTransfer.effectAllowed = 'move'
}
function handleDragOver(e, id) {
e.preventDefault()
if (dragging == null || dragging === id) return
const from = modifiers.findIndex(m => m.id === dragging)
const to = modifiers.findIndex(m => m.id === id)
if (from === -1 || to === -1) return
const reordered = [...modifiers]
const [moved] = reordered.splice(from, 1)
reordered.splice(to, 0, moved)
setItems(reordered)
}
function handleDrop() {
setDragging(null)
const payload = (items ?? modifiers).map((m, i) => ({ id: m.id, sort_order: i }))
reorder.mutate(payload)
}
const filtered = scopeFilter === 'all' ? modifiers : modifiers.filter(m => m.scope === scopeFilter)
return (
<div className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-[14px] font-semibold text-slate-800">Τροποποιητές Τιμής</h2>
<p className="text-[12px] text-slate-500 mt-0.5">
Κανόνες που αλλάζουν τιμές αυτόματα ή χειροκίνητα. Η σειρά καθορίζει προτεραιότητα.
</p>
</div>
<Button variant="primary" onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-1.5" />Νέος Τροποποιητής
</Button>
</div>
{/* Filter pills */}
<div className="flex gap-2">
{[['all','Όλοι'],['global','Καθολικοί'],['item','Item-scope']].map(([v,l]) => (
<button key={v} onClick={() => setScopeFilter(v)}
className={`px-3 py-1 rounded-full text-[12px] font-medium transition-colors ${
scopeFilter === v ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}>{l}</button>
))}
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<div className="w-5 h-5 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
<Percent className="w-10 h-10 mb-3 opacity-30" />
<p className="text-[13px]">Δεν υπάρχουν τροποποιητές</p>
<p className="text-[12px] mt-1">Δημιουργήστε έναν για Happy Hour, εποχιακές τιμές κ.λπ.</p>
</div>
) : (
<div className="space-y-2" onDragOver={e => e.preventDefault()} onDrop={handleDrop}>
{filtered.map(m => (
<div key={m.id} draggable
onDragStart={e => handleDragStart(e, m.id)}
onDragOver={e => handleDragOver(e, m.id)}
className={dragging === m.id ? 'opacity-50' : ''}>
<ModifierCard modifier={m} onEdit={m => { setEditing(m); setFormTab('info') }} onDelete={setDeleting}
dragHandleProps={{
onMouseDown: () => {},
title: 'Σύρετε για αναδιάταξη',
}} />
</div>
))}
</div>
)}
{/* Create */}
{showForm && (
<Modal title="Νέος Τροποποιητής Τιμής" onClose={() => { setShowForm(false); setFormTab('info') }}
maxWidth="max-w-xl" tabs={MODIFIER_TABS} activeTab={formTab} onTabChange={setFormTab}
footer={<>
<Button type="button" variant="secondary" onClick={() => { setShowForm(false); setFormTab('info') }}>Ακύρωση</Button>
<Button type="submit" form="modifier-form" variant="primary" disabled={create.isPending}>
{create.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</>}>
<ModifierForm onSave={body => create.mutate(body)} onCancel={() => { setShowForm(false); setFormTab('info') }}
saving={create.isPending} groups={groups} activeTab={formTab} onTabChange={setFormTab} />
</Modal>
)}
{/* Edit */}
{editing && (
<Modal title="Επεξεργασία Τροποποιητή" onClose={() => { setEditing(null); setFormTab('info') }}
maxWidth="max-w-xl" tabs={MODIFIER_TABS} activeTab={formTab} onTabChange={setFormTab}
footer={<>
<Button type="button" variant="secondary" onClick={() => { setEditing(null); setFormTab('info') }}>Ακύρωση</Button>
<Button type="submit" form="modifier-form" variant="primary" disabled={update.isPending}>
{update.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</>}>
<ModifierForm initial={editing} onSave={body => update.mutate({ id: editing.id, body })}
onCancel={() => { setEditing(null); setFormTab('info') }} saving={update.isPending}
groups={groups} activeTab={formTab} onTabChange={setFormTab} />
</Modal>
)}
{/* Delete */}
{deleting && (
<ConfirmModal title="Διαγραφή Τροποποιητή"
message={`Θέλετε να διαγράψετε τον τροποποιητή "${deleting.name}";`}
confirmLabel="Διαγραφή"
onConfirm={() => remove.mutate(deleting.id)}
onCancel={() => setDeleting(null)} />
)}
</div>
)
}

View File

@@ -0,0 +1,280 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Tag, Power, Pencil, Trash2, Clock } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import Button from '../../../ui/Button'
import Modal from '../../../ui/Modal'
import { ConfirmModal } from '../../../ui/Modal'
const DAYS = ['Δευ', 'Τρι', 'Τετ', 'Πεμ', 'Παρ', 'Σαβ', 'Κυρ']
const api = {
list: () => client.get('/api/pricing/groups').then(r => r.data),
create: body => client.post('/api/pricing/groups', body).then(r => r.data),
update: (id,b) => client.put(`/api/pricing/groups/${id}`, b).then(r => r.data),
toggle: id => client.patch(`/api/pricing/groups/${id}/toggle`).then(r => r.data),
remove: id => client.delete(`/api/pricing/groups/${id}`),
}
const COLORS = [
'#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316',
'#eab308','#22c55e','#14b8a6','#0ea5e9','#64748b',
]
function GroupForm({ initial, onSave, onCancel, saving }) {
const blank = { name: '', description: '', color: COLORS[0], is_active: false,
auto_enable_time: '', auto_disable_time: '', auto_days: [] }
const [f, setF] = useState(initial ? {
...initial,
auto_days: initial.auto_days ?? [],
} : blank)
const set = (k, v) => setF(p => ({ ...p, [k]: v }))
const toggleDay = d => set('auto_days', f.auto_days.includes(d)
? f.auto_days.filter(x => x !== d)
: [...f.auto_days, d])
const hasSchedule = f.auto_enable_time || f.auto_disable_time || f.auto_days.length > 0
function submit(e) {
e.preventDefault()
if (!f.name.trim()) return toast.error('Απαιτείται όνομα')
onSave({
...f,
auto_days: f.auto_days.length ? f.auto_days : null,
auto_enable_time: f.auto_enable_time || null,
auto_disable_time: f.auto_disable_time || null,
})
}
return (
<form onSubmit={submit} className="space-y-4">
{/* Name */}
<div>
<label className="block text-[12px] font-medium text-slate-600 mb-1">Όνομα</label>
<input className="input w-full" value={f.name} onChange={e => set('name', e.target.value)}
placeholder="π.χ. Happy Hour" autoFocus />
</div>
{/* Description */}
<div>
<label className="block text-[12px] font-medium text-slate-600 mb-1">Περιγραφή (προαιρετική)</label>
<input className="input w-full" value={f.description ?? ''} onChange={e => set('description', e.target.value)}
placeholder="Σύντομη περιγραφή..." />
</div>
{/* Color */}
<div>
<label className="block text-[12px] font-medium text-slate-600 mb-2">Χρώμα</label>
<div className="flex flex-wrap gap-2">
{COLORS.map(c => (
<button type="button" key={c} onClick={() => set('color', c)}
className={`w-7 h-7 rounded-full transition-transform ${f.color === c ? 'ring-2 ring-offset-2 ring-slate-400 scale-110' : 'hover:scale-105'}`}
style={{ background: c }} />
))}
<input type="color" value={f.color ?? '#6366f1'} onChange={e => set('color', e.target.value)}
className="w-7 h-7 rounded-full border-0 cursor-pointer p-0.5 bg-transparent" title="Προσαρμοσμένο χρώμα" />
</div>
</div>
{/* Active toggle */}
<label className="flex items-center gap-3 cursor-pointer select-none">
<div onClick={() => set('is_active', !f.is_active)}
className={`relative w-9 h-5 rounded-full transition-colors ${f.is_active ? 'bg-sky-500' : 'bg-slate-200'}`}>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${f.is_active ? 'translate-x-4' : ''}`} />
</div>
<span className="text-[13px] font-medium text-slate-700">Ενεργή τώρα</span>
</label>
{/* Auto-schedule */}
<div className="rounded-lg border border-slate-200 p-3 space-y-3">
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-slate-400" />
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">Αυτόματο Χρονοδιάγραμμα</span>
<span className="text-[11px] text-slate-400">(προαιρετικό)</span>
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-1">Ενεργοποίηση</label>
<input type="time" className="input w-full text-[13px]"
value={f.auto_enable_time ?? ''} onChange={e => set('auto_enable_time', e.target.value)} />
</div>
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-1">Απενεργοποίηση</label>
<input type="time" className="input w-full text-[13px]"
value={f.auto_disable_time ?? ''} onChange={e => set('auto_disable_time', e.target.value)} />
</div>
</div>
<div>
<label className="block text-[11px] text-slate-500 mb-1.5">Ημέρες</label>
<div className="flex gap-1.5 flex-wrap">
{DAYS.map((d, i) => (
<button type="button" key={i} onClick={() => toggleDay(i)}
className={`px-2.5 py-1 rounded text-[12px] font-medium transition-colors ${
f.auto_days.includes(i) ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}>{d}</button>
))}
</div>
</div>
{hasSchedule && (!f.auto_enable_time || !f.auto_disable_time || !f.auto_days.length) && (
<p className="text-[11px] text-amber-600 bg-amber-50 rounded px-2 py-1">
Για πλήρες χρονοδιάγραμμα ορίστε ώρα ενεργοποίησης, ώρα απενεργοποίησης και τουλάχιστον μία ημέρα.
</p>
)}
</div>
<Modal.Footer>
<Button type="button" variant="secondary" onClick={onCancel}>Ακύρωση</Button>
<Button type="submit" variant="primary" disabled={saving}>
{saving ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</Modal.Footer>
</form>
)
}
function GroupCard({ group, onEdit, onDelete }) {
const qc = useQueryClient()
const toggle = useMutation({
mutationFn: () => api.toggle(group.id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['pricing-groups'] }),
onError: () => toast.error('Σφάλμα'),
})
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-4 flex items-start gap-3">
{/* Color dot */}
<div className="w-3 h-3 rounded-full mt-1 shrink-0" style={{ background: group.color ?? '#6366f1' }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-semibold text-slate-800 truncate">{group.name}</span>
<span className={`text-[11px] font-medium px-1.5 py-0.5 rounded-full ${
group.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'
}`}>{group.is_active ? 'Ενεργή' : 'Ανενεργή'}</span>
</div>
{group.description && <p className="text-[12px] text-slate-500 mt-0.5 truncate">{group.description}</p>}
{group.auto_enable_time && group.auto_disable_time && (
<p className="text-[11px] text-slate-400 mt-1 flex items-center gap-1">
<Clock className="w-3 h-3" />
{group.auto_enable_time}{group.auto_disable_time}
{group.auto_days?.length > 0 && (
<span className="ml-1">{group.auto_days.map(d => DAYS[d]).join(', ')}</span>
)}
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{/* Quick toggle */}
<button onClick={() => toggle.mutate()}
className={`p-1.5 rounded-lg transition-colors ${
group.is_active ? 'text-emerald-600 hover:bg-emerald-50' : 'text-slate-400 hover:bg-slate-100'
}`} title={group.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}>
<Power className="w-4 h-4" />
</button>
<button onClick={() => onEdit(group)}
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
<Pencil className="w-4 h-4" />
</button>
<button onClick={() => onDelete(group)}
className="p-1.5 rounded-lg text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)
}
export default function PriceGroupsTab() {
const qc = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState(null)
const [deleting, setDeleting] = useState(null)
const { data: groups = [], isLoading } = useQuery({ queryKey: ['pricing-groups'], queryFn: api.list })
const create = useMutation({
mutationFn: api.create,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-groups'] }); setShowForm(false); toast.success('Ομάδα δημιουργήθηκε') },
onError: () => toast.error('Σφάλμα δημιουργίας'),
})
const update = useMutation({
mutationFn: ({ id, body }) => api.update(id, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-groups'] }); setEditing(null); toast.success('Αποθηκεύτηκε') },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const remove = useMutation({
mutationFn: api.remove,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-groups'] }); setDeleting(null); toast.success('Διαγράφηκε') },
onError: () => toast.error('Σφάλμα διαγραφής'),
})
return (
<div className="p-6 space-y-4">
{/* Header row */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-[14px] font-semibold text-slate-800">Ομάδες Τιμών</h2>
<p className="text-[12px] text-slate-500 mt-0.5">
Ονομαστές ομάδες που ενεργοποιούνται χειροκίνητα ή αυτόματα. Χρησιμοποιούνται ως συνθήκη σε τροποποιητές τιμής.
</p>
</div>
<Button variant="primary" onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-1.5" />Νέα Ομάδα
</Button>
</div>
{/* List */}
{isLoading ? (
<div className="flex justify-center py-12">
<div className="w-5 h-5 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
</div>
) : groups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
<Tag className="w-10 h-10 mb-3 opacity-30" />
<p className="text-[13px]">Δεν υπάρχουν ομάδες τιμών</p>
<p className="text-[12px] mt-1">Δημιουργήστε μια ομάδα για Happy Hour, Εποχή κ.λπ.</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{groups.map(g => (
<GroupCard key={g.id} group={g}
onEdit={setEditing}
onDelete={setDeleting} />
))}
</div>
)}
{/* Create modal */}
{showForm && (
<Modal title="Νέα Ομάδα Τιμών" onClose={() => setShowForm(false)} maxWidth="max-w-md">
<GroupForm onSave={body => create.mutate(body)} onCancel={() => setShowForm(false)} saving={create.isPending} />
</Modal>
)}
{/* Edit modal */}
{editing && (
<Modal title="Επεξεργασία Ομάδας" onClose={() => setEditing(null)} maxWidth="max-w-md">
<GroupForm initial={editing} onSave={body => update.mutate({ id: editing.id, body })}
onCancel={() => setEditing(null)} saving={update.isPending} />
</Modal>
)}
{/* Delete confirm */}
{deleting && (
<ConfirmModal
title="Διαγραφή Ομάδας"
message={`Θέλετε σίγουρα να διαγράψετε την ομάδα "${deleting.name}"; Τυχόν τροποποιητές που τη χρησιμοποιούν δεν θα επηρεαστούν αλλά η συνθήκη δεν θα εκπληρώνεται πλέον.`}
confirmLabel="Διαγραφή"
onConfirm={() => remove.mutate(deleting.id)}
onCancel={() => setDeleting(null)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,854 @@
import { useState, useEffect, useRef } from 'react'
// ── Helpers ───────────────────────────────────────────────────────────────────
const DECIMAL_UNIT_CONFIG = {
kg: { step: 0.05, min: 0.05, presets: [0.10, 0.20, 0.25, 0.50, 0.70, 1.00, 1.50, 2.00, 2.50, 3.00, 4.00, 5.00], offsets: [0.1, 0.2, 0.5, 1.0], label: 'kg' },
liter: { step: 0.05, min: 0.05, presets: [0.10, 0.20, 0.25, 0.50, 0.70, 1.00, 1.50, 2.00, 2.50, 3.00, 4.00, 5.00], offsets: [0.1, 0.2, 0.5, 1.0], label: 'L' },
gram: { step: 10, min: 10, presets: [50, 100, 150, 200, 250, 300, 400, 500], offsets: [10, 50, 100, 200], label: 'g' },
ml: { step: 5, min: 5, presets: [25, 50, 100, 150, 200, 250, 300, 500], offsets: [25, 50, 100, 200], label: 'mL' },
}
function fmtDecimalQty(qty, cfg) {
if (!cfg) return String(qty)
if (cfg.label === 'kg' || cfg.label === 'L') {
const n = Number(qty)
return n % 1 === 0 ? n.toFixed(1) : (Math.round(n * 100) / 100).toFixed(2).replace(/0$/, '')
}
return String(qty)
}
function buildInitialState(product) {
const preferenceSets = product.preference_sets || []
const prefs = {}
const subChoices = {}
const sharedSubs = {}
preferenceSets.forEach(ps => {
const def = ps.default_choice_id != null
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
: null
prefs[ps.id] = def
if (def) {
if (def.sub_choices?.length > 0) {
subChoices[def.id] = def.sub_choices.find(s => s.is_default) ?? def.sub_choices[0]
}
if (ps.shared_subset?.choices?.length > 0 && !def.disables_subset) {
sharedSubs[ps.id] = ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]
}
}
})
return { prefs, subChoices, sharedSubs }
}
const QUICK_NOTES = ['Χωρίς αλάτι', 'Βγάλτε γρήγορα', 'Αλλεργία!', 'Κόψτε σε μικρά κομμάτια', 'Έξτρα χαρτοπετσέτες']
const PRICE_DELTAS = [-5, -3, -2, -1, -0.5, -0.2, +0.2, +0.5, +1, +2, +3, +5]
// ── Primitive UI ──────────────────────────────────────────────────────────────
function CheckCircle({ selected }) {
return (
<div style={{
width: 22, height: 22, borderRadius: '50%', flexShrink: 0,
border: `2px solid ${selected ? '#f59e0b' : '#d1d5db'}`,
background: selected ? '#f59e0b' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 120ms ease',
}}>
{selected && <svg width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M5 12.5L10 17.5L19 7.5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
)
}
function RadioDot({ selected }) {
return (
<div style={{
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
border: `2px solid ${selected ? '#f59e0b' : '#d1d5db'}`,
background: 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{selected && <div style={{ width: 9, height: 9, borderRadius: '50%', background: '#f59e0b' }} />}
</div>
)
}
function Row({ selected, onClick, children, right, left }) {
return (
<div onClick={onClick} style={{
padding: '10px 12px',
background: selected ? 'rgba(245,158,11,0.08)' : '#f8fafc',
border: `1px solid ${selected ? 'rgba(245,158,11,0.4)' : '#e2e8f0'}`,
borderRadius: 10,
display: 'flex', alignItems: 'center', gap: 10,
cursor: onClick ? 'pointer' : 'default',
transition: 'background 120ms ease, border-color 120ms ease',
minHeight: 50,
}}>
{left && <div style={{ flexShrink: 0 }}>{left}</div>}
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
{right && <div style={{ flexShrink: 0 }}>{right}</div>}
</div>
)
}
function Stepper({ value, onChange, min = 0, max = 99 }) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', height: 36, borderRadius: 18, background: '#f1f5f9', border: '1px solid #e2e8f0', overflow: 'hidden' }}
onClick={e => e.stopPropagation()}>
<button onClick={() => onChange(Math.max(min, value - 1))} disabled={value <= min}
style={{ width: 36, height: 36, border: 'none', background: 'transparent', fontSize: 16, fontWeight: 500, cursor: value <= min ? 'default' : 'pointer', color: value <= min ? '#94a3b8' : '#1e293b' }}></button>
<div style={{ minWidth: 26, textAlign: 'center', fontSize: 14, fontWeight: 700, color: '#1e293b' }}>{value}</div>
<button onClick={() => onChange(Math.min(max, value + 1))} disabled={value >= max}
style={{ width: 36, height: 36, border: 'none', background: 'transparent', fontSize: 16, fontWeight: 500, cursor: value >= max ? 'default' : 'pointer', color: value >= max ? '#94a3b8' : '#1e293b' }}>+</button>
</div>
)
}
// ── Option rows ───────────────────────────────────────────────────────────────
function QuickOptionRow({ opt, quickState, setQuickState }) {
const qty = quickState[opt.id] || 0
const selected = qty > 0
return (
<Row selected={selected} onClick={() => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 }))}
left={<CheckCircle selected={selected} />}
right={opt.allow_multiple ? (
<div onClick={e => e.stopPropagation()}>
{selected
? <Stepper value={qty} onChange={v => setQuickState(s => ({ ...s, [opt.id]: v }))} />
: <button onClick={e => { e.stopPropagation(); setQuickState(s => ({ ...s, [opt.id]: 1 })) }}
style={{ width: 30, height: 30, borderRadius: '50%', background: '#f1f5f9', border: '1px solid #e2e8f0', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/></svg>
</button>
}
</div>
) : null}
>
<div style={{ fontSize: 14, fontWeight: 500, color: '#1e293b' }}>{opt.name}</div>
{opt.price > 0 && <div style={{ fontSize: 12, color: '#64748b', marginTop: 1 }}>+{opt.price.toFixed(2)} </div>}
</Row>
)
}
function ExtraOptionRow({ opt, extrasState, setExtrasState, expandedExtra, setExpandedExtra }) {
const sel = extrasState[opt.id]
const selected = !!sel
const open = expandedExtra === opt.id
const hasSubs = opt.sub_choices?.length > 0
const subLabel = sel ? opt.sub_choices?.find(s => s.name === sel.subName)?.name : null
const toggle = () => {
if (selected) {
setExtrasState(s => { const n = { ...s }; delete n[opt.id]; return n })
if (open) setExpandedExtra(null)
} else {
const firstSub = hasSubs ? opt.sub_choices[0] : null
setExtrasState(s => ({ ...s, [opt.id]: { qty: 1, subName: firstSub?.name ?? null } }))
if (hasSubs) setExpandedExtra(opt.id)
}
}
return (
<div>
<Row selected={selected} onClick={toggle}
left={<CheckCircle selected={selected} />}
right={
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} onClick={e => e.stopPropagation()}>
{selected && opt.allow_multiple && (
<Stepper value={sel.qty} onChange={v => {
if (v === 0) { setExtrasState(s => { const n = { ...s }; delete n[opt.id]; return n }); return }
setExtrasState(s => ({ ...s, [opt.id]: { ...sel, qty: v } }))
}} />
)}
{selected && hasSubs && (
<button onClick={e => { e.stopPropagation(); setExpandedExtra(open ? null : opt.id) }}
style={{ width: 30, height: 30, borderRadius: '50%', background: '#f1f5f9', border: '1px solid #e2e8f0', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${open ? 180 : 0}deg)`, transition: 'transform 180ms' }}>
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
</button>
)}
</div>
}
>
<div style={{ fontSize: 14, fontWeight: 500, color: '#1e293b' }}>{opt.name}</div>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 1 }}>
{(opt.extra_cost ?? 0) !== 0 ? `+${opt.extra_cost.toFixed(2)}` : 'Included'}
{subLabel && <span style={{ color: '#f59e0b', fontWeight: 600 }}> · {subLabel}</span>}
</div>
</Row>
{selected && open && hasSubs && (
<div style={{ margin: '5px 0 2px 14px', paddingLeft: 12, borderLeft: '2px solid rgba(245,158,11,0.35)', display: 'flex', flexDirection: 'column', gap: 5 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.6, padding: '4px 2px 2px' }}>Επιλογή</div>
{opt.sub_choices.map((sub, si) => {
const isSel = sel.subName === sub.name
return (
<Row key={si} selected={isSel} onClick={() => setExtrasState(s => ({ ...s, [opt.id]: { ...sel, subName: sub.name } }))} left={<RadioDot selected={isSel} />}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: 13, color: '#1e293b' }}>{sub.name}</div>
{(sub.extra_cost ?? 0) !== 0 && <div style={{ fontSize: 12, color: '#64748b' }}>+{sub.extra_cost.toFixed(2)} </div>}
</div>
</Row>
)
})}
</div>
)}
</div>
)
}
function IngredientRow({ ing, removedState, setRemovedState }) {
const removed = !!removedState[ing.id]
return (
<Row selected={false} onClick={() => setRemovedState(s => ({ ...s, [ing.id]: !s[ing.id] }))}
right={
<div style={{
height: 30, padding: '0 12px', borderRadius: 15,
background: removed ? '#ef4444' : '#f1f5f9',
border: `1px solid ${removed ? '#ef4444' : '#e2e8f0'}`,
color: removed ? '#fff' : '#475569',
fontSize: 12, fontWeight: 600,
display: 'inline-flex', alignItems: 'center',
}}>
{removed ? 'Αφαιρέθηκε' : 'Αφαίρεση'}
</div>
}
>
<div style={{ fontSize: 14, fontWeight: 500, color: removed ? '#94a3b8' : '#1e293b', textDecoration: removed ? 'line-through' : 'none' }}>{ing.name}</div>
</Row>
)
}
function PrefSetBlock({ ps, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
const selChoice = prefs[ps.id] ?? null
const complete = selChoice != null
&& !(selChoice.sub_choices?.length > 0 && subChoices[selChoice.id] == null)
&& !(ps.shared_subset?.choices?.length > 0 && !selChoice.disables_subset && sharedSubs[ps.id] == null)
const showShared = ps.shared_subset?.choices?.length > 0 && selChoice != null && !selChoice.disables_subset
function selectPref(choice) {
setPrefs(p => ({ ...p, [ps.id]: choice }))
if (choice?.sub_choices?.length > 0) {
setSubChoices(s => ({ ...s, [choice.id]: s[choice.id] ?? (choice.sub_choices.find(x => x.is_default) ?? choice.sub_choices[0]) }))
}
if (ps.shared_subset?.choices?.length > 0 && !choice?.disables_subset) {
setSharedSubs(s => s[ps.id] != null ? s : { ...s, [ps.id]: ps.shared_subset.choices.find(x => x.is_default) ?? ps.shared_subset.choices[0] })
}
}
return (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, padding: '0 2px 8px' }}>
<div style={{ fontSize: 13, fontWeight: 700, color: complete ? '#1e293b' : '#ef4444' }}>{ps.name}</div>
{!complete && <div style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', textTransform: 'uppercase', letterSpacing: 0.6 }}>Απαιτείται</div>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{ps.choices.map(ch => {
const isSel = selChoice?.id === ch.id
const hasSubs = ch.sub_choices?.length > 0
return (
<div key={ch.id}>
<Row selected={isSel} onClick={() => selectPref(ch)} left={<RadioDot selected={isSel} />}
right={(ch.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 13, color: '#64748b' }}>{ch.extra_cost > 0 ? '+' : ''}{ch.extra_cost.toFixed(2)} </div> : null}>
<div style={{ fontSize: 14, fontWeight: 500, color: '#1e293b' }}>{ch.name}</div>
</Row>
{isSel && hasSubs && (
<div style={{ margin: '5px 0 2px 14px', paddingLeft: 12, borderLeft: `2px solid ${subChoices[ch.id] == null ? '#ef4444' : 'rgba(245,158,11,0.35)'}`, display: 'flex', flexDirection: 'column', gap: 5 }}>
{ch.sub_choices.map((sub, si) => {
const subSel = subChoices[ch.id]?.name === sub.name
return (
<Row key={si} selected={subSel} onClick={() => setSubChoices(s => ({ ...s, [ch.id]: sub }))} left={<RadioDot selected={subSel} />}
right={(sub.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 12, color: '#64748b' }}>+{sub.extra_cost.toFixed(2)} </div> : null}>
<div style={{ fontSize: 13, color: '#1e293b' }}>{sub.name}</div>
</Row>
)
})}
</div>
)}
</div>
)
})}
{showShared && (
<div style={{ marginTop: 4, marginLeft: 8, paddingLeft: 12, borderLeft: '2px solid rgba(245,158,11,0.35)', display: 'flex', flexDirection: 'column', gap: 5 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.6, padding: '4px 2px 2px' }}>{ps.shared_subset.name}</div>
{ps.shared_subset.choices.map((sub, si) => {
const subSel = sharedSubs[ps.id]?.name === sub.name
return (
<Row key={si} selected={subSel} onClick={() => setSharedSubs(s => ({ ...s, [ps.id]: sub }))} left={<RadioDot selected={subSel} />}
right={(sub.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 12, color: '#64748b' }}>+{sub.extra_cost.toFixed(2)} </div> : null}>
<div style={{ fontSize: 13, color: '#1e293b' }}>{sub.name}</div>
</Row>
)
})}
</div>
)}
</div>
</div>
)
}
// ── Tab content ───────────────────────────────────────────────────────────────
function QuickTab({ product, quickState, setQuickState }) {
const quickOptions = product.quick_options || []
if (quickOptions.length === 0) return <p style={{ color: '#94a3b8', textAlign: 'center', padding: '24px 0', fontSize: 14 }}>Δεν υπάρχουν γρήγορες επιλογές.</p>
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{quickOptions.map(opt => (
<div key={opt.id} style={{ width: opt.is_compact ? 'calc(50% - 4px)' : '100%', minWidth: 0 }}>
<QuickOptionRow opt={opt} quickState={quickState} setQuickState={setQuickState} />
</div>
))}
</div>
)
}
function ExtrasTab({ product, extrasState, setExtrasState, expandedExtra, setExpandedExtra }) {
const options = product.options || []
if (options.length === 0) return <p style={{ color: '#94a3b8', textAlign: 'center', padding: '24px 0', fontSize: 14 }}>Δεν υπάρχουν extras.</p>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{options.map(opt => (
<ExtraOptionRow key={opt.id} opt={opt} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />
))}
</div>
)
}
function IngredientsTab({ product, removedState, setRemovedState }) {
const ingredients = product.ingredients || []
if (ingredients.length === 0) return <p style={{ color: '#94a3b8', textAlign: 'center', padding: '24px 0', fontSize: 14 }}>Δεν υπάρχουν υλικά.</p>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ padding: '8px 12px', background: '#f1f5f9', borderRadius: 8, fontSize: 12, color: '#64748b', marginBottom: 2 }}>
Κάντε κλικ για να αφαιρέσετε υλικό.
</div>
{ingredients.map(ing => (
<IngredientRow key={ing.id} ing={ing} removedState={removedState} setRemovedState={setRemovedState} />
))}
</div>
)
}
function PrefsTab({ product, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
const preferenceSets = product.preference_sets || []
if (preferenceSets.length === 0) return <p style={{ color: '#94a3b8', textAlign: 'center', padding: '24px 0', fontSize: 14 }}>Δεν υπάρχουν προτιμήσεις.</p>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{preferenceSets.map(ps => (
<PrefSetBlock key={ps.id} ps={ps}
prefs={prefs} setPrefs={setPrefs}
subChoices={subChoices} setSubChoices={setSubChoices}
sharedSubs={sharedSubs} setSharedSubs={setSharedSubs}
/>
))}
</div>
)
}
function QuantityTab({ unitCfg, qty, setQty }) {
const [editing, setEditing] = useState(false)
const [qtyInput, setQtyInput] = useState('')
const inputRef = useRef(null)
function clamp(v) { return Math.max(unitCfg.min, Math.round(v * 1000) / 1000) }
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', height: 48, borderRadius: 24, background: '#f1f5f9', border: '1px solid #e2e8f0', overflow: 'hidden' }}>
<button onClick={() => setQty(q => clamp(q - unitCfg.step))}
style={{ width: 48, height: 48, border: 'none', background: 'transparent', fontSize: 20, fontWeight: 500, cursor: 'pointer', color: '#1e293b' }}></button>
{editing ? (
<input ref={inputRef} type="number" inputMode="decimal" value={qtyInput}
onChange={e => setQtyInput(e.target.value)}
onBlur={() => { const v = parseFloat(qtyInput); if (!isNaN(v)) setQty(clamp(v)); setEditing(false) }}
onKeyDown={e => { if (e.key === 'Enter') e.target.blur() }}
autoFocus
style={{ width: 70, height: 48, border: 'none', background: 'transparent', textAlign: 'center', fontSize: 16, fontWeight: 700, color: '#1e293b', outline: 'none', fontFamily: 'inherit' }}
/>
) : (
<div onClick={() => { setQtyInput(String(qty)); setEditing(true) }}
style={{ minWidth: 70, textAlign: 'center', fontSize: 16, fontWeight: 700, color: '#1e293b', cursor: 'text', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
{fmtDecimalQty(qty, unitCfg)}
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.65 }}>{unitCfg.label}</span>
</div>
)}
<button onClick={() => setQty(q => clamp(q + unitCfg.step))}
style={{ width: 48, height: 48, border: 'none', background: 'transparent', fontSize: 20, fontWeight: 500, cursor: 'pointer', color: '#1e293b' }}>+</button>
</div>
</div>
<div>
<p style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', letterSpacing: 0.8, marginBottom: 6, textAlign: 'center' }}>ΓΡΗΓΟΡΗ ΕΠΙΛΟΓΗ</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
{unitCfg.presets.map(v => (
<button key={v} onClick={() => setQty(v)} style={{
padding: '8px 4px', borderRadius: 10, border: 'none',
background: qty === v ? '#f59e0b' : '#f1f5f9',
color: qty === v ? '#fff' : '#1e293b',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}>
{fmtDecimalQty(v, unitCfg)}{unitCfg.label}
</button>
))}
</div>
</div>
<div>
<p style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', letterSpacing: 0.8, marginBottom: 6, textAlign: 'center' }}>ΓΡΗΓΟΡΗ ΠΡΟΣΘΗΚΗ</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 6 }}>
{unitCfg.offsets.map(off => (
<button key={`+${off}`} onClick={() => setQty(q => clamp(q + off))} style={{
padding: '8px 4px', borderRadius: 10, border: 'none',
background: 'rgba(34,197,94,0.1)', color: '#16a34a',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}>+{fmtDecimalQty(off, unitCfg)}{unitCfg.label}</button>
))}
{unitCfg.offsets.map(off => (
<button key={`-${off}`} onClick={() => setQty(q => clamp(q - off))} style={{
padding: '8px 4px', borderRadius: 10, border: 'none',
background: 'rgba(239,68,68,0.08)', color: '#dc2626',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}>{fmtDecimalQty(off, unitCfg)}{unitCfg.label}</button>
))}
</div>
</div>
</div>
)
}
function PriceAdjustTab({ basePrice, priceAdj, setPriceAdj }) {
const [editing, setEditing] = useState(false)
const [rawInput, setRawInput] = useState('')
const inputRef = useRef(null)
const effectivePrice = basePrice + priceAdj
function applyDelta(d) {
setPriceAdj(a => {
const next = Math.round((a + d) * 100) / 100
return Math.max(-(basePrice - 0.01), next)
})
}
function commitInput() {
const v = parseFloat(rawInput.replace(',', '.'))
if (!isNaN(v) && v >= 0.01) setPriceAdj(Math.round((v - basePrice) * 100) / 100)
setEditing(false)
}
useEffect(() => { if (editing && inputRef.current) inputRef.current.focus() }, [editing])
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ padding: '8px 12px', background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.2)', borderRadius: 8, fontSize: 12, color: '#d97706' }}>
Αλλαγή τιμής μόνο για αυτό το αντικείμενο.
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10 }}>
<button onClick={() => applyDelta(-0.1)} style={{ width: 40, height: 40, borderRadius: '50%', border: '1px solid #e2e8f0', background: '#f1f5f9', fontSize: 18, fontWeight: 700, color: '#1e293b', cursor: 'pointer' }}></button>
{editing ? (
<input ref={inputRef} type="number" inputMode="decimal" value={rawInput}
onChange={e => setRawInput(e.target.value)}
onBlur={commitInput}
onKeyDown={e => { if (e.key === 'Enter') e.target.blur() }}
style={{ width: 100, height: 48, border: '2px solid #f59e0b', borderRadius: 12, background: 'white', textAlign: 'center', fontSize: 20, fontWeight: 800, color: '#f59e0b', outline: 'none', fontFamily: 'inherit' }}
/>
) : (
<button onClick={() => { setRawInput(effectivePrice.toFixed(2)); setEditing(true) }} style={{
minWidth: 100, height: 48, borderRadius: 12,
border: `2px solid ${priceAdj !== 0 ? '#f59e0b' : '#e2e8f0'}`,
background: priceAdj !== 0 ? 'rgba(245,158,11,0.08)' : '#f8fafc',
color: priceAdj !== 0 ? '#f59e0b' : '#1e293b',
fontSize: 20, fontWeight: 800, cursor: 'pointer', fontFamily: 'inherit',
}}>
{effectivePrice.toFixed(2)}
</button>
)}
<button onClick={() => applyDelta(+0.1)} style={{ width: 40, height: 40, borderRadius: '50%', border: '1px solid #e2e8f0', background: '#f1f5f9', fontSize: 18, fontWeight: 700, color: '#1e293b', cursor: 'pointer' }}>+</button>
</div>
{priceAdj !== 0 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: '#64748b' }}>Βάση: {basePrice.toFixed(2)} </span>
<span style={{ fontSize: 12, fontWeight: 700, color: priceAdj > 0 ? '#16a34a' : '#dc2626' }}>{priceAdj > 0 ? '+' : ''}{priceAdj.toFixed(2)} </span>
<button onClick={() => setPriceAdj(0)} style={{ padding: '2px 8px', borderRadius: 6, border: '1px solid #e2e8f0', background: '#f1f5f9', color: '#64748b', fontSize: 11, cursor: 'pointer' }}>Επαναφορά</button>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
{PRICE_DELTAS.map(d => (
<button key={d} onClick={() => applyDelta(d)} style={{
padding: '9px 4px', borderRadius: 10, border: 'none',
background: d < 0 ? 'rgba(239,68,68,0.08)' : 'rgba(34,197,94,0.08)',
color: d < 0 ? '#dc2626' : '#16a34a',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}>
{d > 0 ? '+' : ''}{d.toFixed(2 - (Number.isInteger(d) ? 2 : 0))}
</button>
))}
</div>
</div>
)
}
function NotesTab({ note, setNote }) {
return (
<div>
<textarea
value={note}
onChange={e => setNote(e.target.value)}
placeholder="π.χ. Χωρίς αλάτι, κόψτε στη μέση..."
rows={4}
style={{ width: '100%', padding: 12, fontSize: 14, fontFamily: 'inherit', color: '#1e293b', background: 'white', border: '1px solid #e2e8f0', borderRadius: 10, resize: 'none', outline: 'none', boxSizing: 'border-box' }}
/>
<div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.6, marginTop: 14, marginBottom: 6 }}>Γρήγορες σημειώσεις</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{QUICK_NOTES.map(q => (
<button key={q} onClick={() => setNote(n => n ? `${n}\n${q}` : q)}
style={{ height: 32, padding: '0 12px', borderRadius: 16, background: '#f1f5f9', border: '1px solid #e2e8f0', color: '#475569', fontSize: 12, fontWeight: 500, cursor: 'pointer' }}>
+ {q}
</button>
))}
</div>
</div>
)
}
// ── Courses tab ───────────────────────────────────────────────────────────────
function CoursesTab({ courses, courseId, setCourseId }) {
return (
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
onClick={() => setCourseId(null)}
style={{
padding: '10px 14px', borderRadius: 10, textAlign: 'left',
background: courseId === null ? '#2563eb' : '#f8fafc',
color: courseId === null ? '#fff' : '#1e293b',
border: `2px solid ${courseId === null ? '#2563eb' : '#e2e8f0'}`,
fontWeight: 600, fontSize: 14, cursor: 'pointer', fontFamily: 'inherit',
}}
>
No course (fire immediately)
</button>
{courses.map(c => (
<button
key={c.id}
onClick={() => setCourseId(courseId === c.id ? null : c.id)}
style={{
padding: '10px 14px', borderRadius: 10, textAlign: 'left',
background: courseId === c.id ? c.color : '#f8fafc',
color: courseId === c.id ? '#fff' : '#1e293b',
border: `2px solid ${courseId === c.id ? c.color : '#e2e8f0'}`,
fontWeight: 600, fontSize: 14, cursor: 'pointer', fontFamily: 'inherit',
display: 'flex', alignItems: 'center', gap: 10,
}}
>
<span style={{
width: 12, height: 12, borderRadius: '50%',
background: courseId === c.id ? 'rgba(255,255,255,0.5)' : c.color,
flexShrink: 0,
}} />
{c.name}
</button>
))}
</div>
)
}
// ── Main component ────────────────────────────────────────────────────────────
export default function ManagerOrderDrawer({ product, onClose, onAdd, initialState, courses }) {
const preferenceSets = product?.preference_sets || []
const quickOptions = product?.quick_options || []
const options = product?.options || []
const ingredients = product?.ingredients || []
const unitCfg = product ? (DECIMAL_UNIT_CONFIG[product.unit_type] ?? null) : null
const isDecimal = !!unitCfg
const hasTabs = {
quick: quickOptions.length > 0,
extras: options.length > 0,
ingredients: ingredients.length > 0,
prefs: preferenceSets.length > 0,
quantity: isDecimal,
}
const firstTab = hasTabs.quick ? 'quick'
: hasTabs.extras ? 'extras'
: hasTabs.ingredients ? 'ingredients'
: hasTabs.prefs ? 'prefs'
: hasTabs.quantity ? 'quantity'
: 'notes'
const [activeTab, setActiveTab] = useState(firstTab)
const [qty, setQty] = useState(isDecimal ? (unitCfg?.presets[0] ?? 0.1) : 1)
const [quickState, setQuickState] = useState({})
const [extrasState, setExtrasState] = useState({})
const [expandedExtra, setExpandedExtra] = useState(null)
const [removedState, setRemovedState] = useState({})
const [prefs, setPrefs] = useState({})
const [subChoices, setSubChoices] = useState({})
const [sharedSubs, setSharedSubs] = useState({})
const [note, setNote] = useState('')
const [priceAdj, setPriceAdj] = useState(0)
const [addAttempted, setAddAttempted] = useState(false)
const [courseId, setCourseId] = useState(initialState?.courseId ?? null)
// Reset when product changes
useEffect(() => {
if (!product) return
const base = buildInitialState(product)
if (initialState) {
setQty(initialState.qty ?? (isDecimal ? (unitCfg?.presets[0] ?? 0.1) : 1))
setQuickState(initialState.quickState ?? {})
setExtrasState(initialState.extrasState ?? {})
setRemovedState(initialState.removedState ?? {})
setPrefs(initialState.prefs ?? base.prefs)
setSubChoices(initialState.subChoices ?? base.subChoices)
setSharedSubs(initialState.sharedSubs ?? base.sharedSubs)
setNote(initialState.note ?? '')
setPriceAdj(initialState.priceAdj ?? 0)
setCourseId(initialState.courseId ?? null)
setActiveTab(initialState.activeTab ?? firstTab)
} else {
setQty(isDecimal ? (unitCfg?.presets[0] ?? 0.1) : 1)
setQuickState({})
setExtrasState({})
setRemovedState({})
setPrefs(base.prefs)
setSubChoices(base.subChoices)
setSharedSubs(base.sharedSubs)
setNote('')
setPriceAdj(0)
setCourseId(null)
setActiveTab(firstTab)
}
setExpandedExtra(null)
setAddAttempted(false)
}, [product?.id])
// Derived total price
const totalPrice = (() => {
if (!product) return 0
let price = product.base_price
preferenceSets.forEach(ps => {
const choice = prefs[ps.id]
if (!choice) return
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) ? (sharedSubs[ps.id] ?? null) : null
price += (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
})
quickOptions.forEach(opt => { price += (quickState[opt.id] || 0) * (opt.price ?? 0) })
options.forEach(opt => {
const sel = extrasState[opt.id]
if (!sel) return
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
price += ((opt.extra_cost ?? 0) + (sub?.extra_cost ?? 0)) * sel.qty
})
return (price + priceAdj) * qty
})()
function isPrefComplete(ps) {
const choice = prefs[ps.id]
if (!choice) return false
if (choice.sub_choices?.length > 0 && subChoices[choice.id] == null) return false
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset && sharedSubs[ps.id] == null) return false
return true
}
const allPrefsOk = preferenceSets.every(isPrefComplete)
const extrasSubsMissing = options.some(opt => {
const sel = extrasState[opt.id]
return sel && opt.sub_choices?.length > 0 && sel.subName == null
})
const canAdd = allPrefsOk && !extrasSubsMissing
function handleAdd() {
if (!canAdd) {
setAddAttempted(true)
if (!allPrefsOk && hasTabs.prefs) setActiveTab('prefs')
return
}
const prefChoices = preferenceSets.flatMap(ps => {
const choice = prefs[ps.id]
if (!choice) return []
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
const sharedSub = ps.shared_subset?.choices?.length > 0 && !choice.disables_subset ? (sharedSubs[ps.id] ?? null) : null
const defaultChoice = ps.default_choice_id != null ? ps.choices.find(c => c.id === ps.default_choice_id) : null
const isDefaultChoice = defaultChoice && choice.id === defaultChoice.id
const defaultInlineSub = isDefaultChoice && defaultChoice.sub_choices?.length > 0
? (defaultChoice.sub_choices.find(s => s.is_default) ?? defaultChoice.sub_choices[0]) : null
const defaultSharedSub = isDefaultChoice && ps.shared_subset?.choices?.length > 0 && !choice.disables_subset
? (ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]) : null
const isFullyDefault = isDefaultChoice
&& (!inlineSub || inlineSub.name === defaultInlineSub?.name)
&& (!sharedSub || sharedSub.name === defaultSharedSub?.name)
if (isFullyDefault) return []
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0, type: 'pref' }]
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0, type: 'pref_sub' })
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0, type: 'pref_sub' })
return entries
})
const optionEntries = options.flatMap(opt => {
const sel = extrasState[opt.id]
if (!sel) return []
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
const entries = []
for (let i = 0; i < sel.qty; i++) {
entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0, type: 'extra' })
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0, type: 'extra_sub' })
}
return entries
})
const quickEntries = quickOptions.flatMap(opt => {
const q = quickState[opt.id] || 0
if (q === 0) return []
return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0, type: 'quick' }))
})
const removedNames = ingredients.filter(ing => removedState[ing.id]).map(ing => ing.name)
onAdd({
product_id: product.id,
quantity: qty,
unit_price: product.base_price,
selected_options: [...prefChoices, ...quickEntries, ...optionEntries],
removed_ingredients: removedNames,
notes: note,
price_adjustment: priceAdj !== 0 ? priceAdj : undefined,
course_id: courseId ?? undefined,
_drawerState: { qty, quickState, extrasState, removedState, prefs, subChoices, sharedSubs, note, priceAdj, courseId },
})
}
const tabs = [
hasTabs.quick && { id: 'quick', label: 'Quick' },
hasTabs.extras && { id: 'extras', label: 'Extras' },
hasTabs.ingredients && { id: 'ingredients', label: 'Υλικά' },
hasTabs.prefs && { id: 'prefs', label: 'Προτιμήσεις' },
hasTabs.quantity && { id: 'quantity', label: `Ποσότητα (${unitCfg?.label})` },
(courses?.length > 0) && { id: 'courses', label: 'Courses' },
{ id: 'notes', label: 'Σημείωση' },
{ id: 'price_adjust', label: '€ Τιμή' },
].filter(Boolean)
if (!product) return null
const initials = product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
return (
<div style={{
display: 'flex', flexDirection: 'column', height: '100%',
background: 'white', borderLeft: '1px solid #e2e8f0',
}}>
{/* Header */}
<div style={{
padding: '16px 16px 12px',
borderBottom: '1px solid #e2e8f0',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{
width: 52, height: 52, borderRadius: 12, flexShrink: 0,
background: '#f1f5f9', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{product.image_url
? <img src={product.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 18, fontWeight: 700, color: '#94a3b8' }}>{initials}</span>
}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#1e293b', lineHeight: 1.2, marginBottom: 3 }}>{product.name}</div>
<div style={{ fontSize: 13, color: priceAdj !== 0 ? '#f59e0b' : '#64748b' }}>
{priceAdj !== 0
? <><span style={{ textDecoration: 'line-through', opacity: 0.5, marginRight: 4 }}>{product.base_price.toFixed(2)}</span> {(product.base_price + priceAdj).toFixed(2)} </>
: <>{product.base_price.toFixed(2)} </>
}
</div>
</div>
<button onClick={onClose} style={{
width: 32, height: 32, borderRadius: '50%', border: '1px solid #e2e8f0',
background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', flexShrink: 0, color: '#64748b',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
</button>
</div>
{/* Tabs bar */}
{tabs.length > 0 && (
<div style={{ display: 'flex', gap: 2, overflowX: 'auto', scrollbarWidth: 'none' }}>
{tabs.map(t => {
const active = activeTab === t.id
const isAlert = t.id === 'prefs' && !allPrefsOk && addAttempted
return (
<button key={t.id} onClick={() => setActiveTab(t.id)} style={{
padding: '6px 10px',
background: 'none', border: 'none',
borderBottom: `2px solid ${active ? '#f59e0b' : 'transparent'}`,
color: isAlert ? '#ef4444' : active ? '#f59e0b' : '#64748b',
fontSize: 12, fontWeight: active ? 700 : 500,
fontFamily: 'inherit', cursor: 'pointer',
whiteSpace: 'nowrap',
transition: 'color 100ms, border-color 100ms',
}}>
{t.label}
</button>
)
})}
</div>
)}
</div>
{/* Scrollable content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 14px 10px' }}>
{activeTab === 'quick' && <QuickTab product={product} quickState={quickState} setQuickState={setQuickState} />}
{activeTab === 'extras' && <ExtrasTab product={product} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />}
{activeTab === 'ingredients' && <IngredientsTab product={product} removedState={removedState} setRemovedState={setRemovedState} />}
{activeTab === 'prefs' && <PrefsTab product={product} prefs={prefs} setPrefs={setPrefs} subChoices={subChoices} setSubChoices={setSubChoices} sharedSubs={sharedSubs} setSharedSubs={setSharedSubs} />}
{activeTab === 'quantity' && isDecimal && <QuantityTab unitCfg={unitCfg} qty={qty} setQty={setQty} />}
{activeTab === 'courses' && courses?.length > 0 && <CoursesTab courses={courses} courseId={courseId} setCourseId={setCourseId} />}
{activeTab === 'notes' && <NotesTab note={note} setNote={setNote} />}
{activeTab === 'price_adjust' && <PriceAdjustTab basePrice={product.base_price} priceAdj={priceAdj} setPriceAdj={setPriceAdj} />}
</div>
{/* Footer: qty stepper + ADD button */}
<div style={{
padding: '12px 14px',
borderTop: '1px solid #e2e8f0',
background: '#f8fafc',
display: 'flex', alignItems: 'center', gap: 10, flexShrink: 0,
}}>
{isDecimal ? (
<div onClick={() => setActiveTab('quantity')}
style={{ display: 'inline-flex', alignItems: 'center', height: 44, borderRadius: 22, background: '#f1f5f9', border: '1px solid #e2e8f0', overflow: 'hidden', flexShrink: 0, cursor: 'pointer', padding: '0 14px', gap: 4 }}>
<span style={{ fontSize: 16, fontWeight: 700, color: '#1e293b' }}>{fmtDecimalQty(qty, unitCfg)}</span>
<span style={{ fontSize: 12, color: '#64748b' }}>{unitCfg.label}</span>
</div>
) : (
<div style={{ display: 'inline-flex', alignItems: 'center', height: 44, borderRadius: 22, background: '#f1f5f9', border: '1px solid #e2e8f0', overflow: 'hidden', flexShrink: 0 }}>
<button onClick={() => setQty(q => Math.max(1, q - 1))} style={{ width: 44, height: 44, border: 'none', background: 'transparent', fontSize: 20, fontWeight: 500, cursor: qty <= 1 ? 'default' : 'pointer', color: qty <= 1 ? '#94a3b8' : '#1e293b' }}></button>
<div style={{ minWidth: 28, textAlign: 'center', fontSize: 16, fontWeight: 700, color: '#1e293b' }}>{qty}</div>
<button onClick={() => setQty(q => q + 1)} style={{ width: 44, height: 44, border: 'none', background: 'transparent', fontSize: 20, fontWeight: 500, cursor: 'pointer', color: '#1e293b' }}>+</button>
</div>
)}
<button onClick={handleAdd} style={{
flex: 1, height: 44, borderRadius: 22,
background: canAdd ? '#f59e0b' : '#e2e8f0',
border: 'none', color: canAdd ? '#fff' : '#94a3b8',
fontSize: 14, fontWeight: 700, fontFamily: 'inherit',
cursor: canAdd ? 'pointer' : 'not-allowed',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 18px',
transition: 'background 150ms ease',
}}>
<span>ΠΡΟΣΘΗΚΗ</span>
<span>{totalPrice.toFixed(2)} </span>
</button>
</div>
</div>
)
}

View File

@@ -446,7 +446,6 @@ export default function NotesPage() {
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>

View File

@@ -19,7 +19,7 @@ function timeAgo(iso) {
}
function calcTotals(items = []) {
return items.reduce((sum, it) => sum + (it.unit_price ?? 0) * (it.quantity ?? 1), 0)
return items.reduce((sum, it) => sum + ((it.unit_price ?? 0) + (it.price_adjustment ?? 0)) * (it.quantity ?? 1), 0)
}
const STATUS_LABELS = {
@@ -106,7 +106,7 @@ function OrderCard({ order, onAction }) {
{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>
<span className="text-gray-500">{(((it.unit_price ?? 0) + (it.price_adjustment ?? 0)) * it.quantity).toFixed(2)}</span>
</div>
))}
</div>
@@ -310,25 +310,8 @@ export default function OnlineOrdersPage() {
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 + refresh on same row */}
<div className="flex-shrink-0 flex items-stretch border-b border-slate-200 px-6 pt-2">
{TABS.map(tab => {
const count = tab.key === 'incoming' ? incoming.length
: tab.key === 'active' ? active.length
@@ -338,25 +321,34 @@ export default function OnlineOrdersPage() {
<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'
className={`relative flex items-center gap-2 px-4 py-3 text-[13.5px] font-medium transition-colors whitespace-nowrap ${
isActive ? 'text-slate-900' : 'text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
{count > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full font-mono ${
<span className={`text-[11px] px-1.5 py-0.5 rounded-full font-mono font-semibold ${
tab.key === 'incoming'
? 'bg-red-100 text-red-700'
: isActive ? 'bg-primary-100 text-primary-700' : 'bg-gray-100 text-gray-500'
: isActive ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-500'
}`}>
{count}
</span>
)}
{isActive && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-sky-500" />}
</button>
)
})}
<div className="flex-1" />
<div className="flex items-center pb-2">
<button
onClick={fetchAll}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium text-slate-500 hover:text-slate-700 border border-slate-200 rounded-lg bg-white transition-colors disabled:opacity-50"
>
{loading ? '…' : '↻ Ανανέωση'}
</button>
</div>
</div>
{/* Content */}

View File

@@ -3,9 +3,11 @@ import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
import { Percent } from 'lucide-react'
import Badge from '../ui/Badge'
import { ConfirmModal } from '../ui/Modal'
import PaymentMethodModal from '../ui/PaymentMethodModal'
import DesktopOrderingTab from './DesktopOrderingTab'
function PrintOrderModal({ onClose, onPrint, printers }) {
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
@@ -37,8 +39,9 @@ function PrintOrderModal({ onClose, onPrint, printers }) {
)
}
function effPrice(item) { return (item.unit_price ?? 0) + (item.price_adjustment ?? 0) }
function itemTotal(item) {
return (item.unit_price * item.quantity).toFixed(2)
return (effPrice(item) * item.quantity).toFixed(2)
}
const PAYMENT_METHOD_STYLES = {
@@ -147,6 +150,98 @@ function AuditTab({ order, waiterMap }) {
)
}
// ─── Price Events Tab ────────────────────────────────────────────────────────
const PRICE_EVENT_LABELS = {
modifier_applied: 'Τροποποιητής',
deal_triggered: 'Προσφορά',
deal_offer_shown: 'Προσφορά (εμφανίστηκε)',
deal_offer_accepted: 'Προσφορά (αποδεκτή)',
deal_offer_dismissed:'Προσφορά (απορρίφθηκε)',
waiter_discount: 'Έκπτωση σερβιτόρου',
free_item_added: 'Δωρεάν αντικείμενο',
}
const PRICE_EVENT_COLORS = {
modifier_applied: { bg: '#eff6ff', color: '#1d4ed8', border: '#93c5fd' },
deal_triggered: { bg: '#f0fdf4', color: '#15803d', border: '#86efac' },
deal_offer_shown: { bg: '#fefce8', color: '#a16207', border: '#fde68a' },
deal_offer_accepted: { bg: '#f0fdf4', color: '#15803d', border: '#86efac' },
deal_offer_dismissed:{ bg: '#fef2f2', color: '#b91c1c', border: '#fca5a5' },
waiter_discount: { bg: '#fdf4ff', color: '#7e22ce', border: '#d8b4fe' },
free_item_added: { bg: '#f0fdf4', color: '#15803d', border: '#86efac' },
}
function PriceEventsTab({ orderId }) {
const { data: events = [], isLoading } = useQuery({
queryKey: ['price-events', orderId],
queryFn: () => client.get(`/api/pricing/events/order/${orderId}`).then(r => r.data),
})
if (isLoading) return (
<div className="flex justify-center py-16">
<div className="w-5 h-5 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
</div>
)
if (events.length === 0) return (
<div className="py-16 text-center text-gray-400">
<Percent className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p className="text-sm">Δεν υπάρχουν συμβάντα τιμολόγησης για αυτή την παραγγελία.</p>
</div>
)
return (
<div className="divide-y divide-gray-100">
{events.map(ev => {
const label = PRICE_EVENT_LABELS[ev.event_type] ?? ev.event_type
const style = PRICE_EVENT_COLORS[ev.event_type] ?? { bg: '#f8fafc', color: '#334155', border: '#cbd5e1' }
const hasDelta = ev.price_before != null && ev.price_after != null
return (
<div key={ev.id} className="flex items-start gap-3 px-4 py-3">
<div className="shrink-0 mt-0.5">
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: style.bg, color: style.color, border: `1px solid ${style.border}` }}>
{label}
</span>
</div>
<div className="flex-1 min-w-0 text-sm">
<div className="flex flex-wrap gap-x-3 gap-y-0.5 items-baseline">
{ev.modifier_name && <span className="font-medium text-gray-800">{ev.modifier_name}</span>}
{ev.deal_name && <span className="font-medium text-gray-800">{ev.deal_name}</span>}
{ev.applied_by_username && (
<span className="text-gray-500 text-xs">από {ev.applied_by_username}</span>
)}
{hasDelta && (
<span className={`text-xs font-semibold ${ev.delta_amount < 0 ? 'text-green-700' : 'text-red-600'}`}>
{ev.delta_amount > 0 ? '+' : ''}{ev.delta_amount?.toFixed(2)}
<span className="font-normal text-gray-400 ml-1">
({ev.price_before?.toFixed(2)} {ev.price_after?.toFixed(2)})
</span>
</span>
)}
</div>
{ev.waiter_note && (
<p className="text-xs text-gray-400 mt-0.5 italic">"{ev.waiter_note}"</p>
)}
{ev.conditions_snapshot && Object.keys(ev.conditions_snapshot).length > 0 && (
<details className="mt-1">
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-600">Συνθήκες</summary>
<pre className="text-[10px] text-gray-500 bg-gray-50 rounded p-2 mt-1 overflow-x-auto">
{JSON.stringify(ev.conditions_snapshot, null, 2)}
</pre>
</details>
)}
</div>
<div className="shrink-0 text-right">
<span className="text-xs text-gray-400">{formatDate(ev.applied_at)}</span>
</div>
</div>
)
})}
</div>
)
}
export default function OrderDetailPage({ orderId: propOrderId, readOnly = false }) {
const { orderId: paramOrderId } = useParams()
const orderId = propOrderId ?? paramOrderId
@@ -178,6 +273,18 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
staleTime: 60_000,
})
const { data: rawPriceEvents = [] } = useQuery({
queryKey: ['price-events', orderId],
queryFn: () => client.get(`/api/pricing/events/order/${orderId}`).then(r => r.data),
enabled: !!orderId,
staleTime: 30_000,
})
const priceEventsMap = rawPriceEvents.reduce((acc, ev) => {
if (!acc[ev.order_item_id]) acc[ev.order_item_id] = []
acc[ev.order_item_id].push(ev)
return acc
}, {})
const printOrder = useMutation({
mutationFn: (printerId) => client.post(`/api/orders/${orderId}/print`, { printer_id: printerId }),
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
@@ -272,10 +379,52 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
const activeItems = order.items.filter(i => i.status === 'active')
const total = order.items
.filter(i => i.status !== 'cancelled')
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
.reduce((s, i) => s + effPrice(i) * i.quantity, 0)
const isOpen = ['open', 'partially_paid'].includes(order.status)
// The ordering tab needs full height and no outer padding
if (tab === 'order') {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Compact header strip */}
<div style={{ padding: '10px 20px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
{!propOrderId && (
<button onClick={() => navigate(-1)} className="btn btn-ghost text-sm"> Πίσω</button>
)}
<div style={{ fontSize: 14, color: '#64748b' }}>
Τραπέζι {order.table_name || order.table_id}
</div>
<Badge status={order.status} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#1e293b' }}>{total.toFixed(2)}</span>
<div style={{ flex: 1 }} />
{/* Tab strip inline */}
<div style={{ display: 'flex', gap: 1, borderBottom: 'none' }}>
{[['overview', 'Επισκόπηση'], ['order', 'Παραγγελία'], ['pricing', 'Τιμολόγηση'], ['audit', 'Ιστορικό']].map(([key, label]) => (
<button key={key} onClick={() => setTab(key)}
style={{
padding: '6px 14px', fontSize: 13, fontWeight: tab === key ? 700 : 500,
background: 'none', border: 'none', cursor: 'pointer',
borderBottom: `2px solid ${tab === key ? '#4f46e5' : 'transparent'}`,
color: tab === key ? '#4f46e5' : '#64748b',
}}>
{label}
</button>
))}
</div>
</div>
{/* Full-height ordering terminal */}
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<DesktopOrderingTab
orderId={orderId}
tableId={order.table_id}
orderStatus={order.status}
/>
</div>
</div>
)
}
return (
<div className="overflow-y-auto h-full p-6">
<div className="max-w-3xl mx-auto space-y-6">
@@ -286,8 +435,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
{/* Header */}
<div className="card p-5 flex flex-wrap gap-4 items-start justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800">Παραγγελία #{order.id}</h1>
<p className="text-sm text-gray-500 mt-1">
<p className="text-sm text-gray-500">
Τραπέζι {order.table_name || order.table_id} · Ανοίχτηκε {formatDate(order.opened_at)}
{order.closed_at && ` · Έκλεισε ${formatDate(order.closed_at)}`}
</p>
@@ -367,7 +515,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200">
{[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
{[['overview', 'Επισκόπηση'], ['order', 'Παραγγελία'], ['pricing', 'Τιμολόγηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
<button
key={key}
onClick={() => setTab(key)}
@@ -423,12 +571,45 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
const isCancelled = item.status === 'cancelled'
const isClosedItem = item.status === 'closed'
const badgeStatus = isClosedItem ? 'closed_item' : item.status
const hasPriceAdj = !!(item.price_adjustment && item.price_adjustment !== 0)
const isDealItem = !!item.deal_id
const itemPriceEvents = (priceEventsMap[item.id] ?? []).filter(ev => ['modifier_applied','waiter_discount','free_item_added'].includes(ev.event_type))
const EVENT_COLORS_ODP = { modifier_applied: '#2563eb', waiter_discount: '#7c3aed', free_item_added: '#16a34a' }
return (
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${isCancelled ? 'opacity-40 line-through' : ''} ${isClosedItem ? 'bg-amber-50/50' : ''}`}>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
<p className="font-medium text-gray-800 text-sm" style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{item.product?.name ?? `#${item.product_id}`}
{item.course_id != null && (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 18, height: 18, borderRadius: '50%',
background: '#6366f1', color: '#fff',
fontSize: 10, fontWeight: 700,
}} title="Course">
{item.course_id}
</span>
)}
{isDealItem && <span style={{ fontSize: 10, fontWeight: 700, padding: '1px 5px', borderRadius: 4, background: 'rgba(167,139,250,0.12)', color: '#7c3aed', border: '1px solid rgba(167,139,250,0.3)' }}>ΔΩΡΕΑΝ</span>}
</p>
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
<p className="text-xs text-gray-500">x{item.quantity} · {item.unit_price.toFixed(2)}/τμχ</p>
<p className="text-xs" style={{ color: hasPriceAdj ? '#3b82f6' : '#6b7280' }}>
x{item.quantity} · {effPrice(item).toFixed(2)}/τμχ
{hasPriceAdj && (
<span className="ml-1 line-through text-gray-400">{(item.unit_price ?? 0).toFixed(2)}</span>
)}
</p>
{itemPriceEvents.map((ev, ei) => {
const evColor = EVENT_COLORS_ODP[ev.event_type] || '#8a9099'
const evLabel = ev.modifier_name || ev.deal_name || (ev.event_type === 'waiter_discount' ? 'Έκπτωση' : ev.event_type === 'free_item_added' ? 'Δωρεάν' : ev.event_type)
return (
<p key={ei} className="text-xs" style={{ color: evColor, display: 'flex', alignItems: 'center', gap: 3 }}>
<span style={{ opacity: 0.6 }}></span>
<span>{evLabel}</span>
{ev.delta_amount != null && <span style={{ fontWeight: 600 }}>{ev.delta_amount > 0 ? '+' : ''}{ev.delta_amount.toFixed(2)} </span>}
</p>
)
})}
{item.paid_by && (
<p className="text-xs text-green-600 mt-0.5">
Πληρώθηκε: {item.paid_by_name ?? waiterMap[item.paid_by] ?? `#${item.paid_by}`}
@@ -445,7 +626,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm font-semibold text-gray-700 w-14 text-right">{itemTotal(item)}</span>
<span className="text-sm font-semibold w-14 text-right" style={{ color: hasPriceAdj ? '#3b82f6' : '#374151' }}>{itemTotal(item)}</span>
{isOpen && !readOnly && item.status === 'active' && (
<>
<button
@@ -516,6 +697,12 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
</div>
</>}
{tab === 'pricing' && (
<div className="card divide-y divide-gray-100">
<PriceEventsTab orderId={orderId} />
</div>
)}
{tab === 'audit' && (
<div className="card divide-y divide-gray-100">
<AuditTab order={order} waiterMap={waiterMap} />

View File

@@ -0,0 +1,96 @@
import { Phone, PhoneIncoming, ChevronRight, Wifi, WifiOff } from 'lucide-react'
import usePhoneStore from '../store/phoneStore'
function formatTime(date) {
return date.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
}
export default function PhonePage() {
const { log, setActiveCall } = usePhoneStore()
return (
<div style={{ padding: 32, fontFamily: "'DM Sans','Segoe UI',sans-serif", maxWidth: 680, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 28 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'linear-gradient(135deg,#064e3b,#065f46)',
border: '1px solid rgba(52,211,153,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Phone size={18} color="#34d399" />
</div>
<div>
<h1 style={{ fontSize: 16, fontWeight: 700, color: '#0f172a', margin: 0, letterSpacing: '-0.01em' }}>
Εισερχόμενες Κλήσεις
</h1>
<p style={{ fontSize: 12, color: '#94a3b8', margin: '2px 0 0' }}>Grandstream UCM · Caller ID</p>
</div>
</div>
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden' }}>
<div style={{
padding: '12px 16px', borderBottom: '1px solid #f1f5f9',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
Πρόσφατες Κλήσεις
</span>
{log.length > 0 && (
<span style={{ fontSize: 11, color: '#cbd5e1' }}>{log.length} κλήσεις</span>
)}
</div>
{log.length === 0 ? (
<div style={{ padding: '48px 16px', textAlign: 'center', color: '#cbd5e1', fontSize: 13 }}>
Δεν έχουν ληφθεί κλήσεις ακόμα.
</div>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{log.map((entry, i) => (
<li
key={i}
onClick={() => setActiveCall(entry)}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 16px',
borderBottom: i < log.length - 1 ? '1px solid #f8fafc' : 'none',
cursor: 'pointer', transition: 'background 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.background = '#f8fafc' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<div style={{
width: 34, height: 34, borderRadius: 10, flexShrink: 0,
background: entry.customer ? '#fef3c7' : '#f1f5f9',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{entry.customer
? <span style={{ fontSize: 14, fontWeight: 700, color: '#d97706' }}>
{entry.customer.name?.[0]?.toUpperCase()}
</span>
: <PhoneIncoming size={14} color="#94a3b8" />
}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#1e293b' }}>
{entry.customer ? entry.customer.name : entry.caller || '—'}
</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 1 }}>
{entry.customer ? entry.caller : 'Άγνωστος'}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 11, color: '#cbd5e1', fontVariantNumeric: 'tabular-nums' }}>
{formatTime(entry.at)}
</span>
<ChevronRight size={12} color="#cbd5e1" />
</div>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@@ -238,7 +238,7 @@ function PayersModal({ order, waiterMap, onClose }) {
</div>
<div className="overflow-y-auto flex-1 px-6 py-4 space-y-5">
{paymentGroups.map((g, i) => {
const groupTotal = g.items.reduce((s, it) => s + it.unit_price * it.quantity, 0)
const groupTotal = g.items.reduce((s, it) => s + ((it.unit_price ?? 0) + (it.price_adjustment ?? 0)) * it.quantity, 0)
const waiterName = waiterMap[g.waiter_id] || `#${g.waiter_id}`
return (
<div key={i} className="space-y-1">
@@ -257,7 +257,7 @@ function PayersModal({ order, waiterMap, onClose }) {
{g.items.map(it => (
<li key={it.id} className="flex justify-between text-sm text-gray-600">
<span>{it.product?.name ?? `#${it.product_id}`}{it.quantity > 1 ? ` ×${it.quantity}` : ''}</span>
<span>{(it.unit_price * it.quantity).toFixed(2)}</span>
<span>{(((it.unit_price ?? 0) + (it.price_adjustment ?? 0)) * it.quantity).toFixed(2)}</span>
</li>
))}
</ul>
@@ -281,14 +281,15 @@ function OrderDetailsModal({ order, tableMap, waiterMap, onClose, printers, onPr
if (!order) return null
const activeItems = order.items.filter(i => i.status !== 'cancelled')
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const effPrice = i => (i.unit_price ?? 0) + (i.price_adjustment ?? 0)
const total = activeItems.reduce((s, i) => s + effPrice(i) * i.quantity, 0)
// Per-waiter subtotals for paid items
const waiterTotals = {}
for (const item of activeItems.filter(i => i.status === 'paid' && i.paid_by)) {
const wid = item.paid_by
if (!waiterTotals[wid]) waiterTotals[wid] = 0
waiterTotals[wid] += item.unit_price * item.quantity
waiterTotals[wid] += effPrice(item) * item.quantity
}
const waiterTotalEntries = Object.entries(waiterTotals)
@@ -327,8 +328,8 @@ function OrderDetailsModal({ order, tableMap, waiterMap, onClose, printers, onPr
<tr key={item.id} className={item.status === 'cancelled' ? 'opacity-40 line-through' : ''}>
<td className="px-4 py-2.5 text-gray-800">{item.product?.name ?? `#${item.product_id}`}</td>
<td className="px-4 py-2.5 text-center text-gray-700">{item.quantity}</td>
<td className="px-4 py-2.5 text-right text-gray-700">{item.unit_price.toFixed(2)}</td>
<td className="px-4 py-2.5 text-right font-semibold text-gray-800">{(item.unit_price * item.quantity).toFixed(2)}</td>
<td className="px-4 py-2.5 text-right text-gray-700">{effPrice(item).toFixed(2)}</td>
<td className="px-4 py-2.5 text-right font-semibold text-gray-800">{(effPrice(item) * item.quantity).toFixed(2)}</td>
<td className="px-4 py-2.5"><Badge status={item.status} /></td>
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{item.paid_at ? fmtDt(item.paid_at) : '—'}</td>
<td className="px-4 py-2.5 text-gray-500">{item.payment_method ? pmLabel(item.payment_method) : '—'}</td>
@@ -836,7 +837,7 @@ function HistoryTab({ filters, setFilters }) {
{visibleOrders.map(o => {
const total = o.items
.filter(i => i.status !== 'cancelled')
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0)) * i.quantity, 0)
// Opener / closer
const openerName = waiterMap[o.opened_by] || `#${o.opened_by}`

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -437,7 +437,7 @@ export default function AppInfoTab() {
<DataTransferSection />
{user?.role === 'sysadmin' && (
{user?.role === 'superadmin' && (
<div className="card p-5 space-y-3 border-amber-200 bg-amber-50">
<h2 className="font-semibold text-amber-800">Sysadmin</h2>
<p className="text-sm text-amber-700">Έλεγχος κλειδώματος συστήματος.</p>

View File

@@ -6,18 +6,29 @@ import toast from 'react-hot-toast'
// ─── Colour slot metadata ────────────────────────────────────────────────────
const SLOTS = [
{ key: 'cardBg', label: 'Κύριο Φόντο', hint: 'Φόντο κάρτας' },
{ key: 'badgeBg', label: 'Δευτερεύον Φόντο', hint: 'Φόντο badge κατάστασης' },
{ key: 'nameText', label: 'Κύριο Κείμενο', hint: 'Όνομα τραπεζιού' },
{ key: 'badgeText', label: 'Δευτερεύον Κείμενο', hint: 'Ετικέτα badge' },
{ key: 'cardBg', key2: 'cardBg2', label: 'Κύριο Φόντο', hint: 'Φόντο κάρτας' },
{ key: 'badgeBg', key2: 'badgeBg2', label: 'Δευτερεύον Φόντο', hint: 'Φόντο badge κατάστασης' },
{ key: 'nameText', key2: 'nameText2', label: 'Κύριο Κείμενο', hint: 'Όνομα τραπεζιού' },
{ key: 'badgeText', key2: 'badgeText2', label: 'Δευτερεύον Κείμενο', hint: 'Ετικέτα badge' },
]
function useFlashToggle(active, ms = 700) {
const [frame, setFrame] = useState(false)
useEffect(() => {
if (!active) { setFrame(false); return }
const id = setInterval(() => setFrame(f => !f), ms)
return () => clearInterval(id)
}, [active, ms])
return frame
}
const STATUSES = [
{ key: 'free', label: 'Ελεύθερο' },
{ key: 'open', label: 'Ανοιχτό (όχι δικό μου)' },
{ key: 'mine', label: 'Ανοιχτό (δικό μου)' },
{ key: 'partially_paid', label: 'Μερικώς Πληρωμένο' },
{ key: 'paid', label: 'Πληρωμένο' },
{ key: 'kds_ready', label: 'Έτοιμο για Παράδοση (KDS)', hasFlash: true },
]
const STATUS_LABELS_MOCK = {
@@ -26,14 +37,19 @@ const STATUS_LABELS_MOCK = {
mine: 'ΔΙΚΟ ΜΟΥ',
partially_paid: 'ΜΕΡ. ΠΛHΡ.',
paid: 'ΠΛΗΡΩΜΕΝΟ',
kds_ready: 'READY',
}
// Quick-suggest palettes per slot type
const QUICK_SWATCHES = {
cardBg: ['#dde5ef', '#243044', '#FF8F60', '#e8610a', '#FFDC67', '#81D264', '#a78bfa', '#38bdf8', '#f43f5e', '#1e293b'],
badgeBg: ['rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', 'rgba(255,255,255,0.6)', 'rgba(30,41,59,0.85)', '#ffffff', '#000000'],
nameText: ['#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#f8fafc', '#111827', '#fef3c7', '#dcfce7'],
badgeText: ['#3d5270', '#94b8d4', '#e8610a', '#FF8F60', '#FFDC67', '#d4a800', '#81D264', '#ffffff', '#1e293b'],
cardBg: ['#dde5ef', '#243044', '#FF8F60', '#e8610a', '#FFDC67', '#81D264', '#a78bfa', '#38bdf8', '#f43f5e', '#1e293b'],
cardBg2: ['rgba(0,0,0,0)', '#ffffff', '#dde5ef', '#243044', '#FF8F60', '#e8610a', '#22c55e', '#3b82f6'],
badgeBg: ['rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', 'rgba(255,255,255,0.6)', 'rgba(30,41,59,0.85)', '#ffffff', '#000000'],
badgeBg2: ['rgba(0,0,0,0)', 'rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', '#ffffff', '#000000'],
nameText: ['#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#f8fafc', '#111827', '#fef3c7', '#dcfce7'],
nameText2: ['rgba(0,0,0,0)', '#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#15803d', '#bbf7d0'],
badgeText: ['#3d5270', '#94b8d4', '#e8610a', '#FF8F60', '#FFDC67', '#d4a800', '#81D264', '#ffffff', '#1e293b'],
badgeText2: ['rgba(0,0,0,0)', '#3d5270', '#94b8d4', '#15803d', '#bbf7d0', '#22c55e', '#ffffff'],
}
// ─── Color picker modal ──────────────────────────────────────────────────────
@@ -106,7 +122,9 @@ function ColourPickerModal({ value, onClose, onChange, slot }) {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111827' }}>Επιλογή Χρώματος</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>{SLOTS.find(s => s.key === slot)?.label}</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>
{SLOTS.find(s => s.key === slot)?.label || (SLOTS.find(s => s.key2 === slot)?.label + ' (χρώμα 2)')}
</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 22, cursor: 'pointer', color: '#6b7280', lineHeight: 1 }}>×</button>
</div>
@@ -259,34 +277,43 @@ function ColourSlotRow({ mode, status, slotKey, label, value, onOpen }) {
// ─── Mini mock table card (for preview) ──────────────────────────────────────
function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ' }) {
function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ', flashFrame = false }) {
return (
<div style={{
width: '100%', height: 90, borderRadius: 12, background: cfg.cardBg,
width: '100%', height: 90, borderRadius: 12,
background: flashFrame ? (cfg.cardBg2 ?? cfg.cardBg) : cfg.cardBg,
position: 'relative', flexShrink: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.18)',
overflow: 'hidden',
transition: 'background 0.15s',
}}>
{/* Table name + group */}
<div style={{ position: 'absolute', top: 8, left: 10, display: 'flex', flexDirection: 'column', gap: 1 }}>
<span style={{
fontSize: 17, fontWeight: 800, color: cfg.nameText,
fontSize: 17, fontWeight: 800,
color: flashFrame ? (cfg.nameText2 ?? cfg.nameText) : cfg.nameText,
lineHeight: 1, letterSpacing: -0.5,
transition: 'color 0.15s',
}}>{mockName}</span>
<span style={{
fontSize: 7, fontWeight: 600, letterSpacing: 0.8,
color: cfg.nameText + '80',
color: (flashFrame ? (cfg.nameText2 ?? cfg.nameText) : cfg.nameText) + '80',
textTransform: 'uppercase',
}}>{groupName}</span>
</div>
{/* Status badge — tight equal padding on all sides */}
{/* Status badge */}
<div style={{
position: 'absolute', bottom: 7, left: 7,
background: cfg.badgeBg,
background: flashFrame ? (cfg.badgeBg2 ?? cfg.badgeBg) : cfg.badgeBg,
borderRadius: 4, padding: '2px 5px',
lineHeight: 1,
lineHeight: 1, transition: 'background 0.15s',
}}>
<span style={{ fontSize: 7, fontWeight: 700, color: cfg.badgeText, whiteSpace: 'nowrap', lineHeight: 1 }}>
<span style={{
fontSize: 7, fontWeight: 700,
color: flashFrame ? (cfg.badgeText2 ?? cfg.badgeText) : cfg.badgeText,
whiteSpace: 'nowrap', lineHeight: 1,
transition: 'color 0.15s',
}}>
{label}
</span>
</div>
@@ -336,30 +363,82 @@ function PreviewPanel({ colours, mode }) {
// ─── Status block (one status, showing all 4 slots) ──────────────────────────
function StatusBlock({ mode, status, label, colours, onOpen }) {
function StatusBlock({ mode, status, label, hasFlash, colours, onOpen, onSetFlash }) {
const cfg = colours[mode][status]
const flashFrame = useFlashToggle(hasFlash && !!cfg.flash)
return (
<div style={{ background: '#f9fafb', borderRadius: 12, padding: '14px 16px', border: '1px solid #f0f0f0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 88, flexShrink: 0 }}>
<MockCard cfg={cfg} label={STATUS_LABELS_MOCK[status]} mockName="T1" />
<MockCard cfg={cfg} label={STATUS_LABELS_MOCK[status]} mockName="T1" flashFrame={flashFrame} />
</div>
<div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827' }}>{label}</div>
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2 }}>Πατήστε ένα χρώμα για επεξεργασία</div>
{hasFlash && (
<div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<div
onClick={() => onSetFlash(mode, !cfg.flash)}
style={{
width: 36, height: 20, borderRadius: 10,
background: cfg.flash ? '#22c55e' : '#d1d5db',
position: 'relative', cursor: 'pointer', transition: 'background 0.2s',
flexShrink: 0,
}}
>
<div style={{
position: 'absolute', top: 2, left: cfg.flash ? 18 : 2,
width: 16, height: 16, borderRadius: '50%',
background: '#fff', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
transition: 'left 0.2s',
}} />
</div>
<span style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>
Αναλαμπή (Flashing)
</span>
</div>
)}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, borderTop: '1px solid #ebebeb', paddingTop: 8 }}>
{SLOTS.map(slot => (
<ColourSlotRow
key={slot.key}
mode={mode}
status={status}
slotKey={slot.key}
label={slot.label}
value={cfg[slot.key]}
onOpen={onOpen}
/>
<div key={slot.key}>
<ColourSlotRow
mode={mode}
status={status}
slotKey={slot.key}
label={slot.label}
value={cfg[slot.key]}
onOpen={onOpen}
/>
{hasFlash && cfg.flash && slot.key2 && (
<div style={{ paddingLeft: 20, marginTop: -4, marginBottom: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 12, height: 12, borderRadius: 3, background: cfg[slot.key2] || 'rgba(0,0,0,0)', border: '1px solid #e5e7eb', flexShrink: 0 }} />
<button
onClick={() => onOpen(mode, status, slot.key2, cfg[slot.key2] || cfg[slot.key])}
style={{
width: 36, height: 22, borderRadius: 6,
background: cfg[slot.key2] || 'rgba(0,0,0,0)',
border: '1.5px solid #e5e7eb', cursor: 'pointer', flexShrink: 0,
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
backgroundImage: !cfg[slot.key2] || cfg[slot.key2] === 'rgba(0,0,0,0)'
? 'linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%)'
: 'none',
backgroundSize: '6px 6px',
backgroundPosition: '0 0,0 3px,3px -3px,-3px 0',
}}
/>
<div>
<div style={{ fontSize: 11, color: '#6b7280' }}>{slot.label} (χρώμα 2)</div>
<div style={{ fontSize: 10, color: '#9ca3af', fontFamily: 'monospace' }}>
{cfg[slot.key2] || 'διάφανο'}
</div>
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
@@ -368,7 +447,7 @@ function StatusBlock({ mode, status, label, colours, onOpen }) {
// ─── Mode section (light or dark) ────────────────────────────────────────────
function ModeSection({ mode, colours, onOpen }) {
function ModeSection({ mode, colours, onOpen, onSetFlash }) {
const label = mode === 'light' ? '☀️ Φωτεινό θέμα' : '🌙 Σκοτεινό θέμα'
return (
<div>
@@ -380,8 +459,10 @@ function ModeSection({ mode, colours, onOpen }) {
mode={mode}
status={s.key}
label={s.label}
hasFlash={s.hasFlash}
colours={colours}
onOpen={onOpen}
onSetFlash={onSetFlash}
/>
))}
</div>
@@ -402,7 +483,17 @@ export default function ColoursTab() {
client.get('/api/settings/').then(r => {
const raw = r.data?.['ui.table_colours']?.value
if (raw) {
try { setColours(JSON.parse(raw)) } catch {}
try {
const loaded = JSON.parse(raw)
// Deep-merge with defaults so any missing statuses/slots are backfilled
const merged = { light: {}, dark: {} }
for (const mode of ['light', 'dark']) {
for (const [status, defaults] of Object.entries(DEFAULT_COLOURS[mode])) {
merged[mode][status] = { ...defaults, ...(loaded[mode]?.[status] ?? {}) }
}
}
setColours(merged)
} catch {}
}
})
}, [])
@@ -432,6 +523,20 @@ export default function ColoursTab() {
})
}
function setFlash(mode, value) {
setColours(prev => {
const next = {
...prev,
[mode]: {
...prev[mode],
kds_ready: { ...prev[mode].kds_ready, flash: value },
},
}
saveToBackend(next)
return next
})
}
function openModal(mode, status, slot, value) {
setModal({ mode, status, slot, value })
}
@@ -461,8 +566,8 @@ export default function ColoursTab() {
{/* Light + Dark mode settings */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 32 }}>
<ModeSection mode="light" colours={colours} onOpen={openModal} />
<ModeSection mode="dark" colours={colours} onOpen={openModal} />
<ModeSection mode="light" colours={colours} onOpen={openModal} onSetFlash={setFlash} />
<ModeSection mode="dark" colours={colours} onOpen={openModal} onSetFlash={setFlash} />
</div>
</div>

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