- Backend reports: fix cost/profit calculation to handle null unit_cost with has_gap flag; expose total_cost in product & workday summary - StaffTab: add hourly_rate field in payroll section (admin-only) - ProductFormModal: major refactor of form layout and structure - ProductsTab: minor tweaks aligned with form changes - Report pages (Today, WorkDaySummary, CategoryPerformance, ProductPerformance, RevenueTrends): UI improvements and cost data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
814 lines
42 KiB
JavaScript
814 lines
42 KiB
JavaScript
import { useState, useRef, useEffect } from 'react'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import toast from 'react-hot-toast'
|
||
import client from '../api/client'
|
||
import { ConfirmModal } from '../ui/Modal'
|
||
import Button from '../ui/Button'
|
||
|
||
|
||
function avatarColor(name) {
|
||
const palette = ['#3758c9', '#7a44c9', '#2f9e5e', '#d94b26', '#8a6d2b', '#0d7a8a', '#c93775']
|
||
let h = 0
|
||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
||
return palette[h % palette.length]
|
||
}
|
||
|
||
function WaiterAvatar({ waiter, size = 40 }) {
|
||
const displayName = waiter.full_name || waiter.nickname || waiter.username
|
||
if (waiter.avatar_url) {
|
||
return (
|
||
<img
|
||
src={waiter.avatar_url}
|
||
alt={displayName}
|
||
style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }}
|
||
/>
|
||
)
|
||
}
|
||
const parts = displayName.trim().split(' ')
|
||
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
||
return (
|
||
<div style={{
|
||
width: size, height: size, borderRadius: '50%',
|
||
background: avatarColor(displayName),
|
||
color: 'white', fontSize: size * 0.38, fontWeight: 600,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
flexShrink: 0,
|
||
}}>{initials}</div>
|
||
)
|
||
}
|
||
|
||
const DIGITS = ['1','2','3','4','5','6','7','8','9','','0','⌫']
|
||
|
||
function PinInput({ value, onChange }) {
|
||
function press(d) {
|
||
if (d === '⌫') { onChange(value.slice(0, -1)); return }
|
||
if (d === '' || value.length >= 6) return
|
||
onChange(value + d)
|
||
}
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, alignItems: 'center' }}>
|
||
<div style={{ display: 'flex', gap: 10 }}>
|
||
{Array.from({ length: 4 }).map((_, i) => (
|
||
<div key={i} style={{
|
||
width: 13, height: 13, borderRadius: '50%',
|
||
border: `2px solid ${i < value.length ? '#111827' : '#d1d5db'}`,
|
||
background: i < value.length ? '#111827' : 'transparent',
|
||
transition: 'all 150ms',
|
||
}} />
|
||
))}
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, width: '100%' }}>
|
||
{DIGITS.map((d, i) => (
|
||
<button
|
||
key={i} type="button" onClick={() => press(d)} disabled={d === ''}
|
||
style={{
|
||
height: 46, borderRadius: 10, fontSize: 18, fontWeight: 600, border: 'none',
|
||
cursor: d === '' ? 'default' : 'pointer',
|
||
background: d === '' ? 'transparent' : d === '⌫' ? '#f3f4f6' : '#f8fafc',
|
||
color: d === '⌫' ? '#6b7280' : '#111827',
|
||
transition: 'background 100ms',
|
||
visibility: d === '' ? 'hidden' : 'visible',
|
||
}}
|
||
>
|
||
{d}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Actions dropdown menu ─────────────────────────────────────────────────────
|
||
function ActionsMenu({ waiter, onEdit, onZones, onResetPin, onBlock, onDelete }) {
|
||
const [open, setOpen] = useState(false)
|
||
const ref = useRef(null)
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
function handler(e) {
|
||
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
|
||
}
|
||
document.addEventListener('mousedown', handler)
|
||
return () => document.removeEventListener('mousedown', handler)
|
||
}, [open])
|
||
|
||
const items = [
|
||
{ label: 'Επεξεργασία', icon: editIcon, onClick: onEdit },
|
||
waiter.role === 'waiter' && { label: 'Πρόσβαση σε Ζώνες', icon: zonesIcon, onClick: onZones },
|
||
{ label: 'Επαναφορά PIN', icon: pinIcon, onClick: onResetPin },
|
||
{ label: waiter.is_active ? 'Μπλοκάρισμα' : 'Ξεμπλοκάρισμα', icon: blockIcon, onClick: onBlock, color: waiter.is_active ? '#f97316' : '#22c55e' },
|
||
{ label: 'Διαγραφή', icon: trashIcon, onClick: onDelete, color: '#ef4444' },
|
||
].filter(Boolean)
|
||
|
||
return (
|
||
<div ref={ref} style={{ position: 'relative' }}>
|
||
<button
|
||
onClick={() => setOpen(v => !v)}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: 6,
|
||
fontSize: 13, padding: '0 12px', height: 34, borderRadius: 8,
|
||
border: '1px solid #e5e7eb', background: open ? '#f3f4f6' : '#fff',
|
||
color: '#374151', fontWeight: 500, cursor: 'pointer',
|
||
transition: 'background 120ms',
|
||
}}
|
||
>
|
||
Ενέργειες
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M6 9l6 6 6-6" />
|
||
</svg>
|
||
</button>
|
||
|
||
{open && (
|
||
<div style={{
|
||
position: 'absolute', right: 0, top: 'calc(100% + 6px)', zIndex: 100,
|
||
background: '#fff', border: '1px solid #e5e7eb',
|
||
borderRadius: 12, boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||
minWidth: 200, overflow: 'hidden', padding: '4px 0',
|
||
}}>
|
||
{items.map((item, i) => (
|
||
<button
|
||
key={i}
|
||
onClick={() => { setOpen(false); item.onClick() }}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
width: '100%', padding: '9px 14px', border: 'none',
|
||
background: 'transparent', cursor: 'pointer', textAlign: 'left',
|
||
fontSize: 13.5, fontWeight: 500,
|
||
color: item.color || '#111827',
|
||
transition: 'background 80ms',
|
||
}}
|
||
onMouseEnter={e => e.currentTarget.style.background = item.color ? `${item.color}10` : '#f9fafb'}
|
||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||
>
|
||
<span style={{ color: item.color || '#6b7280', display: 'flex', flexShrink: 0 }}>{item.icon}</span>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const editIcon = (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M12 20h9" /><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||
</svg>
|
||
)
|
||
const zonesIcon = (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||
</svg>
|
||
)
|
||
const pinIcon = (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||
</svg>
|
||
)
|
||
const blockIcon = (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="10" /><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||
</svg>
|
||
)
|
||
const trashIcon = (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M3 6h18" /><path d="M8 6V4h8v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||
</svg>
|
||
)
|
||
|
||
// ── ZoneModal ─────────────────────────────────────────────────────────────────
|
||
function ZoneModal({ waiter, groups, onClose }) {
|
||
const qc = useQueryClient()
|
||
const hasAllZones = waiter.zone_assignments.some(z => z.group_id === null)
|
||
const assignedIds = new Set(waiter.zone_assignments.map(z => z.group_id).filter(id => id !== null))
|
||
const [allZones, setAllZones] = useState(hasAllZones)
|
||
const [selected, setSelected] = useState(new Set(assignedIds))
|
||
|
||
const saveZones = useMutation({
|
||
mutationFn: (body) => client.put(`/api/waiters/${waiter.id}/zones`, body),
|
||
onSuccess: () => { toast.success('Zones ενημερώθηκαν'); qc.invalidateQueries({ queryKey: ['waiters'] }); onClose() },
|
||
onError: () => toast.error('Σφάλμα'),
|
||
})
|
||
|
||
function toggleGroup(gid) {
|
||
setSelected(prev => { const next = new Set(prev); next.has(gid) ? next.delete(gid) : next.add(gid); return next })
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
|
||
<h2 className="font-bold text-gray-800">Ζώνες — {waiter.username}</h2>
|
||
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||
<input type="checkbox" className="w-5 h-5 rounded accent-primary-700" checked={allZones}
|
||
onChange={e => { setAllZones(e.target.checked); if (e.target.checked) setSelected(new Set()) }} />
|
||
<span className="font-semibold text-gray-700">Όλες οι ζώνες</span>
|
||
</label>
|
||
{!allZones && (
|
||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||
{groups.length === 0 && <p className="text-sm text-gray-400">Δεν υπάρχουν ομάδες τραπεζιών.</p>}
|
||
{groups.map(g => (
|
||
<label key={g.id} className="flex items-center gap-3 cursor-pointer select-none px-1">
|
||
<input type="checkbox" className="w-5 h-5 rounded accent-primary-700" checked={selected.has(g.id)} onChange={() => toggleGroup(g.id)} />
|
||
<span className="text-gray-700">{g.name}</span>
|
||
{g.color && <span className="w-3 h-3 rounded-full inline-block ml-auto" style={{ background: g.color }} />}
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
{!allZones && selected.size === 0 && (
|
||
<p className="text-xs text-amber-600 bg-amber-50 rounded px-3 py-1.5">Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι.</p>
|
||
)}
|
||
<div className="flex gap-3 pt-2">
|
||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||
<button onClick={() => saveZones.mutate(allZones ? { all_zones: true, group_ids: [] } : { all_zones: false, group_ids: [...selected] })}
|
||
disabled={saveZones.isPending} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Shared form primitives ────────────────────────────────────────────────────
|
||
function FormField({ label, required, desc, children }) {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||
<label style={{ fontSize: 11.5, fontWeight: 600, color: '#374151' }}>
|
||
{label}{required && <span style={{ color: '#ef4444', marginLeft: 2 }}>*</span>}
|
||
</label>
|
||
{children}
|
||
{desc && <p style={{ margin: 0, fontSize: 11, color: '#9ca3af', lineHeight: 1.4 }}>{desc}</p>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SectionLabel({ children }) {
|
||
return (
|
||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#9ca3af', marginBottom: 10 }}>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function InfoBlock({ label, value, valueColor }) {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||
<span style={{ fontSize: 10.5, fontWeight: 600, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{label}</span>
|
||
<span style={{ fontSize: 13.5, fontWeight: 500, color: valueColor || '#111827' }}>{value || '—'}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const inputStyle = {
|
||
width: '100%', padding: '7px 10px', border: '1px solid #e5e7eb', borderRadius: 7,
|
||
fontSize: 13, outline: 'none', color: '#111827', background: '#fff', boxSizing: 'border-box',
|
||
}
|
||
|
||
const EMPTY_FORM = { username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', pin: '', hourly_rate: '' }
|
||
|
||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||
export default function WaitersPage() {
|
||
const qc = useQueryClient()
|
||
const [addModal, setAddModal] = useState(false)
|
||
const [pinModal, setPinModal] = useState(null)
|
||
const [zoneModal, setZoneModal] = useState(null)
|
||
const [confirmDelete, setConfirmDelete] = useState(null)
|
||
const [newPin, setNewPin] = useState('')
|
||
const [newForm, setNewForm] = useState(EMPTY_FORM)
|
||
const [newAvatarFile, setNewAvatarFile] = useState(null)
|
||
const [newAvatarPreview, setNewAvatarPreview] = useState(null)
|
||
const [editModal, setEditModal] = useState(null)
|
||
const [editForm, setEditForm] = useState({ username: '', full_name: '', nickname: '', mobile_phone: '', email: '', note: '', role: 'waiter', hourly_rate: '' })
|
||
const avatarInputRef = useRef(null)
|
||
const newAvatarInputRef = useRef(null)
|
||
|
||
const { data: waiters = [], isLoading } = useQuery({
|
||
queryKey: ['waiters'],
|
||
queryFn: () => client.get('/api/waiters/').then(r => r.data),
|
||
})
|
||
const { data: groups = [] } = useQuery({
|
||
queryKey: ['table-groups'],
|
||
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
|
||
})
|
||
|
||
const invalidate = () => qc.invalidateQueries({ queryKey: ['waiters'] })
|
||
|
||
const createWaiter = useMutation({
|
||
mutationFn: async (body) => {
|
||
const res = await client.post('/api/waiters/', body)
|
||
if (newAvatarFile) {
|
||
const fd = new FormData()
|
||
fd.append('file', newAvatarFile)
|
||
await client.post(`/api/waiters/${res.data.id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||
}
|
||
return res
|
||
},
|
||
onSuccess: () => {
|
||
toast.success('Σερβιτόρος δημιουργήθηκε')
|
||
setAddModal(false); setNewForm(EMPTY_FORM); setNewAvatarFile(null); setNewAvatarPreview(null)
|
||
invalidate()
|
||
},
|
||
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
||
})
|
||
|
||
const updateWaiter = useMutation({
|
||
mutationFn: ({ id, ...body }) => client.put(`/api/waiters/${id}`, body),
|
||
onSuccess: () => { toast.success('Στοιχεία ενημερώθηκαν'); setEditModal(null); invalidate() },
|
||
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
|
||
})
|
||
|
||
const uploadAvatar = useMutation({
|
||
mutationFn: ({ id, file }) => {
|
||
const fd = new FormData(); fd.append('file', file)
|
||
return client.post(`/api/waiters/${id}/avatar`, fd, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||
},
|
||
onSuccess: (res) => { toast.success('Avatar ανέβηκε'); setEditModal(res.data); invalidate() },
|
||
onError: () => toast.error('Σφάλμα μεταφόρτωσης'),
|
||
})
|
||
|
||
const deleteAvatar = useMutation({
|
||
mutationFn: (id) => client.delete(`/api/waiters/${id}/avatar`),
|
||
onSuccess: (res) => { toast.success('Avatar αφαιρέθηκε'); setEditModal(res.data); invalidate() },
|
||
onError: () => toast.error('Σφάλμα'),
|
||
})
|
||
|
||
const toggleBlock = useMutation({
|
||
mutationFn: (id) => client.put(`/api/waiters/${id}/block`),
|
||
onSuccess: () => invalidate(),
|
||
onError: () => toast.error('Σφάλμα'),
|
||
})
|
||
|
||
const resetPin = useMutation({
|
||
mutationFn: ({ id, pin }) => client.put(`/api/waiters/${id}/reset-pin?pin=${encodeURIComponent(pin)}`),
|
||
onSuccess: () => { toast.success('PIN ανανεώθηκε'); setPinModal(null); setNewPin('') },
|
||
onError: () => toast.error('Σφάλμα'),
|
||
})
|
||
|
||
const deleteWaiter = useMutation({
|
||
mutationFn: (id) => client.delete(`/api/waiters/${id}`),
|
||
onSuccess: () => { toast.success('Διαγράφηκε'); setConfirmDelete(null); invalidate() },
|
||
onError: () => toast.error('Σφάλμα'),
|
||
})
|
||
|
||
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση…</div>
|
||
|
||
function openEdit(w) {
|
||
setEditModal(w)
|
||
setEditForm({ username: w.username || '', full_name: w.full_name || '', nickname: w.nickname || '', mobile_phone: w.mobile_phone || '', email: w.email || '', note: w.note || '', role: w.role || 'waiter', hourly_rate: w.hourly_rate ?? '' })
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full min-h-0">
|
||
<div className="flex items-center justify-end gap-2 border-b border-slate-200 px-6 flex-shrink-0" style={{ height: 60 }}>
|
||
<Button variant="primary" size="sm" onClick={() => setAddModal(true)}>+ Νέος σερβιτόρος</Button>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||
|
||
<div className="card divide-y divide-gray-100">
|
||
{waiters.length === 0 && (
|
||
<p className="px-4 py-8 text-center text-gray-400">Δεν υπάρχουν σερβιτόροι.</p>
|
||
)}
|
||
{waiters.map(w => (
|
||
<div key={w.id} className="flex items-center gap-4 px-4 py-3">
|
||
<WaiterAvatar waiter={w} size={44} />
|
||
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-baseline gap-2">
|
||
<p className="font-semibold text-gray-800">{w.full_name || w.username}</p>
|
||
{w.nickname && <span className="text-xs text-gray-400">({w.nickname})</span>}
|
||
</div>
|
||
<p className="text-xs text-gray-500">{w.username} · {w.role}</p>
|
||
{w.mobile_phone && <p className="text-xs text-gray-400">{w.mobile_phone}</p>}
|
||
{w.role === 'waiter' && (
|
||
<p className="text-xs text-gray-400 mt-0.5">
|
||
{w.zone_assignments.length === 0 ? 'Χωρίς ζώνες'
|
||
: w.zone_assignments.some(z => z.group_id === null) ? 'Όλες οι ζώνες'
|
||
: `${w.zone_assignments.length} ζών${w.zone_assignments.length === 1 ? 'η' : 'ες'}`}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Active status badge — only this stays inline */}
|
||
<span style={{
|
||
fontSize: 11.5, fontWeight: 600, padding: '3px 9px', borderRadius: 999,
|
||
background: w.is_active ? '#dcfce7' : '#fee2e2',
|
||
color: w.is_active ? '#15803d' : '#dc2626',
|
||
flexShrink: 0,
|
||
}}>
|
||
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
|
||
</span>
|
||
|
||
{/* Single actions dropdown */}
|
||
<ActionsMenu
|
||
waiter={w}
|
||
onEdit={() => openEdit(w)}
|
||
onZones={() => setZoneModal(w)}
|
||
onResetPin={() => setPinModal(w.id)}
|
||
onBlock={() => toggleBlock.mutate(w.id)}
|
||
onDelete={() => setConfirmDelete(w.id)}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Add modal */}
|
||
{addModal && (
|
||
<AddWaiterModal
|
||
form={newForm} setForm={setNewForm}
|
||
avatarFile={newAvatarFile} avatarPreview={newAvatarPreview}
|
||
setAvatarFile={setNewAvatarFile} setAvatarPreview={setNewAvatarPreview}
|
||
avatarInputRef={newAvatarInputRef}
|
||
isPending={createWaiter.isPending}
|
||
onClose={() => { setAddModal(false); setNewForm(EMPTY_FORM); setNewAvatarFile(null); setNewAvatarPreview(null) }}
|
||
onSubmit={() => createWaiter.mutate({
|
||
username: newForm.username, full_name: newForm.full_name || null,
|
||
nickname: newForm.nickname || null, mobile_phone: newForm.mobile_phone || null,
|
||
email: newForm.email || null, note: newForm.note || null,
|
||
pin: newForm.pin, role: newForm.role, is_active: true,
|
||
hourly_rate: newForm.hourly_rate !== '' ? parseFloat(newForm.hourly_rate) : null,
|
||
})}
|
||
/>
|
||
)}
|
||
|
||
{/* Edit modal */}
|
||
{editModal && (
|
||
<EditWaiterModal
|
||
waiter={editModal} form={editForm} setForm={setEditForm}
|
||
avatarInputRef={avatarInputRef}
|
||
isPending={updateWaiter.isPending}
|
||
isUploadPending={uploadAvatar.isPending}
|
||
isDeletePending={deleteAvatar.isPending}
|
||
onClose={() => setEditModal(null)}
|
||
onSubmit={() => updateWaiter.mutate({
|
||
id: editModal.id,
|
||
username: editForm.username.trim() || undefined,
|
||
full_name: editForm.full_name || null, nickname: editForm.nickname || null,
|
||
mobile_phone: editForm.mobile_phone || null, email: editForm.email || null,
|
||
note: editForm.note || null, role: editForm.role,
|
||
hourly_rate: editForm.hourly_rate !== '' ? parseFloat(editForm.hourly_rate) : null,
|
||
})}
|
||
onUploadAvatar={file => uploadAvatar.mutate({ id: editModal.id, file })}
|
||
onDeleteAvatar={() => deleteAvatar.mutate(editModal.id)}
|
||
/>
|
||
)}
|
||
|
||
{/* Reset PIN modal */}
|
||
{pinModal !== null && (
|
||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-xs p-6 space-y-4">
|
||
<h2 className="font-bold text-gray-800">Επαναφορά PIN — {waiters.find(w => w.id === pinModal)?.username}</h2>
|
||
<PinInput value={newPin} onChange={setNewPin} />
|
||
<div className="flex gap-3 pt-2">
|
||
<button onClick={() => { setPinModal(null); setNewPin('') }} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||
<button onClick={() => resetPin.mutate({ id: pinModal, pin: newPin })} disabled={newPin.length < 4} className="flex-1 btn btn-primary">
|
||
Αποθήκευση
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{confirmDelete !== null && (
|
||
<ConfirmModal
|
||
title="Διαγραφή σερβιτόρου;"
|
||
message="Αυτή η ενέργεια δεν μπορεί να αναιρεθεί."
|
||
confirmLabel="Διαγραφή"
|
||
confirmVariant="danger"
|
||
onConfirm={() => deleteWaiter.mutate(confirmDelete)}
|
||
onCancel={() => setConfirmDelete(null)}
|
||
/>
|
||
)}
|
||
|
||
{zoneModal && (
|
||
<ZoneModal waiter={zoneModal} groups={groups} onClose={() => setZoneModal(null)} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Add Waiter Modal — two-column ─────────────────────────────────────────────
|
||
function AddWaiterModal({ form, setForm, avatarFile, avatarPreview, setAvatarFile, setAvatarPreview, avatarInputRef, isPending, onClose, onSubmit }) {
|
||
const f = (field, val) => setForm(p => ({ ...p, [field]: val }))
|
||
const canSubmit = form.username.trim() && form.full_name.trim() && form.nickname.trim() && form.pin.length >= 4
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
zIndex: 50, padding: 24,
|
||
}}>
|
||
<div style={{
|
||
background: '#fff', borderRadius: 20, boxShadow: '0 24px 72px rgba(0,0,0,0.18)',
|
||
width: '100%', maxWidth: 980,
|
||
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{ padding: '18px 26px 14px', borderBottom: '1px solid #f3f4f6', display: 'flex', alignItems: 'center', gap: 14, flexShrink: 0 }}>
|
||
{avatarPreview
|
||
? <img src={avatarPreview} alt="preview" style={{ width: 48, height: 48, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
||
: <div style={{ width: 48, height: 48, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||
<span style={{ fontSize: 22, color: '#9ca3af' }}>👤</span>
|
||
</div>
|
||
}
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<h2 style={{ margin: 0, fontWeight: 700, fontSize: 16, color: '#111827' }}>Νέος σερβιτόρος</h2>
|
||
<p style={{ margin: '2px 0 0', fontSize: 12, color: '#9ca3af' }}>Συμπληρώστε τα στοιχεία του εργαζόμενου</p>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<input ref={avatarInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => {
|
||
const file = e.target.files?.[0]
|
||
if (file) { setAvatarFile(file); setAvatarPreview(URL.createObjectURL(file)) }
|
||
e.target.value = ''
|
||
}} />
|
||
<button onClick={() => avatarInputRef.current?.click()} type="button"
|
||
style={{ fontSize: 12, padding: '5px 11px', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 7, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
|
||
{avatarPreview ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
|
||
</button>
|
||
{avatarPreview && (
|
||
<button type="button" onClick={() => { setAvatarFile(null); setAvatarPreview(null) }}
|
||
style={{ fontSize: 12, padding: '5px 11px', border: '1px solid #fecaca', background: '#fff5f5', borderRadius: 7, cursor: 'pointer', color: '#ef4444', fontWeight: 500 }}>
|
||
Αφαίρεση
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Body — three columns, scrollable */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||
|
||
{/* Column 1: Identity + Account */}
|
||
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, borderRight: '1px solid #f3f4f6' }}>
|
||
<div>
|
||
<SectionLabel>Ταυτότητα</SectionLabel>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<FormField label="Πλήρες Όνομα" required desc="Εμφανίζεται στα στατιστικά.">
|
||
<input style={inputStyle} placeholder="π.χ. Γιώργος Παπαδόπουλος" value={form.full_name} onChange={e => f('full_name', e.target.value)} autoFocus />
|
||
</FormField>
|
||
<FormField label="Παρατσούκλι" required desc="Εμφανίζεται στην εφαρμογή των τηλεφώνων.">
|
||
<input style={inputStyle} placeholder="π.χ. Γιώργος" value={form.nickname} onChange={e => f('nickname', e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<SectionLabel>Λογαριασμός</SectionLabel>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<FormField label="Όνομα χρήστη" required desc="Χρησιμοποιείται για login.">
|
||
<input style={inputStyle} placeholder="π.χ. giorgos" value={form.username} onChange={e => f('username', e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Ρόλος" required desc="Καθορίζει τα δικαιώματα πρόσβασης.">
|
||
<select style={inputStyle} value={form.role} onChange={e => f('role', e.target.value)}>
|
||
<option value="waiter">Σερβιτόρος</option>
|
||
<option value="manager">Διαχειριστής</option>
|
||
</select>
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Column 2: Contact info */}
|
||
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, borderRight: '1px solid #f3f4f6' }}>
|
||
<div>
|
||
<SectionLabel>Επικοινωνία</SectionLabel>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<FormField label="Κινητό τηλέφωνο" desc="Εσωτερική αναφορά μόνο.">
|
||
<input style={inputStyle} placeholder="π.χ. 6901234567" value={form.mobile_phone} onChange={e => f('mobile_phone', e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Email" desc="Εσωτερική χρήση μόνο.">
|
||
<input style={inputStyle} type="email" placeholder="π.χ. giorgos@restaurant.gr" value={form.email} onChange={e => f('email', e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Σημείωση" desc="Οποιαδήποτε εσωτερική σημείωση.">
|
||
<textarea style={{ ...inputStyle, resize: 'none' }} placeholder="π.χ. Μερική απασχόληση" value={form.note} onChange={e => f('note', e.target.value)} rows={4} />
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Column 3: Payroll + PIN */}
|
||
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, background: '#fafafa' }}>
|
||
<div>
|
||
<SectionLabel>Μισθοδοσία</SectionLabel>
|
||
<FormField label="Ωριαία αμοιβή (€/ώρα)" desc="Μόνο για διαχειριστές. Δεν εμφανίζεται στον εργαζόμενο.">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<input
|
||
type="number" step="0.50" min="0"
|
||
style={{ ...inputStyle, width: 110 }}
|
||
placeholder="π.χ. 5.50"
|
||
value={form.hourly_rate ?? ''}
|
||
onChange={e => f('hourly_rate', e.target.value)}
|
||
/>
|
||
{form.hourly_rate && parseFloat(form.hourly_rate) > 0 && (
|
||
<span style={{ fontSize: 11.5, color: '#16a34a', fontWeight: 500 }}>
|
||
€{parseFloat(form.hourly_rate).toFixed(2)}/ώρα
|
||
</span>
|
||
)}
|
||
{!form.hourly_rate && (
|
||
<span style={{ fontSize: 11, color: '#9ca3af' }}>Δεν παρακολουθείται</span>
|
||
)}
|
||
</div>
|
||
</FormField>
|
||
</div>
|
||
<SectionLabel>Κωδικός PIN</SectionLabel>
|
||
<p style={{ margin: 0, fontSize: 12, color: '#6b7280', lineHeight: 1.5 }}>
|
||
Ο 4ψήφιος κωδικός που θα χρησιμοποιεί ο εργαζόμενος για να ξεκλειδώσει την εφαρμογή. Μπορεί να αλλάξει οποτεδήποτε.
|
||
</p>
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 8 }}>
|
||
<div style={{ width: '100%', maxWidth: 220 }}>
|
||
<PinInput value={form.pin} onChange={pin => f('pin', pin)} />
|
||
{form.pin.length > 0 && form.pin.length < 4 && (
|
||
<p style={{ textAlign: 'center', fontSize: 11.5, color: '#f59e0b', marginTop: 10 }}>
|
||
Χρειάζονται {4 - form.pin.length} ψηφία ακόμη
|
||
</p>
|
||
)}
|
||
{form.pin.length >= 4 && (
|
||
<p style={{ textAlign: 'center', fontSize: 11.5, color: '#22c55e', marginTop: 10, fontWeight: 600 }}>
|
||
✓ PIN ορίστηκε
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div style={{ padding: '14px 26px', borderTop: '1px solid #f3f4f6', display: 'flex', justifyContent: 'flex-end', gap: 10, flexShrink: 0 }}>
|
||
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 13.5, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
|
||
Ακύρωση
|
||
</button>
|
||
<button onClick={onSubmit} disabled={isPending || !canSubmit}
|
||
style={{ padding: '8px 20px', border: 'none', background: canSubmit ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 13.5, fontWeight: 600, cursor: canSubmit ? 'pointer' : 'not-allowed' }}>
|
||
{isPending ? 'Δημιουργία…' : 'Δημιουργία σερβιτόρου'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Edit Waiter Modal — three-column ──────────────────────────────────────────
|
||
function EditWaiterModal({ waiter, form, setForm, avatarInputRef, isPending, isUploadPending, isDeletePending, onClose, onSubmit, onUploadAvatar, onDeleteAvatar }) {
|
||
const f = (field, val) => setForm(p => ({ ...p, [field]: val }))
|
||
const canSubmit = form.username.trim() && form.full_name.trim() && form.nickname.trim()
|
||
|
||
const zoneLabel = !waiter.zone_assignments?.length ? 'Χωρίς ζώνες'
|
||
: waiter.zone_assignments.some(z => z.group_id === null) ? 'Όλες οι ζώνες'
|
||
: `${waiter.zone_assignments.length} ζών${waiter.zone_assignments.length === 1 ? 'η' : 'ες'}`
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
zIndex: 50, padding: 24,
|
||
}}>
|
||
<div style={{
|
||
background: '#fff', borderRadius: 20, boxShadow: '0 24px 72px rgba(0,0,0,0.18)',
|
||
width: '100%', maxWidth: 980,
|
||
maxHeight: '90vh', display: 'flex', flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{ padding: '18px 26px 14px', borderBottom: '1px solid #f3f4f6', display: 'flex', alignItems: 'center', gap: 14, flexShrink: 0 }}>
|
||
<WaiterAvatar waiter={waiter} size={48} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<h2 style={{ margin: 0, fontWeight: 700, fontSize: 16, color: '#111827' }}>
|
||
Επεξεργασία — {waiter.full_name || waiter.username}
|
||
</h2>
|
||
<p style={{ margin: '2px 0 0', fontSize: 12, color: '#9ca3af' }}>Ενημέρωση στοιχείων εργαζόμενου</p>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<input ref={avatarInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
||
onChange={e => { const file = e.target.files?.[0]; if (file) onUploadAvatar(file); e.target.value = '' }} />
|
||
<button onClick={() => avatarInputRef.current?.click()} disabled={isUploadPending}
|
||
style={{ fontSize: 12, padding: '5px 11px', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 7, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
|
||
{isUploadPending ? 'Μεταφόρτωση…' : waiter.avatar_url ? 'Αλλαγή φωτογραφίας' : 'Προσθήκη φωτογραφίας'}
|
||
</button>
|
||
{waiter.avatar_url && (
|
||
<button onClick={onDeleteAvatar} disabled={isDeletePending}
|
||
style={{ fontSize: 12, padding: '5px 11px', border: '1px solid #fecaca', background: '#fff5f5', borderRadius: 7, cursor: 'pointer', color: '#ef4444', fontWeight: 500 }}>
|
||
Αφαίρεση
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Body — three columns, scrollable */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||
|
||
{/* Column 1: Identity + Account */}
|
||
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, borderRight: '1px solid #f3f4f6' }}>
|
||
<div>
|
||
<SectionLabel>Ταυτότητα</SectionLabel>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<FormField label="Πλήρες Όνομα" required desc="Εμφανίζεται στα στατιστικά.">
|
||
<input style={inputStyle} value={form.full_name} onChange={e => f('full_name', e.target.value)} autoFocus />
|
||
</FormField>
|
||
<FormField label="Παρατσούκλι" required desc="Εμφανίζεται στην εφαρμογή των τηλεφώνων.">
|
||
<input style={inputStyle} placeholder="π.χ. Γιώργος" value={form.nickname} onChange={e => f('nickname', e.target.value)} />
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<SectionLabel>Λογαριασμός</SectionLabel>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<FormField label="Όνομα χρήστη" required desc="Χρησιμοποιείται για login.">
|
||
<input style={inputStyle} value={form.username} onChange={e => f('username', e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Ρόλος" required desc="Καθορίζει τα δικαιώματα πρόσβασης.">
|
||
<select style={inputStyle} value={form.role} onChange={e => f('role', e.target.value)}>
|
||
<option value="waiter">Σερβιτόρος</option>
|
||
<option value="manager">Διαχειριστής</option>
|
||
</select>
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Column 2: Contact info */}
|
||
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, borderRight: '1px solid #f3f4f6' }}>
|
||
<div>
|
||
<SectionLabel>Επικοινωνία</SectionLabel>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<FormField label="Κινητό τηλέφωνο" desc="Εσωτερική αναφορά μόνο.">
|
||
<input style={inputStyle} value={form.mobile_phone} onChange={e => f('mobile_phone', e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Email" desc="Εσωτερική χρήση μόνο.">
|
||
<input style={inputStyle} type="email" value={form.email} onChange={e => f('email', e.target.value)} />
|
||
</FormField>
|
||
<FormField label="Σημείωση" desc="Οποιαδήποτε εσωτερική σημείωση.">
|
||
<textarea style={{ ...inputStyle, resize: 'none' }} value={form.note} onChange={e => f('note', e.target.value)} rows={4} />
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Column 3: Info + Payroll + PIN note */}
|
||
<div style={{ padding: '20px 22px', display: 'flex', flexDirection: 'column', gap: 16, background: '#fafafa' }}>
|
||
<div>
|
||
<SectionLabel>Πληροφορίες</SectionLabel>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
{/* Row 1: Date + Status side by side */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||
<InfoBlock label="Εγγραφή"
|
||
value={waiter.created_at ? new Date(waiter.created_at).toLocaleDateString('el-GR') : null} />
|
||
<InfoBlock label="Κατάσταση"
|
||
value={waiter.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
|
||
valueColor={waiter.is_active ? '#16a34a' : '#ef4444'} />
|
||
</div>
|
||
<InfoBlock label="Ρόλος"
|
||
value={waiter.role === 'waiter' ? 'Σερβιτόρος' : 'Διαχειριστής'} />
|
||
{waiter.role === 'waiter' && (
|
||
<InfoBlock label="Ζώνες" value={zoneLabel} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Phase 2B — Payroll */}
|
||
<div>
|
||
<SectionLabel>Μισθοδοσία</SectionLabel>
|
||
<FormField label="Ωριαία αμοιβή (€/ώρα)" desc="Μόνο για διαχειριστές. Δεν εμφανίζεται στον εργαζόμενο.">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<input
|
||
type="number" step="0.50" min="0"
|
||
style={{ ...inputStyle, width: 110 }}
|
||
placeholder="π.χ. 5.50"
|
||
value={form.hourly_rate}
|
||
onChange={e => f('hourly_rate', e.target.value)}
|
||
/>
|
||
{form.hourly_rate !== '' && parseFloat(form.hourly_rate) > 0 && (
|
||
<span style={{ fontSize: 11.5, color: '#16a34a', fontWeight: 500 }}>
|
||
€{parseFloat(form.hourly_rate).toFixed(2)}/ώρα
|
||
</span>
|
||
)}
|
||
{form.hourly_rate === '' && (
|
||
<span style={{ fontSize: 11, color: '#9ca3af' }}>Δεν παρακολουθείται</span>
|
||
)}
|
||
</div>
|
||
</FormField>
|
||
</div>
|
||
|
||
<div style={{ marginTop: 4, padding: 14, background: '#f3f4f6', borderRadius: 10 }}>
|
||
<p style={{ margin: '0 0 4px', fontSize: 11.5, fontWeight: 600, color: '#374151' }}>Κωδικός PIN</p>
|
||
<p style={{ margin: 0, fontSize: 11.5, color: '#6b7280', lineHeight: 1.5 }}>
|
||
Για αλλαγή PIN χρησιμοποιήστε την επιλογή <strong style={{ color: '#374151' }}>Επαναφορά PIN</strong> από το μενού Ενεργειών.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div style={{ padding: '14px 26px', borderTop: '1px solid #f3f4f6', display: 'flex', justifyContent: 'flex-end', gap: 10, flexShrink: 0 }}>
|
||
<button onClick={onClose} style={{ padding: '8px 18px', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 13.5, cursor: 'pointer', color: '#374151', fontWeight: 500 }}>
|
||
Ακύρωση
|
||
</button>
|
||
<button onClick={onSubmit} disabled={isPending || !canSubmit}
|
||
style={{ padding: '8px 20px', border: 'none', background: canSubmit ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 13.5, fontWeight: 600, cursor: canSubmit ? 'pointer' : 'not-allowed' }}>
|
||
{isPending ? 'Αποθήκευση…' : 'Αποθήκευση αλλαγών'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|