Overhaul of the frontend on waiters, orders, and payment events

Manager Dashboard: product reorder/bulk actions, preference sub-choices
UI, expanded reports with DateInput component, waiter management updates,
order detail improvements, Docker config and backend dockerignore added.

Backend: table groups, auto-numbering, has_active_order flag, expanded
reporting endpoints, waiter zone management, user schema updates, system
router additions, table router fixes.

Waiter PWA: TableDetailPage order/payment event improvements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 17:37:34 +03:00
parent ee51e52acf
commit 603fd45eaa
18 changed files with 2243 additions and 256 deletions

View File

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

View File

@@ -0,0 +1,5 @@
pos.db
license_state.json
__pycache__
*.pyc
.env

View File

@@ -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"])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,9 @@
<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

@@ -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 <input>: 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 (
<div
className={`relative cursor-pointer ${className}`}
onClick={() => ref.current?.showPicker?.()}
>
{/* Visible display */}
<div className="absolute inset-0 flex items-center px-3 pointer-events-none z-10 bg-white rounded-lg text-sm text-gray-800">
{value ? formatDateGR(value) : <span className="text-gray-400">ΗΗ/ΜΜ/ΕΕΕΕ</span>}
</div>
{/* Native input — invisible but functional (provides the picker) */}
<input
ref={ref}
type="date"
value={value}
onChange={onChange}
className="opacity-0 w-full h-full absolute inset-0 cursor-pointer"
tabIndex={0}
{...rest}
/>
</div>
)
}
export function DateTimeInput({ value, onChange, className = '', ...rest }) {
const ref = useRef(null)
return (
<div
className={`relative cursor-pointer ${className}`}
onClick={() => ref.current?.showPicker?.()}
>
<div className="absolute inset-0 flex items-center px-3 pointer-events-none z-10 bg-white rounded-lg text-sm text-gray-800">
{value ? formatDateTimeGR(value) : <span className="text-gray-400">ΗΗ/ΜΜ/ΕΕΕΕ ΩΩ:ΛΛ</span>}
</div>
<input
ref={ref}
type="datetime-local"
value={value}
onChange={onChange}
className="opacity-0 w-full h-full absolute inset-0 cursor-pointer"
tabIndex={0}
{...rest}
/>
</div>
)
}

View File

@@ -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 (
<img
src={waiter.avatarUrl}
alt={waiter.name}
style={{
width: size, height: size, borderRadius: '50%', objectFit: 'cover',
flexShrink: 0, boxShadow: '0 0 0 2px var(--cardBg, white)',
}}
/>
)
}
const parts = waiter.name.trim().split(' ')
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarColor(waiter.name),
color: 'white',
fontSize: size * 0.42,
fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
boxShadow: '0 0 0 2px var(--cardBg, white)',
}}>{initials}</div>
)
}
// ─── 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 (
<button
type="button"
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => { setHover(false); setPressed(false) }}
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
style={{
'--cardBg': s.tint,
position: 'relative',
width: '100%', minWidth: 330, height: 200,
padding: '16px 18px 16px 24px',
background: s.tint,
border: '1px solid ' + s.tintStrong,
borderRadius: 14,
boxShadow: pressed
? 'inset 0 2px 4px rgba(16,20,24,0.08)'
: hover
? '0 6px 18px rgba(16,20,24,0.08), 0 2px 4px rgba(16,20,24,0.04)'
: '0 1px 2px rgba(16,20,24,0.04), 0 1px 1px rgba(16,20,24,0.03)',
transform: pressed ? 'translateY(1px)' : hover ? 'translateY(-2px)' : 'translateY(0)',
transition: 'transform 120ms ease, box-shadow 120ms ease',
cursor: onClick ? 'pointer' : 'default',
textAlign: 'left',
font: 'inherit',
color: 'inherit',
display: 'flex', flexDirection: 'column',
outline: 'none',
flexShrink: 0,
}}
>
{/* left accent bar */}
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0, width: 6,
background: s.accent,
borderRadius: '14px 0 0 14px',
}} />
{/* Header: name + status pill */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{
fontSize: 34, fontWeight: 700, lineHeight: 1,
letterSpacing: -0.5,
color: '#111315',
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
}}>{name}</div>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
height: 26, padding: '0 10px',
borderRadius: 999,
background: s.accent,
color: 'white',
fontSize: 12, fontWeight: 600,
letterSpacing: 0.2,
whiteSpace: 'nowrap',
flexShrink: 0,
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'rgba(255,255,255,0.9)' }} />
{s.label}
</div>
</div>
{/* Flags row — fixed height placeholder */}
<div style={{ marginTop: 8, height: 22 }} />
{/* Stats row */}
<div style={{
marginTop: 'auto',
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
alignItems: 'end',
}}>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</div>
<div style={{ fontSize: 22, fontWeight: 600, color: '#111315', marginTop: 2, fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace" }}>
{amount != null ? formatEuro(amount) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}> </span>}
</div>
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.6 }}>Time</div>
<div style={{
fontSize: 22, marginTop: 2,
fontFamily: "'Geist Mono', 'ui-monospace', 'SFMono-Regular', monospace",
fontWeight: occupiedMins != null && occupiedMins >= 90 ? 700 : 500,
color: '#111315',
}}>
{openedAt ? formatDuration(openedAt) : <span style={{ color: '#b8bdc4', letterSpacing: 2 }}> </span>}
</div>
</div>
</div>
{/* Waiter row */}
<div style={{
marginTop: 12,
paddingTop: 10,
borderTop: '1px solid ' + s.tintStrong,
height: 36,
display: 'flex', alignItems: 'center', gap: 8,
}}>
{waiters.length === 0 ? (
<span style={{ color: '#8a9099', fontSize: 13 }}>Unassigned</span>
) : showMulti ? (
<>
<div style={{ display: 'flex' }}>
{waiters.slice(0, 3).map((w, i) => (
<div key={i} style={{ marginLeft: i === 0 ? 0 : -8 }}>
<WaiterBubble waiter={w} size={24} />
</div>
))}
</div>
<span style={{
fontSize: 13, fontWeight: 600, color: '#2b2f33',
background: 'white', border: '1px solid #dfe2e6',
borderRadius: 999, padding: '2px 8px',
}}>Multiple ({waiters.length})</span>
</>
) : (
waiters.map((w, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<WaiterBubble waiter={w} size={24} />
<span style={{ fontSize: 14, color: '#2b2f33', fontWeight: 500 }}>{w.shortName}</span>
</div>
))
)}
</div>
</button>
)
}
// ─── 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() {
<p className="text-center text-gray-400 py-16">Δεν βρέθηκαν τραπέζια.</p>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{filtered.map(({ table, order, tableStatus }) => (
<button
key={table.id}
onClick={() => order && navigate(`/orders/${order.id}`)}
className={`card p-4 text-left transition-shadow hover:shadow-md ${!order ? 'cursor-default' : 'cursor-pointer'}`}
>
<div className="flex items-start justify-between mb-3">
<span className="text-2xl font-extrabold text-gray-800">
{table.label || `T${table.number}`}
</span>
<StatusBadge status={tableStatus} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 16 }}>
{filtered.map(({ table, order, tableStatus }) => {
const waiterNames = order
? order.waiters.map(w => waiterMap[w.waiter_id] || { name: `#${w.waiter_id}`, shortName: `#${w.waiter_id}`, avatarUrl: null })
: []
const amount = order ? orderTotal(order.items) : null
{order ? (
<div className="space-y-1 text-sm text-gray-600">
<p className="font-semibold text-gray-800">{orderTotal(order.items)}</p>
<p> {elapsed(order.opened_at)}</p>
{order.waiters.length > 0 && (
<p className="text-xs text-gray-500 truncate">
{order.waiters.map(w => waiterMap[w.waiter_id] || `#${w.waiter_id}`).join(', ')}
</p>
)}
</div>
) : (
<p className="text-sm text-gray-400 mt-1"></p>
)}
</button>
))}
return (
<TableCardV1
key={table.id}
name={table.label || `T${table.number}`}
status={tableStatus}
amount={amount}
openedAt={order?.opened_at ?? null}
waiters={waiterNames}
onClick={order ? () => navigate(`/orders/${order.id}`) : undefined}
/>
)
})}
</div>
</div>
)

View File

@@ -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 (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-gray-800">Εκτύπωση παραγγελίας</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none"></button>
</div>
<div>
<label className="label">Εκτυπωτής</label>
<select className="input w-full" value={printerId} onChange={e => setPrinterId(e.target.value)}>
<option value=""> Επιλέξτε </option>
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div className="flex gap-3">
<button onClick={onClose} className="btn btn-secondary flex-1">Ακύρωση</button>
<button onClick={submit} className="btn btn-primary flex-1">Εκτύπωση</button>
</div>
</div>
</div>
)
}
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 <p className="py-8 text-center text-gray-400 text-sm">Δεν υπάρχουν εγγραφές.</p>
}
return (
<div className="divide-y divide-gray-100">
{order.audit_logs.map(log => (
<div key={log.id} className="flex items-start gap-3 px-4 py-3">
<div className="shrink-0 mt-0.5">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
log.event_type === 'PAYMENT' ? 'bg-green-100 text-green-700' :
log.event_type.includes('CANCEL') ? 'bg-red-100 text-red-600' :
log.event_type === 'ORDER_CLOSED' ? 'bg-gray-100 text-gray-600' :
'bg-blue-100 text-blue-700'
}`}>
{EVENT_LABELS[log.event_type] ?? log.event_type}
</span>
</div>
<div className="flex-1 min-w-0 text-sm text-gray-700">
<span>{log.waiter_name ?? waiterMap[log.waiter_id] ?? `#${log.waiter_id}`}</span>
{log.amount != null && (
<span className="ml-2 font-semibold text-green-700">{log.amount.toFixed(2)}</span>
)}
{log.payment_method && (
<span className="ml-1 text-gray-400 text-xs">({log.payment_method})</span>
)}
</div>
<span className="text-xs text-gray-400 shrink-0">{formatDate(log.created_at)}</span>
</div>
))}
</div>
)
}
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
</div>
</div>
{/* Waiters */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2>
<div className="flex flex-wrap gap-2">
{order.waiters.map(w => (
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1">
<span className="text-sm">{waiterMap[w.waiter_id] || `#${w.waiter_id}`}</span>
{isOpen && !readOnly && (
<button
onClick={() => removeWaiter.mutate(w.waiter_id)}
className="text-gray-400 hover:text-red-500 text-xs leading-none"
>
</button>
)}
</div>
))}
{isOpen && !readOnly && (
<select
className="text-sm border border-gray-300 rounded-full px-3 py-1 focus:outline-none focus:ring-1 focus:ring-primary-600"
defaultValue=""
onChange={e => { if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
>
<option value="">+ Πρόσθεσε</option>
{waiters.filter(w => !assignedIds.has(w.id)).map(w => (
<option key={w.id} value={w.id}>{w.username}</option>
))}
</select>
)}
</div>
</div>
{/* Items */}
<div className="card divide-y divide-gray-100">
<div className="px-4 py-3">
<h2 className="text-sm font-semibold text-gray-700">Αντικείμενα</h2>
</div>
{order.items.length === 0 && (
<p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p>
)}
{order.items.map(item => (
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${item.status === 'cancelled' ? 'opacity-40 line-through' : ''}`}>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</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>
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={item.status} />
<span className="text-sm font-semibold text-gray-700 w-14 text-right">{itemTotal(item)}</span>
{isOpen && !readOnly && item.status === 'active' && (
<>
<button
onClick={() => payItems.mutate([item.id])}
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
>
Πληρωμή
</button>
<button
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
>
Ακύρωση
</button>
</>
)}
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200">
{[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
<button
key={key}
onClick={() => setTab(key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab === key ? 'border-primary-600 text-primary-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
{label}
</button>
))}
</div>
{/* Actions */}
{isOpen && !readOnly && (
{tab === 'overview' && <>
{/* Waiters */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-gray-700 mb-3">Σερβιτόροι</h2>
<div className="flex flex-wrap gap-2">
{order.waiters.map(w => (
<div key={w.waiter_id} className="flex items-center gap-2 bg-gray-100 rounded-full px-3 py-1">
<span className="text-sm">{waiterMap[w.waiter_id] || `#${w.waiter_id}`}</span>
{isOpen && !readOnly && (
<button
onClick={() => removeWaiter.mutate(w.waiter_id)}
className="text-gray-400 hover:text-red-500 text-xs leading-none"
>
</button>
)}
</div>
))}
{isOpen && !readOnly && (
<select
className="text-sm border border-gray-300 rounded-full px-3 py-1 focus:outline-none focus:ring-1 focus:ring-primary-600"
defaultValue=""
onChange={e => { if (e.target.value) assignWaiter.mutate(Number(e.target.value)) }}
>
<option value="">+ Πρόσθεσε</option>
{waiters.filter(w => !assignedIds.has(w.id)).map(w => (
<option key={w.id} value={w.id}>{w.username}</option>
))}
</select>
)}
</div>
</div>
{/* Items */}
<div className="card divide-y divide-gray-100">
<div className="px-4 py-3">
<h2 className="text-sm font-semibold text-gray-700">Αντικείμενα</h2>
</div>
{order.items.length === 0 && (
<p className="px-4 py-6 text-center text-gray-400 text-sm">Κανένα αντικείμενο.</p>
)}
{order.items.map(item => (
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${item.status === 'cancelled' ? 'opacity-40 line-through' : ''}`}>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</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>
{item.paid_by && (
<p className="text-xs text-green-600 mt-0.5">
Πληρώθηκε: {waiterMap[item.paid_by] ?? `#${item.paid_by}`}
{item.paid_at ? ` · ${formatDate(item.paid_at)}` : ''}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={item.status} />
<span className="text-sm font-semibold text-gray-700 w-14 text-right">{itemTotal(item)}</span>
{isOpen && !readOnly && item.status === 'active' && (
<>
<button
onClick={() => payItems.mutate([item.id])}
className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8"
>
Πληρωμή
</button>
<button
onClick={() => setConfirmAction({ type: 'cancelItem', payload: item.id })}
className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8"
>
Ακύρωση
</button>
</>
)}
</div>
</div>
))}
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3">
{activeItems.length > 0 && (
{isOpen && !readOnly && activeItems.length > 0 && (
<button
onClick={() => payItems.mutate(activeItems.map(i => i.id))}
className="btn btn-primary"
@@ -201,19 +307,35 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
Πληρωμή όλων
</button>
)}
{isOpen && !readOnly && (
<>
<button
onClick={() => setConfirmAction({ type: 'closeOrder' })}
className="btn btn-secondary"
>
Κλείσιμο παραγγελίας
</button>
<button
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
className="btn btn-danger"
>
Ακύρωση παραγγελίας
</button>
</>
)}
<button
onClick={() => setConfirmAction({ type: 'closeOrder' })}
onClick={() => setShowPrintModal(true)}
className="btn btn-secondary"
>
Κλείσιμο παραγγελίας
</button>
<button
onClick={() => setConfirmAction({ type: 'cancelOrder' })}
className="btn btn-danger"
>
Ακύρωση παραγγελίας
🖨 Εκτύπωση
</button>
</div>
</>}
{tab === 'audit' && (
<div className="card divide-y divide-gray-100">
<AuditTab order={order} waiterMap={waiterMap} />
</div>
)}
{confirmAction && (
@@ -234,6 +356,22 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
onCancel={() => setConfirmAction(null)}
/>
)}
{showPrintModal && printers.length > 0 && (
<PrintOrderModal
printers={printers}
onClose={() => setShowPrintModal(false)}
onPrint={(printerId) => printOrder.mutate(printerId)}
/>
)}
{showPrintModal && printers.length === 0 && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 space-y-4">
<p className="text-gray-700">Δεν βρέθηκαν ενεργοί εκτυπωτές.</p>
<button onClick={() => setShowPrintModal(false)} className="btn btn-secondary w-full">Κλείσιμο</button>
</div>
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<img
src={API_URL + waiter.avatar_url}
alt={displayName}
style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }}
/>
)
}
const parts = displayName.trim().split(' ')
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: avatarColor(displayName),
color: 'white', fontSize: size * 0.38, fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>{initials}</div>
)
}
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 (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">Ζώνες {waiter.username}</h2>
<label className="flex items-center gap-3 cursor-pointer select-none">
<input
type="checkbox"
className="w-5 h-5 rounded accent-primary-700"
checked={allZones}
onChange={e => { setAllZones(e.target.checked); if (e.target.checked) setSelected(new Set()) }}
/>
<span className="font-semibold text-gray-700">Όλες οι ζώνες</span>
</label>
{!allZones && (
<div className="space-y-2 max-h-60 overflow-y-auto">
{groups.length === 0 && (
<p className="text-sm text-gray-400">Δεν υπάρχουν ομάδες τραπεζιών.</p>
)}
{groups.map(g => (
<label key={g.id} className="flex items-center gap-3 cursor-pointer select-none px-1">
<input
type="checkbox"
className="w-5 h-5 rounded accent-primary-700"
checked={selected.has(g.id)}
onChange={() => toggleGroup(g.id)}
/>
<span className="text-gray-700">{g.name}</span>
{g.color && (
<span className="w-3 h-3 rounded-full inline-block ml-auto" style={{ background: g.color }} />
)}
</label>
))}
</div>
)}
{!allZones && selected.size === 0 && (
<p className="text-xs text-amber-600 bg-amber-50 rounded px-3 py-1.5">
Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι.
</p>
)}
<div className="flex gap-3 pt-2">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={save} disabled={saveZones.isPending} className="flex-1 btn btn-primary">
Αποθήκευση
</button>
</div>
</div>
</div>
)
}
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 <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
return (
<div className="space-y-4 max-w-3xl">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-800">Σερβιτόροι</h1>
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέος σερβιτόρος</button>
@@ -84,13 +242,31 @@ export default function WaitersPage() {
)}
{waiters.map(w => (
<div key={w.id} className="flex items-center gap-4 px-4 py-3">
<WaiterAvatar waiter={w} size={44} />
<div className="flex-1 min-w-0">
<p className="font-semibold text-gray-800">{w.username}</p>
<p className="text-xs text-gray-500">{w.role}</p>
<div className="flex items-baseline gap-2">
<p className="font-semibold text-gray-800">{w.full_name || w.username}</p>
{w.nickname && <span className="text-xs text-gray-400">({w.nickname})</span>}
</div>
<p className="text-xs text-gray-500">{w.username} · {w.role}</p>
{w.mobile_phone && <p className="text-xs text-gray-400">{w.mobile_phone}</p>}
{w.role === 'waiter' && (
<p className="text-xs text-gray-400 mt-0.5">
{w.zone_assignments.length === 0
? 'Χωρίς ζώνες'
: w.zone_assignments.some(z => z.group_id === null)
? 'Όλες οι ζώνες'
: `${w.zone_assignments.length} ζών${w.zone_assignments.length === 1 ? 'η' : 'ες'}`}
</p>
)}
</div>
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${w.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}>
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
</span>
<button onClick={() => { setEditModal(w); setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '' }) }} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
{w.role === 'waiter' && (
<button onClick={() => setZoneModal(w)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Ζώνες</button>
)}
<button onClick={() => setPinModal(w.id)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Reset PIN</button>
<button onClick={() => toggleBlock.mutate(w.id)} className={`btn text-sm px-3 py-1.5 min-h-0 h-9 ${w.is_active ? 'btn-danger' : 'btn-secondary'}`}>
{w.is_active ? 'Αποκλεισμός' : 'Ενεργοποίηση'}
@@ -105,9 +281,17 @@ export default function WaitersPage() {
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">Νέος σερβιτόρος</h2>
<div>
<label className="label">Πλήρες όνομα</label>
<input className="input" placeholder="π.χ. Γιώργος Παπαδόπουλος" value={newForm.full_name} onChange={e => setNewForm(f => ({ ...f, full_name: e.target.value }))} autoFocus />
</div>
<div>
<label className="label">Όνομα χρήστη</label>
<input className="input" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} autoFocus />
<input className="input" placeholder="π.χ. giorgos" value={newForm.username} onChange={e => setNewForm(f => ({ ...f, username: e.target.value }))} />
</div>
<div>
<label className="label">Κινητό τηλέφωνο</label>
<input className="input" placeholder="π.χ. 6901234567" value={newForm.mobile_phone} onChange={e => setNewForm(f => ({ ...f, mobile_phone: e.target.value }))} />
</div>
<div>
<label className="label">Ρόλος</label>
@@ -123,7 +307,7 @@ export default function WaitersPage() {
<div className="flex gap-3 pt-2">
<button onClick={() => setAddModal(false)} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => createWaiter.mutate({ username: newForm.username, pin: newForm.pin, role: newForm.role, is_active: true })}
onClick={() => createWaiter.mutate({ username: newForm.username, full_name: newForm.full_name || null, mobile_phone: newForm.mobile_phone || null, pin: newForm.pin, role: newForm.role, is_active: true })}
disabled={!newForm.username.trim() || newForm.pin.length < 4}
className="flex-1 btn btn-primary"
>
@@ -134,6 +318,76 @@ export default function WaitersPage() {
</div>
)}
{/* Edit profile modal */}
{editModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">Επεξεργασία {editModal.username}</h2>
{/* Avatar section */}
<div className="flex items-center gap-4">
<WaiterAvatar waiter={editModal} size={64} />
<div className="flex flex-col gap-2">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => {
const file = e.target.files?.[0]
if (file) uploadAvatar.mutate({ id: editModal.id, file })
e.target.value = ''
}}
/>
<button
onClick={() => avatarInputRef.current?.click()}
disabled={uploadAvatar.isPending}
className="btn btn-secondary text-xs px-3 py-1.5 min-h-0 h-8"
>
{uploadAvatar.isPending ? 'Μεταφόρτωση…' : 'Αλλαγή φωτογραφίας'}
</button>
{editModal.avatar_url && (
<button
onClick={() => deleteAvatar.mutate(editModal.id)}
disabled={deleteAvatar.isPending}
className="btn btn-ghost text-xs px-3 py-1.5 min-h-0 h-8 text-red-500 hover:bg-red-50"
>
Αφαίρεση
</button>
)}
</div>
</div>
<div>
<label className="label">Όνομα χρήστη</label>
<input className="input" value={editForm.username} onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))} autoFocus />
</div>
<div>
<label className="label">Πλήρες όνομα</label>
<input className="input" value={editForm.full_name} onChange={e => setEditForm(f => ({ ...f, full_name: e.target.value }))} />
</div>
<div>
<label className="label">Παρατσούκλι (nickname)</label>
<input className="input" placeholder="π.χ. Γιώργος" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
</div>
<div>
<label className="label">Κινητό τηλέφωνο</label>
<input className="input" value={editForm.mobile_phone} onChange={e => setEditForm(f => ({ ...f, mobile_phone: e.target.value }))} />
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => setEditModal(null)} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => updateWaiter.mutate({ id: editModal.id, username: editForm.username.trim() || undefined, full_name: editForm.full_name || null, nickname: editForm.nickname || null, mobile_phone: editForm.mobile_phone || null })}
disabled={updateWaiter.isPending || !editForm.username.trim()}
className="flex-1 btn btn-primary"
>
Αποθήκευση
</button>
</div>
</div>
</div>
)}
{/* Reset PIN modal */}
{pinModal !== null && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
@@ -164,6 +418,10 @@ export default function WaitersPage() {
onCancel={() => setConfirmDelete(null)}
/>
)}
{zoneModal && (
<ZoneModal waiter={zoneModal} groups={groups} onClose={() => setZoneModal(null)} />
)}
</div>
)
}

View File

@@ -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() {
<div className="detail-body">
<OrderSummary
order={order}
selectable={isMyOrder && !paying}
selectable={canInteract && !paying}
selectedIds={selectedIds}
onToggle={toggleItem}
/>
{isMyOrder && activeItems.length > 0 && (
{canInteract && activeItems.length > 0 && (
<div style={{ padding: '4px 12px 8px' }}>
<button className="link-btn" onClick={selectAll} style={{ fontSize: 15 }}>
{allActiveSelected ? '☑ Αποεπιλογή όλων' : '☐ Επιλογή όλων'}
@@ -136,7 +134,7 @@ export default function TableDetailPage() {
</div>
)}
{isMyOrder && (
{canInteract && (
<div className="action-bar">
<button className="btn btn--accent" onClick={() => navigate(`/tables/${tableId}/add`)}>
+ Προσθήκη
@@ -159,12 +157,6 @@ export default function TableDetailPage() {
</button>
</div>
)}
{!isMyOrder && (
<p style={{ textAlign: 'center', color: '#64748b', padding: 16 }}>
Ανάγνωση μόνο άλλος σερβιτόρος
</p>
)}
</div>
)}