Fix order saving, isMyOrder, blocked waiters, options pricing; add preferences, table groups, product images

Backend:
- OrderItemInput accepts option objects {id,name,price_delta} instead of int IDs
- extra_cost from selected options added to unit_price snapshot
- GET /api/products/?all=true for manager (includes unavailable)
- PUT /api/products/{id} now replaces options, ingredients, preference_sets
- POST /api/products/{id}/image — persistent image upload to /app/data/product_images
- New models: ProductPreferenceSet, ProductPreferenceChoice, TableGroup
- tables: group_id FK, hard delete (?hard=true), batch create POST /api/tables/batch
- GET /api/tables/groups + POST/PUT/DELETE groups endpoints
- POST /api/auth/me endpoint for token rehydration
- Auto-migration on startup for new columns

PWA:
- AuthRehydrator: fetches /auth/me on load so isMyOrder works after page reload
- 401 response force-logs out (covers blocked waiters)
- ItemOptionsModal: uses extra_cost correctly, shows preferences as radio buttons

Manager:
- ProductsPage: shows unavailable products greyed out, category color picker + reorder,
  full option/ingredient/preference editing, image upload
- TablesPage: table groups, auto-increment, deactivate vs hard delete, batch add
This commit is contained in:
2026-04-20 18:39:51 +03:00
parent 8f52156f5b
commit 24a029a8cc
16 changed files with 826 additions and 172 deletions

View File

@@ -4,30 +4,54 @@ import toast from 'react-hot-toast'
import client from '../api/client'
import ConfirmModal from '../components/ConfirmModal'
const EMPTY_PRODUCT = { name: '', category_id: '', base_price: '', is_available: true, printer_zone_id: '', options: [], ingredients: [] }
const EMPTY_PRODUCT = {
name: '', category_id: '', base_price: '', is_available: true,
printer_zone_id: '', options: [], ingredients: [], preference_sets: [],
}
// ── Category colour swatch ────────────────────────────────────────────────────
const COLORS = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#ef4444','#ec4899','#8b5cf6','#14b8a6','#f97316','#64748b']
function ColorPicker({ value, onChange }) {
return (
<div className="flex flex-wrap gap-2 mt-1">
{COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => onChange(c)}
className="w-7 h-7 rounded-full border-2 transition-all"
style={{ background: c, borderColor: value === c ? '#000' : 'transparent' }}
/>
))}
</div>
)
}
export default function ProductsPage() {
const qc = useQueryClient()
const [selectedCat, setSelectedCat] = useState(null)
const [editProduct, setEditProduct] = useState(null) // null | 'new' | product object
const [editProduct, setEditProduct] = useState(null)
const [editCat, setEditCat] = useState(null)
const [confirmDelete, setConfirmDelete] = useState(null) // { type: 'product'|'category', id }
const [confirmDelete, setConfirmDelete] = useState(null)
const { data: categories = [] } = useQuery({
queryKey: ['categories'],
queryFn: () => client.get('/api/products/categories').then(r => r.data),
})
// Manager fetches ALL products including unavailable ones
const { data: allProducts = [] } = useQuery({
queryKey: ['products-all'],
queryFn: () => client.get('/api/products/').then(r => r.data),
queryFn: () => client.get('/api/products/?all=true').then(r => r.data),
})
const { data: printers = [] } = useQuery({
queryKey: ['printers'],
queryFn: () => client.get('/api/system/status').then(r => r.data.printers ?? []),
const { data: statusData } = useQuery({
queryKey: ['system-status'],
queryFn: () => client.get('/api/system/status').then(r => r.data),
staleTime: 60_000,
})
const printers = statusData?.printers ?? []
const products = selectedCat
? allProducts.filter(p => p.category_id === selectedCat)
@@ -39,10 +63,9 @@ export default function ProductsPage() {
}
const saveCat = useMutation({
mutationFn: (body) =>
editCat?.id
? client.put(`/api/products/categories/${editCat.id}`, body)
: client.post('/api/products/categories', body),
mutationFn: (body) => editCat?.id
? client.put(`/api/products/categories/${editCat.id}`, body)
: client.post('/api/products/categories', body),
onSuccess: () => { toast.success('Κατηγορία αποθηκεύτηκε'); setEditCat(null); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
@@ -53,18 +76,22 @@ export default function ProductsPage() {
onError: () => toast.error('Σφάλμα'),
})
const reorderCats = useMutation({
mutationFn: (items) => client.put('/api/products/categories/reorder', items),
onSuccess: () => invalidate(),
})
const saveProduct = useMutation({
mutationFn: (body) =>
editProduct?.id
? client.put(`/api/products/${editProduct.id}`, body)
: client.post('/api/products/', body),
mutationFn: (body) => editProduct?.id
? client.put(`/api/products/${editProduct.id}`, body)
: client.post('/api/products/', body),
onSuccess: () => { toast.success('Προϊόν αποθηκεύτηκε'); setEditProduct(null); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
const toggleAvail = useMutation({
mutationFn: ({ id, is_available }) => client.put(`/api/products/${id}`, { is_available }),
onSuccess: () => { invalidate() },
onSuccess: () => invalidate(),
onError: () => toast.error('Σφάλμα'),
})
@@ -74,6 +101,19 @@ export default function ProductsPage() {
onError: () => toast.error('Σφάλμα'),
})
function moveCat(cat, dir) {
const sorted = [...categories].sort((a, b) => a.sort_order - b.sort_order)
const idx = sorted.findIndex(c => c.id === cat.id)
const swapIdx = idx + dir
if (swapIdx < 0 || swapIdx >= sorted.length) return
const updates = sorted.map((c, i) => {
if (i === idx) return { id: c.id, sort_order: sorted[swapIdx].sort_order }
if (i === swapIdx) return { id: c.id, sort_order: sorted[idx].sort_order }
return { id: c.id, sort_order: c.sort_order }
})
reorderCats.mutate(updates)
}
function handleConfirmDelete() {
if (!confirmDelete) return
if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id)
@@ -83,7 +123,7 @@ export default function ProductsPage() {
return (
<div className="flex gap-6 h-full">
{/* Left: Categories */}
<aside className="w-56 shrink-0 space-y-2">
<aside className="w-60 shrink-0 space-y-1">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-gray-700">Κατηγορίες</h2>
<button onClick={() => setEditCat({})} className="btn btn-primary text-xs px-2 py-1 min-h-0 h-8">+</button>
@@ -94,16 +134,16 @@ export default function ProductsPage() {
>
Όλα
</button>
{categories.map(cat => (
{[...categories].sort((a, b) => a.sort_order - b.sort_order).map((cat, idx, arr) => (
<div key={cat.id} className={`flex items-center gap-1 rounded-xl ${selectedCat === cat.id ? 'bg-primary-700 text-white' : 'hover:bg-gray-100'}`}>
<button
onClick={() => setSelectedCat(cat.id)}
className="flex-1 text-left px-3 py-2.5 text-sm font-medium"
>
{cat.color && <span className="w-2.5 h-2.5 rounded-full ml-2 shrink-0" style={{ background: cat.color }} />}
<button onClick={() => setSelectedCat(cat.id)} className="flex-1 text-left px-2 py-2.5 text-sm font-medium truncate">
{cat.name}
</button>
<button onClick={() => setEditCat(cat)} className="p-1.5 rounded-lg hover:bg-black/10 text-xs"></button>
<button onClick={() => setConfirmDelete({ type: 'category', id: cat.id })} className="p-1.5 rounded-lg hover:bg-red-100 text-xs mr-1">🗑</button>
<button onClick={() => moveCat(cat, -1)} disabled={idx === 0} className="p-1 text-xs disabled:opacity-30"></button>
<button onClick={() => moveCat(cat, 1)} disabled={idx === arr.length - 1} className="p-1 text-xs disabled:opacity-30"></button>
<button onClick={() => setEditCat(cat)} className="p-1 text-xs"></button>
<button onClick={() => setConfirmDelete({ type: 'category', id: cat.id })} className="p-1 text-xs mr-1">🗑</button>
</div>
))}
</aside>
@@ -123,41 +163,39 @@ export default function ProductsPage() {
<div className="space-y-3">
{products.map(p => (
<div key={p.id} className="card p-4 flex items-center gap-4">
<div key={p.id} className={`card p-4 flex items-center gap-4 transition-opacity ${!p.is_available ? 'opacity-50' : ''}`}>
{p.image_url && (
<img src={`${import.meta.env.VITE_API_URL || ''}${p.image_url}`} alt={p.name} className="w-12 h-12 rounded-lg object-cover shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-semibold text-gray-800">{p.name}</p>
<p className="font-semibold text-gray-800">
{p.name}
{!p.is_available && <span className="ml-2 text-xs text-red-500 font-normal">(μη διαθέσιμο)</span>}
</p>
<p className="text-sm text-gray-500">
{categories.find(c => c.id === p.category_id)?.name ?? '—'} ·
{p.base_price.toFixed(2)}
{categories.find(c => c.id === p.category_id)?.name ?? '—'} · {p.base_price.toFixed(2)}
{p.printer_zone_id && ` · Εκτυπωτής #${p.printer_zone_id}`}
</p>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={p.is_available}
onChange={e => toggleAvail.mutate({ id: p.id, is_available: e.target.checked })}
className="w-4 h-4 accent-primary-700"
/>
<label className="flex items-center gap-2 cursor-pointer select-none shrink-0">
<input type="checkbox" checked={p.is_available} onChange={e => toggleAvail.mutate({ id: p.id, is_available: e.target.checked })} className="w-4 h-4 accent-primary-700" />
<span className="text-sm text-gray-600">Διαθέσιμο</span>
</label>
<button onClick={() => setEditProduct(p)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
<button onClick={() => setConfirmDelete({ type: 'product', id: p.id })} className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9">Διαγραφή</button>
<button onClick={() => setEditProduct(p)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9 shrink-0">Επεξεργασία</button>
<button onClick={() => setConfirmDelete({ type: 'product', id: p.id })} className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9 shrink-0">Απενεργ.</button>
</div>
))}
</div>
</div>
{/* Category form modal */}
{editCat !== null && (
<CategoryFormModal
cat={editCat}
onSave={(name, sort_order) => saveCat.mutate({ name, sort_order: Number(sort_order) })}
onSave={(name, color) => saveCat.mutate({ name, color })}
onClose={() => setEditCat(null)}
/>
)}
{/* Product form panel */}
{editProduct !== null && (
<ProductFormPanel
product={editProduct}
@@ -170,9 +208,9 @@ export default function ProductsPage() {
{confirmDelete && (
<ConfirmModal
title="Επιβεβαίωση διαγραφής"
message={confirmDelete.type === 'category' ? 'Η κατηγορία θα διαγραφεί.' : 'Το προϊόν θα απενεργοποιηθεί.'}
confirmLabel="Διαγραφή"
title="Επιβεβαίωση"
message={confirmDelete.type === 'category' ? 'Η κατηγορία θα διαγραφεί.' : 'Το προϊόν θα απενεργοποιηθεί (δεν θα εμφανίζεται στους σερβιτόρους).'}
confirmLabel="Επιβεβαίωση"
confirmClass="btn-danger"
onConfirm={handleConfirmDelete}
onCancel={() => setConfirmDelete(null)}
@@ -184,7 +222,7 @@ export default function ProductsPage() {
function CategoryFormModal({ cat, onSave, onClose }) {
const [name, setName] = useState(cat.name || '')
const [sort, setSort] = useState(cat.sort_order ?? 0)
const [color, setColor] = useState(cat.color || '')
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-xs p-6 space-y-4">
@@ -194,12 +232,12 @@ function CategoryFormModal({ cat, onSave, onClose }) {
<input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus />
</div>
<div>
<label className="label">Σειρά ταξινόμησης</label>
<input className="input" type="number" value={sort} onChange={e => setSort(e.target.value)} />
<label className="label">Χρώμα</label>
<ColorPicker value={color} onChange={setColor} />
</div>
<div className="flex gap-3">
<div className="flex gap-3 pt-2">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={() => onSave(name, sort)} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button>
<button onClick={() => onSave(name, color || null)} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button>
</div>
</div>
</div>
@@ -214,45 +252,84 @@ function ProductFormPanel({ product, categories, printers, onSave, onClose }) {
is_available: product.is_available ?? true,
printer_zone_id: product.printer_zone_id || '',
options: product.options?.map(o => ({ name: o.name, extra_cost: o.extra_cost })) ?? [],
ingredients: product.ingredients?.map(i => ({ name: i.name })) ?? [],
ingredients: product.ingredients?.map(i => ({ name: i.name, extra_cost: i.extra_cost ?? 0 })) ?? [],
preference_sets: product.preference_sets?.map(ps => ({
name: ps.name,
choices: ps.choices.map(c => ({ name: c.name, extra_cost: c.extra_cost })),
})) ?? [],
})
const [imageFile, setImageFile] = useState(null)
const [uploading, setUploading] = useState(false)
const qc = useQueryClient()
function setField(k, v) { setForm(f => ({ ...f, [k]: v })) }
// Options
function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0 }] })) }
function removeOption(i) { setForm(f => ({ ...f, options: f.options.filter((_, idx) => idx !== i) })) }
function setOption(i, k, v) {
setForm(f => ({ ...f, options: f.options.map((o, idx) => idx === i ? { ...o, [k]: v } : o) }))
}
function setOption(i, k, v) { setForm(f => ({ ...f, options: f.options.map((o, idx) => idx === i ? { ...o, [k]: v } : o) })) }
function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '' }] })) }
// Ingredients
function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '', extra_cost: 0 }] })) }
function removeIngredient(i) { setForm(f => ({ ...f, ingredients: f.ingredients.filter((_, idx) => idx !== i) })) }
function setIngredient(i, v) {
setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { name: v } : ing) }))
function setIngredient(i, k, v) { setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { ...ing, [k]: v } : ing) })) }
// Preference sets
function addPrefSet() { setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', choices: [] }] })) }
function removePrefSet(si) { setForm(f => ({ ...f, preference_sets: f.preference_sets.filter((_, idx) => idx !== si) })) }
function setPrefSetName(si, v) { setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => idx === si ? { ...ps, name: v } : ps) })) }
function addChoice(si) { setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => idx === si ? { ...ps, choices: [...ps.choices, { name: '', extra_cost: 0 }] } : ps) })) }
function removeChoice(si, ci) { setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => idx === si ? { ...ps, choices: ps.choices.filter((_, cidx) => cidx !== ci) } : ps) })) }
function setChoice(si, ci, k, v) {
setForm(f => ({
...f,
preference_sets: f.preference_sets.map((ps, idx) => idx === si
? { ...ps, choices: ps.choices.map((ch, cidx) => cidx === ci ? { ...ch, [k]: v } : ch) }
: ps
),
}))
}
function submit() {
async function submit() {
const body = {
...form,
category_id: form.category_id ? Number(form.category_id) : null,
base_price: parseFloat(form.base_price),
printer_zone_id: form.printer_zone_id ? Number(form.printer_zone_id) : null,
options: form.options.map(o => ({ ...o, extra_cost: parseFloat(o.extra_cost) || 0 })),
ingredients: form.ingredients.map(i => ({ ...i, extra_cost: parseFloat(i.extra_cost) || 0 })),
preference_sets: form.preference_sets.map(ps => ({
...ps,
choices: ps.choices.map(c => ({ ...c, extra_cost: parseFloat(c.extra_cost) || 0 })),
})),
}
onSave(body)
// Upload image after save if selected
if (imageFile && product.id) {
setUploading(true)
try {
const fd = new FormData()
fd.append('file', imageFile)
await client.post(`/api/products/${product.id}/image`, fd)
qc.invalidateQueries({ queryKey: ['products-all'] })
} catch {
toast.error('Σφάλμα ανεβάσματος εικόνας')
} finally {
setUploading(false)
}
}
}
return (
<div className="fixed inset-0 bg-black/40 flex justify-end z-50">
<div className="bg-white w-full max-w-md h-full overflow-y-auto shadow-xl p-6 space-y-5">
<div className="bg-white w-full max-w-lg h-full overflow-y-auto shadow-xl p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="font-bold text-gray-800 text-lg">{product.id ? 'Επεξεργασία προϊόντος' : 'Νέο προϊόν'}</h2>
<button onClick={onClose} className="btn btn-ghost"></button>
</div>
<div>
<label className="label">Όνομα *</label>
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus />
</div>
<div><label className="label">Όνομα *</label><input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus /></div>
<div>
<label className="label">Κατηγορία</label>
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>
@@ -260,10 +337,7 @@ function ProductFormPanel({ product, categories, printers, onSave, onClose }) {
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div>
<label className="label">Τιμή βάσης () *</label>
<input className="input" type="number" step="0.01" min="0" value={form.base_price} onChange={e => setField('base_price', e.target.value)} />
</div>
<div><label className="label">Τιμή βάσης () *</label><input className="input" type="number" step="0.01" min="0" value={form.base_price} onChange={e => setField('base_price', e.target.value)} /></div>
<div>
<label className="label">Ζώνη εκτυπωτή</label>
<select className="input" value={form.printer_zone_id} onChange={e => setField('printer_zone_id', e.target.value)}>
@@ -276,40 +350,80 @@ function ProductFormPanel({ product, categories, printers, onSave, onClose }) {
<span className="text-sm font-medium text-gray-700">Διαθέσιμο</span>
</label>
{/* Options */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="label mb-0">Επιλογές</label>
<button onClick={addOption} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">+ Επιλογή</button>
{/* Image */}
{product.id && (
<div>
<label className="label">Εικόνα (256×256)</label>
{product.image_url && <img src={`${import.meta.env.VITE_API_URL || ''}${product.image_url}`} className="w-16 h-16 rounded-lg object-cover mb-2" alt="" />}
<input type="file" accept="image/*" className="text-sm" onChange={e => setImageFile(e.target.files[0])} />
</div>
)}
{/* Options */}
<Section title="Επιλογές" onAdd={addOption} addLabel="+ Επιλογή">
{form.options.map((opt, i) => (
<div key={i} className="flex gap-2 mb-2">
<input className="input flex-1" placeholder="Όνομα" value={opt.name} onChange={e => setOption(i, 'name', e.target.value)} />
<input className="input w-24" type="number" step="0.01" placeholder="+€" value={opt.extra_cost} onChange={e => setOption(i, 'extra_cost', parseFloat(e.target.value) || 0)} />
<button onClick={() => removeOption(i)} className="btn btn-danger px-2 min-h-0 h-10"></button>
</div>
<CostRow key={i} name={opt.name} cost={opt.extra_cost}
onName={v => setOption(i, 'name', v)} onCost={v => setOption(i, 'extra_cost', v)}
onRemove={() => removeOption(i)} costLabel="+/- €" />
))}
</div>
</Section>
{/* Ingredients */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="label mb-0">Υλικά</label>
<button onClick={addIngredient} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">+ Υλικό</button>
</div>
<Section title="Υλικά (αφαιρούμενα)" onAdd={addIngredient} addLabel="+ Υλικό">
{form.ingredients.map((ing, i) => (
<div key={i} className="flex gap-2 mb-2">
<input className="input flex-1" placeholder="Υλικό" value={ing.name} onChange={e => setIngredient(i, e.target.value)} />
<button onClick={() => removeIngredient(i)} className="btn btn-danger px-2 min-h-0 h-10"></button>
<CostRow key={i} name={ing.name} cost={ing.extra_cost}
onName={v => setIngredient(i, 'name', v)} onCost={v => setIngredient(i, 'extra_cost', v)}
onRemove={() => removeIngredient(i)} costLabel="+/- €" />
))}
</Section>
{/* Preference Sets */}
<Section title="Προτιμήσεις (αποκλειστική επιλογή)" onAdd={addPrefSet} addLabel="+ Σετ">
{form.preference_sets.map((ps, si) => (
<div key={si} className="border border-gray-200 rounded-xl p-3 mb-3 space-y-2">
<div className="flex gap-2">
<input className="input flex-1 text-sm" placeholder="Όνομα σετ π.χ. Ψήσιμο" value={ps.name} onChange={e => setPrefSetName(si, e.target.value)} />
<button onClick={() => removePrefSet(si)} className="btn btn-danger px-2 min-h-0 h-9"></button>
</div>
{ps.choices.map((ch, ci) => (
<CostRow key={ci} name={ch.name} cost={ch.extra_cost}
onName={v => setChoice(si, ci, 'name', v)} onCost={v => setChoice(si, ci, 'extra_cost', v)}
onRemove={() => removeChoice(si, ci)} costLabel="+/- €" indent />
))}
<button onClick={() => addChoice(si)} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7 ml-2">+ Επιλογή</button>
</div>
))}
</div>
</Section>
<div className="flex gap-3 pt-2">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={submit} disabled={!form.name.trim() || !form.base_price} className="flex-1 btn btn-primary">Αποθήκευση</button>
<button onClick={submit} disabled={!form.name.trim() || !form.base_price || uploading} className="flex-1 btn btn-primary">
{uploading ? 'Ανέβασμα…' : 'Αποθήκευση'}
</button>
</div>
</div>
</div>
)
}
function Section({ title, onAdd, addLabel, children }) {
return (
<div>
<div className="flex items-center justify-between mb-2">
<label className="label mb-0 font-semibold">{title}</label>
<button onClick={onAdd} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">{addLabel}</button>
</div>
{children}
</div>
)
}
function CostRow({ name, cost, onName, onCost, onRemove, costLabel, indent }) {
return (
<div className={`flex gap-2 mb-2 ${indent ? 'ml-2' : ''}`}>
<input className="input flex-1 text-sm" placeholder="Όνομα" value={name} onChange={e => onName(e.target.value)} />
<input className="input w-24 text-sm" type="number" step="0.01" placeholder={costLabel} value={cost} onChange={e => onCost(e.target.value)} />
<button onClick={onRemove} className="btn btn-danger px-2 min-h-0 h-10 text-sm"></button>
</div>
)
}

View File

@@ -8,19 +8,45 @@ export default function TablesPage() {
const qc = useQueryClient()
const [addModal, setAddModal] = useState(false)
const [editModal, setEditModal] = useState(null)
const [confirmDeactivate, setConfirmDeactivate] = useState(null)
const [form, setForm] = useState({ number: '', label: '' })
const [batchModal, setBatchModal] = useState(null) // group id or null
const [groupModal, setGroupModal] = useState(null) // null | {} | group object
const [confirmDelete, setConfirmDelete] = useState(null) // { id, hard }
const [showInactive, setShowInactive] = useState(false)
const { data: tables = [], isLoading } = useQuery({
queryKey: ['tables'],
queryFn: () => client.get('/api/tables/').then(r => r.data),
queryKey: ['tables-all', showInactive],
queryFn: () => client.get(`/api/tables/?include_inactive=${showInactive}`).then(r => r.data),
})
const invalidate = () => qc.invalidateQueries({ queryKey: ['tables'] })
const { data: groups = [] } = useQuery({
queryKey: ['table-groups'],
queryFn: () => client.get('/api/tables/groups').then(r => r.data),
})
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['tables-all'] })
qc.invalidateQueries({ queryKey: ['tables'] })
}
const invalidateGroups = () => qc.invalidateQueries({ queryKey: ['table-groups'] })
// Next auto-increment number within a group (or global)
function nextNumber(groupId) {
const relevant = groupId
? tables.filter(t => t.group_id === groupId)
: tables
if (relevant.length === 0) return 1
return Math.max(...relevant.map(t => t.number)) + 1
}
const createTable = useMutation({
mutationFn: (body) => client.post('/api/tables/', body),
onSuccess: () => { toast.success('Τραπέζι δημιουργήθηκε'); setAddModal(false); setForm({ number: '', label: '' }); invalidate() },
onSuccess: () => { toast.success('Τραπέζι δημιουργήθηκε'); setAddModal(false); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const batchCreate = useMutation({
mutationFn: (body) => client.post('/api/tables/batch', body),
onSuccess: (res) => { toast.success(`${res.data.length} τραπέζια δημιουργήθηκαν`); setBatchModal(null); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
@@ -30,97 +56,222 @@ export default function TablesPage() {
onError: () => toast.error('Σφάλμα'),
})
const deactivateTable = useMutation({
mutationFn: (id) => client.delete(`/api/tables/${id}`),
onSuccess: () => { toast.success('Απενεργοποιήθηκε'); setConfirmDeactivate(null); invalidate() },
const deleteTable = useMutation({
mutationFn: ({ id, hard }) => client.delete(`/api/tables/${id}?hard=${hard}`),
onSuccess: (_, vars) => {
toast.success(vars.hard ? 'Διαγράφηκε' : 'Απενεργοποιήθηκε')
setConfirmDelete(null)
invalidate()
},
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const saveGroup = useMutation({
mutationFn: (body) => groupModal?.id
? client.put(`/api/tables/groups/${groupModal.id}`, body)
: client.post('/api/tables/groups', body),
onSuccess: () => { toast.success('Γκρουπ αποθηκεύτηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
onError: (err) => toast.error(err.response?.data?.detail || 'Σφάλμα'),
})
const deleteGroup = useMutation({
mutationFn: (id) => client.delete(`/api/tables/groups/${id}`),
onSuccess: () => { toast.success('Γκρουπ διαγράφηκε'); setGroupModal(null); invalidateGroups(); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
// Group tables by group
const grouped = [
{ group: null, tables: tables.filter(t => !t.group_id) },
...groups.map(g => ({ group: g, tables: tables.filter(t => t.group_id === g.id) })),
].filter(section => section.tables.length > 0 || section.group)
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
return (
<div className="space-y-4 max-w-2xl">
<div className="flex items-center justify-between">
<div className="space-y-6 max-w-3xl">
<div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-xl font-bold text-gray-800">Τραπέζια</h1>
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέο τραπέζι</button>
<div className="flex gap-2 flex-wrap">
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} className="accent-primary-700" />
Εμφάνιση ανενεργών
</label>
<button onClick={() => setGroupModal({})} className="btn btn-secondary text-sm">+ Νέο γκρουπ</button>
<button onClick={() => setAddModal(true)} className="btn btn-primary">+ Νέο τραπέζι</button>
</div>
</div>
<div className="card divide-y divide-gray-100">
{tables.length === 0 && (
<p className="px-4 py-8 text-center text-gray-400">Δεν υπάρχουν τραπέζια.</p>
)}
{tables.map(t => (
<div key={t.id} className="flex items-center gap-4 px-4 py-3">
<span className="text-2xl font-extrabold text-gray-800 w-10">{t.number}</span>
<p className="flex-1 text-sm text-gray-600">{t.label || '—'}</p>
<button
onClick={() => { setEditModal(t); }}
className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9"
>
Επεξεργασία
</button>
<button
onClick={() => setConfirmDeactivate(t.id)}
className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9"
>
Απενεργοποίηση
</button>
{grouped.map(({ group, tables: gt }) => (
<div key={group?.id ?? 'ungrouped'}>
{group && (
<div className="flex items-center gap-3 mb-2">
<h2 className="font-semibold text-gray-700">{group.name}</h2>
<button onClick={() => setGroupModal(group)} className="text-xs text-gray-400 hover:text-gray-600"></button>
<button onClick={() => setBatchModal(group.id)} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">+ Μαζική προσθήκη</button>
</div>
)}
{!group && gt.length > 0 && <h2 className="font-semibold text-gray-500 mb-2 text-sm">Χωρίς γκρουπ</h2>}
<div className="card divide-y divide-gray-100">
{gt.length === 0 && (
<p className="px-4 py-4 text-sm text-gray-400 text-center">Δεν υπάρχουν τραπέζια σε αυτό το γκρουπ.</p>
)}
{gt.map(t => (
<div key={t.id} className={`flex items-center gap-4 px-4 py-3 ${!t.is_active ? 'opacity-40' : ''}`}>
<span className="text-2xl font-extrabold text-gray-800 w-10">{t.number}</span>
<p className="flex-1 text-sm text-gray-600">{t.label || '—'}</p>
{!t.is_active && <span className="text-xs text-red-400 font-medium">Ανενεργό</span>}
<button onClick={() => setEditModal(t)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Επεξεργασία</button>
{t.is_active
? <button onClick={() => setConfirmDelete({ id: t.id, hard: false })} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9 text-amber-600 hover:bg-amber-50">Απενεργ.</button>
: <button onClick={() => updateTable.mutate({ id: t.id, is_active: true })} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9 text-green-600 hover:bg-green-50">Ενεργοπ.</button>
}
<button onClick={() => setConfirmDelete({ id: t.id, hard: true })} className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9">Διαγραφή</button>
</div>
))}
</div>
))}
</div>
</div>
))}
{/* Add table modal */}
{tables.length === 0 && (
<p className="text-center text-gray-400 py-12">Δεν υπάρχουν τραπέζια. Προσθέστε ένα.</p>
)}
{/* Add single table */}
{addModal && (
<TableModal
title="Νέο τραπέζι"
form={form}
setForm={setForm}
onSave={() => createTable.mutate({ number: Number(form.number), label: form.label || null })}
initial={{ number: nextNumber(null), label: '', group_id: '' }}
groups={groups}
onSave={(f) => createTable.mutate({ number: Number(f.number), label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
onClose={() => setAddModal(false)}
/>
)}
{/* Edit table modal */}
{/* Edit table */}
{editModal && (
<TableModal
title="Επεξεργασία τραπεζιού"
form={{ number: editModal.number, label: editModal.label || '' }}
setForm={(f) => setEditModal(t => ({ ...t, ...f }))}
onSave={() => updateTable.mutate({ id: editModal.id, number: Number(editModal.number), label: editModal.label || null })}
initial={{ number: editModal.number, label: editModal.label || '', group_id: editModal.group_id || '' }}
groups={groups}
onSave={(f) => updateTable.mutate({ id: editModal.id, number: Number(f.number), label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
onClose={() => setEditModal(null)}
/>
)}
{confirmDeactivate !== null && (
{/* Batch add */}
{batchModal !== null && (
<BatchModal
groupId={batchModal}
startNumber={nextNumber(batchModal)}
onSave={(body) => batchCreate.mutate(body)}
onClose={() => setBatchModal(null)}
/>
)}
{/* Group form */}
{groupModal !== null && (
<GroupModal
group={groupModal}
onSave={(name) => saveGroup.mutate({ name })}
onDelete={groupModal.id ? () => deleteGroup.mutate(groupModal.id) : null}
onClose={() => setGroupModal(null)}
/>
)}
{/* Delete confirmation */}
{confirmDelete && (
<ConfirmModal
title="Απενεργοποίηση τραπεζιού;"
message="Το τραπέζι δεν θα εμφανίζεται πλέον."
confirmLabel="Απενεργοποίηση"
title={confirmDelete.hard ? 'Οριστική διαγραφή τραπεζιού;' : 'Απενεργοποίηση τραπεζιού;'}
message={confirmDelete.hard
? 'Το τραπέζι θα διαγραφεί οριστικά. Αδύνατο αν έχει ενεργή παραγγελία.'
: 'Το τραπέζι θα κρυφτεί. Μπορείτε να το επανενεργοποιήσετε αργότερα.'}
confirmLabel={confirmDelete.hard ? 'Διαγραφή' : 'Απενεργοποίηση'}
confirmClass="btn-danger"
onConfirm={() => deactivateTable.mutate(confirmDeactivate)}
onCancel={() => setConfirmDeactivate(null)}
onConfirm={() => deleteTable.mutate(confirmDelete)}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
)
}
function TableModal({ title, form, setForm, onSave, onClose }) {
function TableModal({ title, initial, groups, onSave, onClose }) {
const [form, setForm] = useState(initial)
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-xs p-6 space-y-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">{title}</h2>
<div>
<label className="label">Αριθμός τραπεζιού *</label>
<input className="input" type="number" min="1" value={form.number} onChange={e => setForm(f => ({ ...f, number: e.target.value }))} autoFocus />
</div>
<div>
<label className="label">Ετικέτα (προαιρετικό)</label>
<label className="label">Ετικέτα</label>
<input className="input" placeholder="π.χ. Βεράντα 1" value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))} />
</div>
<div>
<label className="label">Γκρουπ</label>
<select className="input" value={form.group_id} onChange={e => setForm(f => ({ ...f, group_id: e.target.value }))}>
<option value=""> Χωρίς γκρουπ </option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={onSave} disabled={!form.number} className="flex-1 btn btn-primary">Αποθήκευση</button>
<button onClick={() => onSave(form)} disabled={!form.number} className="flex-1 btn btn-primary">Αποθήκευση</button>
</div>
</div>
</div>
)
}
function BatchModal({ groupId, startNumber, onSave, onClose }) {
const [count, setCount] = useState(5)
const [prefix, setPrefix] = useState('')
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">Μαζική προσθήκη τραπεζιών</h2>
<div>
<label className="label">Πλήθος</label>
<input className="input" type="number" min="1" max="200" value={count} onChange={e => setCount(Number(e.target.value))} autoFocus />
</div>
<div>
<label className="label">Πρόθεμα ετικέτας</label>
<input className="input" placeholder="π.χ. Out- → Out-1, Out-2…" value={prefix} onChange={e => setPrefix(e.target.value)} />
</div>
<p className="text-xs text-gray-400">Ξεκινά από αριθμό {startNumber}, δημιουργεί {count} τραπέζια.</p>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => onSave({ group_id: groupId, count, name_prefix: prefix, start_number: startNumber })}
disabled={count < 1 || !prefix.trim()}
className="flex-1 btn btn-primary"
>
Δημιουργία
</button>
</div>
</div>
</div>
)
}
function GroupModal({ group, onSave, onDelete, onClose }) {
const [name, setName] = useState(group.name || '')
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-xs p-6 space-y-4">
<h2 className="font-bold text-gray-800">{group.id ? 'Επεξεργασία γκρουπ' : 'Νέο γκρουπ'}</h2>
<div>
<label className="label">Όνομα γκρουπ</label>
<input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus />
</div>
<div className="flex gap-3">
{onDelete && <button onClick={onDelete} className="btn btn-danger px-3">🗑</button>}
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button onClick={() => onSave(name)} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button>
</div>
</div>
</div>