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 (
)
}
const parts = displayName.trim().split(' ')
const initials = (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
return (
{initials}
)
}
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 (
{Array.from({ length: 4 }).map((_, i) => (
))}
{DIGITS.map((d, i) => (
))}
)
}
// ── 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 (
{open && (
{items.map((item, i) => (
))}
)}
)
}
const editIcon = (
)
const zonesIcon = (
)
const pinIcon = (
)
const blockIcon = (
)
const trashIcon = (
)
// ── 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 (
Ζώνες — {waiter.username}
{!allZones && (
{groups.length === 0 &&
Δεν υπάρχουν ομάδες τραπεζιών.
}
{groups.map(g => (
))}
)}
{!allZones && selected.size === 0 && (
Χωρίς επιλογή ο σερβιτόρος δεν βλέπει κανένα τραπέζι.
)}
)
}
// ── Shared form primitives ────────────────────────────────────────────────────
function FormField({ label, required, desc, children }) {
return (
{children}
{desc &&
{desc}
}
)
}
function SectionLabel({ children }) {
return (
{children}
)
}
function InfoBlock({ label, value, valueColor }) {
return (
{label}
{value || '—'}
)
}
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 Φόρτωση…
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 (
{waiters.length === 0 && (
Δεν υπάρχουν σερβιτόροι.
)}
{waiters.map(w => (
{w.full_name || w.username}
{w.nickname &&
({w.nickname})}
{w.username} · {w.role}
{w.mobile_phone &&
{w.mobile_phone}
}
{w.role === 'waiter' && (
{w.zone_assignments.length === 0 ? 'Χωρίς ζώνες'
: w.zone_assignments.some(z => z.group_id === null) ? 'Όλες οι ζώνες'
: `${w.zone_assignments.length} ζών${w.zone_assignments.length === 1 ? 'η' : 'ες'}`}
)}
{/* Active status badge — only this stays inline */}
{w.is_active ? 'Ενεργός' : 'Αποκλεισμένος'}
{/* Single actions dropdown */}
openEdit(w)}
onZones={() => setZoneModal(w)}
onResetPin={() => setPinModal(w.id)}
onBlock={() => toggleBlock.mutate(w.id)}
onDelete={() => setConfirmDelete(w.id)}
/>
))}
{/* Add modal */}
{addModal && (
{ 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 && (
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 && (
Επαναφορά PIN — {waiters.find(w => w.id === pinModal)?.username}
)}
{confirmDelete !== null && (
deleteWaiter.mutate(confirmDelete)}
onCancel={() => setConfirmDelete(null)}
/>
)}
{zoneModal && (
setZoneModal(null)} />
)}
)
}
// ── 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 (
{/* Header */}
{avatarPreview
?

:
👤
}
Νέος σερβιτόρος
Συμπληρώστε τα στοιχεία του εργαζόμενου
{
const file = e.target.files?.[0]
if (file) { setAvatarFile(file); setAvatarPreview(URL.createObjectURL(file)) }
e.target.value = ''
}} />
{avatarPreview && (
)}
{/* Body — three columns, scrollable */}
{/* Column 1: Identity + Account */}
{/* Column 2: Contact info */}
{/* Column 3: Payroll + PIN */}
Κωδικός PIN
Ο 4ψήφιος κωδικός που θα χρησιμοποιεί ο εργαζόμενος για να ξεκλειδώσει την εφαρμογή. Μπορεί να αλλάξει οποτεδήποτε.
f('pin', pin)} />
{form.pin.length > 0 && form.pin.length < 4 && (
Χρειάζονται {4 - form.pin.length} ψηφία ακόμη
)}
{form.pin.length >= 4 && (
✓ PIN ορίστηκε
)}
{/* Footer */}
)
}
// ── 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 (
{/* Header */}
{/* Body — three columns, scrollable */}
{/* Column 1: Identity + Account */}
{/* Column 2: Contact info */}
{/* Column 3: Info + Payroll + PIN note */}
Πληροφορίες
{/* Row 1: Date + Status side by side */}
{waiter.role === 'waiter' && (
)}
{/* Phase 2B — Payroll */}
Κωδικός PIN
Για αλλαγή PIN χρησιμοποιήστε την επιλογή Επαναφορά PIN από το μενού Ενεργειών.
{/* Footer */}
)
}