diff --git a/docker-compose.yml b/docker-compose.yml index 428244b..54e9d97 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: - ./local_backend/license_state.json:/app/license_state.json - ./logo.png:/app/logo.png:ro - ./data/product_images:/app/data/product_images + - ./data/avatars:/app/data/avatars extra_hosts: - "host.docker.internal:host-gateway" diff --git a/local_backend/.dockerignore b/local_backend/.dockerignore new file mode 100644 index 0000000..3ec9458 --- /dev/null +++ b/local_backend/.dockerignore @@ -0,0 +1,5 @@ +pos.db +license_state.json +__pycache__ +*.pyc +.env diff --git a/local_backend/main.py b/local_backend/main.py index 893e59f..9d2fd28 100644 --- a/local_backend/main.py +++ b/local_backend/main.py @@ -9,11 +9,11 @@ from middleware.license_check import LicenseCheckMiddleware from services.cloud_sync import start_cloud_sync # Import all models so SQLAlchemy can create their tables -import models.user # noqa: F401 +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 +import models.order # noqa: F401 — also registers OrderAuditLog, OrderDiscount from routers import auth, tables, products, orders, waiters, reports, system @@ -36,6 +36,45 @@ def _run_migrations(): "ALTER TABLE product_preference_choices ADD COLUMN disables_subset INTEGER NOT NULL DEFAULT 0", "ALTER TABLE product_preference_sets ADD COLUMN shared_subset TEXT", "ALTER TABLE product_options ADD COLUMN sub_choices TEXT", + # Zone-based access control + """CREATE TABLE IF NOT EXISTS waiter_zones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + waiter_id INTEGER NOT NULL REFERENCES users(id), + group_id INTEGER REFERENCES table_groups(id), + assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP + )""", + # Payment tracking on items + "ALTER TABLE order_items ADD COLUMN paid_by INTEGER REFERENCES users(id)", + "ALTER TABLE order_items ADD COLUMN paid_at DATETIME", + "ALTER TABLE order_items ADD COLUMN payment_method VARCHAR", + # Full audit log + """CREATE TABLE IF NOT EXISTS order_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL REFERENCES orders(id), + event_type VARCHAR NOT NULL, + waiter_id INTEGER REFERENCES users(id), + item_ids TEXT, + amount REAL, + payment_method VARCHAR, + note TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )""", + # Waiter profile fields + "ALTER TABLE users ADD COLUMN full_name VARCHAR", + "ALTER TABLE users ADD COLUMN nickname VARCHAR", + "ALTER TABLE users ADD COLUMN mobile_phone VARCHAR", + "ALTER TABLE users ADD COLUMN avatar_url VARCHAR", + # Discounts table (future-proofed, schema ready now) + """CREATE TABLE IF NOT EXISTS order_discounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL REFERENCES orders(id), + item_id INTEGER REFERENCES order_items(id), + discount_type VARCHAR NOT NULL, + discount_value REAL NOT NULL, + applied_by INTEGER NOT NULL REFERENCES users(id), + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, + reason TEXT + )""", ] for sql in migrations: try: @@ -70,6 +109,11 @@ IMAGE_DIR = "/app/data/product_images" os.makedirs(IMAGE_DIR, exist_ok=True) app.mount("/static/product_images", StaticFiles(directory=IMAGE_DIR), name="product_images") +# Serve waiter avatars as static files +AVATAR_DIR = "/app/data/avatars" +os.makedirs(AVATAR_DIR, exist_ok=True) +app.mount("/static/avatars", StaticFiles(directory=AVATAR_DIR), name="avatars") + app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(tables.router, prefix="/api/tables", tags=["tables"]) app.include_router(products.router, prefix="/api/products", tags=["products"]) diff --git a/local_backend/models/order.py b/local_backend/models/order.py index 26e0f4a..6c5b9fd 100644 --- a/local_backend/models/order.py +++ b/local_backend/models/order.py @@ -22,6 +22,8 @@ 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") + audit_logs = relationship("OrderAuditLog", back_populates="order", cascade="all, delete-orphan") + discounts = relationship("OrderDiscount", back_populates="order", cascade="all, delete-orphan") class OrderWaiter(Base): @@ -52,9 +54,15 @@ class OrderItem(Base): added_at = Column(DateTime, default=datetime.utcnow) printed = Column(Boolean, default=False, nullable=False) + # Payment tracking + paid_by = Column(Integer, ForeignKey("users.id"), nullable=True) + paid_at = Column(DateTime, nullable=True) + payment_method = Column(String, nullable=True) # 'cash'|'card'|'other' — future use + order = relationship("Order", back_populates="items") product = relationship("Product", back_populates="order_items") - added_by_user = relationship("User", 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") class PrintLog(Base): @@ -70,3 +78,44 @@ class PrintLog(Base): order = relationship("Order", back_populates="print_logs") printer = relationship("Printer", back_populates="print_logs") + + +class OrderAuditLog(Base): + """Immutable append-only audit trail for every action on an order.""" + __tablename__ = "order_audit_log" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + event_type = Column(String, nullable=False) + # ORDER_OPENED | ITEMS_ADDED | PAYMENT | ORDER_CLOSED | ORDER_CANCELLED | ITEM_CANCELLED + waiter_id = Column(Integer, ForeignKey("users.id"), nullable=True) + item_ids = Column(Text, nullable=True) # JSON list of OrderItem ids (for ITEMS_ADDED, PAYMENT, ITEM_CANCELLED) + amount = Column(Float, nullable=True) # total value for PAYMENT events + payment_method = Column(String, nullable=True) + note = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + order = relationship("Order", back_populates="audit_logs") + waiter = relationship("User") + + @property + def waiter_name(self): + return self.waiter.username if self.waiter else None + + +class OrderDiscount(Base): + """Records a discount applied to an order or a specific item.""" + __tablename__ = "order_discounts" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + item_id = Column(Integer, ForeignKey("order_items.id"), nullable=True) # NULL = whole-order discount + discount_type = Column(String, nullable=False) # 'percent' | 'fixed' + discount_value = Column(Float, nullable=False) # e.g. 10.0 = 10% or €10.00 + applied_by = Column(Integer, ForeignKey("users.id"), nullable=False) + applied_at = Column(DateTime, default=datetime.utcnow) + reason = Column(Text, nullable=True) + + order = relationship("Order", back_populates="discounts") + item = relationship("OrderItem") + applied_by_user = relationship("User") diff --git a/local_backend/models/table.py b/local_backend/models/table.py index 0a7c6db..d1f4fe8 100644 --- a/local_backend/models/table.py +++ b/local_backend/models/table.py @@ -13,6 +13,7 @@ class TableGroup(Base): color = Column(String, nullable=True) tables = relationship("Table", back_populates="group") + waiter_zones = relationship("WaiterZone", back_populates="group") class Table(Base): diff --git a/local_backend/models/user.py b/local_backend/models/user.py index 097e5b0..0392487 100644 --- a/local_backend/models/user.py +++ b/local_backend/models/user.py @@ -12,12 +12,18 @@ class User(Base): pin_hash = Column(String, nullable=False) role = Column(String, nullable=False) # 'waiter' | 'manager' | 'sysadmin' is_active = Column(Boolean, default=True, nullable=False) + full_name = Column(String, nullable=True) + nickname = Column(String, nullable=True) + mobile_phone = Column(String, nullable=True) + avatar_url = Column(String, nullable=True) created_at = Column(DateTime, default=datetime.utcnow) 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", back_populates="added_by_user") + order_items = relationship("OrderItem", foreign_keys="OrderItem.added_by", back_populates="added_by_user") + items_paid = relationship("OrderItem", foreign_keys="OrderItem.paid_by", back_populates="paid_by_user") order_assignments = relationship("OrderWaiter", back_populates="waiter") + zone_assignments = relationship("WaiterZone", back_populates="waiter", cascade="all, delete-orphan") primary_assignments = relationship( "AssistantAssignment", @@ -31,6 +37,21 @@ class User(Base): ) +class WaiterZone(Base): + """Maps a waiter to a table group they are allowed to operate in. + If a waiter has NO rows here, they see NOTHING. + A sentinel row with group_id=NULL means 'all zones'.""" + __tablename__ = "waiter_zones" + + id = Column(Integer, primary_key=True, index=True) + waiter_id = Column(Integer, ForeignKey("users.id"), nullable=False) + group_id = Column(Integer, ForeignKey("table_groups.id"), nullable=True) # NULL = all zones + assigned_at = Column(DateTime, default=datetime.utcnow) + + waiter = relationship("User", back_populates="zone_assignments") + group = relationship("TableGroup", back_populates="waiter_zones") + + class AssistantAssignment(Base): __tablename__ = "assistant_assignments" diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py index 49f672c..0256f33 100644 --- a/local_backend/routers/reports.py +++ b/local_backend/routers/reports.py @@ -1,49 +1,170 @@ -from fastapi import APIRouter, Depends, Query +import json + +from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks +from pydantic import BaseModel from sqlalchemy.orm import Session from sqlalchemy import func from datetime import date, datetime, timedelta from typing import Optional, List from database import get_db -from models.order import Order, OrderItem, OrderWaiter +from models.order import Order, OrderItem, OrderWaiter, PrintLog from models.user import User from models.table import Table +from models.printer import Printer from schemas.order import OrderOut from schemas.table import TableOut from routers.deps import require_manager +from services.printer_service import print_waiter_report, print_printer_report, print_order_receipt router = APIRouter() @router.get("/shift") def shift_summary( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), report_date: Optional[date] = Query(default=None, alias="date"), waiter_id: Optional[int] = None, db: Session = Depends(get_db), user: User = Depends(require_manager), ): - target = report_date or date.today() - start = datetime.combine(target, datetime.min.time()) - end = start + timedelta(days=1) + """Payments collected per waiter — based on paid_by on order items.""" + if from_dt and to_dt: + start = datetime.fromisoformat(from_dt) + end = datetime.fromisoformat(to_dt) + else: + target = report_date or date.today() + start = datetime.combine(target, datetime.min.time()) + end = start + timedelta(days=1) - q = db.query(Order).filter(Order.opened_at >= start, Order.opened_at < end) + q = db.query(OrderItem).filter( + OrderItem.status == "paid", + OrderItem.paid_at >= start, + OrderItem.paid_at < end, + ) if waiter_id: - q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id) - orders = q.all() + q = q.filter(OrderItem.paid_by == waiter_id) + items = q.all() - summary = {} - for order in orders: - waiter = db.query(User).filter(User.id == order.opened_by).first() - key = waiter.username if waiter else "unknown" - if key not in summary: - summary[key] = {"orders": 0, "items": 0, "total": 0.0} - summary[key]["orders"] += 1 - for item in order.items: - if item.status in ("active", "paid"): - summary[key]["items"] += item.quantity - summary[key]["total"] += item.unit_price * item.quantity + 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()} - return {"date": str(target), "waiters": summary} + # Build per-waiter summary keyed by waiter_id + summary: dict[int, dict] = {} + for item in items: + wid = item.paid_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, + "order_data": {}, + } + summary[wid]["items"] += item.quantity + val = item.unit_price * item.quantity + summary[wid]["total"] += val + + oid = item.order_id + if oid not in summary[wid]["order_data"]: + 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 "", + "table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}", + "total": 0.0, + "items": [], + } + summary[wid]["order_data"][oid]["total"] += val + product_name = item.product.name if item.product else f"#{item.product_id}" + summary[wid]["order_data"][oid]["items"].append( + {"name": product_name, "quantity": item.quantity} + ) + + result = [] + for entry in summary.values(): + entry["orders"] = len(entry["order_data"]) + entry["order_data"] = list(entry["order_data"].values()) + result.append(entry) + + return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result} + + +@router.get("/shift/orders") +def shift_orders_summary( + from_dt: Optional[str] = Query(default=None, alias="from"), + to_dt: Optional[str] = Query(default=None, alias="to"), + report_date: Optional[date] = Query(default=None, alias="date"), + waiter_id: Optional[int] = None, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + """Items sent (added) per waiter — regardless of payment status.""" + if from_dt and to_dt: + start = datetime.fromisoformat(from_dt) + end = datetime.fromisoformat(to_dt) + else: + target = report_date or date.today() + start = datetime.combine(target, datetime.min.time()) + end = start + timedelta(days=1) + + q = db.query(OrderItem).filter( + OrderItem.status.in_(["active", "paid"]), + OrderItem.added_at >= start, + OrderItem.added_at < end, + ) + if waiter_id: + q = q.filter(OrderItem.added_by == waiter_id) + items = q.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()} + + summary: dict[int, dict] = {} + for item in 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, + "order_data": {}, + } + summary[wid]["items"] += item.quantity + val = item.unit_price * item.quantity + summary[wid]["total"] += val + + oid = item.order_id + if oid not in summary[wid]["order_data"]: + 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 "", + "table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}", + "total": 0.0, + "items": [], + } + summary[wid]["order_data"][oid]["total"] += val + product_name = item.product.name if item.product else f"#{item.product_id}" + summary[wid]["order_data"][oid]["items"].append( + {"name": product_name, "quantity": item.quantity} + ) + + result = [] + for entry in summary.values(): + entry["orders"] = len(entry["order_data"]) + entry["order_data"] = list(entry["order_data"].values()) + result.append(entry) + + return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result} @router.get("/orders/history", response_model=List[OrderOut]) @@ -52,6 +173,7 @@ def order_history( to_date: Optional[str] = Query(default=None, alias="to"), waiter_id: Optional[int] = None, order_status: Optional[str] = Query(default=None, alias="status"), + table_id: Optional[int] = None, page: int = 1, page_size: int = 50, db: Session = Depends(get_db), @@ -66,6 +188,8 @@ def order_history( q = q.join(OrderWaiter).filter(OrderWaiter.waiter_id == waiter_id) if order_status: q = q.filter(Order.status == order_status) + if table_id: + q = q.filter(Order.table_id == table_id) return q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all() @@ -84,3 +208,233 @@ def tables_summary(db: Session = Depends(get_db), user: User = Depends(require_m "order_id": active_order.id if active_order else None, }) return result + + +@router.get("/printers") +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), +): + """Returns totals per printer based on print_log entries in the date range.""" + q = db.query(PrintLog).filter(PrintLog.success == True) + if from_date: + q = q.filter(PrintLog.printed_at >= datetime.fromisoformat(from_date)) + if to_date: + q = q.filter(PrintLog.printed_at <= datetime.fromisoformat(to_date)) + logs = q.all() + + printers_db = {p.id: p for p in db.query(Printer).all()} + tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()} + + # summary[pid] — aggregated totals + summary: dict[int, dict] = {} + # order_map[pid][order_id] — per-order detail with items + order_map: dict[int, dict] = {} + + for log in logs: + pid = log.printer_id + if pid not in summary: + printer = printers_db.get(pid) + summary[pid] = { + "printer_id": pid, + "printer_name": printer.name if printer else f"Printer #{pid}", + "print_jobs": 0, + "orders": set(), + "items": 0, + "total": 0.0, + } + order_map[pid] = {} + + summary[pid]["print_jobs"] += 1 + summary[pid]["orders"].add(log.order_id) + + oid = log.order_id + if oid not in order_map[pid]: + order = db.query(Order).filter(Order.id == oid).first() + order_map[pid][oid] = { + "order_id": oid, + "time": log.printed_at.strftime("%H:%M"), + "table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}", + "total": 0.0, + "items": [], + } + + try: + item_ids = json.loads(log.item_ids) + except Exception: + item_ids = [] + for item_id in item_ids: + item = db.query(OrderItem).filter(OrderItem.id == item_id).first() + if item and item.status in ("active", "paid"): + summary[pid]["items"] += item.quantity + val = item.unit_price * item.quantity + summary[pid]["total"] += val + order_map[pid][oid]["total"] += val + product_name = item.product.name if item.product else f"#{item.product_id}" + order_map[pid][oid]["items"].append({"name": product_name, "quantity": item.quantity}) + + result = [] + for pid, entry in summary.items(): + entry["orders"] = len(entry["orders"]) + entry["order_data"] = list(order_map.get(pid, {}).values()) + result.append(entry) + return {"printers": result} + + +class PrintWaiterReportBody(BaseModel): + waiter_name: str + printer_id: int + mode: str # "simple" | "extensive" + from_dt: str + to_dt: str + + +class PrintPrinterReportBody(BaseModel): + printer_target_id: int + printer_id: int + mode: str # "simple" | "extensive" + from_dt: str + to_dt: str + + +class PrintOrderBody(BaseModel): + printer_id: int + + +@router.post("/print/waiter") +def print_waiter( + body: PrintWaiterReportBody, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found or inactive") + + from_dt = datetime.fromisoformat(body.from_dt) + to_dt = datetime.fromisoformat(body.to_dt) + + # Gather orders for this waiter in time range + waiter = db.query(User).filter(User.username == body.waiter_name).first() + q = db.query(Order).filter( + Order.opened_at >= from_dt, + Order.opened_at <= to_dt, + ) + if waiter: + q = q.filter(Order.opened_by == waiter.id) + else: + q = q.filter(False) + orders = q.all() + + # Enrich with table names + tables = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()} + 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) + 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 "", + "table": tables.get(o.table_id, f"#{o.table_id}"), + "total": total, + "items": [ + {"name": (i.product.name if i.product else f"#{i.product_id}"), "quantity": i.quantity} + for i in active_items + ], + }) + + items_count = sum( + i.quantity for o in orders for i in o.items if i.status in ("active", "paid") + ) + grand_total = sum(d["total"] for d in order_data) + + report = { + "waiter_name": body.waiter_name, + "orders": len(orders), + "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"), + } + + background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode) + return {"status": "printing"} + + +@router.post("/print/printer") +def print_printer_totals( + body: PrintPrinterReportBody, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + user: User = Depends(require_manager), +): + printer = db.query(Printer).filter(Printer.id == body.printer_id, Printer.is_active == True).first() + if not printer: + raise HTTPException(status_code=404, detail="Printer not found or inactive") + + target_printer = db.query(Printer).filter(Printer.id == body.printer_target_id).first() + target_name = target_printer.name if target_printer else f"Printer #{body.printer_target_id}" + + from_dt = datetime.fromisoformat(body.from_dt) + to_dt = datetime.fromisoformat(body.to_dt) + + logs = db.query(PrintLog).filter( + PrintLog.printer_id == body.printer_target_id, + PrintLog.success == True, + PrintLog.printed_at >= from_dt, + PrintLog.printed_at <= to_dt, + ).all() + + tables = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()} + + # Build per-order entries keyed by order_id; each log may add more items + order_map: dict = {} + items_count = 0 + grand_total = 0.0 + for log in logs: + oid = log.order_id + if oid not in order_map: + order = db.query(Order).filter(Order.id == oid).first() + if order: + order_map[oid] = { + "id": oid, + "time": log.printed_at.strftime("%H:%M"), + "table": tables.get(order.table_id, f"#{order.table_id}"), + "total": 0.0, + "items": [], + } + try: + item_ids = json.loads(log.item_ids) + except Exception: + item_ids = [] + for item_id in item_ids: + item = db.query(OrderItem).filter(OrderItem.id == item_id).first() + if item and item.status in ("active", "paid"): + items_count += item.quantity + val = item.unit_price * item.quantity + grand_total += val + if oid in order_map: + order_map[oid]["total"] += val + product_name = item.product.name if item.product else f"#{item.product_id}" + order_map[oid]["items"].append({"name": product_name, "quantity": item.quantity}) + + order_data = list(order_map.values()) + + report = { + "printer_name": target_name, + "print_jobs": len(logs), + "orders": len(order_map), + "items": items_count, + "total": grand_total, + "order_data": order_data if body.mode == "extensive" else [], + "from_dt": from_dt.strftime("%d/%m/%Y %H:%M"), + "to_dt": to_dt.strftime("%d/%m/%Y %H:%M"), + } + + background_tasks.add_task(print_printer_report, printer.ip_address, printer.port, report, body.mode) + return {"status": "printing"} diff --git a/local_backend/routers/system.py b/local_backend/routers/system.py index c86d87e..f263303 100644 --- a/local_backend/routers/system.py +++ b/local_backend/routers/system.py @@ -38,6 +38,11 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren } +@router.get("/printers", response_model=List[PrinterOut]) +def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)): + return db.query(Printer).filter(Printer.is_active == True).all() + + @router.post("/printers/test") def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): printer = db.query(Printer).filter(Printer.id == printer_id).first() diff --git a/local_backend/routers/tables.py b/local_backend/routers/tables.py index 3d1117b..846d595 100644 --- a/local_backend/routers/tables.py +++ b/local_backend/routers/tables.py @@ -5,7 +5,7 @@ from typing import List from database import get_db from models.table import Table, TableGroup from models.order import Order -from models.user import User +from models.user import User, WaiterZone from schemas.table import ( TableCreate, TableUpdate, TableFloorplanUpdate, TableOut, TableGroupCreate, TableGroupUpdate, TableGroupOut, @@ -69,6 +69,19 @@ def list_tables(include_inactive: bool = False, db: Session = Depends(get_db), u q = db.query(Table) if not include_inactive: q = q.filter(Table.is_active == True) + + # Zone-based filtering for waiters + if user.role not in ("manager", "sysadmin"): + zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == user.id).all() + # No zone rows → sees nothing + if not zones: + return [] + # Any row with group_id=None → sees all tables (all-zones sentinel) + has_all_zones = any(z.group_id is None for z in zones) + if not has_all_zones: + allowed_group_ids = [z.group_id for z in zones] + q = q.filter(Table.group_id.in_(allowed_group_ids)) + tables = q.order_by(Table.group_id, Table.number).all() active_table_ids = { diff --git a/local_backend/routers/waiters.py b/local_backend/routers/waiters.py index 54a4805..0645e75 100644 --- a/local_backend/routers/waiters.py +++ b/local_backend/routers/waiters.py @@ -1,20 +1,30 @@ +import os +import uuid import bcrypt -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from sqlalchemy.orm import Session from typing import List from database import get_db -from models.user import User, AssistantAssignment -from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut +from models.user import User, AssistantAssignment, WaiterZone +from schemas.user import UserCreate, UserUpdate, UserOut, AssistantAssignmentOut, SetZonesRequest from routers.deps import require_manager router = APIRouter() +AVATAR_DIR = "/app/data/avatars" -class ResetPinRequest: - def __init__(self, pin: str): - self.pin = pin +# ── 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 + + +# ── CRUD ────────────────────────────────────────────────────────────────────── @router.get("/", response_model=List[UserOut]) def list_waiters(db: Session = Depends(get_db), user: User = Depends(require_manager)): @@ -35,9 +45,7 @@ def create_waiter(body: UserCreate, db: Session = Depends(get_db), user: 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 = db.query(User).filter(User.id == waiter_id).first() - if not waiter: - raise HTTPException(status_code=404, detail="Waiter not found") + waiter = _waiter_or_404(waiter_id, db) for field, value in body.model_dump(exclude_none=True).items(): setattr(waiter, field, value) db.commit() @@ -47,9 +55,7 @@ def update_waiter(waiter_id: int, body: UserUpdate, db: Session = Depends(get_db @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 = db.query(User).filter(User.id == waiter_id).first() - if not waiter: - raise HTTPException(status_code=404, detail="Waiter not found") + waiter = _waiter_or_404(waiter_id, db) waiter.pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode() db.commit() return {"status": "pin reset"} @@ -57,9 +63,7 @@ def reset_pin(waiter_id: int, pin: str, db: Session = Depends(get_db), user: Use @router.put("/{waiter_id}/block") def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)): - waiter = db.query(User).filter(User.id == waiter_id).first() - if not waiter: - raise HTTPException(status_code=404, detail="Waiter not found") + waiter = _waiter_or_404(waiter_id, db) waiter.is_active = not waiter.is_active db.commit() return {"is_active": waiter.is_active} @@ -67,13 +71,79 @@ def toggle_block(waiter_id: int, db: Session = Depends(get_db), user: User = Dep @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 = db.query(User).filter(User.id == waiter_id).first() - if not waiter: - raise HTTPException(status_code=404, detail="Waiter not found") + waiter = _waiter_or_404(waiter_id, db) db.delete(waiter) 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) + 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 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}" + 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}" + db.commit() + db.refresh(waiter) + return waiter + + +@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)) + if os.path.exists(old_path): + os.remove(old_path) + waiter.avatar_url = None + db.commit() + db.refresh(waiter) + return waiter + + +# ── 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. + + - 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 + db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).delete() + + if body.all_zones: + db.add(WaiterZone(waiter_id=waiter_id, group_id=None)) + elif body.group_ids: + for gid in body.group_ids: + db.add(WaiterZone(waiter_id=waiter_id, group_id=gid)) + + db.commit() + zones = db.query(WaiterZone).filter(WaiterZone.waiter_id == waiter_id).all() + return {"waiter_id": waiter_id, "zones": [{"id": z.id, "group_id": z.group_id} for z in zones]} + + +# ── 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)): existing = db.query(AssistantAssignment).filter( diff --git a/local_backend/schemas/user.py b/local_backend/schemas/user.py index 8a75764..f0b005b 100644 --- a/local_backend/schemas/user.py +++ b/local_backend/schemas/user.py @@ -1,12 +1,16 @@ from pydantic import BaseModel from datetime import datetime -from typing import Optional +from typing import Optional, List class UserBase(BaseModel): username: str role: str is_active: bool = True + full_name: Optional[str] = None + nickname: Optional[str] = None + mobile_phone: Optional[str] = None + avatar_url: Optional[str] = None class UserCreate(UserBase): @@ -17,15 +21,35 @@ class UserUpdate(BaseModel): username: Optional[str] = None role: Optional[str] = None is_active: Optional[bool] = None + full_name: Optional[str] = None + nickname: Optional[str] = None + mobile_phone: Optional[str] = None + + +class WaiterZoneOut(BaseModel): + id: int + waiter_id: int + group_id: Optional[int] = None # None = all zones + + model_config = {"from_attributes": True} class UserOut(UserBase): id: int created_at: datetime + zone_assignments: List[WaiterZoneOut] = [] model_config = {"from_attributes": True} +class SetZonesRequest(BaseModel): + """Replace all zone assignments for a waiter in one call. + group_ids=[] means remove all (sees nothing). + group_ids=[null] or all_zones=True means the wildcard 'all zones' sentinel.""" + group_ids: Optional[List[Optional[int]]] = None # list of group ids; None in list = all-zones sentinel + all_zones: bool = False # convenience flag: if True, set a single NULL-group_id row + + class AssistantAssignmentOut(BaseModel): id: int primary_waiter_id: int diff --git a/manager_dashboard/index.html b/manager_dashboard/index.html index d563033..9212436 100644 --- a/manager_dashboard/index.html +++ b/manager_dashboard/index.html @@ -4,6 +4,9 @@ POS Manager + + +
diff --git a/manager_dashboard/src/components/DateInput.jsx b/manager_dashboard/src/components/DateInput.jsx new file mode 100644 index 0000000..c213ec3 --- /dev/null +++ b/manager_dashboard/src/components/DateInput.jsx @@ -0,0 +1,79 @@ +/** + * DateInput / DateTimeInput + * + * Native date pickers display in the OS/browser locale (MM/DD/YYYY on en-US). + * These wrappers overlay the native input with a visible DD/MM/YYYY display + * while keeping the full native picker UX (click, keyboard, mobile wheel). + * + * Props mirror a plain : value (YYYY-MM-DD or YYYY-MM-DDTHH:MM), + * onChange (receives the same synthetic event), className. + */ +import { useRef } from 'react' + +function formatDateGR(value) { + // value is "YYYY-MM-DD" + if (!value) return '' + const [y, m, d] = value.split('-') + if (!y || !m || !d) return value + return `${d}/${m}/${y}` +} + +function formatDateTimeGR(value) { + // value is "YYYY-MM-DDTHH:MM" + if (!value) return '' + const [datePart, timePart] = value.split('T') + if (!datePart) return value + const [y, m, d] = datePart.split('-') + if (!y || !m || !d) return value + return `${d}/${m}/${y}${timePart ? ' ' + timePart : ''}` +} + +export function DateInput({ value, onChange, className = '', ...rest }) { + const ref = useRef(null) + + return ( +
ref.current?.showPicker?.()} + > + {/* Visible display */} +
+ {value ? formatDateGR(value) : ΗΗ/ΜΜ/ΕΕΕΕ} +
+ {/* Native input — invisible but functional (provides the picker) */} + +
+ ) +} + +export function DateTimeInput({ value, onChange, className = '', ...rest }) { + const ref = useRef(null) + + return ( +
ref.current?.showPicker?.()} + > +
+ {value ? formatDateTimeGR(value) : ΗΗ/ΜΜ/ΕΕΕΕ ΩΩ:ΛΛ} +
+ +
+ ) +} diff --git a/manager_dashboard/src/pages/DashboardPage.jsx b/manager_dashboard/src/pages/DashboardPage.jsx index 0896158..fa9b1a5 100644 --- a/manager_dashboard/src/pages/DashboardPage.jsx +++ b/manager_dashboard/src/pages/DashboardPage.jsx @@ -2,24 +2,232 @@ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import client from '../api/client' -import StatusBadge from '../components/StatusBadge' + +const API_URL = import.meta.env.VITE_API_URL || '' const FILTERS = ['all', 'open', 'partially_paid', 'free'] const FILTER_LABELS = { all: 'Όλα', open: 'Ανοιχτά', partially_paid: 'Μερική πληρωμή', free: 'Ελεύθερα' } -function elapsed(openedAt) { - const diff = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000) - if (diff < 60) return `${diff}λ` - return `${Math.floor(diff / 60)}ω ${diff % 60}λ` +// ─── Design tokens ──────────────────────────────────────────────────────────── +const COLORS = { + open: { + label: 'Ανοιχτό', + tint: '#eef7f0', tintStrong: '#d7ecdc', + accent: '#2f9e5e', ink: '#1f7042', + }, + partially_paid: { + label: 'Μερική πληρ.', + tint: '#f4eefb', tintStrong: '#e3d4f3', + accent: '#7a44c9', ink: '#57309a', + }, + free: { + label: 'Ελεύθερο', + tint: '#f4f4f2', tintStrong: '#dfe2e6', + accent: '#8a9099', ink: '#5a6169', + }, +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── +function formatEuro(n) { + return '€' + parseFloat(n).toFixed(2) +} + +function formatDuration(openedAt) { + const mins = Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000) + if (mins < 60) return `${mins}m` + const h = Math.floor(mins / 60) + const m = mins % 60 + return m === 0 ? `${h}h` : `${h}h ${m}m` +} + +function occupiedMinsFromDate(openedAt) { + return Math.floor((Date.now() - new Date(openedAt).getTime()) / 60000) } function orderTotal(items = []) { return items .filter(i => i.status !== 'cancelled') .reduce((s, i) => s + i.unit_price * i.quantity, 0) - .toFixed(2) } +function avatarColor(name) { + const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775'] + let h = 0 + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0 + return palette[h % palette.length] +} + +function WaiterBubble({ waiter, size = 26 }) { + // waiter: { name, avatarUrl } + if (waiter.avatarUrl) { + return ( + {waiter.name} + ) + } + const parts = waiter.name.trim().split(' ') + const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase() + return ( +
{initials}
+ ) +} + +// ─── V1 Table Card ──────────────────────────────────────────────────────────── +function TableCardV1({ name, status, amount, openedAt, waiters = [], onClick }) { + const s = COLORS[status] || COLORS.free + const [hover, setHover] = useState(false) + const [pressed, setPressed] = useState(false) + + const occupiedMins = openedAt ? occupiedMinsFromDate(openedAt) : null + const showMulti = waiters.length >= 3 + + return ( + + ) +} + +// ─── Page ───────────────────────────────────────────────────────────────────── export default function DashboardPage() { const [filter, setFilter] = useState('all') const navigate = useNavigate() @@ -27,13 +235,13 @@ export default function DashboardPage() { const { data: tables = [], isLoading: tablesLoading } = useQuery({ queryKey: ['tables'], queryFn: () => client.get('/api/tables/').then(r => r.data), - refetchInterval: 30_000, + refetchInterval: 5_000, }) const { data: orders = [], isLoading: ordersLoading } = useQuery({ queryKey: ['orders-active'], queryFn: () => client.get('/api/orders/').then(r => r.data), - refetchInterval: 30_000, + refetchInterval: 5_000, }) const { data: waiters = [] } = useQuery({ @@ -42,9 +250,14 @@ export default function DashboardPage() { staleTime: 60_000, }) - const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username])) + // waiterMap: id → { name (display), shortName (nickname or first name), avatarUrl } + const waiterMap = Object.fromEntries(waiters.map(w => { + const name = w.full_name || w.nickname || w.username + const shortName = w.nickname || (w.full_name ? w.full_name.split(' ')[0] : w.username) + const avatarUrl = w.avatar_url ? API_URL + w.avatar_url : null + return [w.id, { name, shortName, avatarUrl }] + })) - // Build enriched table list const tableCards = tables.map(table => { const order = orders.find(o => o.table_id === table.id && ['open', 'partially_paid'].includes(o.status) @@ -82,35 +295,25 @@ export default function DashboardPage() {

Δεν βρέθηκαν τραπέζια.

)} -
- {filtered.map(({ table, order, tableStatus }) => ( - - ))} + return ( + navigate(`/orders/${order.id}`) : undefined} + /> + ) + })}
) diff --git a/manager_dashboard/src/pages/OrderDetailPage.jsx b/manager_dashboard/src/pages/OrderDetailPage.jsx index 1264098..f5d5685 100644 --- a/manager_dashboard/src/pages/OrderDetailPage.jsx +++ b/manager_dashboard/src/pages/OrderDetailPage.jsx @@ -6,6 +6,36 @@ import client from '../api/client' import StatusBadge from '../components/StatusBadge' import ConfirmModal from '../components/ConfirmModal' +function PrintOrderModal({ onClose, onPrint, printers }) { + const [printerId, setPrinterId] = useState(printers[0]?.id ?? '') + function submit() { + if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return } + onPrint(Number(printerId)) + onClose() + } + return ( +
+
+
+

Εκτύπωση παραγγελίας

+ +
+
+ + +
+
+ + +
+
+
+ ) +} + function itemTotal(item) { return (item.unit_price * item.quantity).toFixed(2) } @@ -15,13 +45,58 @@ function formatDate(dt) { return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' }) } +const EVENT_LABELS = { + ORDER_OPENED: 'Άνοιγμα', + ITEMS_ADDED: 'Προσθήκη', + PAYMENT: 'Πληρωμή', + ORDER_CLOSED: 'Κλείσιμο', + ORDER_CANCELLED: 'Ακύρωση', + ITEM_CANCELLED: 'Ακύρωση αντ.', +} + +function AuditTab({ order, waiterMap }) { + if (!order.audit_logs || order.audit_logs.length === 0) { + return

Δεν υπάρχουν εγγραφές.

+ } + return ( +
+ {order.audit_logs.map(log => ( +
+
+ + {EVENT_LABELS[log.event_type] ?? log.event_type} + +
+
+ {log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`} + {log.amount != null && ( + €{log.amount.toFixed(2)} + )} + {log.payment_method && ( + ({log.payment_method}) + )} +
+ {formatDate(log.created_at)} +
+ ))} +
+ ) +} + export default function OrderDetailPage({ orderId: propOrderId, readOnly = false }) { const { orderId: paramOrderId } = useParams() const orderId = propOrderId ?? paramOrderId const navigate = useNavigate() const qc = useQueryClient() + const [tab, setTab] = useState('overview') const [confirmAction, setConfirmAction] = useState(null) // { type, payload } + const [showPrintModal, setShowPrintModal] = useState(false) const { data: order, isLoading } = useQuery({ queryKey: ['order', orderId], @@ -35,6 +110,18 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false staleTime: 60_000, }) + const { data: printers = [] } = useQuery({ + queryKey: ['printers'], + queryFn: () => client.get('/api/system/printers').then(r => r.data), + staleTime: 60_000, + }) + + const printOrder = useMutation({ + mutationFn: (printerId) => client.post(`/api/orders/${orderId}/print`, { printer_id: printerId }), + onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'), + onError: () => toast.error('Σφάλμα εκτύπωσης'), + }) + const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.username])) const assignedIds = new Set((order?.waiters ?? []).map(w => w.waiter_id)) @@ -119,81 +206,100 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false - {/* Waiters */} -
-

Σερβιτόροι

-
- {order.waiters.map(w => ( -
- {waiterMap[w.waiter_id] || `#${w.waiter_id}`} - {isOpen && !readOnly && ( - - )} -
- ))} - {isOpen && !readOnly && ( - - )} -
-
- - {/* Items */} -
-
-

Αντικείμενα

-
- {order.items.length === 0 && ( -

Κανένα αντικείμενο.

- )} - {order.items.map(item => ( -
-
-

{item.product?.name ?? `#${item.product_id}`}

- {item.notes &&

{item.notes}

} -

x{item.quantity} · €{item.unit_price.toFixed(2)}/τμχ

-
-
- - €{itemTotal(item)} - {isOpen && !readOnly && item.status === 'active' && ( - <> - - - - )} -
-
+ {/* Tabs */} +
+ {[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => ( + ))}
- {/* Actions */} - {isOpen && !readOnly && ( + {tab === 'overview' && <> + {/* Waiters */} +
+

Σερβιτόροι

+
+ {order.waiters.map(w => ( +
+ {waiterMap[w.waiter_id] || `#${w.waiter_id}`} + {isOpen && !readOnly && ( + + )} +
+ ))} + {isOpen && !readOnly && ( + + )} +
+
+ + {/* Items */} +
+
+

Αντικείμενα

+
+ {order.items.length === 0 && ( +

Κανένα αντικείμενο.

+ )} + {order.items.map(item => ( +
+
+

{item.product?.name ?? `#${item.product_id}`}

+ {item.notes &&

{item.notes}

} +

x{item.quantity} · €{item.unit_price.toFixed(2)}/τμχ

+ {item.paid_by && ( +

+ Πληρώθηκε: {waiterMap[item.paid_by] ?? `#${item.paid_by}`} + {item.paid_at ? ` · ${formatDate(item.paid_at)}` : ''} +

+ )} +
+
+ + €{itemTotal(item)} + {isOpen && !readOnly && item.status === 'active' && ( + <> + + + + )} +
+
+ ))} +
+ + {/* Actions */}
- {activeItems.length > 0 && ( + {isOpen && !readOnly && activeItems.length > 0 && ( )} + {isOpen && !readOnly && ( + <> + + + + )} -
+ } + + {tab === 'audit' && ( +
+ +
)} {confirmAction && ( @@ -234,6 +356,22 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false onCancel={() => setConfirmAction(null)} /> )} + + {showPrintModal && printers.length > 0 && ( + setShowPrintModal(false)} + onPrint={(printerId) => printOrder.mutate(printerId)} + /> + )} + {showPrintModal && printers.length === 0 && ( +
+
+

Δεν βρέθηκαν ενεργοί εκτυπωτές.

+ +
+
+ )}
) } diff --git a/manager_dashboard/src/pages/ReportsPage.jsx b/manager_dashboard/src/pages/ReportsPage.jsx index 9d44fe5..e3f6bc9 100644 --- a/manager_dashboard/src/pages/ReportsPage.jsx +++ b/manager_dashboard/src/pages/ReportsPage.jsx @@ -1,12 +1,20 @@ -import { useState } from 'react' -import { useQuery } from '@tanstack/react-query' -import { useNavigate } from 'react-router-dom' +import { useState, useEffect, useRef, useCallback } from 'react' +import { useQuery, useMutation } from '@tanstack/react-query' +import toast from 'react-hot-toast' import client from '../api/client' import StatusBadge from '../components/StatusBadge' +import { DateTimeInput } from '../components/DateInput' function today() { return new Date().toISOString().slice(0, 10) } +function todayStart() { return today() + 'T00:00' } +function todayEnd() { return today() + 'T23:59' } + +function fmtDt(dt) { + if (!dt) return '—' + return new Date(dt).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' }) +} function csvDownload(rows, filename) { const header = Object.keys(rows[0]).join(',') @@ -18,128 +26,760 @@ function csvDownload(rows, filename) { a.click() } -export default function ReportsPage() { - const [tab, setTab] = useState('shift') - const [shiftDate, setShiftDate] = useState(today()) - const [historyFilters, setHistoryFilters] = useState({ from: today(), to: today(), status: '' }) +// ── Shared label class for consistent toolbar height ───────────────────────── +// All toolbar controls use h-10 so they align with date inputs + +const CTRL = 'h-10 rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-primary-500' +const SELECT = CTRL + ' pr-8 appearance-none bg-[url(\'data:image/svg+xml;utf8,\')] bg-[length:1.25rem] bg-[right_0.5rem_center] bg-no-repeat' +const BTN_SEC = 'h-10 px-4 rounded-lg border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors whitespace-nowrap' +const BTN_PRI = 'h-10 px-4 rounded-lg bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 transition-colors whitespace-nowrap' + +// ── Shared modal close hook (ESC + click-outside) ──────────────────────────── + +function useModalClose(onClose) { + const overlayRef = useRef(null) + + useEffect(() => { + function onKey(e) { if (e.key === 'Escape') onClose() } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [onClose]) + + const onOverlayClick = useCallback((e) => { + if (e.target === overlayRef.current) onClose() + }, [onClose]) + + return { overlayRef, onOverlayClick } +} + +// ── Print Modal (waiter / printer reports) ──────────────────────────────────── + +function PrintModal({ title, onClose, onPrint, printers, defaultFrom, defaultTo }) { + const [mode, setMode] = useState('simple') + const [fromDt, setFromDt] = useState(defaultFrom || todayStart()) + const [toDt, setToDt] = useState(defaultTo || todayEnd()) + const [printerId, setPrinterId] = useState(printers[0]?.id ?? '') + const { overlayRef, onOverlayClick } = useModalClose(onClose) + + function submit() { + if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return } + onPrint({ mode, fromDt, toDt, printerId: Number(printerId) }) + onClose() + } return ( -
-

Αναφορές

- -
- - +
+
+
+

{title}

+ +
+
+

Τύπος εκτύπωσης

+
+ + +
+
+
+
+ + setFromDt(e.target.value)} /> +
+
+ + setToDt(e.target.value)} /> +
+
+
+ + +
+
+ + +
- - {tab === 'shift' && } - {tab === 'history' && }
) } -function ShiftTab({ date, setDate }) { - const { data, isLoading } = useQuery({ - queryKey: ['report-shift', date], - queryFn: () => client.get(`/api/reports/shift?date=${date}`).then(r => r.data), +// ── Printer select modal (single order) ─────────────────────────────────────── + +function PrintOrderModal({ onClose, onPrint, printers }) { + const [printerId, setPrinterId] = useState(printers[0]?.id ?? '') + const { overlayRef, onOverlayClick } = useModalClose(onClose) + function submit() { + if (!printerId) { toast.error('Επιλέξτε εκτυπωτή'); return } + onPrint(Number(printerId)); onClose() + } + return ( +
+
+
+

Εκτύπωση παραγγελίας

+ +
+
+ + +
+
+ + +
+
+
+ ) +} + +// ── No-printers fallback modal ──────────────────────────────────────────────── + +function NoPrintersModal({ onClose }) { + const { overlayRef, onOverlayClick } = useModalClose(onClose) + return ( +
+
+

Δεν βρέθηκαν ενεργοί εκτυπωτές.

+ +
+
+ ) +} + + +// ── Printer Details Modal ───────────────────────────────────────────────────── + +function PrinterDetailsModal({ printerRow, tableMap, onClose }) { + const orderData = printerRow?.order_data ?? [] + const { overlayRef, onOverlayClick } = useModalClose(onClose) + + return ( +
+
+
+

Εκτυπωτής: {printerRow?.printer_name}

+ +
+
+ Εργασίες: {printerRow?.print_jobs} + Παραγγελίες: {printerRow?.orders} + Αντικείμενα: {printerRow?.items} + Σύνολο: €{printerRow?.total?.toFixed(2)} +
+
+ {orderData.length === 0 && ( +

Δεν υπάρχουν αναλυτικά δεδομένα.
Χρησιμοποιήστε εκτύπωση Αναλυτική για πλήρη λεπτομέρεια.

+ )} + {orderData.length > 0 && ( +
+ {orderData.map((od, i) => ( +
+
+ + {od.time} — {tableMap[od.table_id] ?? od.table} + + €{od.total.toFixed(2)} +
+ {od.items?.length > 0 && ( +
    + {od.items.map((item, j) => ( +
  • + {item.quantity} × {item.name} +
  • + ))} +
+ )} +
+ ))} +
+ )} +
+
+ +
+
+
+ ) +} + +// ── Payers Modal (multi-waiter payment breakdown) ──────────────────────────── + +function PayersModal({ order, waiterMap, onClose }) { + const { overlayRef, onOverlayClick } = useModalClose(onClose) + const paidItems = order.items.filter(i => i.status === 'paid' && i.paid_by) + + // Group items by (waiter_id, paid_at rounded to minute) to create payment groups + const groups = {} + for (const item of paidItems) { + const key = `${item.paid_by}__${item.paid_at}` + if (!groups[key]) { + groups[key] = { waiter_id: item.paid_by, paid_at: item.paid_at, payment_method: item.payment_method, items: [] } + } + groups[key].items.push(item) + } + + const paymentGroups = Object.values(groups).sort((a, b) => (a.paid_at || '') < (b.paid_at || '') ? -1 : 1) + + const pmLabel = (m) => m === 'card' ? 'Κάρτα' : m === 'cash' ? 'Μετρητά' : m === 'other' ? 'Άλλο' : '—' + + return ( +
+
+
+

Πληρωμές — Παραγγελία #{order.id}

+ +
+
+ {paymentGroups.map((g, i) => { + const groupTotal = g.items.reduce((s, it) => s + it.unit_price * it.quantity, 0) + const waiterName = waiterMap[g.waiter_id] || `#${g.waiter_id}` + return ( +
+
+
+ {fmtDt(g.paid_at)} + · + {waiterName} + {g.payment_method && ( + {pmLabel(g.payment_method)} + )} +
+ €{groupTotal.toFixed(2)} +
+
    + {g.items.map(it => ( +
  • + {it.product?.name ?? `#${it.product_id}`}{it.quantity > 1 ? ` ×${it.quantity}` : ''} + €{(it.unit_price * it.quantity).toFixed(2)} +
  • + ))} +
+
+ ) + })} +
+
+ +
+
+
+ ) +} + +// ── Order Details Modal ─────────────────────────────────────────────────────── + +function OrderDetailsModal({ order, tableMap, waiterMap, onClose, printers, onPrint }) { + const [showPrint, setShowPrint] = useState(false) + const { overlayRef, onOverlayClick } = useModalClose(onClose) + 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) + + // 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 + } + const waiterTotalEntries = Object.entries(waiterTotals) + + const pmLabel = (m) => m === 'card' ? 'Κάρτα' : m === 'cash' ? 'Μετρητά' : m === 'other' ? 'Άλλο' : '—' + + return ( +
+
+
+

Παραγγελία #{order.id}

+ +
+
+ Τραπέζι: {tableMap[order.table_id] || `#${order.table_id}`} + Ανοίχτηκε: {fmtDt(order.opened_at)} + Έκλεισε: {fmtDt(order.closed_at)} + + {order.notes && "{order.notes}"} +
+
+ + + + + + + + + + + + + + + {order.items.map(item => ( + + + + + + + + + + + ))} + + + {waiterTotalEntries.length > 1 && waiterTotalEntries.map(([wid, wTotal]) => ( + + + + + ))} + + + + + +
ΠροϊόνΠοσ.Τιμή/τμχΣύνολοΚατ.ΠληρώθηκεΤύποςΣερβιτόρος
{item.product?.name ?? `#${item.product_id}`}{item.quantity}€{item.unit_price.toFixed(2)}€{(item.unit_price * item.quantity).toFixed(2)}{item.paid_at ? fmtDt(item.paid_at) : '—'}{item.payment_method ? pmLabel(item.payment_method) : '—'}{item.paid_by ? (waiterMap[item.paid_by] || `#${item.paid_by}`) : '—'}
Σύνολο — {waiterMap[wid] || `#${wid}`}€{wTotal.toFixed(2)} +
Σύνολο€{total.toFixed(2)} +
+
+
+ {printers.length > 0 && ( + + )} + +
+
+ {showPrint && ( + setShowPrint(false)} + onPrint={(printerId) => { onPrint(order.id, printerId); setShowPrint(false) }} + /> + )} +
+ ) +} + +// ── Main Page ───────────────────────────────────────────────────────────────── + +export default function ReportsPage() { + const [tab, setTab] = useState('shift') + const [historyFilters, setHistoryFilters] = useState({ from: todayStart(), to: todayEnd(), status: '', table_id: '', hideEmpty: true }) + + const TABS = [ + ['shift', 'Σύνοψη Πληρωμών Βάρδιας'], + ['shift-orders', 'Σύνοψη Παραγγελιών Βάρδιας'], + ['printers', 'Σύνοψη εκτυπωτών'], + ['history', 'Ιστορικό παραγγελιών'], + ] + + return ( +
+

Αναφορές

+ +
+ {TABS.map(([key, label]) => ( + + ))} +
+ + {tab === 'shift' && } + {tab === 'shift-orders' && } + {tab === 'printers' && } + {tab === 'history' && } +
+ ) +} + +// ── Shift / Waiter Totals Tab ───────────────────────────────────────────────── + +function WaiterShiftDetailsModal({ row, tableMap, onClose }) { + const { overlayRef, onOverlayClick } = useModalClose(onClose) + return ( +
+
+
+

Σερβιτόρος: {row.waiter_name}

+ +
+
+ Παραγγελίες: {row.orders} + Αντικείμενα: {row.items} + Σύνολο: €{row.total.toFixed(2)} +
+
+ {(row.order_data ?? []).length === 0 && ( +

Δεν βρέθηκαν παραγγελίες.

+ )} + {(row.order_data ?? []).length > 0 && ( + + + + + + + + + + + + {row.order_data.map(od => ( + + + + + + + + ))} + +
#ΤραπέζιΆνοιγμαΚλείσιμοΣύνολο
{od.id}{od.table}{od.time_open}{od.time_close || '—'}€{od.total.toFixed(2)}
+ )} +
+
+ +
+
+
+ ) +} + +function ShiftTab({ endpoint, title }) { + const [fromDt, setFromDt] = useState(todayStart()) + const [toDt, setToDt] = useState(todayEnd()) + const [printTarget, setPrintTarget] = useState(null) // waiter row + const [detailTarget, setDetailTarget] = useState(null) // waiter row + + const { data, isLoading, refetch } = useQuery({ + queryKey: ['report-shift', endpoint, fromDt, toDt], + queryFn: () => client.get(`${endpoint}?from=${encodeURIComponent(fromDt)}&to=${encodeURIComponent(toDt)}`).then(r => r.data), }) - const rows = data - ? Object.entries(data.waiters).map(([name, s]) => ({ - Σερβιτόρος: name, - Παραγγελίες: s.orders, - 'Αντικείμενα': s.items, - 'Σύνολο (€)': s.total.toFixed(2), - })) - : [] + const { data: printers = [] } = useQuery({ + queryKey: ['printers'], + queryFn: () => client.get('/api/system/printers').then(r => r.data), + staleTime: 60_000, + }) - const grandTotal = rows.reduce((s, r) => s + parseFloat(r['Σύνολο (€)']), 0) + const { data: tables = [] } = useQuery({ + queryKey: ['tables'], + queryFn: () => client.get('/api/tables/').then(r => r.data), + staleTime: 60_000, + }) + const tableMap = Object.fromEntries(tables.map(t => [t.id, t.label || `T${t.number}`])) + + const printMutation = useMutation({ + mutationFn: (body) => client.post('/api/reports/print/waiter', body), + onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'), + onError: () => toast.error('Σφάλμα εκτύπωσης'), + }) + + // New API returns { waiters: [{waiter_id, waiter_name, orders, items, total, order_data}] } + const rows = data?.waiters ?? [] + + const grandTotal = rows.reduce((s, r) => s + r.total, 0) + const grandOrders = rows.reduce((s, r) => s + r.orders, 0) + const grandItems = rows.reduce((s, r) => s + r.items, 0) + + function handlePrint({ mode, fromDt: fd, toDt: td, printerId }) { + printMutation.mutate({ waiter_name: printTarget.waiter_name, printer_id: printerId, mode, from_dt: fd, to_dt: td }) + } + + const csvRows = rows.map(r => ({ + Σερβιτόρος: r.waiter_name, + Παραγγελίες: r.orders, + Αντικείμενα: r.items, + 'Σύνολο (€)': r.total.toFixed(2), + })) return (
-
+ {/* Toolbar */} +
- - setDate(e.target.value)} /> + + setFromDt(e.target.value)} />
+
+ + setToDt(e.target.value)} /> +
+ {rows.length > 0 && ( - )}
{isLoading &&

Φόρτωση…

} - {!isLoading && rows.length === 0 && (

Δεν υπάρχουν δεδομένα για αυτή την ημερομηνία.

)} {rows.length > 0 && ( -
+
- {Object.keys(rows[0]).map(h => ( - - ))} + + + + + {rows.map((r, i) => ( - {Object.values(r).map((v, j) => ( - - ))} + + + + + ))} - - + + +
{h}ΣερβιτόροςΠαραγγελίεςΑντικείμεναΣύνολο (€)
{v}{r.waiter_name}{r.orders}{r.items}€{r.total.toFixed(2)} +
+ + +
+
Σύνολο{rows.reduce((s, r) => s + r['Παραγγελίες'], 0)}{rows.reduce((s, r) => s + r['Αντικείμενα'], 0)}{grandOrders}{grandItems} €{grandTotal.toFixed(2)}
)} + + {printTarget && printers.length > 0 && ( + setPrintTarget(null)} + onPrint={handlePrint} + /> + )} + {printTarget && printers.length === 0 && setPrintTarget(null)} />} + + {detailTarget && ( + setDetailTarget(null)} + /> + )}
) } -function HistoryTab({ filters, setFilters }) { - const navigate = useNavigate() - const [page, setPage] = useState(1) +// ── Printer Totals Tab ──────────────────────────────────────────────────────── - const params = new URLSearchParams({ from: filters.from, to: filters.to + 'T23:59:59', page }) - if (filters.status) params.set('status', filters.status) +function PrintersTab() { + const [fromDt, setFromDt] = useState(todayStart()) + const [toDt, setToDt] = useState(todayEnd()) + const [printTarget, setPrintTarget] = useState(null) // printer_id + const [detailTarget, setDetailTarget] = useState(null) // full printer row object + + const params = new URLSearchParams({ from: fromDt, to: toDt }) + + const { data, isLoading, refetch } = useQuery({ + queryKey: ['report-printers', fromDt, toDt], + queryFn: () => client.get(`/api/reports/printers?${params}`).then(r => r.data), + }) + + const { data: printers = [] } = useQuery({ + queryKey: ['printers'], + queryFn: () => client.get('/api/system/printers').then(r => r.data), + staleTime: 60_000, + }) + + const { data: tables = [] } = useQuery({ + queryKey: ['tables'], + queryFn: () => client.get('/api/tables/').then(r => r.data), + staleTime: 60_000, + }) + const tableMap = Object.fromEntries(tables.map(t => [t.id, t.label || `T${t.number}`])) + + const printMutation = useMutation({ + mutationFn: (body) => client.post('/api/reports/print/printer', body), + onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'), + onError: () => toast.error('Σφάλμα εκτύπωσης'), + }) + + const rows = data?.printers ?? [] + + function handlePrint({ mode, fromDt: fd, toDt: td, printerId }) { + printMutation.mutate({ printer_target_id: printTarget, printer_id: printerId, mode, from_dt: fd, to_dt: td }) + } + + return ( +
+ {/* Toolbar */} +
+
+ + setFromDt(e.target.value)} /> +
+
+ + setToDt(e.target.value)} /> +
+ +
+ + {isLoading &&

Φόρτωση…

} + {!isLoading && rows.length === 0 && ( +

Δεν βρέθηκαν δεδομένα για αυτό το διάστημα.

+ )} + + {rows.length > 0 && ( +
+ + + + + + + + + + + + {rows.map((r, i) => ( + + + + + + + + + ))} + + + + + + + + + + +
ΕκτυπωτήςΕργασίεςΠαραγγελίεςΑντικείμεναΣύνολο (€) +
{r.printer_name}{r.print_jobs}{r.orders}{r.items}€{r.total.toFixed(2)} +
+ + +
+
Σύνολο{rows.reduce((s, r) => s + r.print_jobs, 0)}{rows.reduce((s, r) => s + r.orders, 0)}{rows.reduce((s, r) => s + r.items, 0)}€{rows.reduce((s, r) => s + r.total, 0).toFixed(2)} +
+
+ )} + + {printTarget !== null && printers.length > 0 && ( + r.printer_id === printTarget)?.printer_name ?? 'Εκτυπωτής'}`} + printers={printers} + defaultFrom={fromDt} + defaultTo={toDt} + onClose={() => setPrintTarget(null)} + onPrint={handlePrint} + /> + )} + + {detailTarget && ( + setDetailTarget(null)} + /> + )} +
+ ) +} + +// ── Order History Tab ───────────────────────────────────────────────────────── + +function HistoryTab({ filters, setFilters }) { + const [page, setPage] = useState(1) + const [detailOrder, setDetailOrder] = useState(null) // full order object for modal + const [printOrderId, setPrintOrderId] = useState(null) + const [payersModal, setPayersModal] = useState(null) // order object for multi-payer modal + + const { data: tables = [] } = useQuery({ + queryKey: ['tables'], + queryFn: () => client.get('/api/tables/').then(r => r.data), + staleTime: 60_000, + }) + const { data: printers = [] } = useQuery({ + queryKey: ['printers'], + queryFn: () => client.get('/api/system/printers').then(r => r.data), + staleTime: 60_000, + }) + const { data: waiters = [] } = useQuery({ + queryKey: ['waiters'], + queryFn: () => client.get('/api/waiters/').then(r => r.data), + staleTime: 60_000, + }) + + const tableMap = Object.fromEntries(tables.map(t => [t.id, t.label || `T${t.number}`])) + const waiterMap = Object.fromEntries(waiters.map(w => [w.id, w.full_name || w.username])) + + const params = new URLSearchParams({ from: filters.from, to: filters.to, page }) + if (filters.status) params.set('status', filters.status) + if (filters.table_id) params.set('table_id', filters.table_id) const { data: orders = [], isLoading } = useQuery({ queryKey: ['order-history', filters, page], queryFn: () => client.get(`/api/reports/orders/history?${params}`).then(r => r.data), }) + const printMutation = useMutation({ + mutationFn: ({ orderId, printerId }) => client.post(`/api/orders/${orderId}/print`, { printer_id: printerId }), + onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'), + onError: () => toast.error('Σφάλμα εκτύπωσης'), + }) + function setF(k, v) { setFilters(f => ({ ...f, [k]: v })); setPage(1) } + const visibleOrders = filters.hideEmpty + ? orders.filter(o => o.items.some(i => i.status !== 'cancelled')) + : orders + return (
-
+ {/* Toolbar */} +
- - setF('from', e.target.value)} /> + + setF('from', e.target.value)} />
- - setF('to', e.target.value)} /> + + setF('to', e.target.value)} />
- - setF('status', e.target.value)}> @@ -147,48 +787,109 @@ function HistoryTab({ filters, setFilters }) {
+
+ + +
+
{isLoading &&

Φόρτωση…

} - - {!isLoading && orders.length === 0 && ( + {!isLoading && visibleOrders.length === 0 && (

Δεν βρέθηκαν παραγγελίες.

)} - {orders.length > 0 && ( -
+ {visibleOrders.length > 0 && ( +
+ + + - - {orders.map(o => { + {visibleOrders.map(o => { const total = o.items .filter(i => i.status !== 'cancelled') .reduce((s, i) => s + i.unit_price * i.quantity, 0) + + // Opener / closer + const openerName = waiterMap[o.opened_by] || `#${o.opened_by}` + const closerName = o.closed_by ? (waiterMap[o.closed_by] || `#${o.closed_by}`) : null + + // Latest payment info from items + const paidItems = o.items.filter(i => i.status === 'paid' && i.paid_by) + const payerIds = [...new Set(paidItems.map(i => i.paid_by))] + const latestPaidAt = paidItems.reduce((max, i) => (!max || i.paid_at > max) ? i.paid_at : max, null) + return ( - + + + + - ) @@ -198,12 +899,38 @@ function HistoryTab({ filters, setFilters }) { )} - {/* Pagination */}
Σελίδα {page}
+ + {detailOrder && ( + setDetailOrder(null)} + onPrint={(orderId, printerId) => printMutation.mutate({ orderId, printerId })} + /> + )} + + {payersModal && ( + setPayersModal(null)} + /> + )} + + {printOrderId !== null && printers.length > 0 && ( + setPrintOrderId(null)} + onPrint={(printerId) => { printMutation.mutate({ orderId: printOrderId, printerId }); setPrintOrderId(null) }} + /> + )} ) } diff --git a/manager_dashboard/src/pages/WaitersPage.jsx b/manager_dashboard/src/pages/WaitersPage.jsx index 0044e1e..b330c24 100644 --- a/manager_dashboard/src/pages/WaitersPage.jsx +++ b/manager_dashboard/src/pages/WaitersPage.jsx @@ -1,9 +1,42 @@ -import { useState } from 'react' +import { useState, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import toast from 'react-hot-toast' import client from '../api/client' import ConfirmModal from '../components/ConfirmModal' +const API_URL = import.meta.env.VITE_API_URL || '' + +function avatarColor(name) { + const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775'] + let h = 0 + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0 + return palette[h % palette.length] +} + +function WaiterAvatar({ waiter, size = 40 }) { + const displayName = waiter.full_name || waiter.nickname || waiter.username + if (waiter.avatar_url) { + return ( + {displayName} + ) + } + const parts = displayName.trim().split(' ') + const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase() + return ( +
{initials}
+ ) +} + const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫'] function PinInput({ value, onChange }) { @@ -30,27 +63,152 @@ function PinInput({ value, onChange }) { ) } +function ZoneModal({ waiter, groups, onClose }) { + const qc = useQueryClient() + // Derive initial state from waiter's zone_assignments + const hasAllZones = waiter.zone_assignments.some(z => z.group_id === null) + const assignedIds = new Set(waiter.zone_assignments.map(z => z.group_id).filter(id => id !== null)) + + const [allZones, setAllZones] = useState(hasAllZones) + const [selected, setSelected] = useState(new Set(assignedIds)) + + const saveZones = useMutation({ + mutationFn: (body) => client.put(`/api/waiters/${waiter.id}/zones`, body), + onSuccess: () => { toast.success('Zones ενημερώθηκαν'); qc.invalidateQueries({ queryKey: ['waiters'] }); onClose() }, + onError: () => toast.error('Σφάλμα'), + }) + + function toggleGroup(gid) { + setSelected(prev => { + const next = new Set(prev) + if (next.has(gid)) next.delete(gid); else next.add(gid) + return next + }) + } + + function save() { + if (allZones) { + saveZones.mutate({ all_zones: true, group_ids: [] }) + } else { + saveZones.mutate({ all_zones: false, group_ids: [...selected] }) + } + } + + return ( +
+
+

Ζώνες — {waiter.username}

+ + + + {!allZones && ( +
+ {groups.length === 0 && ( +

Δεν υπάρχουν ομάδες τραπεζιών.

+ )} + {groups.map(g => ( + + ))} +
+ )} + + {!allZones && selected.size === 0 && ( +

+ Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι. +

+ )} + +
+ + +
+
+
+ ) +} + + export default function WaitersPage() { const qc = useQueryClient() const [addModal, setAddModal] = useState(false) const [pinModal, setPinModal] = useState(null) // waiter id + const [zoneModal, setZoneModal] = useState(null) // waiter object const [confirmDelete, setConfirmDelete] = useState(null) // waiter id const [newPin, setNewPin] = useState('') - const [newForm, setNewForm] = useState({ username: '', pin: '', role: 'waiter' }) + const [newForm, setNewForm] = useState({ username: '', full_name: '', mobile_phone: '', pin: '', role: 'waiter' }) + const [editModal, setEditModal] = useState(null) // waiter object + const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '' }) + const avatarInputRef = useRef(null) const { data: waiters = [], isLoading } = useQuery({ queryKey: ['waiters'], queryFn: () => client.get('/api/waiters/').then(r => r.data), }) + const { data: groups = [] } = useQuery({ + queryKey: ['table-groups'], + queryFn: () => client.get('/api/tables/groups').then(r => r.data), + }) + const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] }) const createWaiter = useMutation({ mutationFn: (body) => client.post('/api/waiters/', body), - onSuccess: () => { toast.success('Σερβιτόρος δημιουργήθηκε'); setAddModal(false); setNewForm({ username: '', pin: '', role: 'waiter' }); invalidate() }, + onSuccess: () => { toast.success('Σερβιτόρος δημιουργήθηκε'); setAddModal(false); setNewForm({ username: '', full_name: '', mobile_phone: '', pin: '', role: 'waiter' }); invalidate() }, onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'), }) + const updateWaiter = useMutation({ + mutationFn: ({ id, ...body }) => client.put(`/api/waiters/${id}`, body), + onSuccess: () => { toast.success('Στοιχεία ενημερώθηκαν'); setEditModal(null); invalidate() }, + onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'), + }) + + const uploadAvatar = useMutation({ + mutationFn: ({ id, file }) => { + const fd = new FormData() + fd.append('file', file) + return client.post(`/api/waiters/${id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }) + }, + onSuccess: (res) => { + toast.success('Avatar ανέβηκε') + setEditModal(res.data) + invalidate() + }, + onError: () => toast.error('Σφάλμα μεταφόρτωσης'), + }) + + const deleteAvatar = useMutation({ + mutationFn: (id) => client.delete(`/api/waiters/${id}/avatar`), + onSuccess: (res) => { + toast.success('Avatar αφαιρέθηκε') + setEditModal(res.data) + invalidate() + }, + onError: () => toast.error('Σφάλμα'), + }) + const toggleBlock = useMutation({ mutationFn: (id) => client.put(`/api/waiters/${id}/block`), onSuccess: () => { invalidate() }, @@ -72,7 +230,7 @@ export default function WaitersPage() { if (isLoading) return
Φόρτωση…
return ( -
+

Σερβιτόροι

@@ -84,13 +242,31 @@ export default function WaitersPage() { )} {waiters.map(w => (
+
-

{w.username}

-

{w.role}

+
+

{w.full_name || w.username}

+ {w.nickname && ({w.nickname})} +
+

{w.username} · {w.role}

+ {w.mobile_phone &&

{w.mobile_phone}

} + {w.role === 'waiter' && ( +

+ {w.zone_assignments.length === 0 + ? 'Χωρίς ζώνες' + : w.zone_assignments.some(z => z.group_id === null) + ? 'Όλες οι ζώνες' + : `${w.zone_assignments.length} ζών${w.zone_assignments.length === 1 ? 'η' : 'ες'}`} +

+ )}
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'} + + {w.role === 'waiter' && ( + + )}
)} + {/* Edit profile modal */} + {editModal && ( +
+
+

Επεξεργασία — {editModal.username}

+ + {/* Avatar section */} +
+ +
+ { + const file = e.target.files?.[0] + if (file) uploadAvatar.mutate({ id: editModal.id, file }) + e.target.value = '' + }} + /> + + {editModal.avatar_url && ( + + )} +
+
+ +
+ + setEditForm(f => ({ ...f, username: e.target.value }))} autoFocus /> +
+
+ + setEditForm(f => ({ ...f, full_name: e.target.value }))} /> +
+
+ + setEditForm(f => ({ ...f, nickname: e.target.value }))} /> +
+
+ + setEditForm(f => ({ ...f, mobile_phone: e.target.value }))} /> +
+
+ + +
+
+
+ )} + {/* Reset PIN modal */} {pinModal !== null && (
@@ -164,6 +418,10 @@ export default function WaitersPage() { onCancel={() => setConfirmDelete(null)} /> )} + + {zoneModal && ( + setZoneModal(null)} /> + )}
) } diff --git a/waiter_pwa/src/pages/TableDetailPage.jsx b/waiter_pwa/src/pages/TableDetailPage.jsx index 0ed961e..13db93a 100644 --- a/waiter_pwa/src/pages/TableDetailPage.jsx +++ b/waiter_pwa/src/pages/TableDetailPage.jsx @@ -44,10 +44,8 @@ export default function TableDetailPage() { const activeItems = order?.items?.filter(i => i.status === 'active') || [] const allPaid = order && activeItems.length === 0 - - const isMyOrder = order && ( - order.opened_by === user?.id || order.waiters?.some(w => w.waiter_id === user?.id) - ) + // Any waiter whose zone covers this table can interact with orders on it + const canInteract = !!order async function openOrder() { try { @@ -123,12 +121,12 @@ export default function TableDetailPage() {
- {isMyOrder && activeItems.length > 0 && ( + {canInteract && activeItems.length > 0 && (
)} - {isMyOrder && ( + {canInteract && (
)} - - {!isMyOrder && ( -

- Ανάγνωση μόνο — άλλος σερβιτόρος -

- )}
)}
# Τραπέζι ΑνοίχτηκεΈκλεισεΠληρώθηκε ΚατάστασηΣημείωση Σύνολο +
{o.id}{o.table_id}{tableMap[o.table_id] || `#${o.table_id}`} - {new Date(o.opened_at).toLocaleString('el-GR', { dateStyle: 'short', timeStyle: 'short' })} +
{fmtDt(o.opened_at)}
+
{openerName}
+
+ {o.closed_at ? <> +
{fmtDt(o.closed_at)}
+ {closerName &&
{closerName}
} + : '—'} +
+ {payerIds.length === 0 ? '—' : payerIds.length === 1 ? ( + <> +
{waiterMap[payerIds[0]] || `#${payerIds[0]}`}
+ {latestPaidAt &&
{fmtDt(latestPaidAt)}
} + + ) : ( + + )}
{o.notes || '—'} €{total.toFixed(2)} - + +
+ + +