from datetime import date, datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from typing import List, Optional from database import get_db from models.schedule import ScheduledShift from models.shift import WaiterShift from models.user import User from schemas.schedule import ScheduledShiftCreate, ScheduledShiftUpdate, ScheduledShiftOut from routers.deps import require_manager router = APIRouter() def _duration_hours(start: str, end: str) -> float: """Compute hours between two HH:MM strings. Handles overnight spans.""" sh, sm = int(start[:2]), int(start[3:5]) eh, em = int(end[:2]), int(end[3:5]) mins = (eh * 60 + em) - (sh * 60 + sm) if mins <= 0: mins += 24 * 60 # overnight return round(mins / 60, 2) def _enrich(s: ScheduledShift) -> dict: u = s.user user_name = (u.full_name or u.username) if u else f"#{s.user_id}" hourly_rate = u.hourly_rate if u else None dur = _duration_hours(s.start_time, s.end_time) estimated_pay = round(dur * hourly_rate, 2) if hourly_rate else None return { "id": s.id, "user_id": s.user_id, "user_name": user_name, "hourly_rate": hourly_rate, "scheduled_date": s.scheduled_date, "start_time": s.start_time, "end_time": s.end_time, "notes": s.notes, "created_by_id": s.created_by_id, "created_at": s.created_at, "duration_hours": dur, "estimated_pay": estimated_pay, } @router.get("/", response_model=List[ScheduledShiftOut]) def list_schedule( from_date: Optional[date] = Query(default=None, alias="from"), to_date: Optional[date] = Query(default=None, alias="to"), user_id: Optional[int] = None, db: Session = Depends(get_db), user: User = Depends(require_manager), ): q = db.query(ScheduledShift) if from_date: q = q.filter(ScheduledShift.scheduled_date >= from_date) if to_date: q = q.filter(ScheduledShift.scheduled_date <= to_date) if user_id: q = q.filter(ScheduledShift.user_id == user_id) shifts = q.order_by(ScheduledShift.scheduled_date, ScheduledShift.start_time).all() return [_enrich(s) for s in shifts] @router.post("/", response_model=ScheduledShiftOut, status_code=status.HTTP_201_CREATED) def create_scheduled_shift( body: ScheduledShiftCreate, db: Session = Depends(get_db), user: User = Depends(require_manager), ): target = db.query(User).filter(User.id == body.user_id, User.is_active == True).first() if not target: raise HTTPException(status_code=404, detail="User not found") s = ScheduledShift( user_id=body.user_id, scheduled_date=body.scheduled_date, start_time=body.start_time, end_time=body.end_time, notes=body.notes, created_by_id=user.id, ) db.add(s) db.commit() db.refresh(s) return _enrich(s) @router.put("/{shift_id}", response_model=ScheduledShiftOut) def update_scheduled_shift( shift_id: int, body: ScheduledShiftUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager), ): s = db.query(ScheduledShift).filter(ScheduledShift.id == shift_id).first() if not s: raise HTTPException(status_code=404, detail="Scheduled shift not found") for field, value in body.model_dump(exclude_none=True).items(): setattr(s, field, value) db.commit() db.refresh(s) return _enrich(s) @router.delete("/{shift_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_scheduled_shift( shift_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager), ): s = db.query(ScheduledShift).filter(ScheduledShift.id == shift_id).first() if not s: raise HTTPException(status_code=404, detail="Scheduled shift not found") db.delete(s) db.commit() @router.get("/week") def week_schedule( week_start: Optional[date] = Query(default=None), db: Session = Depends(get_db), user: User = Depends(require_manager), ): """Current (or specified) week: scheduled shifts + actual WaiterShifts for comparison.""" if week_start is None: today = date.today() week_start = today - timedelta(days=today.weekday()) # Monday week_end = week_start + timedelta(days=6) # Sunday scheduled = db.query(ScheduledShift).filter( ScheduledShift.scheduled_date >= week_start, ScheduledShift.scheduled_date <= week_end, ).order_by(ScheduledShift.scheduled_date, ScheduledShift.start_time).all() # Actual shifts that started within the week week_start_dt = datetime.combine(week_start, datetime.min.time()).replace(tzinfo=timezone.utc) week_end_dt = datetime.combine(week_end, datetime.max.time()).replace(tzinfo=timezone.utc) actuals = db.query(WaiterShift).filter( WaiterShift.started_at >= week_start_dt, WaiterShift.started_at <= week_end_dt, ).all() waiters = {u.id: u for u in db.query(User).filter(User.role == "waiter", User.is_active == True).all()} def _dt(dt): if not dt: return None return (dt.isoformat() + "Z") if dt.tzinfo is None else dt.isoformat() # Build actual shifts lookup: (user_id, date_str) → list of actual shifts actuals_map: dict = {} for a in actuals: d = a.started_at.date() if a.started_at else None if d: key = (a.waiter_id, d.isoformat()) if key not in actuals_map: actuals_map[key] = [] actuals_map[key].append({ "id": a.id, "started_at": _dt(a.started_at), "ended_at": _dt(a.ended_at), "is_active": a.ended_at is None, }) # Estimate total weekly labor cost total_estimated_pay = 0.0 scheduled_out = [] for s in scheduled: enriched = _enrich(s) key = (s.user_id, s.scheduled_date.isoformat()) enriched["actual_shifts"] = actuals_map.get(key, []) scheduled_out.append(enriched) if enriched["estimated_pay"]: total_estimated_pay += enriched["estimated_pay"] return { "week_start": week_start.isoformat(), "week_end": week_end.isoformat(), "scheduled": scheduled_out, "total_estimated_pay": round(total_estimated_pay, 2), "waiters": [ {"id": u.id, "name": u.full_name or u.username, "hourly_rate": u.hourly_rate} for u in sorted(waiters.values(), key=lambda x: x.full_name or x.username) ], }