feat: Feature 2 — waiter order cancellation + report updates

Backend:
- Add can_cancel_orders to User model and schema
- Add global orders.waiter_cancellations_allowed setting (migration)
- Cancel endpoints: mark items with cancelled_by/cancelled_at, fire cancellation print
- print_cancellation_ticket: routes to same printer zones, prints ΑΚΥΡΩΣΗ banner
- Fix cancellations_log: date filter, waiter filter, join syntax
- shift/orders: add cancellations count and hours_worked per waiter
- _enrich_shift: add cancellations count to shift data
- Add cancel-permissions endpoint for PWA

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 12:47:02 +03:00
parent b5b647422a
commit 8a5a6f8be9
16 changed files with 855 additions and 108 deletions

View File

@@ -8,7 +8,7 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, timezone
from typing import Optional, List
from database import get_db
@@ -136,9 +136,36 @@ def shift_orders_summary(
q = q.filter(OrderItem.added_by == waiter_id)
items = q.all()
# Also fetch cancelled items for the same window/waiter
cq = db.query(OrderItem).filter(OrderItem.status == "cancelled")
if business_day_id:
cq = cq.join(Order, OrderItem.order_id == Order.id).filter(Order.business_day_id == business_day_id)
else:
cq = cq.filter(OrderItem.added_at >= start, OrderItem.added_at < end)
if waiter_id:
cq = cq.filter(OrderItem.added_by == waiter_id)
cancelled_items = cq.all()
waiters_db = {u.id: u for u in db.query(User).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
# Track shifts for duration (hours worked)
shift_q = db.query(WaiterShift)
if business_day_id:
shift_q = shift_q.filter(WaiterShift.business_day_id == business_day_id)
else:
shift_q = shift_q.filter(WaiterShift.started_at >= start, WaiterShift.started_at < end)
if waiter_id:
shift_q = shift_q.filter(WaiterShift.waiter_id == waiter_id)
shifts_list = shift_q.all()
# hours worked per waiter
hours_by_waiter: dict[int, float] = {}
for s in shifts_list:
wid = s.waiter_id
end_time = s.ended_at or datetime.now(timezone.utc).replace(tzinfo=None)
delta = (end_time - s.started_at).total_seconds() / 3600.0
hours_by_waiter[wid] = hours_by_waiter.get(wid, 0.0) + delta
summary: dict[int, dict] = {}
for item in items:
wid = item.added_by
@@ -150,6 +177,7 @@ def shift_orders_summary(
"waiter_name": wname,
"items": 0,
"total": 0.0,
"cancellations": 0,
"order_data": {},
}
summary[wid]["items"] += item.quantity
@@ -173,10 +201,26 @@ def shift_orders_summary(
{"name": product_name, "quantity": item.quantity}
)
for item in cancelled_items:
wid = item.added_by
if wid not in summary:
w = waiters_db.get(wid)
wname = (w.full_name or w.username) if w else f"#{wid}"
summary[wid] = {
"waiter_id": wid,
"waiter_name": wname,
"items": 0,
"total": 0.0,
"cancellations": 0,
"order_data": {},
}
summary[wid]["cancellations"] = summary[wid].get("cancellations", 0) + item.quantity
result = []
for entry in summary.values():
entry["orders"] = len(entry["order_data"])
entry["order_data"] = list(entry["order_data"].values())
entry["hours_worked"] = round(hours_by_waiter.get(entry["waiter_id"], 0.0), 2)
result.append(entry)
return {"from": start.isoformat(), "to": end.isoformat(), "waiters": result}
@@ -1380,22 +1424,45 @@ def cancellations_log(
user: User = Depends(require_manager),
):
q = db.query(OrderItem).filter(OrderItem.status == "cancelled")
# Date filter on cancelled_at if available, otherwise fall back to added_at
if from_dt:
q = q.filter(OrderItem.added_at >= datetime.fromisoformat(from_dt))
dt_from = datetime.fromisoformat(from_dt)
from sqlalchemy import or_
q = q.filter(or_(
OrderItem.cancelled_at >= dt_from,
(OrderItem.cancelled_at == None) & (OrderItem.added_at >= dt_from),
))
if to_dt:
q = q.filter(OrderItem.added_at <= datetime.fromisoformat(to_dt))
dt_to = datetime.fromisoformat(to_dt)
from sqlalchemy import or_
q = q.filter(or_(
OrderItem.cancelled_at <= dt_to,
(OrderItem.cancelled_at == None) & (OrderItem.added_at <= dt_to),
))
if business_day_id:
q = q.join(Order).filter(Order.business_day_id == business_day_id)
q = q.join(Order, Order.id == OrderItem.order_id).filter(Order.business_day_id == business_day_id)
if waiter_id:
q = q.filter(OrderItem.cancelled_by == waiter_id)
# Show items cancelled BY this waiter OR items that were on this waiter's order
from sqlalchemy import or_
q = q.filter(or_(
OrderItem.cancelled_by == waiter_id,
OrderItem.added_by == waiter_id,
))
items = q.order_by(OrderItem.added_at.desc()).all()
waiters_db = {u.id: u for u in db.query(User).all()}
tables_db = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
orders_cache = {}
def _get_order(oid):
if oid not in orders_cache:
orders_cache[oid] = db.query(Order).filter(Order.id == oid).first()
return orders_cache[oid]
result = []
for item in items:
order = db.query(Order).filter(Order.id == item.order_id).first()
order = _get_order(item.order_id)
table_name = tables_db.get(order.table_id, f"#{order.table_id}") if order else ""
waiter = waiters_db.get(item.added_by)
waiter_name = (waiter.full_name or waiter.username) if waiter else f"#{item.added_by}"