feat: major dashboard & waiter PWA overhaul

- Manager dashboard: replaced monolithic DashboardTab/OperationsPage with new
  DashboardPage; added OrderDetailModal, ShiftDetailModal, DeleteConfirmModal,
  PaymentMethodModal; updated Sidebar routing and App navigation
- Reports: reworked WorkDaySummary, OrderHistory, ShiftsOverview with detail modals
- Backend routers: extended orders, reports, shifts, products, business_day endpoints;
  updated cloud_sync service
- Waiter PWA: refreshed app icons, improved ConnectionLostModal UX, updated
  TableCard, SSEContext, connectionStore; added useProductCache hook; vite config tweaks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 15:24:54 +03:00
parent aa92623802
commit 5de89a722c
40 changed files with 1906 additions and 1171 deletions

View File

@@ -162,6 +162,33 @@ def close_business_day(
return day
@router.delete("/{day_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_business_day(
day_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Permanently delete a business day. Only allowed when it has no shifts."""
day = db.query(BusinessDay).filter(BusinessDay.id == day_id).first()
if not day:
raise HTTPException(status_code=404, detail="Business day not found")
if day.status == "open":
raise HTTPException(status_code=400, detail="Cannot delete an open business day — close it first")
shift_count = db.query(WaiterShift).filter(WaiterShift.business_day_id == day_id).count()
if shift_count > 0:
raise HTTPException(
status_code=409,
detail=f"Δεν είναι δυνατή η διαγραφή: η εργάσιμη μέρα έχει {shift_count} βάρδια/ες. Διαγράψτε πρώτα όλες τις βάρδιες.",
)
db.query(Order).filter(Order.business_day_id == day_id).update(
{"business_day_id": None}, synchronize_session=False
)
db.delete(day)
db.commit()
@router.get("/history")
def business_day_history(
db: Session = Depends(get_db),

View File

@@ -120,14 +120,83 @@ def list_active_orders(db: Session = Depends(get_db), user: User = Depends(get_c
]
@router.get("/{order_id}", response_model=OrderOut)
@router.get("/{order_id}")
def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if not _can_access_order(order, user, db):
raise HTTPException(status_code=403, detail="Access denied")
return order
# Resolve all user IDs referenced by this order in one query
user_ids = set()
user_ids.add(order.opened_by)
if order.closed_by:
user_ids.add(order.closed_by)
for item in order.items:
user_ids.add(item.added_by)
if item.paid_by:
user_ids.add(item.paid_by)
for log in order.audit_logs:
if log.waiter_id:
user_ids.add(log.waiter_id)
users = db.query(User).filter(User.id.in_(user_ids)).all()
name_map = {u.id: u.nickname or u.full_name or u.username for u in users}
def fmt_item(i):
return {
"id": i.id,
"order_id": i.order_id,
"product_id": i.product_id,
"product": {"id": i.product.id, "name": i.product.name} if i.product else None,
"added_by": i.added_by,
"added_by_name": name_map.get(i.added_by),
"quantity": i.quantity,
"unit_price": i.unit_price,
"selected_options": i.selected_options,
"removed_ingredients": i.removed_ingredients,
"notes": i.notes,
"status": i.status,
"added_at": i.added_at,
"printed": i.printed,
"paid_by": i.paid_by,
"paid_by_name": name_map.get(i.paid_by) if i.paid_by else None,
"paid_at": i.paid_at,
"payment_method": i.payment_method,
"paid_in_shift_id": i.paid_in_shift_id,
}
def fmt_log(l):
return {
"id": l.id,
"order_id": l.order_id,
"event_type": l.event_type,
"waiter_id": l.waiter_id,
"waiter_name": name_map.get(l.waiter_id) if l.waiter_id else None,
"item_ids": l.item_ids,
"amount": l.amount,
"payment_method": l.payment_method,
"note": l.note,
"created_at": l.created_at,
"offline_at": l.offline_at,
"is_duplicate": l.is_duplicate,
}
return {
"id": order.id,
"table_id": order.table_id,
"opened_by": order.opened_by,
"opened_at": order.opened_at,
"status": order.status,
"closed_at": order.closed_at,
"closed_by": order.closed_by,
"notes": order.notes,
"business_day_id": order.business_day_id,
"items": [fmt_item(i) for i in order.items],
"waiters": [{"waiter_id": w.waiter_id} for w in order.waiters],
"audit_logs": [fmt_log(l) for l in order.audit_logs],
}
@router.post("/", response_model=OrderOut, status_code=status.HTTP_201_CREATED)
@@ -310,10 +379,26 @@ def close_order(order_id: int, db: Session = Depends(get_db), user: User = Depen
raise HTTPException(status_code=403, detail="Access denied")
if order.status not in ("paid", "open", "partially_paid"):
raise HTTPException(status_code=400, detail="Cannot close order in current status")
now = datetime.now(timezone.utc)
# Mark all still-active items as 'closed' — unpaid, closed by manager
active_items = db.query(OrderItem).filter(
OrderItem.order_id == order_id,
OrderItem.status == "active",
).all()
closed_item_ids = []
for item in active_items:
item.status = "closed"
closed_item_ids.append(item.id)
order.status = "closed"
order.closed_at = datetime.now(timezone.utc)
order.closed_at = now
order.closed_by = user.id
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id)
note = f"Κλείσιμο από manager — {len(closed_item_ids)} απλήρωτα αντικείμενα" if closed_item_ids else None
_audit(db, order_id, "ORDER_CLOSED", waiter_id=user.id,
item_ids=closed_item_ids if closed_item_ids else None, note=note)
db.commit()
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})
return {"status": "closed"}
@@ -419,10 +504,26 @@ def cancel_order(order_id: int, db: Session = Depends(get_db), user: User = Depe
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
now = datetime.now(timezone.utc)
# Cancel all still-active items
active_items = db.query(OrderItem).filter(
OrderItem.order_id == order_id,
OrderItem.status == "active",
).all()
cancelled_item_ids = []
for item in active_items:
item.status = "cancelled"
cancelled_item_ids.append(item.id)
order.status = "cancelled"
order.closed_at = datetime.now(timezone.utc)
order.closed_at = now
order.closed_by = user.id
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id)
note = f"Ακύρωση από manager — {len(cancelled_item_ids)} αντικείμενα ακυρώθηκαν" if cancelled_item_ids else None
_audit(db, order_id, "ORDER_CANCELLED", waiter_id=user.id,
item_ids=cancelled_item_ids if cancelled_item_ids else None, note=note)
db.commit()
broadcast_sync("order_closed", {"order_id": order_id, "table_id": order.table_id})

View File

@@ -17,9 +17,14 @@ from schemas.product import (
CategoryReparentRequest,
)
from routers.deps import get_current_user, require_manager
from services.sse_bus import broadcast_sync
router = APIRouter()
def _broadcast_products_changed():
broadcast_sync("products_changed", {})
IMAGE_DIR = "/app/data/product_images"
@@ -118,6 +123,7 @@ def create_category(body: CategoryCreate, db: Session = Depends(get_db), user: U
db.add(cat)
db.commit()
db.refresh(cat)
_broadcast_products_changed()
return cat
@@ -128,6 +134,7 @@ def reorder_categories(items: List[CategoryReorderItem], db: Session = Depends(g
if cat:
cat.sort_order = item.sort_order
db.commit()
_broadcast_products_changed()
@router.put("/categories/reorder-subcategories", status_code=status.HTTP_204_NO_CONTENT)
@@ -138,6 +145,7 @@ def reorder_subcategories(items: List[SubcategoryReorderItem], db: Session = Dep
if cat:
cat.sort_order = item.sort_order
db.commit()
_broadcast_products_changed()
@router.put("/categories/reorder-general", status_code=status.HTTP_204_NO_CONTENT)
@@ -148,6 +156,7 @@ def reorder_general(items: List[ParentGeneralReorderItem], db: Session = Depends
if cat:
cat.general_sort_order = item.general_sort_order
db.commit()
_broadcast_products_changed()
@router.put("/categories/{category_id}/reparent", response_model=CategoryOut)
@@ -176,6 +185,7 @@ def reparent_category(category_id: int, body: CategoryReparentRequest, db: Sessi
cat.sort_order = sibling_count
db.commit()
db.refresh(cat)
_broadcast_products_changed()
return cat
@@ -188,6 +198,7 @@ def update_category(category_id: int, body: CategoryUpdate, db: Session = Depend
setattr(cat, field, value)
db.commit()
db.refresh(cat)
_broadcast_products_changed()
return cat
@@ -198,6 +209,7 @@ def delete_category(category_id: int, db: Session = Depends(get_db), user: User
raise HTTPException(status_code=404, detail="Category not found")
db.delete(cat)
db.commit()
_broadcast_products_changed()
# ── Products ──────────────────────────────────────────────────────────────────
@@ -218,6 +230,7 @@ def reorder_products(items: List[ProductReorderItem], db: Session = Depends(get_
if product:
product.sort_order = item.sort_order
db.commit()
_broadcast_products_changed()
@router.post("/", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
@@ -255,6 +268,7 @@ def create_product(body: ProductCreate, db: Session = Depends(get_db), user: Use
_replace_preference_sets(db, product, body.preference_sets)
db.commit()
db.refresh(product)
_broadcast_products_changed()
return product
@@ -275,6 +289,7 @@ def update_product(product_id: int, body: ProductUpdate, db: Session = Depends(g
_replace_preference_sets(db, product, body.preference_sets)
db.commit()
db.refresh(product)
_broadcast_products_changed()
return product
@@ -305,6 +320,7 @@ async def upload_product_image(product_id: int, file: UploadFile = File(...), db
product.image_url = f"/static/product_images/{filename}"
db.commit()
db.refresh(product)
_broadcast_products_changed()
return product
@@ -329,3 +345,4 @@ def delete_product(product_id: int, hard: bool = False, db: Session = Depends(ge
else:
db.delete(product)
db.commit()
_broadcast_products_changed()

View File

@@ -178,7 +178,7 @@ def shift_orders_summary(
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
@router.get("/orders/history", response_model=List[OrderOut])
@router.get("/orders/history")
def order_history(
from_date: Optional[str] = Query(default=None, alias="from"),
to_date: Optional[str] = Query(default=None, alias="to"),
@@ -191,7 +191,14 @@ def order_history(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
q = db.query(Order)
from sqlalchemy.orm import joinedload
from models.table import Table as TableModel
q = db.query(Order).options(
joinedload(Order.items).joinedload(OrderItem.product),
joinedload(Order.waiters),
joinedload(Order.audit_logs),
)
if business_day_id:
q = q.filter(Order.business_day_id == business_day_id)
elif from_date or to_date:
@@ -205,7 +212,109 @@ def order_history(
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()
orders = q.order_by(Order.opened_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
# Collect all waiter IDs and table IDs to resolve in bulk
waiter_ids: set[int] = set()
table_ids: set[int] = set()
item_waiter_ids: set[int] = set()
for o in orders:
if o.opened_by:
waiter_ids.add(o.opened_by)
if o.closed_by:
waiter_ids.add(o.closed_by)
if o.table_id:
table_ids.add(o.table_id)
for item in o.items:
if item.added_by:
item_waiter_ids.add(item.added_by)
if item.paid_by:
item_waiter_ids.add(item.paid_by)
for log in o.audit_logs:
if log.waiter_id:
waiter_ids.add(log.waiter_id)
all_waiter_ids = waiter_ids | item_waiter_ids
waiters_map: dict[int, str] = {}
if all_waiter_ids:
for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all():
waiters_map[w.id] = w.full_name or w.username
tables_map: dict[int, str] = {}
if table_ids:
for t in db.query(TableModel).filter(TableModel.id.in_(table_ids)).all():
prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else ""
tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}"
def _wname(wid):
if wid is None:
return None
return waiters_map.get(wid, f"#{wid}")
def _dt_local(dt):
if dt is None:
return None
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
result = []
for o in orders:
items_out = []
for item in o.items:
items_out.append({
"id": item.id,
"order_id": item.order_id,
"product_id": item.product_id,
"product": {"id": item.product.id, "name": item.product.name} if item.product else None,
"added_by": item.added_by,
"added_by_name": _wname(item.added_by),
"added_at": _dt_local(item.added_at),
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"status": item.status,
"paid_by": item.paid_by,
"paid_by_name": _wname(item.paid_by),
"paid_at": _dt_local(item.paid_at),
"payment_method": item.payment_method,
"paid_in_shift_id": item.paid_in_shift_id,
"notes": item.notes,
"printed": item.printed,
"selected_options": item.selected_options,
"removed_ingredients": item.removed_ingredients,
})
audit_out = []
for log in o.audit_logs:
audit_out.append({
"id": log.id,
"order_id": log.order_id,
"event_type": log.event_type,
"waiter_id": log.waiter_id,
"waiter_name": _wname(log.waiter_id),
"item_ids": log.item_ids,
"amount": log.amount,
"payment_method": log.payment_method,
"note": log.note,
"created_at": _dt_local(log.created_at),
"offline_at": log.offline_at,
"is_duplicate": log.is_duplicate,
})
result.append({
"id": o.id,
"table_id": o.table_id,
"table_name": tables_map.get(o.table_id) if o.table_id else None,
"opened_by": o.opened_by,
"opened_by_name": _wname(o.opened_by),
"opened_at": _dt_local(o.opened_at),
"closed_by": o.closed_by,
"closed_by_name": _wname(o.closed_by),
"closed_at": _dt_local(o.closed_at),
"status": o.status,
"notes": o.notes,
"business_day_id": o.business_day_id,
"items": items_out,
"waiters": [{"waiter_id": w.waiter_id} for w in o.waiters],
"audit_logs": audit_out,
})
return result
@router.get("/tables/summary")
@@ -689,9 +798,8 @@ def business_days_list(
orders = db.query(Order).filter(Order.business_day_id == d.id).all()
closed_orders = [o for o in orders if o.status in ("closed", "paid")]
cancelled_orders = [o for o in orders if o.status == "cancelled"]
waiter_ids = set()
for s in (db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()):
waiter_ids.add(s.waiter_id)
day_shifts = db.query(WaiterShift).filter(WaiterShift.business_day_id == d.id).all()
waiter_ids = {s.waiter_id for s in day_shifts}
revenue = sum(
sum(i.unit_price * i.quantity for i in o.items if i.status in ("active", "paid"))
for o in closed_orders
@@ -710,6 +818,7 @@ def business_days_list(
"closed_order_count": len(closed_orders),
"cancellation_count": len(cancelled_orders),
"waiter_count": len(waiter_ids),
"shift_count": len(day_shifts),
"revenue": round(revenue, 2),
})
return {"business_days": result}

View File

@@ -289,22 +289,66 @@ def get_shift(
return _enrich_shift(shift, db)
@router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_shift(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Permanently delete a shift, its breaks, and all orders opened during that shift."""
from models.order import Order, OrderItem, OrderAuditLog, OrderWaiter
import json as _json
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
if shift.ended_at is None:
raise HTTPException(status_code=400, detail="Cannot delete an active shift — end it first")
# Find all orders opened during this shift's time window by this waiter
orders = db.query(Order).filter(
Order.opened_by == shift.waiter_id,
Order.opened_at >= shift.started_at,
Order.opened_at <= shift.ended_at,
).all()
for order in orders:
db.query(OrderAuditLog).filter(OrderAuditLog.order_id == order.id).delete(synchronize_session=False)
db.query(OrderWaiter).filter(OrderWaiter.order_id == order.id).delete(synchronize_session=False)
db.query(OrderItem).filter(OrderItem.order_id == order.id).delete(synchronize_session=False)
db.delete(order)
db.delete(shift)
db.commit()
@router.get("/{shift_id}/summary")
def get_shift_summary(
shift_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
"""Full shift summary: enriched shift data + paid items grouped by order."""
"""Full shift summary with complete per-item waiter attribution.
Returns all items that either:
- were paid in this shift (paid_in_shift_id == shift_id), OR
- were added by this waiter (added_by == waiter_id) and are active/unpaid or paid in another shift.
Each item includes added_by_name, paid_by_name, timestamps, and order context.
Orders include opener_name and closer_name.
"""
from models.order import Order
from models.table import Table
from sqlalchemy.orm import joinedload
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
if not shift:
raise HTTPException(status_code=404, detail="Shift not found")
from models.table import Table
items = db.query(OrderItem).options(
waiter_id = shift.waiter_id
# Collect all relevant items: paid in this shift OR added by this waiter
paid_items = db.query(OrderItem).options(
joinedload(OrderItem.product),
joinedload(OrderItem.order),
).filter(
@@ -312,48 +356,115 @@ def get_shift_summary(
OrderItem.status == "paid",
).all()
# Build table_id -> display name map for all referenced tables
table_ids = {item.order.table_id for item in items if item.order and item.order.table_id}
ordered_items = db.query(OrderItem).options(
joinedload(OrderItem.product),
joinedload(OrderItem.order),
).filter(
OrderItem.added_by == waiter_id,
OrderItem.added_at >= shift.started_at,
OrderItem.added_at <= shift.ended_at,
(OrderItem.paid_in_shift_id != shift_id) | (OrderItem.paid_in_shift_id == None),
).all()
# Deduplicate: union by item id, paid_items takes precedence
items_by_id: dict[int, OrderItem] = {}
for item in ordered_items:
items_by_id[item.id] = item
for item in paid_items:
items_by_id[item.id] = item
all_items = list(items_by_id.values())
# Build lookup maps
all_waiter_ids = set()
for item in all_items:
all_waiter_ids.add(item.added_by)
if item.paid_by:
all_waiter_ids.add(item.paid_by)
all_order_ids = {item.order_id for item in all_items}
# Load orders with opener/closer
orders_db: dict[int, Order] = {}
if all_order_ids:
for o in db.query(Order).filter(Order.id.in_(all_order_ids)).all():
orders_db[o.id] = o
if o.opened_by:
all_waiter_ids.add(o.opened_by)
if o.closed_by:
all_waiter_ids.add(o.closed_by)
# Load table names
table_ids = {o.table_id for o in orders_db.values() if o.table_id}
tables_map: dict[int, str] = {}
if table_ids:
tbl_rows = db.query(Table).filter(Table.id.in_(table_ids)).all()
for t in tbl_rows:
for t in db.query(Table).filter(Table.id.in_(table_ids)).all():
prefix = (t.group.prefix if t.group and t.group.prefix else "") if t.group else ""
tables_map[t.id] = t.label if t.label else f"{prefix}{t.number}"
orders_seen = {}
for item in items:
# Load waiter names
waiters_map: dict[int, str] = {}
if all_waiter_ids:
for w in db.query(User).filter(User.id.in_(all_waiter_ids)).all():
waiters_map[w.id] = w.full_name or w.username
def _wname(wid):
if wid is None:
return None
return waiters_map.get(wid, f"#{wid}")
# Build orders dict
orders_out: dict[int, dict] = {}
for oid, o in orders_db.items():
tid = o.table_id
orders_out[oid] = {
"order_id": oid,
"table_id": tid,
"table_name": tables_map.get(tid) if tid else None,
"opened_at": _dt(o.opened_at),
"closed_at": _dt(o.closed_at),
"opener_name": _wname(o.opened_by),
"closer_name": _wname(o.closed_by),
"status": o.status,
"items": [],
}
# Attach items to their orders
for item in all_items:
oid = item.order_id
if oid not in orders_seen:
o = item.order
tid = o.table_id if o else None
orders_seen[oid] = {
"order_id": oid,
"table_id": tid,
"table_name": tables_map.get(tid) if tid else None,
"opened_at": _dt(o.opened_at) if o else None,
"items": [],
}
orders_seen[oid]["items"].append({
if oid not in orders_out:
continue
orders_out[oid]["items"].append({
"id": item.id,
"product_name": item.product.name if item.product else f"#{item.product_id}",
"quantity": item.quantity,
"unit_price": float(item.unit_price),
"subtotal": round(float(item.unit_price) * item.quantity, 2),
"status": item.status,
"added_by_id": item.added_by,
"added_by_name": _wname(item.added_by),
"added_at": _dt(item.added_at),
"paid_by_id": item.paid_by,
"paid_by_name": _wname(item.paid_by),
"paid_at": _dt(item.paid_at),
"paid_in_shift_id": item.paid_in_shift_id,
"payment_method": item.payment_method,
})
# Compute hours worked
# Remove orders with no items (shouldn't happen but safety net)
populated_orders = [o for o in orders_out.values() if o["items"]]
# Compute duration
started = shift.started_at
ended = shift.ended_at
duration_minutes = None
if started and ended:
duration_minutes = int((ended - started).total_seconds() / 60)
elif started:
from datetime import datetime, timezone as tz
duration_minutes = int((datetime.now(tz.utc) - started.replace(tzinfo=tz.utc) if started.tzinfo is None else datetime.now(tz.utc) - started).total_seconds() / 60)
from datetime import timezone as tz
now = datetime.now(tz.utc)
s = started if started.tzinfo else started.replace(tzinfo=tz.utc)
duration_minutes = int((now - s).total_seconds() / 60)
enriched = _enrich_shift(shift, db)
enriched["orders"] = list(orders_seen.values())
enriched["orders"] = populated_orders
enriched["duration_minutes"] = duration_minutes
return enriched

View File

@@ -76,7 +76,12 @@ def _compute_expiry_fields(expires_at_str: str | None) -> dict:
def _get_local_ip() -> str | None:
"""Best-effort detection of the machine's LAN IP address."""
"""Best-effort detection of the host machine's LAN IP address.
When running inside Docker the socket trick returns the container/bridge IP,
so we honour HOST_IP if it is explicitly provided via the environment."""
import os
if host_ip := os.environ.get("HOST_IP", "").strip():
return host_ip
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))