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>
820 lines
40 KiB
JavaScript
820 lines
40 KiB
JavaScript
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>
|
||
)
|
||
}
|