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

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