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:
@@ -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"])
|
||||
|
||||
24
local_backend/models/schedule.py
Normal file
24
local_backend/models/schedule.py
Normal file
@@ -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])
|
||||
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)
|
||||
],
|
||||
}
|
||||
35
local_backend/schemas/schedule.py
Normal file
35
local_backend/schemas/schedule.py
Normal file
@@ -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}
|
||||
@@ -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() {
|
||||
<Route path="tabs" element={<TabsPage />} />
|
||||
<Route path="waste" element={<WastePage />} />
|
||||
<Route path="kds" element={<KdsPage />} />
|
||||
<Route path="schedule" element={<SchedulePage />} />
|
||||
<Route path="online-orders" element={<OnlineOrdersPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
|
||||
@@ -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: 'Ρυθμίσεις' },
|
||||
]
|
||||
|
||||
|
||||
375
manager_dashboard/src/pages/SchedulePage.jsx
Normal file
375
manager_dashboard/src/pages/SchedulePage.jsx
Normal file
@@ -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 (
|
||||
<div style={{
|
||||
borderRadius: 7, overflow: 'hidden',
|
||||
border: `1.5px solid ${hasActual ? '#86efac' : '#bfdbfe'}`,
|
||||
background: hasActual ? '#f0fdf4' : '#eff6ff',
|
||||
fontSize: 11.5,
|
||||
}}>
|
||||
{/* Scheduled row */}
|
||||
<div style={{ padding: '5px 8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 4 }}>
|
||||
<span style={{ fontWeight: 700, color: '#1d4ed8', whiteSpace: 'nowrap' }}>
|
||||
{fmtTime(shift.start_time)}–{fmtTime(shift.end_time)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{shift.estimated_pay != null && (
|
||||
<span style={{ color: '#6b7280', fontSize: 10.5 }}>{fmt(shift.estimated_pay)}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(shift.id)}
|
||||
style={{ fontSize: 10, color: '#9ca3af', background: 'none', border: 'none', cursor: 'pointer', padding: '0 2px', lineHeight: 1 }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actual row */}
|
||||
{hasActual ? (
|
||||
actual.map(a => (
|
||||
<div key={a.id} style={{
|
||||
padding: '3px 8px', borderTop: '1px solid #bbf7d0',
|
||||
fontSize: 10.5, color: '#15803d', fontWeight: 600,
|
||||
}}>
|
||||
✓ {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 ? ' (ενεργή)' : ''}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ padding: '3px 8px', borderTop: '1px solid #bfdbfe', fontSize: 10.5, color: '#94a3b8' }}>
|
||||
Δεν ξεκίνησε
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 50, padding: 20 }}>
|
||||
<div style={{ background: 'white', borderRadius: 14, width: '100%', maxWidth: 420, boxShadow: '0 20px 60px rgba(0,0,0,0.18)' }}>
|
||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700 }}>Νέα Βάρδια</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 1 }}>{fmtShortDate(date)}</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}>✕</button>
|
||||
</div>
|
||||
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Σερβιτόρος</label>
|
||||
<select style={inputStyle} value={userId} onChange={e => setUserId(e.target.value)}>
|
||||
{waiters.map(w => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Έναρξη</label>
|
||||
<input type="time" style={inputStyle} value={start} onChange={e => setStart(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Λήξη</label>
|
||||
<input type="time" style={inputStyle} value={end} onChange={e => setEnd(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151', display: 'block', marginBottom: 4 }}>Σημείωση <span style={{ fontWeight: 400, color: '#9ca3af' }}>(προαιρετική)</span></label>
|
||||
<input style={inputStyle} value={notes} onChange={e => setNotes(e.target.value)} placeholder="π.χ. Μόνο βράδυ" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={onClose} style={{ padding: '7px 16px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer' }}>Ακύρωση</button>
|
||||
<button
|
||||
onClick={() => onAdd({ user_id: Number(userId), scheduled_date: date, start_time: start, end_time: end, notes: notes || null })}
|
||||
disabled={!canAdd || isPending}
|
||||
style={{ padding: '7px 18px', border: 'none', borderRadius: 7, fontSize: 13, fontWeight: 600, cursor: canAdd ? 'pointer' : 'default', background: canAdd ? '#111827' : '#e5e7eb', color: canAdd ? 'white' : '#9ca3af' }}
|
||||
>
|
||||
{isPending ? 'Αποθήκευση…' : 'Προσθήκη'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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<number, Record<string, { scheduled: any[], actual: any[] }>> = {}
|
||||
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '14px 24px 12px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Πρόγραμμα</h1>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||
{fmtShortDate(weekStartStr)} – {fmtShortDate(weekEndStr)}
|
||||
{totalPay > 0 && <span style={{ marginLeft: 10, color: '#6b7280' }}>Εκτιμώμενο κόστος εβδομάδας: <strong style={{ color: '#111315' }}>{fmt(totalPay)}</strong></span>}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => setWeekAnchor(w => addDays(w, -7))}
|
||||
style={{ padding: '6px 14px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer' }}>← Προηγ.</button>
|
||||
<button onClick={() => setWeekAnchor(weekMonday(new Date()))}
|
||||
style={{ padding: '6px 14px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer' }}>Αυτή η εβδομάδα</button>
|
||||
<button onClick={() => setWeekAnchor(w => addDays(w, 7))}
|
||||
style={{ padding: '6px 14px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer' }}>Επόμ. →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div style={{ flex: 1, overflowX: 'auto', overflowY: 'auto', padding: '12px 24px 16px' }}>
|
||||
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '48px 0' }}>Φόρτωση…</div>}
|
||||
{!isLoading && (
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%', minWidth: 700 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ padding: '8px 12px', textAlign: 'left', fontSize: 12, fontWeight: 700, color: '#9ca3af', width: 140 }}>Σερβιτόρος</th>
|
||||
{days.map(d => {
|
||||
const isToday = toLocalDateStr(new Date()) === d.dateStr
|
||||
return (
|
||||
<th key={d.dateStr} style={{
|
||||
padding: '8px 4px', textAlign: 'center', fontSize: 12,
|
||||
fontWeight: isToday ? 800 : 600,
|
||||
color: isToday ? '#3b82f6' : '#374151',
|
||||
minWidth: 110,
|
||||
}}>
|
||||
<div>{d.label}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: isToday ? '#3b82f6' : '#9ca3af' }}>
|
||||
{fmtShortDate(d.dateStr)}
|
||||
</div>
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Rows for scheduled waiters */}
|
||||
{activeWaiterIds.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ textAlign: 'center', color: '#d1d5db', fontSize: 13, padding: '40px 0' }}>
|
||||
Δεν υπάρχουν προγραμματισμένες βάρδιες αυτή την εβδομάδα.<br />
|
||||
Κάντε κλικ σε ένα κελί για να προσθέσετε.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{activeWaiterIds.map(uid => {
|
||||
const w = waiters.find(w => w.id === uid) || { id: uid, name: waiterNames[uid] || `#${uid}`, hourly_rate: null }
|
||||
return (
|
||||
<tr key={uid} style={{ borderTop: '1px solid #f3f4f6' }}>
|
||||
<td style={{ padding: '8px 12px', verticalAlign: 'top' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#111315' }}>{w.name}</div>
|
||||
{w.hourly_rate && <div style={{ fontSize: 11, color: '#9ca3af' }}>{fmt(w.hourly_rate)}/ώρα</div>}
|
||||
</td>
|
||||
{days.map(d => {
|
||||
const cell = grid[uid]?.[d.dateStr]
|
||||
return (
|
||||
<td key={d.dateStr} style={{ padding: '4px', verticalAlign: 'top', position: 'relative' }}
|
||||
onClick={() => { if (!cell?.scheduled?.length) setAddModal({ date: d.dateStr, userId: uid }) }}
|
||||
>
|
||||
<div style={{ minHeight: 48, cursor: cell?.scheduled?.length ? 'default' : 'pointer' }}>
|
||||
{cell?.scheduled?.map(s => (
|
||||
<ShiftSlot
|
||||
key={s.id} shift={s}
|
||||
actual={cell.actual}
|
||||
onDelete={id => { if (window.confirm('Διαγραφή βάρδιας;')) deleteShift.mutate(id) }}
|
||||
/>
|
||||
))}
|
||||
{!cell?.scheduled?.length && (
|
||||
<div style={{
|
||||
height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#e5e7eb', fontSize: 18, borderRadius: 7,
|
||||
border: '1.5px dashed transparent',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = '#bfdbfe'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'transparent'}
|
||||
>
|
||||
+
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Add row for other waiters not yet scheduled */}
|
||||
<tr style={{ borderTop: '1px solid #f3f4f6' }}>
|
||||
<td colSpan={8} style={{ padding: '8px 12px' }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{waiters.filter(w => !activeWaiterIds.includes(w.id)).map(w => (
|
||||
<button key={w.id}
|
||||
onClick={() => setAddModal({ date: weekStartStr, userId: w.id })}
|
||||
style={{
|
||||
padding: '4px 12px', fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||
border: '1px dashed #d1d5db', borderRadius: 20, background: 'white', color: '#6b7280',
|
||||
}}>
|
||||
+ {w.name}
|
||||
</button>
|
||||
))}
|
||||
{waiters.length === 0 && (
|
||||
<span style={{ fontSize: 12, color: '#d1d5db' }}>Δεν υπάρχουν ενεργοί σερβιτόροι.</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ padding: '8px 24px', borderTop: '1px solid #f0f0ef', background: '#fafafa', display: 'flex', gap: 16, flexShrink: 0 }}>
|
||||
{[
|
||||
{ color: '#bfdbfe', bg: '#eff6ff', label: 'Προγραμματισμένη (δεν ξεκίνησε)' },
|
||||
{ color: '#86efac', bg: '#f0fdf4', label: 'Προγραμματισμένη + Πραγματική' },
|
||||
].map(l => (
|
||||
<div key={l.label} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: '#6b7280' }}>
|
||||
<div style={{ width: 14, height: 10, borderRadius: 3, background: l.bg, border: `1.5px solid ${l.color}` }} />
|
||||
{l.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{addModal && (
|
||||
<AddShiftModal
|
||||
date={addModal.date}
|
||||
waiters={addModal.userId
|
||||
? waiters.filter(w => w.id === addModal.userId).concat(waiters.filter(w => w.id !== addModal.userId))
|
||||
: waiters}
|
||||
onClose={() => setAddModal(null)}
|
||||
isPending={addShift.isPending}
|
||||
onAdd={(body) => addShift.mutate(body)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user