feat: bump client-services (accumulated feature work + deploy fixes)

Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 10:00:14 +03:00
co-authored by Claude Sonnet 5
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions
@@ -0,0 +1,810 @@
import { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import {
Plus, Pencil, Trash2, Printer, X, Check, ChevronDown,
Zap, Settings2, ArrowUpDown, Layers, CheckSquare, SkipForward,
GripVertical, ChevronUp, AlertTriangle, FlameKindling,
} from 'lucide-react'
import client from '../../api/client'
// ── API ───────────────────────────────────────────────────────────────────────
const fetchZones = () => client.get('/api/prep-zones').then(r => r.data)
const fetchPrinters = () => client.get('/api/system/printers').then(r => r.data)
const fetchCategories = () => client.get('/api/products/categories').then(r => r.data)
// ── Constants ─────────────────────────────────────────────────────────────────
const AUTO_PRINT_OPTIONS = [
{ value: 'none', label: 'Χωρίς εκτύπωση', desc: 'Μη αυτόματη εκτύπωση' },
{ value: 'master', label: 'Μόνο κύριος', desc: 'Εκτύπωση στον κύριο' },
{ value: 'all', label: 'Όλοι εκτυπωτές', desc: 'Master + δευτερεύοντες' },
]
const SORT_OPTIONS = [
{ value: 'order_time', label: 'Σειρά παραγγελίας', desc: 'Όπως προστέθηκαν στο καλάθι' },
{ value: 'item_count', label: 'Πλήθος (φθίνον)', desc: 'Πρώτα τα αντικείμενα με τη μεγαλύτερη ποσότητα' },
{ value: 'alpha', label: 'Αλφαβητικά', desc: 'Ταξινόμηση κατά όνομα' },
]
const EMPTY_ZONE = {
name: '', description: '', notification_name: '',
printer_ids: [], master_printer_id: null,
auto_print: 'none', master_copies: 1, secondary_copies: 1,
sort_items_by: 'order_time', group_by_category: false, category_order: [], print_checkboxes: false,
bypass_pending: false, bypass_kds: false, auto_ready_to_served: false,
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function Toggle({ value, onChange, disabled }) {
return (
<button
type="button"
onClick={() => !disabled && onChange(!value)}
disabled={disabled}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus:outline-none
${value ? 'bg-primary-600' : 'bg-gray-200'} ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform duration-200
${value ? 'translate-x-4' : 'translate-x-0.5'}`} />
</button>
)
}
function CopySpinner({ value, onChange, min = 1, max = 9 }) {
return (
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => onChange(Math.max(min, value - 1))}
className="w-8 h-8 rounded-lg border border-gray-300 flex items-center justify-center text-gray-600 hover:bg-gray-100 hover:border-gray-400 transition-colors text-base font-medium"
>−</button>
<span className="w-9 text-center text-sm font-bold text-gray-800 tabular-nums">{value}×</span>
<button
type="button"
onClick={() => onChange(Math.min(max, value + 1))}
className="w-8 h-8 rounded-lg border border-gray-300 flex items-center justify-center text-gray-600 hover:bg-gray-100 hover:border-gray-400 transition-colors text-base font-medium"
>+</button>
</div>
)
}
// ── Click-outside hook for dropdowns ─────────────────────────────────────────
function useClickOutside(ref, onClose) {
useEffect(() => {
function handle(e) {
if (ref.current && !ref.current.contains(e.target)) onClose()
}
document.addEventListener('mousedown', handle)
return () => document.removeEventListener('mousedown', handle)
}, [ref, onClose])
}
// ── Master printer single-select ──────────────────────────────────────────────
function MasterPrinterPicker({ printers, value, onChange }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
useClickOutside(ref, () => setOpen(false))
const selected = printers.find(p => p.id === value)
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-gray-300 rounded-lg bg-white text-sm text-left hover:border-gray-400 transition-colors"
>
<span className="flex items-center gap-2 flex-1 min-w-0">
{selected ? (
<>
<span className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
<span className="font-medium text-gray-800 truncate">{selected.name}</span>
<span className="text-xs text-gray-400 font-mono ml-auto shrink-0">{selected.ip_address}:{selected.port}</span>
</>
) : (
<span className="text-gray-400">— Χωρίς κύριο —</span>
)}
</span>
<ChevronDown size={13} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
<button
type="button"
onClick={() => { onChange(null); setOpen(false) }}
className={`w-full flex items-center gap-3 px-3 py-2.5 text-sm text-left transition-colors hover:bg-gray-50 ${!value ? 'bg-gray-50' : ''}`}
>
<span className="w-4 h-4 rounded border border-gray-300 flex items-center justify-center shrink-0">
{!value && <Check size={10} className="text-gray-600" />}
</span>
<span className="text-gray-400 italic text-xs">Χωρίς κύριο εκτυπωτή</span>
</button>
{printers.map(p => (
<button
key={p.id}
type="button"
onClick={() => { onChange(p.id); setOpen(false) }}
className={`w-full flex items-center gap-3 px-3 py-2.5 text-sm text-left transition-colors hover:bg-gray-50 ${value === p.id ? 'bg-primary-50' : ''}`}
>
<span className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${value === p.id ? 'bg-primary-600 border-primary-600' : 'border-gray-300'}`}>
{value === p.id && <Check size={10} className="text-white" />}
</span>
<Printer size={13} className="text-gray-400 shrink-0" />
<span className={`flex-1 ${value === p.id ? 'font-semibold text-primary-700' : 'text-gray-700'}`}>{p.name}</span>
<span className="text-xs text-gray-400 font-mono">{p.ip_address}:{p.port}</span>
</button>
))}
</div>
)}
</div>
)
}
// ── Secondary printers multi-select ──────────────────────────────────────────
function SecondaryPrinterPicker({ printers, selected, masterId, onChange }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
useClickOutside(ref, () => setOpen(false))
const selectedSet = new Set(selected)
const eligible = printers.filter(p => p.id !== masterId)
const selectedPrinters = eligible.filter(p => selectedSet.has(p.id))
const toggle = (id) => {
const next = new Set(selectedSet)
if (next.has(id)) next.delete(id); else next.add(id)
onChange([...next])
}
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-gray-300 rounded-lg bg-white text-sm text-left hover:border-gray-400 transition-colors min-h-[38px]"
>
<span className="flex flex-wrap gap-1 flex-1 min-w-0">
{selectedPrinters.length === 0 ? (
<span className="text-gray-400">— Χωρίς δευτερεύοντες —</span>
) : (
selectedPrinters.map(p => (
<span key={p.id} className="inline-flex items-center gap-1 bg-gray-100 text-gray-600 rounded px-1.5 py-0.5 text-xs font-medium">
<Printer size={10} />{p.name}
</span>
))
)}
</span>
<ChevronDown size={13} className={`text-gray-400 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden">
{eligible.length === 0 ? (
<div className="px-3 py-4 text-sm text-gray-400 text-center italic">
{printers.length <= 1 ? 'Δεν υπάρχουν άλλοι εκτυπωτές' : 'Ο μόνος εκτυπωτής είναι ο κύριος'}
</div>
) : eligible.map(p => {
const on = selectedSet.has(p.id)
return (
<button
key={p.id}
type="button"
onClick={() => toggle(p.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 text-sm text-left transition-colors hover:bg-gray-50 ${on ? 'bg-gray-50' : ''}`}
>
<span className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${on ? 'bg-gray-700 border-gray-700' : 'border-gray-300'}`}>
{on && <Check size={10} className="text-white" />}
</span>
<Printer size={13} className="text-gray-400 shrink-0" />
<span className={`flex-1 ${on ? 'font-semibold text-gray-800' : 'text-gray-700'}`}>{p.name}</span>
<span className="text-xs text-gray-400 font-mono">{p.ip_address}:{p.port}</span>
</button>
)
})}
<div className="border-t border-gray-100 px-3 py-2 text-xs text-gray-400 italic">
Κλικ έξω για κλείσιμο
</div>
</div>
)}
</div>
)
}
// ── Category order modal ──────────────────────────────────────────────────────
function CategoryOrderModal({ categories, order, onSave, onClose }) {
const ranked = order.map(id => categories.find(c => c.id === id)).filter(Boolean)
const unranked = categories.filter(c => !order.includes(c.id))
const [list, setList] = useState([...ranked, ...unranked])
const dragIdx = useRef(null)
const onDragStart = (i) => { dragIdx.current = i }
const onDragOver = (e, i) => {
e.preventDefault()
if (dragIdx.current === null || dragIdx.current === i) return
const next = [...list]
const [moved] = next.splice(dragIdx.current, 1)
next.splice(i, 0, moved)
dragIdx.current = i
setList(next)
}
const onDragEnd = () => { dragIdx.current = null }
const move = (i, dir) => {
const j = i + dir
if (j < 0 || j >= list.length) return
const next = [...list];
[next[i], next[j]] = [next[j], next[i]]
setList(next)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
<div>
<h2 className="text-sm font-bold text-gray-900">Σειρά Κατηγοριών Εκτύπωσης</h2>
<p className="text-xs text-gray-500 mt-0.5">Σύρετε για αλλαγή σειράς</p>
</div>
<button type="button" onClick={onClose} className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors">
<X size={16} />
</button>
</div>
<div className="px-3 py-3 max-h-80 overflow-y-auto space-y-1">
{list.map((cat, i) => (
<div
key={cat.id}
draggable
onDragStart={() => onDragStart(i)}
onDragOver={e => onDragOver(e, i)}
onDragEnd={onDragEnd}
className="flex items-center gap-2.5 px-3 py-2.5 bg-gray-50 rounded-xl border border-gray-100 cursor-grab active:cursor-grabbing select-none group hover:bg-white hover:border-gray-200 hover:shadow-sm transition-all"
>
<GripVertical size={14} className="text-gray-300 group-hover:text-gray-400 shrink-0" />
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: cat.color || '#94a3b8' }} />
<span className="text-sm text-gray-700 font-medium flex-1 min-w-0 truncate">{cat.name}</span>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button type="button" onClick={() => move(i, -1)} disabled={i === 0}
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-20">
<ChevronUp size={13} />
</button>
<button type="button" onClick={() => move(i, 1)} disabled={i === list.length - 1}
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-20">
<ChevronDown size={13} />
</button>
</div>
<span className="text-xs text-gray-300 font-mono w-5 text-right shrink-0">{i + 1}</span>
</div>
))}
{list.length === 0 && (
<div className="py-8 text-center text-sm text-gray-400">Δεν υπάρχουν κατηγορίες</div>
)}
</div>
<div className="px-5 py-4 border-t border-gray-100 flex gap-2 justify-end">
<button type="button" onClick={onClose} className="btn btn-secondary">Ακύρωση</button>
<button
type="button"
onClick={() => { onSave(list.map(c => c.id)); onClose() }}
className="btn btn-primary flex items-center gap-2"
>
<Check size={14} /> Αποθήκευση σειράς
</button>
</div>
</div>
</div>
)
}
// ── Section header ────────────────────────────────────────────────────────────
function Section({ icon: Icon_, label, children }) {
return (
<div className="space-y-3">
<div className="flex items-center gap-2 pb-1.5 border-b border-gray-100">
<Icon_ size={13} className="text-gray-400" />
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-widest">{label}</span>
</div>
{children}
</div>
)
}
// ── Toggle row ────────────────────────────────────────────────────────────────
function ToggleRow({ label, desc, value, onChange, disabled, children, warning }) {
return (
<div className={`rounded-xl border transition-colors ${value ? 'border-primary-200 bg-primary-50/40' : 'border-gray-200 bg-gray-50/40'} ${disabled ? 'opacity-60' : ''}`}>
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-gray-800">{label}</span>
{warning && <AlertTriangle size={12} className="text-amber-500 shrink-0" />}
</div>
{desc && <p className="text-xs text-gray-500 mt-0.5 leading-relaxed">{desc}</p>}
</div>
<Toggle value={value} onChange={onChange} disabled={disabled} />
</div>
{value && children && (
<div className="px-4 pb-3">{children}</div>
)}
</div>
)
}
// ── Zone Form ─────────────────────────────────────────────────────────────────
function ZoneForm({ initial, printers, categories, onSave, onCancel, saving }) {
const z = initial || EMPTY_ZONE
const [name, setName] = useState(z.name)
const [description, setDesc] = useState(z.description || '')
const [notifName, setNotifName] = useState(z.notification_name || '')
const [allPrinterIds, setAllPrinterIds] = useState(z.printer_ids || [])
const [masterId, setMasterId] = useState(z.master_printer_id ?? null)
const [autoPrint, setAutoPrint] = useState(z.auto_print || 'none')
const [masterCopies, setMasterCopies] = useState(z.master_copies || 1)
const [secondaryCopies, setSecondaryCopies] = useState(z.secondary_copies || 1)
const [sortBy, setSortBy] = useState(z.sort_items_by || 'order_time')
const [groupCat, setGroupCat] = useState(z.group_by_category || false)
const [catOrder, setCatOrder] = useState(z.category_order || [])
const [checkboxes, setCheckboxes] = useState(z.print_checkboxes || false)
const [bypassPending, setBypassPending] = useState(z.bypass_pending || false)
const [bypassKds, setBypassKds] = useState(z.bypass_kds || false)
const [autoReadyServed, setAutoReadyServed] = useState(z.auto_ready_to_served || false)
const [catModal, setCatModal] = useState(false)
const secondaryIds = allPrinterIds.filter(id => id !== masterId)
const handleMasterChange = (id) => {
setMasterId(id)
if (id && !allPrinterIds.includes(id)) setAllPrinterIds(prev => [...prev, id])
}
const handleSecondaryChange = (ids) => {
const next = new Set(ids)
if (masterId) next.add(masterId)
setAllPrinterIds([...next])
}
const handleBypassKds = (v) => { setBypassKds(v); if (v) setBypassPending(true) }
const handleBypassPending = (v) => { setBypassPending(v); if (!v) setBypassKds(false) }
const valid = name.trim().length > 0
const submit = (e) => {
e.preventDefault()
if (!valid) return
onSave({
name: name.trim(),
description: description.trim() || null,
notification_name: notifName.trim() || null,
printer_ids: allPrinterIds,
master_printer_id: masterId || null,
auto_print: autoPrint,
master_copies: masterCopies,
secondary_copies: secondaryCopies,
sort_items_by: sortBy,
group_by_category: groupCat,
category_order: catOrder,
print_checkboxes: checkboxes,
bypass_pending: bypassPending,
bypass_kds: bypassKds,
auto_ready_to_served: autoReadyServed,
})
}
return (
<>
<form onSubmit={submit} className="space-y-5">
{/* Identity */}
<Section icon={Settings2} label="Στοιχεία Ζώνης">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Όνομα ζώνης *</label>
<input className="input" value={name} onChange={e => setName(e.target.value)}
placeholder="π.χ. Kitchen – Grill" autoFocus />
</div>
<div>
<label className="label">Όνομα ειδοποίησης</label>
<input className="input" value={notifName} onChange={e => setNotifName(e.target.value)}
placeholder="π.χ. Κουζίνα" />
</div>
</div>
<div>
<label className="label">Περιγραφή</label>
<input className="input" value={description} onChange={e => setDesc(e.target.value)}
placeholder="Προαιρετική περιγραφή" />
</div>
</Section>
{/* Printers */}
<Section icon={Printer} label="Εκτυπωτές">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Κύριος εκτυπωτής</label>
<MasterPrinterPicker printers={printers} value={masterId} onChange={handleMasterChange} />
<p className="mt-1 text-xs text-gray-400">Ένας μόνο, υψηλή προτεραιότητα.</p>
</div>
<div>
<label className="label">Δευτερεύοντες εκτυπωτές</label>
<SecondaryPrinterPicker
printers={printers} selected={secondaryIds}
masterId={masterId} onChange={handleSecondaryChange}
/>
<p className="mt-1 text-xs text-gray-400">Μόνο στη λειτουργία «Όλοι».</p>
</div>
</div>
<div>
<label className="label">Αυτόματη εκτύπωση</label>
<div className="grid grid-cols-3 gap-2">
{AUTO_PRINT_OPTIONS.map(opt => (
<button key={opt.value} type="button" onClick={() => setAutoPrint(opt.value)}
className={`px-3 py-2.5 rounded-xl border text-left transition-all
${autoPrint === opt.value
? 'border-primary-500 bg-primary-50 ring-1 ring-primary-500'
: 'border-gray-200 hover:border-gray-300 bg-white'}`}>
<div className={`text-xs font-bold leading-tight ${autoPrint === opt.value ? 'text-primary-700' : 'text-gray-700'}`}>{opt.label}</div>
<div className="text-[11px] text-gray-400 mt-0.5">{opt.desc}</div>
</button>
))}
</div>
</div>
{autoPrint !== 'none' && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Αντίτυπα κύριου</label>
<CopySpinner value={masterCopies} onChange={setMasterCopies} />
</div>
{autoPrint === 'all' && (
<div>
<label className="label">Αντίτυπα δευτερευόντων</label>
<CopySpinner value={secondaryCopies} onChange={setSecondaryCopies} />
</div>
)}
</div>
)}
</Section>
{/* Ticket formatting */}
<Section icon={ArrowUpDown} label="Μορφοποίηση Δελτίου">
<div>
<label className="label">Ταξινόμηση αντικειμένων</label>
<div className="grid grid-cols-3 gap-2">
{SORT_OPTIONS.map(opt => (
<button key={opt.value} type="button" onClick={() => setSortBy(opt.value)}
className={`px-3 py-2.5 rounded-xl border text-left transition-all
${sortBy === opt.value
? 'border-primary-500 bg-primary-50 ring-1 ring-primary-500'
: 'border-gray-200 hover:border-gray-300 bg-white'}`}>
<div className={`text-xs font-bold leading-tight ${sortBy === opt.value ? 'text-primary-700' : 'text-gray-700'}`}>{opt.label}</div>
<div className="text-[11px] text-gray-400 mt-0.5">{opt.desc}</div>
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<ToggleRow
label="Ομαδοποίηση κατά κατηγορία"
desc="Αντικείμενα ομαδοποιούνται ανά κατηγορία."
value={groupCat} onChange={setGroupCat}
>
<button type="button" onClick={() => setCatModal(true)}
className="mt-1 inline-flex items-center gap-1.5 text-xs font-semibold text-primary-600 hover:text-primary-700 bg-white border border-primary-200 rounded-lg px-3 py-1.5 hover:bg-primary-50 transition-colors">
<Layers size={12} />
Σειρά κατηγοριών
{catOrder.length > 0 && (
<span className="bg-primary-100 text-primary-700 rounded px-1 text-[10px] font-bold">{catOrder.length}</span>
)}
</button>
</ToggleRow>
<ToggleRow
label="Checkboxes"
desc="Προσθέτει [ ] πριν από κάθε αντικείμενο."
value={checkboxes} onChange={setCheckboxes}
/>
</div>
</Section>
{/* KDS Behavior */}
<Section icon={Zap} label="Συμπεριφορά KDS">
<div className="grid grid-cols-1 gap-3">
<ToggleRow
label="Παράκαμψη Pending & Prep"
desc="Αντικείμενα ξεκινούν απευθείας σε READY. Χρήσιμο για ποτά ή μπύρες."
value={bypassPending} onChange={handleBypassPending}
/>
<ToggleRow
label="Παράκαμψη KDS Status"
desc="Αντικείμενα πηγαίνουν κατευθείαν σε SERVED. Ενεργοποιεί αυτόματα και την Παράκαμψη Pending."
value={bypassKds} onChange={handleBypassKds} warning
/>
<ToggleRow
label="Αυτόματο Ready → Served"
desc="Όταν ο σεφ επισημαίνει READY στο KDS, αυτόματα αναβαθμίζεται και σε SERVED."
value={autoReadyServed} onChange={setAutoReadyServed}
/>
</div>
</Section>
{/* Actions */}
<div className="flex gap-3 justify-end pt-3 border-t border-gray-100">
<button type="button" onClick={onCancel} className="btn btn-secondary">
Ακύρωση
</button>
<button type="submit" disabled={!valid || saving} className="btn btn-primary flex items-center gap-2">
<Check size={15} />
{saving ? 'Αποθήκευση…' : 'Αποθήκευση'}
</button>
</div>
</form>
{catModal && (
<CategoryOrderModal
categories={categories} order={catOrder}
onSave={setCatOrder} onClose={() => setCatModal(false)}
/>
)}
</>
)
}
// ── Zone Card ─────────────────────────────────────────────────────────────────
function Badge({ children, variant = 'default' }) {
const cls = {
default: 'bg-gray-100 text-gray-600',
primary: 'bg-primary-100 text-primary-700',
green: 'bg-green-100 text-green-700',
amber: 'bg-amber-100 text-amber-700',
red: 'bg-red-100 text-red-700',
}[variant] || 'bg-gray-100 text-gray-600'
return (
<span className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-semibold ${cls}`}>
{children}
</span>
)
}
function ZoneCard({ zone, printers, onEdit, onDelete }) {
const [confirmDelete, setConfirmDelete] = useState(false)
const printerMap = Object.fromEntries(printers.map(p => [p.id, p]))
const masterPrinter = printerMap[zone.master_printer_id]
const secondaryPrinters = (zone.secondary_printer_ids || []).map(id => printerMap[id]).filter(Boolean)
const printLabel = {
none: { text: 'Χωρίς εκτύπωση', variant: 'default' },
master: { text: 'Κύριος μόνο', variant: 'primary' },
all: { text: 'Όλοι', variant: 'primary' },
}[zone.auto_print] || { text: '—', variant: 'default' }
const sortLabel = {
order_time: 'Σειρά παρ.',
item_count: 'Κατά πλήθος',
alpha: 'Αλφαβητικά',
}[zone.sort_items_by] || zone.sort_items_by
return (
<div className="bg-white border border-gray-200 rounded-2xl shadow-sm overflow-hidden hover:shadow-md hover:border-gray-300 transition-all group">
<div className="px-4 pt-4 pb-3 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-bold text-gray-900 text-sm leading-tight">{zone.name}</h3>
{zone.bypass_kds && <Badge variant="red"><Zap size={10} />Bypass KDS</Badge>}
{!zone.bypass_kds && zone.bypass_pending && <Badge variant="amber"><SkipForward size={10} />Bypass Pending</Badge>}
{zone.auto_ready_to_served && <Badge variant="green"><Check size={10} />Auto Served</Badge>}
</div>
{zone.notification_name && (
<p className="text-xs text-primary-600 font-medium mt-0.5">{zone.notification_name}</p>
)}
{zone.description && <p className="text-xs text-gray-400 mt-0.5">{zone.description}</p>}
</div>
<div className="flex gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => onEdit(zone)}
className="p-1.5 rounded-lg text-gray-400 hover:text-primary-600 hover:bg-primary-50 transition-colors">
<Pencil size={13} />
</button>
{!confirmDelete ? (
<button onClick={() => setConfirmDelete(true)}
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors">
<Trash2 size={13} />
</button>
) : (
<div className="flex gap-1 items-center">
<button onClick={() => setConfirmDelete(false)}
className="p-1.5 rounded-lg text-gray-400 hover:bg-gray-100 transition-colors">
<X size={13} />
</button>
<button onClick={() => onDelete(zone.id)}
className="px-2 py-1 rounded-lg text-xs font-bold bg-red-600 text-white hover:bg-red-700 transition-colors">
Διαγραφή
</button>
</div>
)}
</div>
</div>
<div className="px-4 pb-3 space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wide w-14 shrink-0">Κύριος</span>
{masterPrinter ? (
<>
<span className="inline-flex items-center gap-1 bg-primary-50 text-primary-700 rounded-md px-2 py-0.5 text-xs font-medium">
<Printer size={10} />{masterPrinter.name}
</span>
{zone.auto_print !== 'none' && (
<span className="text-xs text-gray-400 font-mono">{zone.master_copies}×</span>
)}
</>
) : (
<span className="text-xs text-gray-400 italic">Χωρίς</span>
)}
</div>
{secondaryPrinters.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wide w-14 shrink-0">Δευτ.</span>
{secondaryPrinters.map(p => (
<span key={p.id} className="inline-flex items-center gap-1 bg-gray-100 text-gray-600 rounded-md px-2 py-0.5 text-xs font-medium">
<Printer size={10} />{p.name}
</span>
))}
{zone.auto_print === 'all' && (
<span className="text-xs text-gray-400 font-mono">{zone.secondary_copies}×</span>
)}
</div>
)}
</div>
<div className="px-4 py-2.5 border-t border-gray-100 bg-gray-50/60 flex flex-wrap gap-1.5 items-center">
<Badge variant={zone.auto_print !== 'none' ? 'primary' : 'default'}>{printLabel.text}</Badge>
<Badge>{sortLabel}</Badge>
{zone.group_by_category && <Badge><Layers size={10} />Κατά κατηγορία</Badge>}
{zone.print_checkboxes && <Badge><CheckSquare size={10} />Checkboxes</Badge>}
</div>
</div>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function PrepZonesConfigPage() {
const qc = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState(null)
const { data: zones = [], isLoading: lZ } = useQuery({ queryKey: ['prep-zones'], queryFn: fetchZones })
const { data: printers = [], isLoading: lP } = useQuery({ queryKey: ['printers'], queryFn: fetchPrinters })
const { data: categories = [], isLoading: lC } = useQuery({ queryKey: ['categories'], queryFn: fetchCategories })
const invalidate = () => qc.invalidateQueries({ queryKey: ['prep-zones'] })
const create = useMutation({
mutationFn: body => client.post('/api/prep-zones', body).then(r => r.data),
onSuccess: () => { invalidate(); setShowForm(false); toast.success('Ζώνη δημιουργήθηκε') },
onError: () => toast.error('Σφάλμα κατά τη δημιουργία'),
})
const update = useMutation({
mutationFn: ({ id, ...body }) => client.put(`/api/prep-zones/${id}`, body).then(r => r.data),
onSuccess: () => { invalidate(); setEditing(null); toast.success('Ζώνη ενημερώθηκε') },
onError: () => toast.error('Σφάλμα κατά την ενημέρωση'),
})
const remove = useMutation({
mutationFn: id => client.delete(`/api/prep-zones/${id}`),
onSuccess: () => { invalidate(); toast.success('Ζώνη διαγράφηκε') },
onError: () => toast.error('Σφάλμα κατά τη διαγραφή'),
})
if (lZ || lP || lC) {
return (
<div className="p-8 flex items-center gap-3 text-sm text-gray-400">
<div className="w-4 h-4 rounded-full border-2 border-gray-300 border-t-primary-500 animate-spin" />
Φόρτωση…
</div>
)
}
const flatCategories = categories.flatMap(c => [c, ...(c.subcategories || [])])
const formOpen = showForm || !!editing
return (
// h-full + overflow-y-auto makes this page scroll within the fixed AppLayout <main>
<div className="h-full overflow-y-auto">
<div className="p-6">
{/* Page header */}
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-orange-100 flex items-center justify-center shrink-0">
<FlameKindling size={16} className="text-orange-600" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900 leading-tight">Ζώνες Προετοιμασίας</h1>
<p className="text-sm text-gray-500">Ζώνες, εκτυπωτές και συμπεριφορά KDS</p>
</div>
</div>
{!formOpen && (
<button onClick={() => setShowForm(true)} className="btn btn-primary flex items-center gap-2 shrink-0">
<Plus size={15} />
Νέα Ζώνη
</button>
)}
</div>
{/* Two-column layout when form is open */}
{formOpen ? (
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-6 items-start xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
{/* Form panel */}
<div className={`bg-white rounded-2xl p-6 shadow-sm border ${showForm ? 'border-primary-200' : 'border-amber-200'}`}>
<div className="flex items-center gap-2 mb-5">
<div className={`w-1 h-5 rounded-full ${showForm ? 'bg-primary-500' : 'bg-amber-400'}`} />
<h2 className="text-sm font-bold text-gray-800">
{showForm ? 'Νέα Ζώνη Προετοιμασίας' : `Επεξεργασία: ${editing?.name}`}
</h2>
</div>
<ZoneForm
initial={editing}
printers={printers}
categories={flatCategories}
onSave={showForm
? (body) => create.mutate(body)
: (body) => update.mutate({ id: editing.id, ...body })}
onCancel={() => { setShowForm(false); setEditing(null) }}
saving={create.isPending || update.isPending}
/>
</div>
{/* Zone list sidebar */}
<div className="space-y-3">
{zones.length === 0 ? (
<div className="text-center py-10 text-gray-400 bg-white rounded-2xl border border-gray-200">
<FlameKindling size={24} className="mx-auto mb-2 text-gray-300" />
<p className="text-sm">Δεν υπάρχουν ζώνες ακόμα</p>
</div>
) : zones.map(zone => (
<ZoneCard
key={zone.id}
zone={zone}
printers={printers}
onEdit={z => { setShowForm(false); setEditing(z) }}
onDelete={id => remove.mutate(id)}
/>
))}
</div>
</div>
) : (
/* Full-width zone grid when no form open */
zones.length === 0 ? (
<div className="text-center py-20 text-gray-400">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gray-100 flex items-center justify-center">
<FlameKindling size={28} className="text-gray-300" />
</div>
<div className="font-semibold text-gray-600 mb-1">Δεν υπάρχουν ζώνες ακόμα</div>
<p className="text-sm">Δημιουργήστε την πρώτη ζώνη για να αρχίσετε.</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{zones.map(zone => (
<ZoneCard
key={zone.id}
zone={zone}
printers={printers}
onEdit={z => { setShowForm(false); setEditing(z) }}
onDelete={id => remove.mutate(id)}
/>
))}
</div>
)
)}
</div>
</div>
)
}