diff --git a/manager_dashboard/src/pages/ProductsPage.jsx b/manager_dashboard/src/pages/ProductsPage.jsx index 6b0fdc4..6fb0143 100644 --- a/manager_dashboard/src/pages/ProductsPage.jsx +++ b/manager_dashboard/src/pages/ProductsPage.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import toast from 'react-hot-toast' import client from '../api/client' @@ -9,17 +9,13 @@ const EMPTY_PRODUCT = { 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 (
{COLORS.map(c => ( - + +
+ ) +} + +// Default-toggle button: filled circle = default, empty circle = not default +function DefaultBtn({ isDefault, onClick, title }) { + return ( + + ) +} + +// Price stepper: manual input + −/+ buttons in 0.10 steps +function PriceInput({ value, onChange, placeholder, className = '', allowNegative = false }) { + const step = 0.10 + const num = parseFloat(value) || 0 + function inc() { onChange(Math.round((num + step) * 100) / 100) } + function dec() { + const next = Math.round((num - step) * 100) / 100 + onChange(allowNegative ? next : Math.max(0, next)) + } + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder ?? '0.00'} + className="flex-1 min-w-0 text-center text-sm outline-none bg-transparent px-1" + /> + +
+ ) +} + +function moveItem(arr, i, dir) { + const j = i + dir + if (j < 0 || j >= arr.length) return arr + const next = [...arr] + ;[next[i], next[j]] = [next[j], next[i]] + return next +} + +// ─── Sub-choice rows (shared between Options and Preferences) ───────────────── +function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove, onAdd, parentLabel }) { + if (!subChoices || subChoices.length === 0) return null + return ( +
+

+ Υπο-επιλογές του «{parentLabel || '…'}» — εμφανίζονται μόνο αν επιλεγεί +

+ {subChoices.map((sc, sci) => ( +
+ onMove(sci, -1)} onDown={() => onMove(sci, 1)} + disableUp={sci === 0} disableDown={sci === subChoices.length - 1} + /> + onToggleDefault(sci)} /> + onChange(sci, 'name', e.target.value)} /> + onChange(sci, 'extra_cost', v)} + allowNegative className="w-28 text-sm" /> + +
+ ))} + +
+ ) +} + export default function ProductsPage() { const qc = useQueryClient() const [selectedCat, setSelectedCat] = useState(null) const [editProduct, setEditProduct] = useState(null) const [editCat, setEditCat] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null) + const [showInactive, setShowInactive] = useState(false) + const [multiSelect, setMultiSelect] = useState(false) + const [selected, setSelected] = useState(new Set()) + const [sortMode, setSortMode] = useState('custom') 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/?all=true').then(r => r.data), }) - const { data: statusData } = useQuery({ queryKey: ['system-status'], queryFn: () => client.get('/api/system/status').then(r => r.data), @@ -53,9 +141,13 @@ export default function ProductsPage() { }) const printers = statusData?.printers ?? [] - const products = selectedCat - ? allProducts.filter(p => p.category_id === selectedCat) - : allProducts + const filteredByCat = selectedCat ? allProducts.filter(p => p.category_id === selectedCat) : allProducts + const baseList = showInactive ? filteredByCat : filteredByCat.filter(p => p.is_available) + const products = [...baseList].sort((a, b) => { + if (sortMode === 'name') return a.name.localeCompare(b.name, 'el') + if (sortMode === 'price') return a.base_price - b.base_price + return (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.id - b.id + }) const invalidate = () => { qc.invalidateQueries({ queryKey: ['products-all'] }) @@ -63,43 +155,53 @@ 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: b => editCat?.id ? client.put(`/api/products/categories/${editCat.id}`, b) : client.post('/api/products/categories', b), onSuccess: () => { toast.success('Κατηγορία αποθηκεύτηκε'); setEditCat(null); invalidate() }, onError: () => toast.error('Σφάλμα'), }) - const deleteCat = useMutation({ - mutationFn: (id) => client.delete(`/api/products/categories/${id}`), + mutationFn: id => client.delete(`/api/products/categories/${id}`), onSuccess: () => { toast.success('Διαγράφηκε'); setConfirmDelete(null); invalidate() }, onError: () => toast.error('Σφάλμα'), }) - const reorderCats = useMutation({ - mutationFn: (items) => client.put('/api/products/categories/reorder', items), + 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: b => editProduct?.id ? client.put(`/api/products/${editProduct.id}`, b) : client.post('/api/products/', b), 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(), onError: () => toast.error('Σφάλμα'), }) - - const deleteProduct = useMutation({ - mutationFn: (id) => client.delete(`/api/products/${id}`), + const deactivateProduct = useMutation({ + mutationFn: id => client.delete(`/api/products/${id}`), onSuccess: () => { toast.success('Απενεργοποιήθηκε'); setConfirmDelete(null); invalidate() }, onError: () => toast.error('Σφάλμα'), }) + const hardDeleteProduct = useMutation({ + mutationFn: id => client.delete(`/api/products/${id}?hard=true`), + onSuccess: () => { toast.success('Διαγράφηκε οριστικά'); setConfirmDelete(null); invalidate() }, + onError: err => toast.error(err.response?.data?.detail || 'Σφάλμα'), + }) + const reorderProducts = useMutation({ + mutationFn: items => client.put('/api/products/reorder', items), + onSuccess: () => invalidate(), + }) + + function moveProd(prod, dir) { + const sorted = [...baseList].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.id - b.id) + const idx = sorted.findIndex(p => p.id === prod.id) + const swapIdx = idx + dir + if (swapIdx < 0 || swapIdx >= sorted.length) return + const normalised = sorted.map((p, i) => ({ id: p.id, sort_order: i })) + ;[normalised[idx].sort_order, normalised[swapIdx].sort_order] = [normalised[swapIdx].sort_order, normalised[idx].sort_order] + reorderProducts.mutate(normalised) + } function moveCat(cat, dir) { const sorted = [...categories].sort((a, b) => a.sort_order - b.sort_order) @@ -114,12 +216,42 @@ export default function ProductsPage() { reorderCats.mutate(updates) } + function toggleSelect(id) { + setSelected(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n }) + } + function selectAll() { setSelected(new Set(products.map(p => p.id))) } + function clearSelect() { setSelected(new Set()) } + function exitMultiSelect() { setMultiSelect(false); setSelected(new Set()) } + + async function bulkAction(action) { + const ids = [...selected] + if (!ids.length) return + try { + if (action === 'available') { await Promise.all(ids.map(id => client.put(`/api/products/${id}`, { is_available: true }))); toast.success(`${ids.length} διαθέσιμα`) } + else if (action === 'unavailable') { await Promise.all(ids.map(id => client.put(`/api/products/${id}`, { is_available: false }))); toast.success(`${ids.length} μη διαθέσιμα`) } + else if (action === 'delete') { setConfirmDelete({ type: 'bulk-hard', ids }); return } + invalidate(); exitMultiSelect() + } catch { toast.error('Σφάλμα') } + } + + async function confirmBulkDelete(ids) { + const results = await Promise.allSettled(ids.map(id => client.delete(`/api/products/${id}?hard=true`))) + const ok = results.filter(r => r.status === 'fulfilled').length + const fail = results.filter(r => r.status === 'rejected').length + if (ok) toast.success(`${ok} διαγράφηκαν`) + if (fail) toast.error(`${fail} δεν διαγράφηκαν (υπάρχουν σε παραγγελίες)`) + setConfirmDelete(null); invalidate(); exitMultiSelect() + } + function handleConfirmDelete() { if (!confirmDelete) return if (confirmDelete.type === 'category') deleteCat.mutate(confirmDelete.id) - if (confirmDelete.type === 'product') deleteProduct.mutate(confirmDelete.id) + if (confirmDelete.type === 'product-hard') hardDeleteProduct.mutate(confirmDelete.id) + if (confirmDelete.type === 'bulk-hard') confirmBulkDelete(confirmDelete.ids) } + const printerName = id => printers.find(p => p.id === id)?.name ?? `#${id}` + return (
{/* Left: Categories */} @@ -128,18 +260,14 @@ export default function ProductsPage() {

Κατηγορίες

- {[...categories].sort((a, b) => a.sort_order - b.sort_order).map((cat, idx, arr) => (
{cat.color && } - + @@ -149,68 +277,111 @@ export default function ProductsPage() { {/* Right: Products */} -
-
-

+
+
+

Προϊόντα {selectedCat ? `— ${categories.find(c => c.id === selectedCat)?.name}` : ''}

- + + + {!multiSelect && } + {multiSelect && ( +
+ {selected.size} επιλεγμένα + + + + + + +
+ )} + {!multiSelect && }
- {products.length === 0 && ( -

Δεν υπάρχουν προϊόντα.

- )} + {products.length === 0 &&

Δεν υπάρχουν προϊόντα.

}
- {products.map(p => ( -
+ {products.map((p, idx) => ( +
toggleSelect(p.id) : undefined} + className={`card p-4 flex items-center gap-3 transition-opacity ${!p.is_available ? 'opacity-60' : ''} ${multiSelect ? 'cursor-pointer select-none' : ''} ${multiSelect && selected.has(p.id) ? 'ring-2 ring-primary-500 bg-primary-50' : ''}`} + > + {multiSelect && ( + toggleSelect(p.id)} + onClick={e => e.stopPropagation()} className="w-4 h-4 accent-primary-700 shrink-0" /> + )} {p.image_url && ( - {p.name} + {p.name} )}

{p.name} - {!p.is_available && (μη διαθέσιμο)} + {!p.is_available && (ανενεργό)}

{categories.find(c => c.id === p.category_id)?.name ?? '—'} · €{p.base_price.toFixed(2)} - {p.printer_zone_id && ` · Εκτυπωτής #${p.printer_zone_id}`} + {p.printer_zone_id && ` · ${printerName(p.printer_zone_id)}`}

- - - + {!multiSelect && ( + <> + {/* Availability toggle button — green when available, grey when not */} + + + + {/* Reorder arrows — far right */} + {sortMode === 'custom' && ( +
+ + +
+ )} + + )}
))}
{editCat !== null && ( - saveCat.mutate({ name, color })} - onClose={() => setEditCat(null)} - /> + saveCat.mutate({ name, color })} onClose={() => setEditCat(null)} /> )} - {editProduct !== null && ( - saveProduct.mutate(body)} + saveProduct.mutate(b)} + onCopy={formData => setEditProduct({ ...EMPTY_PRODUCT, ...formData, id: undefined, image_url: undefined, name: formData.name + ' (αντίγραφο)' })} onClose={() => setEditProduct(null)} /> )} - {confirmDelete && ( setConfirmDelete(null)} @@ -244,186 +415,568 @@ function CategoryFormModal({ cat, onSave, onClose }) { ) } -function ProductFormPanel({ product, categories, printers, onSave, onClose }) { - const [form, setForm] = useState({ +// ─── Product Form Modal ─────────────────────────────────────────────────────── + +function buildFormFromProduct(product) { + return { name: product.name || '', - category_id: product.category_id || '', - base_price: product.base_price || '', + category_id: product.category_id ?? '', + base_price: product.base_price ?? '', 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 })) ?? [], + printer_zone_id: product.printer_zone_id ?? '', + options: product.options?.map(o => ({ + name: o.name, + extra_cost: o.extra_cost ?? 0, + sub_choices: o.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [], + })) ?? [], 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 })), + default_choice_index: ps.choices ? ps.choices.findIndex(c => c.id === ps.default_choice_id) : -1, + choices: ps.choices?.map(c => ({ + name: c.name, + extra_cost: c.extra_cost ?? 0, + disables_subset: c.disables_subset ?? false, + sub_choices: c.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [], + })) ?? [], + shared_subset: ps.shared_subset ? { + name: ps.shared_subset.name, + choices: ps.shared_subset.choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [], + } : null, })) ?? [], - }) + } +} + +function ProductFormModal({ product, categories, printers, onSave, onCopy, onClose }) { + const [form, setForm] = useState(() => buildFormFromProduct(product)) + const [activeTab, setActiveTab] = useState('ingredients') const [imageFile, setImageFile] = useState(null) const [uploading, setUploading] = useState(false) const qc = useQueryClient() + useEffect(() => { + function onKey(e) { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + useEffect(() => { + setForm(buildFormFromProduct(product)) + setActiveTab('ingredients') + setImageFile(null) + }, [product.id, product.name]) + function setField(k, v) { setForm(f => ({ ...f, [k]: v })) } - // Options - function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0 }] })) } + // ── Options ── + function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0, sub_choices: [] }] })) } 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 moveOption(i, dir) { setForm(f => ({ ...f, options: moveItem(f.options, i, dir) })) } - // Ingredients + function addOptionSubChoice(oi) { + setForm(f => ({ ...f, options: f.options.map((o, idx) => + idx !== oi ? o : { ...o, sub_choices: [...(o.sub_choices || []), { name: '', extra_cost: 0, is_default: false }] } + )})) + } + function removeOptionSubChoice(oi, sci) { + setForm(f => ({ ...f, options: f.options.map((o, idx) => + idx !== oi ? o : { ...o, sub_choices: o.sub_choices.filter((_, i) => i !== sci) } + )})) + } + function setOptionSubChoice(oi, sci, k, v) { + setForm(f => ({ ...f, options: f.options.map((o, idx) => + idx !== oi ? o : { + ...o, sub_choices: o.sub_choices.map((sc, scidx) => { + if (scidx !== sci) return k === 'is_default' && v === true ? { ...sc, is_default: false } : sc + return { ...sc, [k]: v } + }) + } + )})) + } + function moveOptionSubChoice(oi, sci, dir) { + setForm(f => ({ ...f, options: f.options.map((o, idx) => + idx !== oi ? o : { ...o, sub_choices: moveItem(o.sub_choices, sci, dir) } + )})) + } + function toggleOptionSubDefault(oi, sci) { + const sc = form.options[oi]?.sub_choices?.[sci] + setOptionSubChoice(oi, sci, 'is_default', !sc?.is_default) + } + + // ── 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, k, v) { setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { ...ing, [k]: v } : ing) })) } + function moveIngredient(i, dir) { setForm(f => ({ ...f, ingredients: moveItem(f.ingredients, i, dir) })) } - // 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) })) } + // ── Preference sets ── + function addPrefSet() { + const newIdx = form.preference_sets.length + setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', default_choice_index: -1, choices: [], shared_subset: null }] })) + setActiveTab(newIdx) + } + function removePrefSet(si) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.filter((_, idx) => idx !== si) })) + setActiveTab('ingredients') + } + function setPrefSetField(si, k, v) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => idx === si ? { ...ps, [k]: 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, disables_subset: false, sub_choices: [] }] } : ps + )})) + } + function removeChoice(si, ci) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => { + if (idx !== si) return ps + const newChoices = ps.choices.filter((_, cidx) => cidx !== ci) + const d = ps.default_choice_index + const newDefault = d === ci ? -1 : d > ci ? d - 1 : d + return { ...ps, choices: newChoices, default_choice_index: newDefault } + })})) + } 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 - ), - })) + 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 moveChoice(si, ci, dir) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => { + if (idx !== si) return ps + const newChoices = moveItem(ps.choices, ci, dir) + const j = ci + dir + let nd = ps.default_choice_index + if (nd === ci) nd = j; else if (nd === j) nd = ci + return { ...ps, choices: newChoices, default_choice_index: nd } + })})) + } + function toggleDefaultChoice(si, ci) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => + idx !== si ? ps : { ...ps, default_choice_index: ps.default_choice_index === ci ? -1 : ci } + )})) + } + function addSubChoice(si, ci) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) => + pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) => + cidx !== ci ? ch : { ...ch, sub_choices: [...(ch.sub_choices || []), { name: '', extra_cost: 0, is_default: false }] } + )} + )})) + } + function removeSubChoice(si, ci, sci) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) => + pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) => + cidx !== ci ? ch : { ...ch, sub_choices: ch.sub_choices.filter((_, i) => i !== sci) } + )} + )})) + } + function setSubChoice(si, ci, sci, k, v) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) => + pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) => + cidx !== ci ? ch : { ...ch, sub_choices: ch.sub_choices.map((sc, scidx) => { + if (scidx !== sci) return k === 'is_default' && v === true ? { ...sc, is_default: false } : sc + return { ...sc, [k]: v } + })} + )} + )})) + } + function moveSubChoice(si, ci, sci, dir) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, pidx) => + pidx !== si ? ps : { ...ps, choices: ps.choices.map((ch, cidx) => + cidx !== ci ? ch : { ...ch, sub_choices: moveItem(ch.sub_choices, sci, dir) } + )} + )})) + } + + // Shared subset helpers + function setSharedSubsetName(si, name) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => { + if (idx !== si) return ps + return { ...ps, shared_subset: ps.shared_subset ? { ...ps.shared_subset, name } : { name, choices: [] } } + })})) + } + function addSharedSubsetChoice(si) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => { + if (idx !== si) return ps + const ss = ps.shared_subset || { name: '', choices: [] } + return { ...ps, shared_subset: { ...ss, choices: [...ss.choices, { name: '', extra_cost: 0, is_default: false }] } } + })})) + } + function removeSharedSubsetChoice(si, sci) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => { + if (idx !== si) return ps + const newChoices = ps.shared_subset.choices.filter((_, i) => i !== sci) + return { ...ps, shared_subset: newChoices.length ? { ...ps.shared_subset, choices: newChoices } : null } + })})) + } + function setSharedSubsetChoice(si, sci, k, v) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => { + if (idx !== si) return ps + const newChoices = ps.shared_subset.choices.map((sc, scidx) => { + if (scidx !== sci) return k === 'is_default' && v === true ? { ...sc, is_default: false } : sc + return { ...sc, [k]: v } + }) + return { ...ps, shared_subset: { ...ps.shared_subset, choices: newChoices } } + })})) + } + function moveSharedSubsetChoice(si, sci, dir) { + setForm(f => ({ ...f, preference_sets: f.preference_sets.map((ps, idx) => + idx !== si ? ps : { ...ps, shared_subset: { ...ps.shared_subset, choices: moveItem(ps.shared_subset.choices, sci, dir) } } + )})) + } + + function buildBody() { + return { + name: form.name, + category_id: form.category_id ? Number(form.category_id) : null, + base_price: parseFloat(form.base_price), + is_available: form.is_available, + printer_zone_id: form.printer_zone_id ? Number(form.printer_zone_id) : null, + options: form.options.map(o => ({ + name: o.name, + extra_cost: parseFloat(o.extra_cost) || 0, + sub_choices: (o.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })), + })), + ingredients: form.ingredients.map(i => ({ name: i.name, extra_cost: parseFloat(i.extra_cost) || 0 })), + preference_sets: form.preference_sets.map(ps => ({ + name: ps.name, + default_choice_index: ps.default_choice_index >= 0 ? ps.default_choice_index : null, + shared_subset: ps.shared_subset?.choices?.length ? { + name: ps.shared_subset.name || '', + choices: ps.shared_subset.choices.map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })), + } : null, + choices: ps.choices.map(c => ({ + name: c.name, + extra_cost: parseFloat(c.extra_cost) || 0, + disables_subset: c.disables_subset ?? false, + sub_choices: (c.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })), + })), + })), + } } 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 + onSave(buildBody()) if (imageFile && product.id) { setUploading(true) try { - const fd = new FormData() - fd.append('file', imageFile) + 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) - } + } catch { toast.error('Σφάλμα ανεβάσματος εικόνας') } + finally { setUploading(false) } } } + const isNew = !product.id + const canSave = form.name.trim() && form.base_price + + const tabs = [ + { key: 'ingredients', label: 'Υλικά', count: form.ingredients.length }, + { key: 'options', label: 'Επιλογές', count: form.options.length }, + ...form.preference_sets.map((ps, i) => ({ key: i, label: ps.name || `Προτ. ${i + 1}`, count: ps.choices.length })), + { key: '__add_pref__', label: '+ Προτίμηση', isAdd: true }, + ] + return ( -
-
-
-

{product.id ? 'Επεξεργασία προϊόντος' : 'Νέο προϊόν'}

- +
+
+ + {/* ── Header ── */} +
+

+ {isNew ? 'Νέο προϊόν' : `Επεξεργασία — ${product.name}`} +

+
-
setField('name', e.target.value)} autoFocus />
-
- - -
-
setField('base_price', e.target.value)} />
-
- - -
- + {/* ── Body: left/right split ── */} +
- {/* Image */} - {product.id && ( -
- - {product.image_url && } - setImageFile(e.target.files[0])} /> -
- )} + {/* LEFT: product info */} +
+

Στοιχεία προϊόντος

- {/* Options */} -
- {form.options.map((opt, i) => ( - setOption(i, 'name', v)} onCost={v => setOption(i, 'extra_cost', v)} - onRemove={() => removeOption(i)} costLabel="+/- €" /> - ))} -
- - {/* Ingredients */} -
- {form.ingredients.map((ing, i) => ( - setIngredient(i, 'name', v)} onCost={v => setIngredient(i, 'extra_cost', v)} - onRemove={() => removeIngredient(i)} costLabel="+/- €" /> - ))} -
- - {/* Preference Sets */} -
- {form.preference_sets.map((ps, si) => ( -
-
- setPrefSetName(si, e.target.value)} /> - -
- {ps.choices.map((ch, ci) => ( - setChoice(si, ci, 'name', v)} onCost={v => setChoice(si, ci, 'extra_cost', v)} - onRemove={() => removeChoice(si, ci)} costLabel="+/- €" indent /> - ))} - +
+ + setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
- ))} -
-
- - +
+ + setField('base_price', v)} className="w-full" /> +
+ +
+ + +
+ +
+ + +
+ + + + {!isNew && ( +
+ +
+ {product.image_url && ( + + )} + setImageFile(e.target.files[0])} /> +
+
+ )} +
+ + {/* RIGHT: tabs */} +
+ {/* Tab bar */} +
+ {tabs.map(tab => { + if (tab.isAdd) return ( + + ) + const isActive = activeTab === tab.key + return ( + + ) + })} +
+ + {/* Tab content */} +
+ + {/* ── Ingredients tab ── */} + {activeTab === 'ingredients' && ( +
+
+

Υλικά που ο πελάτης μπορεί να αφαιρέσει.

+ +
+ {!form.ingredients.length &&

Δεν υπάρχουν υλικά.

} +
+ {form.ingredients.map((ing, i) => ( +
+ moveIngredient(i, -1)} onDown={() => moveIngredient(i, 1)} + disableUp={i === 0} disableDown={i === form.ingredients.length - 1} /> + setIngredient(i, 'name', e.target.value)} /> + setIngredient(i, 'extra_cost', v)} + allowNegative className="w-32" /> + +
+ ))} +
+
+ )} + + {/* ── Options tab ── */} + {activeTab === 'options' && ( +
+
+

Προσθέτα (πολλαπλή επιλογή). Κάθε επιλογή μπορεί να έχει δικές της υπο-επιλογές.

+ +
+ {!form.options.length &&

Δεν υπάρχουν επιλογές.

} +
+ {form.options.map((opt, i) => ( +
+
+ moveOption(i, -1)} onDown={() => moveOption(i, 1)} + disableUp={i === 0} disableDown={i === form.options.length - 1} /> + setOption(i, 'name', e.target.value)} /> + setOption(i, 'extra_cost', v)} + allowNegative className="w-32" /> + + +
+ moveOptionSubChoice(i, sci, dir)} + onToggleDefault={sci => toggleOptionSubDefault(i, sci)} + onChange={(sci, k, v) => setOptionSubChoice(i, sci, k, v)} + onRemove={sci => removeOptionSubChoice(i, sci)} + onAdd={() => addOptionSubChoice(i)} + /> +
+ ))} +
+
+ )} + + {/* ── Preference set tabs ── */} + {typeof activeTab === 'number' && form.preference_sets[activeTab] && (() => { + const si = activeTab + const ps = form.preference_sets[si] + const hasSharedSubset = !!(ps.shared_subset) + return ( +
+
+ setPrefSetField(si, 'name', e.target.value)} autoFocus /> + +
+ +

+ ● = προεπιλογή (κλικ ξανά για αποεπιλογή) · ⊘ = απενεργοποιεί το κοινό υπο-σύνολο +

+ +
+ {ps.choices.map((ch, ci) => ( +
+
+ moveChoice(si, ci, -1)} onDown={() => moveChoice(si, ci, 1)} + disableUp={ci === 0} disableDown={ci === ps.choices.length - 1} /> + toggleDefaultChoice(si, ci)} + /> + setChoice(si, ci, 'name', e.target.value)} /> + setChoice(si, ci, 'extra_cost', v)} + allowNegative className="w-32" /> + {hasSharedSubset && ( + + )} + + +
+ moveSubChoice(si, ci, sci, dir)} + onToggleDefault={sci => setSubChoice(si, ci, sci, 'is_default', !ch.sub_choices[sci]?.is_default)} + onChange={(sci, k, v) => setSubChoice(si, ci, sci, k, v)} + onRemove={sci => removeSubChoice(si, ci, sci)} + onAdd={() => addSubChoice(si, ci)} + /> +
+ ))} +
+ + + + {/* Shared subset */} +
+
+
+

Κοινό υπο-σύνολο

+

Εμφανίζεται για όλες τις επιλογές εκτός αυτών με ⊘

+
+ {!ps.shared_subset ? ( + + ) : ( + + )} +
+ {ps.shared_subset && ( +
+
+ + setSharedSubsetName(si, e.target.value)} /> +
+
+ {(ps.shared_subset.choices || []).map((sc, sci) => ( +
+ moveSharedSubsetChoice(si, sci, -1)} onDown={() => moveSharedSubsetChoice(si, sci, 1)} + disableUp={sci === 0} disableDown={sci === ps.shared_subset.choices.length - 1} /> + setSharedSubsetChoice(si, sci, 'is_default', !sc.is_default)} /> + setSharedSubsetChoice(si, sci, 'name', e.target.value)} /> + setSharedSubsetChoice(si, sci, 'extra_cost', v)} + allowNegative className="w-32 text-sm" /> + +
+ ))} +
+ +
+ )} +
+
+ ) + })()} +
+
+
+ + {/* ── Footer ── */} +
+ +
+ {!isNew && ( + + )} + +
) } - -function Section({ title, onAdd, addLabel, children }) { - return ( -
-
- - -
- {children} -
- ) -} - -function CostRow({ name, cost, onName, onCost, onRemove, costLabel, indent }) { - return ( -
- onName(e.target.value)} /> - onCost(e.target.value)} /> - -
- ) -} diff --git a/manager_dashboard/src/pages/TablesPage.jsx b/manager_dashboard/src/pages/TablesPage.jsx index b086b99..a96ed43 100644 --- a/manager_dashboard/src/pages/TablesPage.jsx +++ b/manager_dashboard/src/pages/TablesPage.jsx @@ -4,14 +4,40 @@ import toast from 'react-hot-toast' import client from '../api/client' import ConfirmModal from '../components/ConfirmModal' +const ZONE_COLORS = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#ef4444','#ec4899','#8b5cf6','#14b8a6','#f97316','#64748b'] + +function ZoneColorPicker({ value, onChange }) { + return ( +
+
+ ) +} + export default function TablesPage() { const qc = useQueryClient() const [addModal, setAddModal] = useState(false) const [editModal, setEditModal] = useState(null) - const [batchModal, setBatchModal] = useState(null) // group id or null + const [batchModal, setBatchModal] = useState(null) // group object or null const [groupModal, setGroupModal] = useState(null) // null | {} | group object - const [confirmDelete, setConfirmDelete] = useState(null) // { id, hard } + const [confirmDelete, setConfirmDelete] = useState(null) const [showInactive, setShowInactive] = useState(false) + const [activeTab, setActiveTab] = useState('all') // 'all' | group.id const { data: tables = [], isLoading } = useQuery({ queryKey: ['tables-all', showInactive], @@ -29,15 +55,6 @@ export default function TablesPage() { } 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); invalidate() }, @@ -70,81 +87,125 @@ export default function TablesPage() { 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() }, + 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() }, + 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) + // Filter tables for the active tab + const visibleTables = activeTab === 'all' + ? tables + : activeTab === 'ungrouped' + ? tables.filter(t => !t.group_id) + : tables.filter(t => t.group_id === activeTab) if (isLoading) return
Φόρτωση…
return ( -
+
+ {/* Header */}

Τραπέζια

-
+
- - + +
- {grouped.map(({ group, tables: gt }) => ( -
- {group && ( -
-

{group.name}

- - + {/* Zone tabs */} +
+ {[ + { id: 'all', label: 'Όλα', color: null }, + ...groups.map(g => ({ id: g.id, label: g.prefix ? `${g.prefix} – ${g.name}` : g.name, color: g.color, group: g })), + ...(tables.some(t => !t.group_id) ? [{ id: 'ungrouped', label: 'Χωρίς ζώνη', color: null }] : []), + ].map(tab => ( + + ))} +
+ + {/* Zone header (when viewing a specific zone) */} + {activeTab !== 'all' && activeTab !== 'ungrouped' && (() => { + const g = groups.find(g => g.id === activeTab) + if (!g) return null + return ( +
+
+ {g.name} + {g.prefix && {g.prefix}}
- )} - {!group && gt.length > 0 &&

Χωρίς γκρουπ

} - -
- {gt.length === 0 && ( -

Δεν υπάρχουν τραπέζια σε αυτό το γκρουπ.

- )} - {gt.map(t => ( -
- {t.number} -

{t.label || '—'}

- {!t.is_active && Ανενεργό} - - {t.is_active - ? - : - } - -
- ))} + +
-
- ))} + ) + })()} - {tables.length === 0 && ( -

Δεν υπάρχουν τραπέζια. Προσθέστε ένα.

- )} + {/* Tables list */} +
+ {visibleTables.length === 0 && ( +

+ {showInactive ? 'Δεν υπάρχουν τραπέζια.' : 'Δεν υπάρχουν ενεργά τραπέζια.'} +

+ )} + {visibleTables.map((t, idx) => ( +
+ {idx + 1} +

{t.label || `Τραπέζι ${t.number}`}

+ {t.group && ( + + {t.group.prefix ? `${t.group.prefix}` : t.group.name} + + )} + {!t.is_active && Ανενεργό} + + {t.is_active + ? + : + } + +
+ ))} +
{/* Add single table */} {addModal && ( createTable.mutate({ number: Number(f.number), label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })} + onSave={(f) => createTable.mutate({ label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })} onClose={() => setAddModal(false)} /> )} @@ -153,9 +214,9 @@ export default function TablesPage() { {editModal && ( updateTable.mutate({ id: editModal.id, number: Number(f.number), label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })} + onSave={(f) => updateTable.mutate({ id: editModal.id, label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })} onClose={() => setEditModal(null)} /> )} @@ -163,18 +224,17 @@ export default function TablesPage() { {/* Batch add */} {batchModal !== null && ( batchCreate.mutate(body)} onClose={() => setBatchModal(null)} /> )} - {/* Group form */} + {/* Group/Zone form */} {groupModal !== null && ( saveGroup.mutate({ name })} + onSave={(data) => saveGroup.mutate(data)} onDelete={groupModal.id ? () => deleteGroup.mutate(groupModal.id) : null} onClose={() => setGroupModal(null)} /> @@ -204,53 +264,63 @@ function TableModal({ title, initial, groups, onSave, onClose }) {

{title}

- - setForm(f => ({ ...f, number: e.target.value }))} autoFocus /> + + setForm(f => ({ ...f, label: e.target.value }))} + autoFocus + /> +

Αφήστε κενό για αυτόματη αρίθμηση.

- - setForm(f => ({ ...f, label: e.target.value }))} /> -
-
- +
- +
) } -function BatchModal({ groupId, startNumber, onSave, onClose }) { +function BatchModal({ group, onSave, onClose }) { const [count, setCount] = useState(5) - const [prefix, setPrefix] = useState('') + const [prefix, setPrefix] = useState(group?.prefix ? `${group.prefix}-` : '') return (

Μαζική προσθήκη τραπεζιών

+ {group &&

Ζώνη: {group.name}

} +
+ + setPrefix(e.target.value)} + autoFocus + /> +

Τα ονόματα θα αριθμηθούν αυτόματα συνεχίζοντας από εκεί που σταμάτησαν.

+
- setCount(Number(e.target.value))} autoFocus /> + setCount(Number(e.target.value))} />
-
- - setPrefix(e.target.value)} /> -
-

Ξεκινά από αριθμό {startNumber}, δημιουργεί {count} τραπέζια.

@@ -260,18 +330,29 @@ function BatchModal({ groupId, startNumber, onSave, onClose }) { function GroupModal({ group, onSave, onDelete, onClose }) { const [name, setName] = useState(group.name || '') + const [prefix, setPrefix] = useState(group.prefix || '') + const [color, setColor] = useState(group.color || null) return (
-
-

{group.id ? 'Επεξεργασία γκρουπ' : 'Νέο γκρουπ'}

+
+

{group.id ? 'Επεξεργασία ζώνης' : 'Νέα ζώνη'}

- - setName(e.target.value)} autoFocus /> + + setName(e.target.value)} autoFocus placeholder="π.χ. Beachside" /> +
+
+ + setPrefix(e.target.value)} placeholder="π.χ. BS" /> +

Χρησιμοποιείται ως προτεινόμενο πρόθεμα στη μαζική προσθήκη.

+
+
+ +
- {onDelete && } + {onDelete && } - +