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>
This commit is contained in:
187
local_backend/routers/schedule.py
Normal file
187
local_backend/routers/schedule.py
Normal file
@@ -0,0 +1,187 @@
|
||||
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)
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user