Files
bonamin 06dccb981e feat: Phase 2J — staff shift scheduling
Backend:
- New model: ScheduledShift (scheduled_shifts table)
  Planning data separate from waiter_shifts (actual data) — linked conceptually by user+date
  start_time/end_time stored as "HH:MM" strings (SQLite has no native Time type)
- New router /api/schedule/:
  - GET / — list by date range + optional user_id filter
  - POST / — create scheduled shift (validates user exists)
  - PUT /{id} — update times/notes
  - DELETE /{id} — remove
  - GET /week?week_start= — full week view: scheduled shifts with actual WaiterShift
    comparison for the same user+date, estimated weekly labor cost, waiters list with rates
- Each ScheduledShiftOut includes duration_hours (handles overnight) + estimated_pay
  (duration × hourly_rate, null if rate not set)
- Migration: CREATE TABLE IF NOT EXISTS scheduled_shifts

Frontend:
- SchedulePage (/schedule): weekly calendar grid — rows = staff, columns = Mon–Sun
  - Week navigation (← Προηγ. / Αυτή η εβδομάδα / Επόμ. →)
  - Estimated weekly labor cost in header when shifts have rates
  - ShiftSlot cells: blue = scheduled only, green = scheduled + actual shift happened
    Shows scheduled times + actual start/end from real WaiterShift data
  - Click empty cell → AddShiftModal (pre-selects that row's waiter, any date in week)
  - "+" row at bottom for waiters not yet scheduled this week
  - Delete button (✕) per slot with confirm
  - Legend explaining the color scheme
- Sidebar: CalendarDays icon for Πρόγραμμα (after KDS)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 03:33:45 +03:00

188 lines
6.5 KiB
Python

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)
],
}