import { useState, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import toast from 'react-hot-toast' import client from '../api/client' // ── Constants ───────────────────────────────────────────────────────────────── const SCHED_STORAGE_KEY = 'xenia_schedule_settings_v1' const SCHED_DEFAULTS = { viewMode: 'bywaiter', firstDayMonday: true, firstHour: 6, autoStartWorkday: false, autoCloseWorkday: false, autoCloseShiftsOnWorkday: false, autoStartStaffShift: false, autoCloseStaffShift: false, workdayStart: '08:00', workdayEnd: '23:00', } function loadSchedSettings() { try { return { ...SCHED_DEFAULTS, ...JSON.parse(localStorage.getItem(SCHED_STORAGE_KEY) || '{}') } } catch { return { ...SCHED_DEFAULTS } } } function saveSchedSettings(s) { localStorage.setItem(SCHED_STORAGE_KEY, JSON.stringify(s)) } // ── Helpers ─────────────────────────────────────────────────────────────────── // Index 0=Sun, 1=Mon … 6=Sat (matches Date.getDay()) const DAY_LABELS_BY_DOW = ['Κυρ', 'Δευ', 'Τρί', 'Τετ', 'Πέμ', 'Παρ', 'Σάβ'] function fmt(n) { if (n == null) return '—' return n.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' } function weekAnchorFor(d, firstDayMonday) { const day = new Date(d) const dow = day.getDay() // 0=Sun if (firstDayMonday) { const diff = dow === 0 ? -6 : 1 - dow day.setDate(day.getDate() + diff) } else { day.setDate(day.getDate() - dow) } 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) { const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0') return `${y}-${m}-${day}` } 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 timeToMinutes(t) { if (!t) return 0 const [h, m] = t.split(':').map(Number) return h * 60 + m } function getInitials(name) { if (!name) return '?' return name.split(' ').map(p => p[0]).join('').toUpperCase().slice(0, 2) } function timesOverlap(s1, e1, s2, e2) { const a = timeToMinutes(s1), b = timeToMinutes(e1) const c = timeToMinutes(s2), d = timeToMinutes(e2) const bAdj = b <= a ? b + 1440 : b const dAdj = d <= c ? d + 1440 : d return a < dAdj && c < bAdj } // ── TripleSwitch ────────────────────────────────────────────────────────────── function TripleSwitch({ value, onChange, options }) { return (
{options.map(opt => ( ))}
) } // ── ShiftModal (add + edit) ─────────────────────────────────────────────────── function ShiftModal({ date, waiters, existingShifts, editShift, onClose, onSave, isPending }) { const [userId, setUserId] = useState(editShift?.user_id ?? (waiters[0]?.id ?? '')) const [start, setStart] = useState(editShift?.start_time ?? '09:00') const [end, setEnd] = useState(editShift?.end_time ?? '17:00') const [notes, setNotes] = useState(editShift?.notes ?? '') const isEdit = !!editShift const conflict = useMemo(() => { if (!userId || !start || !end) return null const uid = Number(userId) const others = (existingShifts || []).filter(s => { const ds = typeof s.scheduled_date === 'string' ? s.scheduled_date : toLocalDateStr(new Date(s.scheduled_date)) return s.user_id === uid && ds === date && (!isEdit || s.id !== editShift.id) }) return others.find(s => timesOverlap(start, end, s.start_time, s.end_time)) || null }, [userId, start, end, date, existingShifts, isEdit, editShift]) const canSave = userId && start && end && !conflict const inputStyle = { width: '100%', padding: '7px 10px', border: '1px solid #e5e7eb', borderRadius: 7, fontSize: 13, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', } return (
{isEdit ? 'Επεξεργασία Βάρδιας' : 'Νέα Βάρδια'}
{fmtShortDate(date)}
setStart(e.target.value)} />
setEnd(e.target.value)} />
{conflict && (
Επικάλυψη με βάρδια {fmtTime(conflict.start_time)}–{fmtTime(conflict.end_time)}
)}
setNotes(e.target.value)} placeholder="π.χ. Μόνο βράδυ" />
) } // ── Schedule Settings Modal ─────────────────────────────────────────────────── function ToggleRow({ label, description, checked, onChange, indent }) { return (
{label}
{description &&
{description}
}
) } function ScheduleSettingsModal({ settings, onChange, onClose }) { const [s, setS] = useState({ ...settings }) function set(key, val) { setS(prev => ({ ...prev, [key]: val })) } const inputStyle = { padding: '5px 10px', border: '1px solid #e5e7eb', borderRadius: 7, fontSize: 13, fontFamily: 'inherit', outline: 'none' } const sectionLabel = (t) => (
{t}
) return (
Ρυθμίσεις Προγράμματος
{sectionLabel('Εμφάνιση')}
Πρώτη μέρα εβδομάδας
{[{ v: true, l: 'Δευ' }, { v: false, l: 'Κυρ' }].map(opt => ( ))}
Πρώτη ώρα ημέρας
Αρχή ωρολογιακής προβολής (Ωριαία / Εργάσιμη)
Ώρες λειτουργίας
Για προβολή Εργάσιμης Ημέρας
set('workdayStart', e.target.value)} style={{ ...inputStyle, width: 90 }} /> set('workdayEnd', e.target.value)} style={{ ...inputStyle, width: 90 }} />
{sectionLabel('Αυτόματο Πρόγραμμα Καταστήματος')} set('autoStartWorkday', v)} /> set('autoCloseWorkday', v)} /> {s.autoCloseWorkday && ( set('autoCloseShiftsOnWorkday', v)} indent /> )} set('autoStartStaffShift', v)} /> set('autoCloseStaffShift', v)} />
) } // ── BY WAITER helpers ───────────────────────────────────────────────────────── function TimeBar({ shifts }) { if (!shifts || shifts.length === 0) return null const MINS = 24 * 60 return (
{shifts.map((s, i) => { const sm = timeToMinutes(s.start_time) const em = timeToMinutes(s.end_time) const dur = em > sm ? em - sm : em + MINS - sm const left = (sm / MINS) * 100 const width = (dur / MINS) * 100 const hasActual = s.actual_shifts?.length > 0 return (
) })}
) } function ShiftCard({ shift, actual, onEdit, onDelete }) { const hasActual = actual && actual.length > 0 const isActive = actual?.some(a => a.is_active) return (
{fmtTime(shift.start_time)}–{fmtTime(shift.end_time)}
{shift.estimated_pay != null && ( {fmt(shift.estimated_pay)} )}
{shift.notes && (
{shift.notes}
)} {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 ? ' (ενεργή)' : ''}
)) ) : (
Δεν ξεκίνησε
)}
) } // ── BY WAITER view ──────────────────────────────────────────────────────────── function ByWaiterView({ days, waiters, grid, scheduled, onAddShift, onEditShift, onDeleteShift }) { return (
{days.map(d => { const isToday = toLocalDateStr(new Date()) === d.dateStr return ( ) })} {waiters.length === 0 && ( )} {waiters.map((w, rowIdx) => { const rowBg = rowIdx % 2 === 0 ? 'white' : '#fafafa' return ( {days.map(d => { const cell = grid[w.id]?.[d.dateStr] const cellShifts = cell?.scheduled || [] const dayShiftsForWaiter = scheduled.filter(s => { const ds = typeof s.scheduled_date === 'string' ? s.scheduled_date : toLocalDateStr(new Date(s.scheduled_date)) return s.user_id === w.id && ds === d.dateStr }) return ( ) })} ) })}
Σερβιτόρος
{d.label}
{fmtShortDate(d.dateStr)}
Δεν υπάρχουν ενεργοί σερβιτόροι.
{w.name}
{w.hourly_rate != null &&
{fmt(w.hourly_rate)}/ώρα
}
{cellShifts.map(s => ( onEditShift(s, d.dateStr)} onDelete={() => onDeleteShift(s.id)} /> ))} {cellShifts.length > 0 && }
) } // ── HOURLY / WORKDAY view ───────────────────────────────────────────────────── function HourlyView({ days, waiters, scheduled, firstHour, workdayBounds, onAddShift }) { const [tooltip, setTooltip] = useState(null) const HOUR_HEIGHT = 54 const TOTAL_HOURS = 24 const shiftsMap = useMemo(() => { const m = {} for (const s of scheduled) { const ds = typeof s.scheduled_date === 'string' ? s.scheduled_date : toLocalDateStr(new Date(s.scheduled_date)) if (!m[ds]) m[ds] = [] m[ds].push(s) } return m }, [scheduled]) const hours = useMemo(() => Array.from({ length: TOTAL_HOURS }, (_, i) => (firstHour + i) % 24), [firstHour]) function minuteToY(rawMinute) { const adj = ((rawMinute - firstHour * 60) + 24 * 60) % (24 * 60) return (adj / 60) * HOUR_HEIGHT } const BAR_W = 28 const BAR_GAP = 3 return (
{/* Time axis */}
{hours.map((h, i) => (
{String(h).padStart(2, '0')}:00
))}
{/* Day columns */}
{days.map((d, di) => { const isToday = toLocalDateStr(new Date()) === d.dateStr const dayShifts = shiftsMap[d.dateStr] || [] // Only waiters with shifts on this day const activeWaiters = waiters.filter(w => dayShifts.some(s => s.user_id === w.id)) const totalBarsW = activeWaiters.length * BAR_W + Math.max(0, activeWaiters.length - 1) * BAR_GAP return (
{/* Day header */}
{d.label}
{fmtShortDate(d.dateStr)}
{/* Timeline */}
onAddShift(d.dateStr, null)} > {/* Hour grid lines */} {hours.map((h, i) => { const inWorkday = workdayBounds ? (workdayBounds[0] <= workdayBounds[1] ? h >= workdayBounds[0] && h < workdayBounds[1] : h >= workdayBounds[0] || h < workdayBounds[1]) : false return (
) })} {/* Midnight marker */} {firstHour !== 0 && (
00:00
)} {/* Waiter bars — only active waiters, centered */} {activeWaiters.map((w, wi) => { const wShifts = dayShifts.filter(s => s.user_id === w.id) // Center the group of bars within the column const offsetX = `calc(50% - ${totalBarsW / 2}px + ${wi * (BAR_W + BAR_GAP)}px)` return wShifts.map(s => { const sm = timeToMinutes(s.start_time) const em = timeToMinutes(s.end_time) const dur = em > sm ? em - sm : em + 1440 - sm const top = minuteToY(sm) const height = Math.max((dur / 60) * HOUR_HEIGHT, 10) const hasActual = s.actual_shifts?.length > 0 const initials = getInitials(w.name) const fitsText = height > 20 return (
{ const rect = e.currentTarget.getBoundingClientRect() setTooltip({ shift: s, waiter: w, x: rect.right + 8, y: rect.top }) }} onMouseLeave={() => setTooltip(null)} onClick={e => e.stopPropagation()} > {fitsText ? initials : null}
) }) })}
) })}
{/* Tooltip */} {tooltip && (
{tooltip.waiter?.name}
{fmtTime(tooltip.shift.start_time)} – {fmtTime(tooltip.shift.end_time)}
{tooltip.shift.notes &&
{tooltip.shift.notes}
}
)}
) } // ── Main page ───────────────────────────────────────────────────────────────── export default function SchedulePage() { const qc = useQueryClient() const [schedSettings, setSchedSettings] = useState(loadSchedSettings) const [weekAnchor, setWeekAnchor] = useState(() => weekAnchorFor(new Date(), loadSchedSettings().firstDayMonday)) const [addModal, setAddModal] = useState(null) const [editModal, setEditModal] = useState(null) const [showSettings, setShowSettings] = useState(false) function updateSettings(s) { setSchedSettings(s) saveSchedSettings(s) if (s.firstDayMonday !== schedSettings.firstDayMonday) { setWeekAnchor(weekAnchorFor(weekAnchor, s.firstDayMonday)) } } 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 updateShift = useMutation({ mutationFn: ({ id, ...body }) => client.put(`/api/schedule/${id}`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: ['schedule-week'] }); setEditModal(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 const days = useMemo(() => Array.from({ length: 7 }, (_, i) => { const d = addDays(weekAnchor, i) return { date: d, dateStr: toLocalDateStr(d), label: DAY_LABELS_BY_DOW[d.getDay()] } }), [weekAnchor]) const grid = useMemo(() => { const g = {} for (const s of scheduled) { if (!g[s.user_id]) g[s.user_id] = {} const ds = typeof s.scheduled_date === 'string' ? s.scheduled_date : toLocalDateStr(new Date(s.scheduled_date)) if (!g[s.user_id][ds]) g[s.user_id][ds] = { scheduled: [], actual: [] } g[s.user_id][ds].scheduled.push(s) g[s.user_id][ds].actual.push(...(s.actual_shifts || [])) } return g }, [scheduled]) function handleSaveShift(body) { const { _editId, ...rest } = body if (_editId) { updateShift.mutate({ id: _editId, ...rest }) } else { addShift.mutate(rest) } } function handleDeleteShift(id) { if (window.confirm('Διαγραφή βάρδιας;')) deleteShift.mutate(id) } const workdayBounds = useMemo(() => { if (schedSettings.viewMode !== 'workday') return null return [ parseInt(schedSettings.workdayStart.split(':')[0], 10), parseInt(schedSettings.workdayEnd.split(':')[0], 10), ] }, [schedSettings.viewMode, schedSettings.workdayStart, schedSettings.workdayEnd]) const btnStyle = { padding: '6px 14px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', } return (
{/* Header */}
Πρόγραμμα Εβδομάδας
{fmtShortDate(weekStartStr)} – {fmtShortDate(weekEndStr)} {totalPay > 0 && Εκτ. κόστος: {fmt(totalPay)}}
{/* Settings + view switch group */}
updateSettings({ ...schedSettings, viewMode: v })} options={[ { value: 'bywaiter', label: 'ΑΝΑ ΣΕΡΒΙΤΌΡΟ' }, { value: 'hourly', label: 'ΩΡΙΑΙΑ' }, { value: 'workday', label: 'ΕΡΓΆΣΙΜΗ' }, ]} />
{/* Week navigation group */}
{isLoading && (
Φόρτωση…
)} {!isLoading && schedSettings.viewMode === 'bywaiter' && ( setAddModal({ date, userId })} onEditShift={(shift, date) => setEditModal({ shift, date })} onDeleteShift={handleDeleteShift} /> )} {!isLoading && (schedSettings.viewMode === 'hourly' || schedSettings.viewMode === 'workday') && ( setAddModal({ date, userId })} /> )} {/* Legend */} {!isLoading && schedSettings.viewMode === 'bywaiter' && (
{[ { color: '#bfdbfe', bg: '#eff6ff', label: 'Προγρ. (δεν ξεκίνησε)' }, { color: '#86efac', bg: '#f0fdf4', label: 'Προγρ. + Πραγματική' }, ].map(l => (
{l.label}
))}
Μπάρα 24ω (μπλε=σχεδ., πράσ.=πραγμ.)
)} {addModal && ( w.id === addModal.userId).concat(waiters.filter(w => w.id !== addModal.userId)) : waiters} existingShifts={scheduled} editShift={null} onClose={() => setAddModal(null)} isPending={addShift.isPending} onSave={handleSaveShift} /> )} {editModal && ( setEditModal(null)} isPending={updateShift.isPending} onSave={handleSaveShift} /> )} {showSettings && ( setShowSettings(false)} /> )}
) }