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:
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