diff --git a/local_backend/main.py b/local_backend/main.py index 533c26b..6cea8d3 100644 --- a/local_backend/main.py +++ b/local_backend/main.py @@ -25,6 +25,7 @@ import models.expenses # noqa: F401 — registers Contact, Expense, Expens import models.customers # noqa: F401 — registers Customer import models.tabs # noqa: F401 — registers Tab, TabEntry, TabPayment import models.waste # noqa: F401 — registers WasteLog +import models.schedule # noqa: F401 — registers ScheduledShift from routers import auth, tables, products, orders, waiters, reports, system, setup as setup_router from routers import business_day as business_day_router @@ -42,6 +43,7 @@ from routers import customers as customers_router from routers import tabs as tabs_router from routers import waste as waste_router from routers import kds as kds_router +from routers import schedule as schedule_router def _run_migrations(): @@ -280,6 +282,17 @@ def _run_migrations(): # Phase 2B — staff payroll "ALTER TABLE users ADD COLUMN hourly_rate REAL", "ALTER TABLE waiter_shifts ADD COLUMN hourly_rate_snapshot REAL", + # Phase 2J — staff shift scheduling + """CREATE TABLE IF NOT EXISTS scheduled_shifts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + scheduled_date DATE NOT NULL, + start_time VARCHAR NOT NULL, + end_time VARCHAR NOT NULL, + notes TEXT, + created_by_id INTEGER NOT NULL REFERENCES users(id), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )""", # Phase 2H — void/waste log """CREATE TABLE IF NOT EXISTS waste_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -462,3 +475,4 @@ app.include_router(customers_router.router, prefix="/api/customers", tag app.include_router(tabs_router.router, prefix="/api/tabs", tags=["tabs"]) app.include_router(waste_router.router, prefix="/api/waste", tags=["waste"]) app.include_router(kds_router.router, prefix="/api/kds", tags=["kds"]) +app.include_router(schedule_router.router, prefix="/api/schedule", tags=["schedule"]) diff --git a/local_backend/models/schedule.py b/local_backend/models/schedule.py new file mode 100644 index 0000000..f3ce22b --- /dev/null +++ b/local_backend/models/schedule.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, Integer, String, Date, Time, Text, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from database import Base + + +def _utcnow(): + return datetime.now(timezone.utc) + + +class ScheduledShift(Base): + __tablename__ = "scheduled_shifts" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + scheduled_date = Column(Date, nullable=False) + start_time = Column(String, nullable=False) # "HH:MM" stored as string (SQLite has no Time type) + end_time = Column(String, nullable=False) + notes = Column(Text, nullable=True) + created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), default=_utcnow) + + user = relationship("User", foreign_keys=[user_id]) + created_by = relationship("User", foreign_keys=[created_by_id]) diff --git a/local_backend/routers/schedule.py b/local_backend/routers/schedule.py new file mode 100644 index 0000000..6a991f3 --- /dev/null +++ b/local_backend/routers/schedule.py @@ -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) + ], + } diff --git a/local_backend/schemas/schedule.py b/local_backend/schemas/schedule.py new file mode 100644 index 0000000..4571e2e --- /dev/null +++ b/local_backend/schemas/schedule.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import date, datetime + + +class ScheduledShiftCreate(BaseModel): + user_id: int + scheduled_date: date + start_time: str # "HH:MM" + end_time: str # "HH:MM" + notes: Optional[str] = None + + +class ScheduledShiftUpdate(BaseModel): + start_time: Optional[str] = None + end_time: Optional[str] = None + notes: Optional[str] = None + + +class ScheduledShiftOut(BaseModel): + id: int + user_id: int + user_name: Optional[str] = None + hourly_rate: Optional[float] = None + scheduled_date: date + start_time: str + end_time: str + notes: Optional[str] = None + created_by_id: int + created_at: datetime + # computed + duration_hours: Optional[float] = None + estimated_pay: Optional[float] = None + + model_config = {"from_attributes": True} diff --git a/manager_dashboard/src/App.jsx b/manager_dashboard/src/App.jsx index f1fb887..5ba6615 100644 --- a/manager_dashboard/src/App.jsx +++ b/manager_dashboard/src/App.jsx @@ -18,6 +18,7 @@ import CustomersPage from './pages/CustomersPage' import TabsPage from './pages/TabsPage' import WastePage from './pages/WastePage' import KdsPage from './pages/KdsPage' +import SchedulePage from './pages/SchedulePage' import client from './api/client' function Spinner() { @@ -95,6 +96,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/manager_dashboard/src/components/Sidebar.jsx b/manager_dashboard/src/components/Sidebar.jsx index fda63cc..4096429 100644 --- a/manager_dashboard/src/components/Sidebar.jsx +++ b/manager_dashboard/src/components/Sidebar.jsx @@ -1,6 +1,6 @@ import { NavLink } from 'react-router-dom' import { useState, useEffect, useRef } from 'react' -import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser, Users, CreditCard, Trash2, ChefHat } from 'lucide-react' +import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser, Users, CreditCard, Trash2, ChefHat, CalendarDays } from 'lucide-react' import { getIncomingOrders } from '../api/client' export default function Sidebar() { @@ -33,6 +33,7 @@ export default function Sidebar() { { to: '/tabs', icon: CreditCard, label: 'Καρτέλες' }, { to: '/waste', icon: Trash2, label: 'Αποβλήτα' }, { to: '/kds', icon: ChefHat, label: 'KDS' }, + { to: '/schedule', icon: CalendarDays, label: 'Πρόγραμμα' }, { to: '/settings', icon: Settings, label: 'Ρυθμίσεις' }, ] diff --git a/manager_dashboard/src/pages/SchedulePage.jsx b/manager_dashboard/src/pages/SchedulePage.jsx new file mode 100644 index 0000000..21270c3 --- /dev/null +++ b/manager_dashboard/src/pages/SchedulePage.jsx @@ -0,0 +1,375 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' +import client from '../api/client' + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const DAY_LABELS = ['Δευ', 'Τρί', 'Τετ', 'Πέμ', 'Παρ', 'Σάβ', 'Κυρ'] + +function fmt(n) { + if (n == null) return '—' + return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' +} + +function weekMonday(d) { + const day = new Date(d) + const dow = day.getDay() + const diff = dow === 0 ? -6 : 1 - dow + day.setDate(day.getDate() + diff) + day.setHours(0, 0, 0, 0) + return day +} + +function addDays(d, n) { + const r = new Date(d) + r.setDate(r.getDate() + n) + return r +} + +function toLocalDateStr(d) { + return d.toISOString().slice(0, 10) +} + +function fmtShortDate(iso) { + if (!iso) return '' + const d = new Date(iso + 'T00:00:00') + return d.toLocaleDateString('el-GR', { day: '2-digit', month: '2-digit' }) +} + +function fmtTime(t) { + if (!t) return '—' + return t.slice(0, 5) +} + +function durationLabel(h) { + if (!h) return '' + const hrs = Math.floor(h) + const mins = Math.round((h - hrs) * 60) + if (hrs === 0) return `${mins}λ` + if (mins === 0) return `${hrs}ω` + return `${hrs}ω${mins}λ` +} + +// ── Shift slot in cell ──────────────────────────────────────────────────────── + +function ShiftSlot({ shift, actual, onDelete }) { + const hasActual = actual && actual.length > 0 + const isActive = actual?.some(a => a.is_active) + + return ( +
+ {/* Scheduled row */} +
+ + {fmtTime(shift.start_time)}–{fmtTime(shift.end_time)} + +
+ {shift.estimated_pay != null && ( + {fmt(shift.estimated_pay)} + )} + +
+
+ + {/* Actual row */} + {hasActual ? ( + actual.map(a => ( +
+ ✓ {a.started_at ? new Date(a.started_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' }) : '—'} + {a.ended_at + ? ` – ${new Date(a.ended_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })}` + : isActive ? ' (ενεργή)' : ''} +
+ )) + ) : ( +
+ Δεν ξεκίνησε +
+ )} +
+ ) +} + +// ── Add shift modal ─────────────────────────────────────────────────────────── + +function AddShiftModal({ date, waiters, onClose, onAdd, isPending }) { + const [userId, setUserId] = useState(waiters[0]?.id ?? '') + const [start, setStart] = useState('09:00') + const [end, setEnd] = useState('17:00') + const [notes, setNotes] = useState('') + + const inputStyle = { + width: '100%', padding: '7px 10px', border: '1px solid #e5e7eb', + borderRadius: 7, fontSize: 13, outline: 'none', fontFamily: 'inherit', + } + + const canAdd = userId && start && end + + return ( +
+
+
+
+
Νέα Βάρδια
+
{fmtShortDate(date)}
+
+ +
+
+
+ + +
+
+
+ + setStart(e.target.value)} /> +
+
+ + setEnd(e.target.value)} /> +
+
+
+ + setNotes(e.target.value)} placeholder="π.χ. Μόνο βράδυ" /> +
+
+
+ + +
+
+
+ ) +} + +// ── Main page ───────────────────────────────────────────────────────────────── + +export default function SchedulePage() { + const qc = useQueryClient() + const [weekAnchor, setWeekAnchor] = useState(() => weekMonday(new Date())) + const [addModal, setAddModal] = useState(null) // date string | null + + const weekStartStr = toLocalDateStr(weekAnchor) + const weekEndStr = toLocalDateStr(addDays(weekAnchor, 6)) + + const { data, isLoading } = useQuery({ + queryKey: ['schedule-week', weekStartStr], + queryFn: () => client.get('/api/schedule/week', { params: { week_start: weekStartStr } }).then(r => r.data), + staleTime: 30_000, + }) + + const addShift = useMutation({ + mutationFn: (body) => client.post('/api/schedule/', body), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['schedule-week'] }); setAddModal(null); toast.success('Βάρδια προστέθηκε') }, + onError: () => toast.error('Σφάλμα αποθήκευσης'), + }) + + const deleteShift = useMutation({ + mutationFn: (id) => client.delete(`/api/schedule/${id}`), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['schedule-week'] }); toast.success('Βάρδια διαγράφηκε') }, + onError: () => toast.error('Σφάλμα διαγραφής'), + }) + + const waiters = data?.waiters ?? [] + const scheduled = data?.scheduled ?? [] + const totalPay = data?.total_estimated_pay ?? 0 + + // Build lookup: waiter_id → name + const waiterNames = Object.fromEntries(waiters.map(w => [w.id, w.name])) + + // Build 7 day columns + const days = Array.from({ length: 7 }, (_, i) => { + const d = addDays(weekAnchor, i) + return { date: d, dateStr: toLocalDateStr(d), label: DAY_LABELS[i] } + }) + + // Build per-waiter per-day grid + // { waiter_id: { dateStr: { scheduled: [...], actual: [...] } } } + const grid: Record> = {} + + for (const s of scheduled) { + if (!grid[s.user_id]) grid[s.user_id] = {} + const ds = typeof s.scheduled_date === 'string' ? s.scheduled_date : toLocalDateStr(new Date(s.scheduled_date)) + if (!grid[s.user_id][ds]) grid[s.user_id][ds] = { scheduled: [], actual: [] } + grid[s.user_id][ds].scheduled.push(s) + grid[s.user_id][ds].actual.push(...(s.actual_shifts || [])) + } + + // Unique waiter ids that appear in this week's schedule + const activeWaiterIds = [...new Set(scheduled.map(s => s.user_id))] + + return ( +
+ {/* Header */} +
+
+

Πρόγραμμα

+

+ {fmtShortDate(weekStartStr)} – {fmtShortDate(weekEndStr)} + {totalPay > 0 && Εκτιμώμενο κόστος εβδομάδας: {fmt(totalPay)}} +

+
+
+ + + +
+
+ + {/* Grid */} +
+ {isLoading &&
Φόρτωση…
} + {!isLoading && ( + + + + + {days.map(d => { + const isToday = toLocalDateStr(new Date()) === d.dateStr + return ( + + ) + })} + + + + {/* Rows for scheduled waiters */} + {activeWaiterIds.length === 0 && ( + + + + )} + {activeWaiterIds.map(uid => { + const w = waiters.find(w => w.id === uid) || { id: uid, name: waiterNames[uid] || `#${uid}`, hourly_rate: null } + return ( + + + {days.map(d => { + const cell = grid[uid]?.[d.dateStr] + return ( + + ) + })} + + ) + })} + + {/* Add row for other waiters not yet scheduled */} + + + + +
Σερβιτόρος +
{d.label}
+
+ {fmtShortDate(d.dateStr)} +
+
+ Δεν υπάρχουν προγραμματισμένες βάρδιες αυτή την εβδομάδα.
+ Κάντε κλικ σε ένα κελί για να προσθέσετε. +
+
{w.name}
+ {w.hourly_rate &&
{fmt(w.hourly_rate)}/ώρα
} +
{ if (!cell?.scheduled?.length) setAddModal({ date: d.dateStr, userId: uid }) }} + > +
+ {cell?.scheduled?.map(s => ( + { if (window.confirm('Διαγραφή βάρδιας;')) deleteShift.mutate(id) }} + /> + ))} + {!cell?.scheduled?.length && ( +
e.currentTarget.style.borderColor = '#bfdbfe'} + onMouseLeave={e => e.currentTarget.style.borderColor = 'transparent'} + > + + +
+ )} +
+
+
+ {waiters.filter(w => !activeWaiterIds.includes(w.id)).map(w => ( + + ))} + {waiters.length === 0 && ( + Δεν υπάρχουν ενεργοί σερβιτόροι. + )} +
+
+ )} +
+ + {/* Legend */} +
+ {[ + { color: '#bfdbfe', bg: '#eff6ff', label: 'Προγραμματισμένη (δεν ξεκίνησε)' }, + { color: '#86efac', bg: '#f0fdf4', label: 'Προγραμματισμένη + Πραγματική' }, + ].map(l => ( +
+
+ {l.label} +
+ ))} +
+ + {addModal && ( + w.id === addModal.userId).concat(waiters.filter(w => w.id !== addModal.userId)) + : waiters} + onClose={() => setAddModal(null)} + isPending={addShift.isPending} + onAdd={(body) => addShift.mutate(body)} + /> + )} +
+ ) +}