- 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>
471 lines
16 KiB
Python
471 lines
16 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.orm import Session
|
|
from typing import Optional
|
|
from datetime import datetime, timezone
|
|
|
|
from database import get_db
|
|
from models.shift import WaiterShift, ShiftBreak
|
|
from models.business_day import BusinessDay
|
|
from models.order import OrderItem
|
|
from models.settings import PosSettings
|
|
from models.user import User
|
|
from schemas.shift import StartShiftRequest, EndShiftRequest
|
|
from routers.deps import get_current_user, require_manager
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _dt(dt):
|
|
"""Serialize a naive-UTC datetime to ISO string with Z so JS parses it as UTC."""
|
|
if dt is None:
|
|
return None
|
|
return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat()
|
|
|
|
|
|
def _get_setting(db: Session, key: str, default: str = "true") -> str:
|
|
s = db.query(PosSettings).filter(PosSettings.key == key).first()
|
|
return s.value if s else default
|
|
|
|
|
|
def compute_shift_total(shift_id: int, db: Session) -> float:
|
|
items = db.query(OrderItem).filter(
|
|
OrderItem.paid_in_shift_id == shift_id,
|
|
OrderItem.status == "paid",
|
|
).all()
|
|
return round(sum(i.unit_price * i.quantity for i in items), 2)
|
|
|
|
|
|
def _enrich_shift(shift: WaiterShift, db: Session) -> dict:
|
|
w = shift.waiter
|
|
wname = (w.full_name or w.username) if w else f"#{shift.waiter_id}"
|
|
total = compute_shift_total(shift.id, db) if shift.ended_at is None else (shift.total_collected or 0.0)
|
|
return {
|
|
"id": shift.id,
|
|
"waiter_id": shift.waiter_id,
|
|
"waiter_name": wname,
|
|
"business_day_id": shift.business_day_id,
|
|
"started_at": _dt(shift.started_at),
|
|
"ended_at": _dt(shift.ended_at),
|
|
"starting_cash": shift.starting_cash,
|
|
"total_collected": total,
|
|
"net_to_deliver": round(total + (shift.starting_cash or 0.0), 2),
|
|
"is_active": shift.ended_at is None,
|
|
"notes": shift.notes,
|
|
"breaks": [
|
|
{"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
|
|
for b in shift.breaks
|
|
],
|
|
}
|
|
|
|
|
|
@router.get("/my")
|
|
def my_shift(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
|
shift = db.query(WaiterShift).filter(
|
|
WaiterShift.waiter_id == user.id,
|
|
WaiterShift.ended_at == None,
|
|
).first()
|
|
return _enrich_shift(shift, db) if shift else None
|
|
|
|
|
|
@router.post("/start", status_code=status.HTTP_201_CREATED)
|
|
def start_shift(
|
|
body: StartShiftRequest,
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
target_id = body.waiter_id
|
|
|
|
if target_id and target_id != user.id:
|
|
if user.role not in ("manager", "sysadmin"):
|
|
raise HTTPException(status_code=403, detail="Only managers can start shifts for other waiters")
|
|
target = db.query(User).filter(User.id == target_id, User.is_active == True).first()
|
|
if not target:
|
|
raise HTTPException(status_code=404, detail="Waiter not found")
|
|
else:
|
|
target_id = user.id
|
|
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_start") != "true":
|
|
raise HTTPException(status_code=403, detail="Shift start requires manager confirmation")
|
|
|
|
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
|
if not active_day:
|
|
raise HTTPException(status_code=400, detail="No open business day — manager must open the restaurant first")
|
|
|
|
existing = db.query(WaiterShift).filter(
|
|
WaiterShift.waiter_id == target_id,
|
|
WaiterShift.ended_at == None,
|
|
).first()
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
|
|
|
|
shift = WaiterShift(
|
|
waiter_id=target_id,
|
|
business_day_id=active_day.id,
|
|
starting_cash=body.starting_cash,
|
|
)
|
|
db.add(shift)
|
|
db.commit()
|
|
db.refresh(shift)
|
|
return _enrich_shift(shift, db)
|
|
|
|
|
|
@router.post("/end")
|
|
def end_shift(
|
|
body: EndShiftRequest,
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
if user.role == "waiter" and _get_setting(db, "shifts.waiter_self_end") != "true":
|
|
raise HTTPException(status_code=403, detail="Shift end requires manager confirmation")
|
|
|
|
shift = db.query(WaiterShift).filter(
|
|
WaiterShift.waiter_id == user.id,
|
|
WaiterShift.ended_at == None,
|
|
).first()
|
|
if not shift:
|
|
raise HTTPException(status_code=404, detail="No active shift found")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
shift.total_collected = compute_shift_total(shift.id, db)
|
|
shift.ended_at = now
|
|
if body.notes:
|
|
shift.notes = body.notes
|
|
|
|
open_break = db.query(ShiftBreak).filter(
|
|
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
|
).first()
|
|
if open_break:
|
|
open_break.ended_at = now
|
|
|
|
db.commit()
|
|
db.refresh(shift)
|
|
return _enrich_shift(shift, db)
|
|
|
|
|
|
@router.post("/manager/start", status_code=status.HTTP_201_CREATED)
|
|
def manager_start_shift(
|
|
body: StartShiftRequest,
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(require_manager),
|
|
):
|
|
if not body.waiter_id:
|
|
raise HTTPException(status_code=400, detail="waiter_id is required")
|
|
|
|
target = db.query(User).filter(User.id == body.waiter_id, User.is_active == True).first()
|
|
if not target:
|
|
raise HTTPException(status_code=404, detail="Waiter not found")
|
|
|
|
active_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
|
|
if not active_day:
|
|
raise HTTPException(status_code=400, detail="No open business day")
|
|
|
|
existing = db.query(WaiterShift).filter(
|
|
WaiterShift.waiter_id == body.waiter_id,
|
|
WaiterShift.ended_at == None,
|
|
).first()
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="Waiter already has an active shift")
|
|
|
|
shift = WaiterShift(
|
|
waiter_id=body.waiter_id,
|
|
business_day_id=active_day.id,
|
|
starting_cash=body.starting_cash,
|
|
)
|
|
db.add(shift)
|
|
db.commit()
|
|
db.refresh(shift)
|
|
return _enrich_shift(shift, db)
|
|
|
|
|
|
@router.post("/manager/end/{shift_id}")
|
|
def manager_end_shift(
|
|
shift_id: int,
|
|
body: EndShiftRequest,
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(require_manager),
|
|
):
|
|
shift = db.query(WaiterShift).filter(
|
|
WaiterShift.id == shift_id,
|
|
WaiterShift.ended_at == None,
|
|
).first()
|
|
if not shift:
|
|
raise HTTPException(status_code=404, detail="Active shift not found")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
shift.total_collected = compute_shift_total(shift.id, db)
|
|
shift.ended_at = now
|
|
if body.notes:
|
|
shift.notes = body.notes
|
|
|
|
open_break = db.query(ShiftBreak).filter(
|
|
ShiftBreak.shift_id == shift.id, ShiftBreak.ended_at == None
|
|
).first()
|
|
if open_break:
|
|
open_break.ended_at = now
|
|
|
|
db.commit()
|
|
db.refresh(shift)
|
|
return _enrich_shift(shift, db)
|
|
|
|
|
|
@router.post("/{shift_id}/break/start")
|
|
def start_break(
|
|
shift_id: int,
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
|
if not shift:
|
|
raise HTTPException(status_code=404, detail="Shift not found")
|
|
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
|
|
raise HTTPException(status_code=403, detail="Access denied")
|
|
if shift.ended_at:
|
|
raise HTTPException(status_code=400, detail="Shift already ended")
|
|
|
|
open_break = db.query(ShiftBreak).filter(
|
|
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
|
|
).first()
|
|
if open_break:
|
|
raise HTTPException(status_code=400, detail="Break already in progress")
|
|
|
|
b = ShiftBreak(shift_id=shift_id)
|
|
db.add(b)
|
|
db.commit()
|
|
db.refresh(b)
|
|
return {"id": b.id, "shift_id": b.shift_id, "started_at": _dt(b.started_at), "ended_at": _dt(b.ended_at)}
|
|
|
|
|
|
@router.post("/{shift_id}/break/end")
|
|
def end_break(
|
|
shift_id: int,
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
|
if not shift:
|
|
raise HTTPException(status_code=404, detail="Shift not found")
|
|
if shift.waiter_id != user.id and user.role not in ("manager", "sysadmin"):
|
|
raise HTTPException(status_code=403, detail="Access denied")
|
|
|
|
open_break = db.query(ShiftBreak).filter(
|
|
ShiftBreak.shift_id == shift_id, ShiftBreak.ended_at == None
|
|
).first()
|
|
if not open_break:
|
|
raise HTTPException(status_code=404, detail="No active break found")
|
|
|
|
open_break.ended_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
db.refresh(open_break)
|
|
return {"id": open_break.id, "shift_id": open_break.shift_id, "started_at": _dt(open_break.started_at), "ended_at": _dt(open_break.ended_at)}
|
|
|
|
|
|
@router.get("/")
|
|
def list_shifts(
|
|
waiter_id: Optional[int] = None,
|
|
business_day_id: Optional[int] = None,
|
|
active_only: bool = False,
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(require_manager),
|
|
):
|
|
q = db.query(WaiterShift)
|
|
if waiter_id:
|
|
q = q.filter(WaiterShift.waiter_id == waiter_id)
|
|
if business_day_id:
|
|
q = q.filter(WaiterShift.business_day_id == business_day_id)
|
|
if active_only:
|
|
q = q.filter(WaiterShift.ended_at == None)
|
|
shifts = q.order_by(WaiterShift.started_at.desc()).all()
|
|
return {"shifts": [_enrich_shift(s, db) for s in shifts]}
|
|
|
|
|
|
@router.get("/{shift_id}")
|
|
def get_shift(
|
|
shift_id: int,
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(require_manager),
|
|
):
|
|
shift = db.query(WaiterShift).filter(WaiterShift.id == shift_id).first()
|
|
if not shift:
|
|
raise HTTPException(status_code=404, detail="Shift not found")
|
|
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 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")
|
|
|
|
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(
|
|
OrderItem.paid_in_shift_id == shift_id,
|
|
OrderItem.status == "paid",
|
|
).all()
|
|
|
|
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:
|
|
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}"
|
|
|
|
# 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_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,
|
|
})
|
|
|
|
# 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 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"] = populated_orders
|
|
enriched["duration_minutes"] = duration_minutes
|
|
return enriched
|