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

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