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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user