Files
xenia-pos-local/manager_dashboard/src/pages/StaffTab.jsx
bonamin 5da378a0ae feat: reports cost tracking, staff hourly rate, product form refactor
- 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>
2026-06-08 09:58:55 +03:00

814 lines
42 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, 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>
)
}