Files
xenia-pos-local/manager_dashboard/src/pages/SchedulePage.jsx
bonamin 34ae328b0d feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:00:14 +03:00

820 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div style={{ display: 'flex', background: '#f3f4f6', borderRadius: 8, padding: 3, gap: 2, alignItems: 'stretch' }}>
{options.map(opt => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
style={{
padding: '6px 13px', borderRadius: 6, border: 'none', fontSize: 13, fontWeight: 600,
cursor: 'pointer', transition: 'all 0.15s', fontFamily: 'inherit',
background: value === opt.value ? 'white' : 'transparent',
color: value === opt.value ? '#111827' : '#6b7280',
boxShadow: value === opt.value ? '0 1px 3px rgba(0,0,0,0.12)' : 'none',
}}
>{opt.label}</button>
))}
</div>
)
}
// ── 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 (
<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 }}>{isEdit ? 'Επεξεργασία Βάρδιας' : 'Νέα Βάρδια'}</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)} disabled={isEdit}>
{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>
{conflict && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 7, padding: '8px 12px', fontSize: 12, color: '#dc2626' }}>
Επικάλυψη με βάρδια {fmtTime(conflict.start_time)}{fmtTime(conflict.end_time)}
</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', fontFamily: 'inherit' }}>Ακύρωση</button>
<button
onClick={() => onSave({ user_id: Number(userId), scheduled_date: date, start_time: start, end_time: end, notes: notes || null, _editId: editShift?.id })}
disabled={!canSave || isPending}
style={{ padding: '7px 18px', border: 'none', borderRadius: 7, fontSize: 13, fontWeight: 600, cursor: canSave ? 'pointer' : 'default', background: canSave ? '#111827' : '#e5e7eb', color: canSave ? 'white' : '#9ca3af', fontFamily: 'inherit' }}
>
{isPending ? 'Αποθήκευση…' : isEdit ? 'Αποθήκευση' : 'Προσθήκη'}
</button>
</div>
</div>
</div>
)
}
// ── Schedule Settings Modal ───────────────────────────────────────────────────
function ToggleRow({ label, description, checked, onChange, indent }) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, padding: `10px ${indent ? 36 : 20}px 10px ${indent ? 36 : 20}px`, borderBottom: '1px solid #f4f4f2' }}>
<div>
<div style={{ fontSize: 13.5, fontWeight: 500, color: indent ? '#374151' : '#111827' }}>{label}</div>
{description && <div style={{ fontSize: 11.5, color: '#9ca3af', marginTop: 2 }}>{description}</div>}
</div>
<button
role="switch"
onClick={() => onChange(!checked)}
style={{
flexShrink: 0, width: 42, height: 23, borderRadius: 999, border: 'none', cursor: 'pointer',
background: checked ? '#3b82f6' : '#e5e7eb', position: 'relative', transition: 'background 0.15s',
}}
>
<span style={{
position: 'absolute', top: 3, width: 17, height: 17, borderRadius: '50%', background: 'white',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)', transition: 'left 0.15s',
left: checked ? 22 : 3,
}} />
</button>
</div>
)
}
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) => (
<div style={{ padding: '12px 20px 4px', fontSize: 11, fontWeight: 700, color: '#9ca3af', letterSpacing: '0.08em', textTransform: 'uppercase' }}>{t}</div>
)
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 20 }}>
<div style={{ background: 'white', borderRadius: 14, width: '100%', maxWidth: 480, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid #f0f0ef', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700 }}>Ρυθμίσεις Προγράμματος</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#9ca3af' }}></button>
</div>
<div style={{ overflowY: 'auto', flex: 1 }}>
{sectionLabel('Εμφάνιση')}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }}>
<div style={{ fontSize: 13.5, fontWeight: 500, color: '#111827' }}>Πρώτη μέρα εβδομάδας</div>
<div style={{ display: 'flex', background: '#f3f4f6', borderRadius: 7, padding: 2, gap: 2 }}>
{[{ v: true, l: 'Δευ' }, { v: false, l: 'Κυρ' }].map(opt => (
<button key={opt.l} onClick={() => set('firstDayMonday', opt.v)} style={{
padding: '4px 12px', borderRadius: 5, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
background: s.firstDayMonday === opt.v ? 'white' : 'transparent',
color: s.firstDayMonday === opt.v ? '#111827' : '#6b7280',
boxShadow: s.firstDayMonday === opt.v ? '0 1px 2px rgba(0,0,0,0.1)' : 'none',
}}>{opt.l}</button>
))}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }}>
<div>
<div style={{ fontSize: 13.5, fontWeight: 500, color: '#111827' }}>Πρώτη ώρα ημέρας</div>
<div style={{ fontSize: 11.5, color: '#9ca3af', marginTop: 1 }}>Αρχή ωρολογιακής προβολής (Ωριαία / Εργάσιμη)</div>
</div>
<select value={s.firstHour} onChange={e => set('firstHour', Number(e.target.value))} style={inputStyle}>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={i}>{String(i).padStart(2, '0')}:00</option>
))}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, padding: '10px 20px', borderBottom: '1px solid #f4f4f2' }}>
<div>
<div style={{ fontSize: 13.5, fontWeight: 500, color: '#111827' }}>Ώρες λειτουργίας</div>
<div style={{ fontSize: 11.5, color: '#9ca3af', marginTop: 1 }}>Για προβολή Εργάσιμης Ημέρας</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input type="time" value={s.workdayStart} onChange={e => set('workdayStart', e.target.value)} style={{ ...inputStyle, width: 90 }} />
<span style={{ color: '#9ca3af', fontSize: 12 }}></span>
<input type="time" value={s.workdayEnd} onChange={e => set('workdayEnd', e.target.value)} style={{ ...inputStyle, width: 90 }} />
</div>
</div>
{sectionLabel('Αυτόματο Πρόγραμμα Καταστήματος')}
<ToggleRow label="Αυτόματη Έναρξη Εργάσιμης" description="Η εργάσιμη ξεκινά αυτόματα σύμφωνα με το πρόγραμμα" checked={s.autoStartWorkday} onChange={v => set('autoStartWorkday', v)} />
<ToggleRow label="Αυτόματο Κλείσιμο Εργάσιμης" description="Η εργάσιμη κλείνει αυτόματα σύμφωνα με το πρόγραμμα" checked={s.autoCloseWorkday} onChange={v => set('autoCloseWorkday', v)} />
{s.autoCloseWorkday && (
<ToggleRow label="Κλείσιμο βαρδιών προσωπικού" description="Κλείνουν αυτόματα αν δεν υπάρχουν ανοιχτές παραγγελίες" checked={s.autoCloseShiftsOnWorkday} onChange={v => set('autoCloseShiftsOnWorkday', v)} indent />
)}
<ToggleRow label="Αυτόματη Έναρξη Βάρδιας Προσωπικού" description="Οι βάρδιες ξεκινούν αυτόματα σύμφωνα με το πρόγραμμα" checked={s.autoStartStaffShift} onChange={v => set('autoStartStaffShift', v)} />
<ToggleRow label="Αυτόματο Κλείσιμο Βάρδιας Προσωπικού" description="Οι βάρδιες κλείνουν αυτόματα σύμφωνα με το πρόγραμμα" checked={s.autoCloseStaffShift} onChange={v => set('autoCloseStaffShift', v)} />
</div>
<div style={{ padding: '12px 20px', borderTop: '1px solid #f0f0ef', display: 'flex', justifyContent: 'flex-end', gap: 8, flexShrink: 0 }}>
<button onClick={onClose} style={{ padding: '7px 16px', border: '1px solid #e5e7eb', background: 'white', borderRadius: 7, fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>Ακύρωση</button>
<button
onClick={() => { onChange(s); onClose() }}
style={{ padding: '7px 18px', border: 'none', borderRadius: 7, fontSize: 13, fontWeight: 600, cursor: 'pointer', background: '#111827', color: 'white', fontFamily: 'inherit' }}
>Αποθήκευση</button>
</div>
</div>
</div>
)
}
// ── BY WAITER helpers ─────────────────────────────────────────────────────────
function TimeBar({ shifts }) {
if (!shifts || shifts.length === 0) return null
const MINS = 24 * 60
return (
<div style={{ position: 'relative', width: '100%', height: 14, background: '#f3f4f6', borderRadius: 3, overflow: 'hidden', marginTop: 3 }}>
{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 (
<div key={s.id || i} style={{
position: 'absolute', top: 0, bottom: 0,
left: `${left}%`, width: `${Math.max(width, 0.5)}%`,
background: hasActual ? '#86efac' : '#93c5fd',
borderRadius: 2,
}} title={`${fmtTime(s.start_time)}${fmtTime(s.end_time)}`} />
)
})}
</div>
)
}
function ShiftCard({ shift, actual, onEdit, 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, cursor: 'pointer',
}}
onClick={onEdit}
>
<div style={{ padding: '4px 7px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 3 }}>
<span style={{ fontWeight: 700, color: '#1d4ed8', whiteSpace: 'nowrap' }}>
{fmtTime(shift.start_time)}{fmtTime(shift.end_time)}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
{shift.estimated_pay != null && (
<span style={{ color: '#6b7280', fontSize: 10 }}>{fmt(shift.estimated_pay)}</span>
)}
<button
onClick={e => { e.stopPropagation(); onDelete() }}
style={{ fontSize: 10, color: '#9ca3af', background: 'none', border: 'none', cursor: 'pointer', padding: '0 2px', lineHeight: 1 }}
></button>
</div>
</div>
{shift.notes && (
<div style={{ padding: '2px 7px 4px', fontSize: 10, color: '#6b7280', fontStyle: 'italic', borderTop: `1px solid ${hasActual ? '#bbf7d0' : '#bfdbfe'}` }}>
{shift.notes}
</div>
)}
{hasActual ? (
actual.map(a => (
<div key={a.id} style={{ padding: '2px 7px 3px', borderTop: `1px solid #bbf7d0`, fontSize: 10, 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: '2px 7px 3px', borderTop: '1px solid #bfdbfe', fontSize: 10, color: '#94a3b8' }}>Δεν ξεκίνησε</div>
)}
</div>
)
}
// ── BY WAITER view ────────────────────────────────────────────────────────────
function ByWaiterView({ days, waiters, grid, scheduled, onAddShift, onEditShift, onDeleteShift }) {
return (
<div style={{ overflowX: 'auto', overflowY: 'auto', flex: 1 }}>
<table style={{ borderCollapse: 'collapse', width: '100%', minWidth: 760 }}>
<thead>
<tr style={{ background: '#fafafa' }}>
<th style={{ padding: '9px 16px', textAlign: 'left', fontSize: 11.5, fontWeight: 700, color: '#9ca3af', width: 150, borderBottom: '2px solid #f0f0ef' }}>Σερβιτόρος</th>
{days.map(d => {
const isToday = toLocalDateStr(new Date()) === d.dateStr
return (
<th key={d.dateStr} style={{
padding: '9px 6px', textAlign: 'center', fontSize: 12,
fontWeight: isToday ? 800 : 600,
color: isToday ? '#3b82f6' : '#374151',
minWidth: 115, borderBottom: '2px solid #f0f0ef',
}}>
<div>{d.label}</div>
<div style={{ fontSize: 11, fontWeight: 500, color: isToday ? '#3b82f6' : '#9ca3af' }}>{fmtShortDate(d.dateStr)}</div>
</th>
)
})}
</tr>
</thead>
<tbody>
{waiters.length === 0 && (
<tr><td colSpan={8} style={{ textAlign: 'center', color: '#d1d5db', fontSize: 13, padding: '48px 0' }}>Δεν υπάρχουν ενεργοί σερβιτόροι.</td></tr>
)}
{waiters.map((w, rowIdx) => {
const rowBg = rowIdx % 2 === 0 ? 'white' : '#fafafa'
return (
<tr key={w.id} style={{ background: rowBg, borderTop: '1px solid #f0f0ef' }}>
<td style={{ padding: '8px 16px', verticalAlign: 'top', background: rowBg }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#111315' }}>{w.name}</div>
{w.hourly_rate != null && <div style={{ fontSize: 11, color: '#9ca3af' }}>{fmt(w.hourly_rate)}/ώρα</div>}
</td>
{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 (
<td key={d.dateStr} style={{ padding: '5px 4px', verticalAlign: 'top', background: rowBg }}>
<div style={{ minHeight: 52, display: 'flex', flexDirection: 'column', gap: 3 }}>
{cellShifts.map(s => (
<ShiftCard
key={s.id} shift={s}
actual={cell.actual}
onEdit={() => onEditShift(s, d.dateStr)}
onDelete={() => onDeleteShift(s.id)}
/>
))}
<button
onClick={() => onAddShift(d.dateStr, w.id)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: 24, borderRadius: 6, border: '1.5px dashed #d1d5db',
background: 'transparent', color: '#c0c4cc', fontSize: 14,
cursor: 'pointer', transition: 'all 0.12s', fontFamily: 'inherit',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#93c5fd'; e.currentTarget.style.color = '#3b82f6' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#c0c4cc' }}
>+</button>
{cellShifts.length > 0 && <TimeBar shifts={dayShiftsForWaiter} />}
</div>
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
// ── 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 (
<div style={{ overflowX: 'auto', overflowY: 'auto', flex: 1, paddingBottom: 16 }}>
<div style={{ display: 'flex', minWidth: 640 }}>
{/* Time axis */}
<div style={{ width: 46, flexShrink: 0, paddingTop: 42 }}>
{hours.map((h, i) => (
<div key={h} style={{ height: HOUR_HEIGHT, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-end', paddingRight: 8 }}>
<span style={{ fontSize: 10, color: '#9ca3af', marginTop: -7, lineHeight: 1 }}>{String(h).padStart(2, '0')}:00</span>
</div>
))}
</div>
{/* Day columns */}
<div style={{ display: 'flex', flex: 1, gap: 6 }}>
{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 (
<div key={d.dateStr} style={{ flex: 1, minWidth: 80 }}>
{/* Day header */}
<div style={{
height: 42, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column',
borderBottom: '1px solid #e5e7eb',
background: isToday ? '#eff6ff' : di % 2 === 0 ? '#fafafa' : '#f5f5f5',
borderRadius: '6px 6px 0 0',
}}>
<div style={{ fontSize: 12, fontWeight: isToday ? 800 : 600, color: isToday ? '#3b82f6' : '#374151' }}>{d.label}</div>
<div style={{ fontSize: 10.5, color: isToday ? '#3b82f6' : '#9ca3af' }}>{fmtShortDate(d.dateStr)}</div>
</div>
{/* Timeline */}
<div
style={{
position: 'relative', height: TOTAL_HOURS * HOUR_HEIGHT, cursor: 'pointer',
background: isToday ? '#f8fbff' : di % 2 === 0 ? '#fafafa' : '#f5f5f5',
}}
onClick={() => 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 (
<div key={h} style={{
position: 'absolute', left: 0, right: 0, top: i * HOUR_HEIGHT, height: HOUR_HEIGHT,
borderTop: `1px solid ${h === 0 ? '#bfdbfe' : '#e9eaec'}`,
background: inWorkday ? 'rgba(219,234,254,0.35)' : 'transparent',
}} />
)
})}
{/* Midnight marker */}
{firstHour !== 0 && (
<div style={{ position: 'absolute', left: 0, right: 0, top: minuteToY(0), borderTop: '1.5px solid #93c5fd', zIndex: 3, pointerEvents: 'none' }}>
<span style={{ fontSize: 9, color: '#93c5fd', marginLeft: 2, lineHeight: 1 }}>00:00</span>
</div>
)}
{/* 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 (
<div
key={s.id}
style={{
position: 'absolute', top, left: offsetX, width: BAR_W, height,
borderRadius: 4, overflow: 'hidden', zIndex: 2,
background: hasActual ? '#86efac' : '#93c5fd',
border: `1px solid ${hasActual ? '#4ade80' : '#60a5fa'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 9, fontWeight: 700, color: hasActual ? '#166534' : '#1e40af',
cursor: 'default',
}}
onMouseEnter={e => {
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}
</div>
)
})
})}
</div>
</div>
)
})}
</div>
</div>
{/* Tooltip */}
{tooltip && (
<div style={{
position: 'fixed', left: tooltip.x, top: tooltip.y,
background: '#111827', color: 'white', borderRadius: 8,
padding: '8px 12px', fontSize: 12, zIndex: 100, pointerEvents: 'none',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)', maxWidth: 220,
}}>
<div style={{ fontWeight: 700, marginBottom: 3 }}>{tooltip.waiter?.name}</div>
<div>{fmtTime(tooltip.shift.start_time)} {fmtTime(tooltip.shift.end_time)}</div>
{tooltip.shift.notes && <div style={{ color: '#9ca3af', marginTop: 3, fontStyle: 'italic' }}>{tooltip.shift.notes}</div>}
</div>
)}
</div>
)
}
// ── 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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '11px 20px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827' }}>Πρόγραμμα Εβδομάδας</div>
<div style={{ fontSize: 12, color: '#9ca3af' }}>
{fmtShortDate(weekStartStr)} {fmtShortDate(weekEndStr)}
{totalPay > 0 && <span style={{ marginLeft: 10, color: '#6b7280' }}>Εκτ. κόστος: <strong style={{ color: '#111315' }}>{fmt(totalPay)}</strong></span>}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{/* Settings + view switch group */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button
onClick={() => setShowSettings(true)}
title="Ρυθμίσεις προγράμματος"
style={{ ...btnStyle, padding: '6px 10px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15, color: '#6b7280' }}
></button>
<TripleSwitch
value={schedSettings.viewMode}
onChange={v => updateSettings({ ...schedSettings, viewMode: v })}
options={[
{ value: 'bywaiter', label: 'ΑΝΑ ΣΕΡΒΙΤΌΡΟ' },
{ value: 'hourly', label: 'ΩΡΙΑΙΑ' },
{ value: 'workday', label: 'ΕΡΓΆΣΙΜΗ' },
]}
/>
</div>
{/* Week navigation group */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginLeft: 8 }}>
<button onClick={() => setWeekAnchor(w => addDays(w, -7))} style={btnStyle}> Προηγ.</button>
<button onClick={() => setWeekAnchor(weekAnchorFor(new Date(), schedSettings.firstDayMonday))} style={btnStyle}>Αυτή η εβδομάδα</button>
<button onClick={() => setWeekAnchor(w => addDays(w, 7))} style={btnStyle}>Επόμ. </button>
</div>
</div>
</div>
{isLoading && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#9ca3af', fontSize: 13 }}>Φόρτωση</div>
)}
{!isLoading && schedSettings.viewMode === 'bywaiter' && (
<ByWaiterView
days={days}
waiters={waiters}
grid={grid}
scheduled={scheduled}
onAddShift={(date, userId) => setAddModal({ date, userId })}
onEditShift={(shift, date) => setEditModal({ shift, date })}
onDeleteShift={handleDeleteShift}
/>
)}
{!isLoading && (schedSettings.viewMode === 'hourly' || schedSettings.viewMode === 'workday') && (
<HourlyView
days={days}
waiters={waiters}
scheduled={scheduled}
firstHour={schedSettings.firstHour}
workdayBounds={workdayBounds}
onAddShift={(date, userId) => setAddModal({ date, userId })}
/>
)}
{/* Legend */}
{!isLoading && schedSettings.viewMode === 'bywaiter' && (
<div style={{ padding: '7px 20px', borderTop: '1px solid #f0f0ef', background: '#fafafa', display: 'flex', gap: 16, flexWrap: 'wrap', 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 style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: '#6b7280' }}>
<div style={{ width: 36, height: 8, borderRadius: 2, background: 'linear-gradient(to right, #93c5fd 50%, #86efac 50%)' }} />
Μπάρα 24ω (μπλε=σχεδ., πράσ.=πραγμ.)
</div>
</div>
)}
{addModal && (
<ShiftModal
date={addModal.date}
waiters={addModal.userId
? waiters.filter(w => 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 && (
<ShiftModal
date={editModal.date}
waiters={waiters}
existingShifts={scheduled}
editShift={editModal.shift}
onClose={() => setEditModal(null)}
isPending={updateShift.isPending}
onSave={handleSaveShift}
/>
)}
{showSettings && (
<ScheduleSettingsModal
settings={schedSettings}
onChange={updateSettings}
onClose={() => setShowSettings(false)}
/>
)}
</div>
)
}