import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
import ConfirmModal from '../components/ConfirmModal'
const EMPTY_PRODUCT = {
name: '', category_id: '', base_price: '', is_available: true, lifecycle_status: 'active',
printer_zone_id: '', quick_options: [], options: [], ingredients: [], preference_sets: [],
}
const COLORS = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#ef4444','#ec4899','#8b5cf6','#14b8a6','#f97316','#64748b']
function ColorPicker({ value, onChange }) {
return (
{COLORS.map(c => (
)
}
function IconBase({ className = '', viewBox, strokeWidth = '1.5', children }) {
return (
)
}
function AddIcon({ className = '' }) {
return (
)
}
function MoveUpIcon({ className = '' }) {
return (
)
}
function MoveDownIcon({ className = '' }) {
return (
)
}
function EditIcon({ className = '' }) {
return (
)
}
function DeleteIcon({ className = '' }) {
return (
)
}
function HeartIcon({ filled, className = '' }) {
return (
)
}
function ReorderBtns({ onUp, onDown, disableUp, disableDown }) {
return (
)
}
function DefaultBtn({ isDefault, onClick, title }) {
return (
)
}
function FavoriteBtn({ isFavorite, onClick, title }) {
return (
)
}
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
}
function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove, onAdd, parentLabel }) {
if (!subChoices || subChoices.length === 0) return null
return (
Υπο-επιλογές του «{parentLabel || '…'}» — εμφανίζονται μόνο αν επιλεγεί
{subChoices.map((sc, sci) => (
))}
)
}
// Build the ordered list of sub-category-like rows for a parent:
// interleaves the "General" virtual row at its general_sort_order position
function buildSubList(parent, subcategories) {
const subs = [...subcategories].sort((a, b) => a.sort_order - b.sort_order)
const generalRow = { _isGeneral: true, sort_order: parent.general_sort_order }
const all = [...subs, generalRow].sort((a, b) => a.sort_order - b.sort_order)
return all
}
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 [showArchived, setShowArchived] = 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),
})
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),
staleTime: 60_000,
})
const printers = statusData?.printers ?? []
// selectedCat can be:
// null → all products
// number → specific category (if top-level: includes all sub-cats too)
// '__general_' → only direct products of that top-level category (no sub-cat)
const isGeneralSel = typeof selectedCat === 'string' && selectedCat.startsWith('__general_')
const generalParentId = isGeneralSel ? Number(selectedCat.replace('__general_', '')) : null
const selectedCatObj = isGeneralSel ? null : categories.find(c => c.id === selectedCat)
const visibleCatIds = isGeneralSel
? [generalParentId] // only direct products on the parent
: selectedCat
? selectedCatObj?.parent_id == null
? [selectedCat, ...categories.filter(c => c.parent_id === selectedCat).map(c => c.id)]
: [selectedCat]
: null
const filteredByCat = visibleCatIds ? allProducts.filter(p => visibleCatIds.includes(p.category_id)) : allProducts
const baseList = filteredByCat.filter(p => {
if (p.lifecycle_status === 'archived') return showArchived
if (!p.is_available) return showInactive
return true
})
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'] })
qc.invalidateQueries({ queryKey: ['categories'] })
}
const saveCat = useMutation({
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}`),
onSuccess: () => { toast.success('Διαγράφηκε'); setConfirmDelete(null); invalidate() },
onError: () => toast.error('Σφάλμα'),
})
const reorderCats = useMutation({
mutationFn: items => client.put('/api/products/categories/reorder', items),
onSuccess: () => invalidate(),
})
const reorderSubcats = useMutation({
mutationFn: items => client.put('/api/products/categories/reorder-subcategories', items),
onSuccess: () => invalidate(),
})
const reorderGeneral = useMutation({
mutationFn: items => client.put('/api/products/categories/reorder-general', items),
onSuccess: () => invalidate(),
})
const toggleAutoExpanded = useMutation({
mutationFn: ({ id, auto_expanded }) => client.put(`/api/products/categories/${id}`, { auto_expanded }),
onSuccess: () => invalidate(),
})
const saveProduct = useMutation({
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 archiveProduct = 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.filter(c => !c.parent_id).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)
}
// Shared helper: move any row (sub-cat or General) in the mixed sub-list.
// Normalises the whole list to 0-based sequential indices, swaps the two positions,
// then writes all updated values. This avoids colliding sort_order values.
function _moveInSubList(parent, itemIdx, dir) {
const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
const subList = buildSubList(parent, subs)
const swapIdx = itemIdx + dir
if (itemIdx < 0 || swapIdx < 0 || swapIdx >= subList.length) return
// Normalise to clean sequential indices
const normalised = subList.map((row, i) => ({ ...row, _normOrder: i }))
;[normalised[itemIdx]._normOrder, normalised[swapIdx]._normOrder] =
[normalised[swapIdx]._normOrder, normalised[itemIdx]._normOrder]
// Write subcats and general separately
const subcatUpdates = normalised
.filter(r => !r._isGeneral)
.map(r => ({ id: r.id, sort_order: r._normOrder }))
const generalRow = normalised.find(r => r._isGeneral)
if (subcatUpdates.length) reorderSubcats.mutate(subcatUpdates)
if (generalRow) reorderGeneral.mutate([{ id: parent.id, general_sort_order: generalRow._normOrder }])
}
function moveSubcat(parent, subcat, dir) {
const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
const subList = buildSubList(parent, subs)
const idx = subList.findIndex(r => !r._isGeneral && r.id === subcat.id)
_moveInSubList(parent, idx, dir)
}
function moveGeneral(parent, dir) {
const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
const subList = buildSubList(parent, subs)
const idx = subList.findIndex(r => r._isGeneral)
_moveInSubList(parent, idx, dir)
}
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 === 'archive') { setConfirmDelete({ type: 'bulk-archive', ids }); return }
invalidate(); exitMultiSelect()
} catch { toast.error('Σφάλμα') }
}
async function confirmBulkArchive(ids) {
const results = await Promise.allSettled(ids.map(id => client.delete(`/api/products/${id}`)))
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-archive') archiveProduct.mutate(confirmDelete.id)
if (confirmDelete.type === 'product-hard') hardDeleteProduct.mutate(confirmDelete.id)
if (confirmDelete.type === 'bulk-archive') confirmBulkArchive(confirmDelete.ids)
}
const printerName = id => printers.find(p => p.id === id)?.name ?? `#${id}`
const categoryIconClass = 'w-4 h-4'
const categoryActionBtnClass = 'p-1 rounded-md hover:bg-black/5 disabled:opacity-30 disabled:cursor-not-allowed'
const categoryMoveBtnClass = `${categoryActionBtnClass} text-primary-700 hover:text-primary-800`
const categoryEditBtnClass = `${categoryActionBtnClass} text-orange-500 hover:text-orange-600`
const categoryDeleteBtnClass = `${categoryActionBtnClass} text-red-600 hover:text-red-700`
const topLevelCats = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
return (
{/* Left: Categories */}
{/* Right: Products */}
{products.length === 0 &&
Δεν υπάρχουν προϊόντα.
}
{products.map((p, idx) => {
const isArchived = p.lifecycle_status === 'archived'
return (
toggleSelect(p.id) : undefined}
className={`card p-4 flex items-center gap-3 transition-opacity ${(!p.is_available || isArchived) ? '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}
{isArchived && (
Αρχείο
)}
{!isArchived && !p.is_available && (
Ανενεργό
)}
{(() => {
const cat = categories.find(c => c.id === p.category_id)
if (!cat) return '—'
if (cat.parent_id) {
const parent = categories.find(c => c.id === cat.parent_id)
return `${parent?.name ?? '?'} / ${cat.name}`
}
return cat.name
})()} · €{p.base_price.toFixed(2)}
{p.printer_zone_id && ` · ${printerName(p.printer_zone_id)}`}
{!multiSelect && (
<>
{/* Availability toggle — only for non-archived products */}
{!isArchived && (
)}
{/* Archive/delete button — context-sensitive */}
{isArchived ? (
) : (
)}
{sortMode === 'custom' && !isArchived && (
)}
>
)}
)
})}
{editCat !== null && (
c.id === editCat.parent_id)?.name : null}
onSave={(name, color) => saveCat.mutate({ name, color, parent_id: editCat.parent_id ?? null })}
onClose={() => setEditCat(null)}
/>
)}
{editProduct !== null && (
saveProduct.mutate(b)}
onCopy={formData => setEditProduct({ ...EMPTY_PRODUCT, ...formData, id: undefined, image_url: undefined, name: formData.name + ' (αντίγραφο)' })}
onClose={() => setEditProduct(null)}
/>
)}
{confirmDelete && (
setConfirmDelete(null)}
/>
)}
)
}
function CategoryFormModal({ cat, parentName, onSave, onClose }) {
const [name, setName] = useState(cat.name || '')
const [color, setColor] = useState(cat.color || '')
const isNew = !cat.id
const isSub = !!cat.parent_id
const title = isNew
? isSub ? `Νέα υποκατηγορία${parentName ? ` σε «${parentName}»` : ''}` : 'Νέα κατηγορία'
: isSub ? 'Επεξεργασία υποκατηγορίας' : 'Επεξεργασία κατηγορίας'
return (
{title}
{isSub && parentName && (
Κατηγορία: {parentName}
)}
setName(e.target.value)} autoFocus />
)
}
// ─── Product Form Modal ───────────────────────────────────────────────────────
function buildFormFromProduct(product) {
return {
name: product.name || '',
category_id: product.category_id ?? '',
base_price: product.base_price ?? '',
is_available: product.is_available ?? true,
lifecycle_status: product.lifecycle_status ?? 'active',
printer_zone_id: product.printer_zone_id ?? '',
quick_options: product.quick_options?.map(q => ({
name: q.name,
price: q.price ?? 0,
allow_multiple: q.allow_multiple ?? false,
sort_order: q.sort_order ?? 0,
is_favorite: q.is_favorite ?? false,
favorite_sort_order: q.favorite_sort_order ?? 0,
})) ?? [],
options: product.options?.map(o => ({
name: o.name,
extra_cost: o.extra_cost ?? 0,
allow_multiple: o.allow_multiple ?? false,
sub_choices: o.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false })) ?? [],
is_favorite: o.is_favorite ?? false,
favorite_sort_order: o.favorite_sort_order ?? 0,
})) ?? [],
ingredients: product.ingredients?.map(i => ({
name: i.name,
extra_cost: i.extra_cost ?? 0,
is_favorite: i.is_favorite ?? false,
favorite_sort_order: i.favorite_sort_order ?? 0,
})) ?? [],
preference_sets: product.preference_sets?.map(ps => ({
name: ps.name,
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,
is_favorite: ps.is_favorite ?? false,
favorite_sort_order: ps.favorite_sort_order ?? 0,
})) ?? [],
}
}
// Build a flat sorted list of all favorited items across all types
function buildFavoritesList(form) {
const items = []
form.quick_options.forEach((q, i) => {
if (q.is_favorite) items.push({ type: 'quick', idx: i, favorite_sort_order: q.favorite_sort_order ?? 0 })
})
form.ingredients.forEach((ing, i) => {
if (ing.is_favorite) items.push({ type: 'ingredient', idx: i, favorite_sort_order: ing.favorite_sort_order ?? 0 })
})
form.options.forEach((o, i) => {
if (o.is_favorite) items.push({ type: 'option', idx: i, favorite_sort_order: o.favorite_sort_order ?? 0 })
})
form.preference_sets.forEach((ps, i) => {
if (ps.is_favorite) items.push({ type: 'pref', idx: i, favorite_sort_order: ps.favorite_sort_order ?? 0 })
})
return items.sort((a, b) => a.favorite_sort_order - b.favorite_sort_order)
}
function getFavSortField(form, type, idx) {
if (type === 'quick') return form.quick_options[idx]?.favorite_sort_order ?? 0
if (type === 'ingredient') return form.ingredients[idx]?.favorite_sort_order ?? 0
if (type === 'option') return form.options[idx]?.favorite_sort_order ?? 0
if (type === 'pref') return form.preference_sets[idx]?.favorite_sort_order ?? 0
return 0
}
function setFavSortField(form, type, idx, value) {
if (type === 'quick') return { ...form, quick_options: form.quick_options.map((q, i) => i === idx ? { ...q, favorite_sort_order: value } : q) }
if (type === 'ingredient') return { ...form, ingredients: form.ingredients.map((ing, i) => i === idx ? { ...ing, favorite_sort_order: value } : ing) }
if (type === 'option') return { ...form, options: form.options.map((o, i) => i === idx ? { ...o, favorite_sort_order: value } : o) }
if (type === 'pref') return { ...form, preference_sets: form.preference_sets.map((ps, i) => i === idx ? { ...ps, favorite_sort_order: value } : ps) }
return form
}
function getItemLabel(form, type, idx) {
if (type === 'quick') return form.quick_options[idx]?.name || '(χωρίς όνομα)'
if (type === 'ingredient') return form.ingredients[idx]?.name || '(χωρίς όνομα)'
if (type === 'option') return form.options[idx]?.name || '(χωρίς όνομα)'
if (type === 'pref') return form.preference_sets[idx]?.name || '(χωρίς όνομα)'
return ''
}
function getItemTypeLabel(type) {
if (type === 'quick') return 'Γρήγορη'
if (type === 'ingredient') return 'Υλικό'
if (type === 'option') return 'Έξτρα'
if (type === 'pref') return 'Προτίμηση'
return ''
}
function ProductFormModal({ product, categories, printers, onSave, onCopy, onClose }) {
const [form, setForm] = useState(() => buildFormFromProduct(product))
const [activeTab, setActiveTab] = useState('favorites')
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('favorites')
setImageFile(null)
}, [product.id, product.name])
function setField(k, v) { setForm(f => ({ ...f, [k]: v })) }
// ── Favorites reorder ──
function moveFavorite(favList, favIdx, dir) {
const newList = [...favList]
const swapIdx = favIdx + dir
if (swapIdx < 0 || swapIdx >= newList.length) return
// Swap favorite_sort_order values
const aOrder = newList[favIdx].favorite_sort_order
const bOrder = newList[swapIdx].favorite_sort_order
setForm(f => {
let next = setFavSortField(f, newList[favIdx].type, newList[favIdx].idx, bOrder)
next = setFavSortField(next, newList[swapIdx].type, newList[swapIdx].idx, aOrder)
return next
})
}
function toggleFavorite(type, idx) {
setForm(f => {
const currentFavs = buildFavoritesList(f)
const isFav = (() => {
if (type === 'quick') return f.quick_options[idx]?.is_favorite
if (type === 'ingredient') return f.ingredients[idx]?.is_favorite
if (type === 'option') return f.options[idx]?.is_favorite
if (type === 'pref') return f.preference_sets[idx]?.is_favorite
})()
// Assign next available sort order when adding
const newSortOrder = isFav ? 0 : (currentFavs.length > 0 ? Math.max(...currentFavs.map(x => x.favorite_sort_order)) + 1 : 0)
if (type === 'quick') return { ...f, quick_options: f.quick_options.map((q, i) => i === idx ? { ...q, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : q) }
if (type === 'ingredient') return { ...f, ingredients: f.ingredients.map((ing, i) => i === idx ? { ...ing, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : ing) }
if (type === 'option') return { ...f, options: f.options.map((o, i) => i === idx ? { ...o, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : o) }
if (type === 'pref') return { ...f, preference_sets: f.preference_sets.map((ps, i) => i === idx ? { ...ps, is_favorite: !isFav, favorite_sort_order: isFav ? 0 : newSortOrder } : ps) }
return f
})
}
// ── Quick Options ──
function addQuickOption() { setForm(f => ({ ...f, quick_options: [...f.quick_options, { name: '', price: 0, allow_multiple: false, sort_order: f.quick_options.length, is_favorite: false, favorite_sort_order: 0 }] })) }
function removeQuickOption(i) { setForm(f => ({ ...f, quick_options: f.quick_options.filter((_, idx) => idx !== i) })) }
function setQuickOption(i, k, v) { setForm(f => ({ ...f, quick_options: f.quick_options.map((q, idx) => idx === i ? { ...q, [k]: v } : q) })) }
function moveQuickOption(i, dir) { setForm(f => ({ ...f, quick_options: moveItem(f.quick_options, i, dir) })) }
// ── Options ──
function addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0, allow_multiple: false, sub_choices: [], is_favorite: false, favorite_sort_order: 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 moveOption(i, dir) { setForm(f => ({ ...f, options: moveItem(f.options, i, dir) })) }
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, is_favorite: false, favorite_sort_order: 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() {
const newIdx = form.preference_sets.length
setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', default_choice_index: -1, choices: [], shared_subset: null, is_favorite: false, favorite_sort_order: 0 }] }))
setActiveTab(newIdx)
}
function removePrefSet(si) {
setForm(f => ({ ...f, preference_sets: f.preference_sets.filter((_, idx) => idx !== si) }))
setActiveTab('favorites')
}
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
)}))
}
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) }
)}
)}))
}
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,
lifecycle_status: form.lifecycle_status,
printer_zone_id: form.printer_zone_id ? Number(form.printer_zone_id) : null,
quick_options: form.quick_options.map((q, i) => ({
name: q.name,
price: parseFloat(q.price) || 0,
allow_multiple: q.allow_multiple ?? false,
sort_order: i,
is_favorite: q.is_favorite ?? false,
favorite_sort_order: q.favorite_sort_order ?? 0,
})),
options: form.options.map(o => ({
name: o.name,
extra_cost: parseFloat(o.extra_cost) || 0,
allow_multiple: o.allow_multiple ?? false,
sub_choices: (o.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false })),
is_favorite: o.is_favorite ?? false,
favorite_sort_order: o.favorite_sort_order ?? 0,
})),
ingredients: form.ingredients.map(i => ({
name: i.name,
extra_cost: parseFloat(i.extra_cost) || 0,
is_favorite: i.is_favorite ?? false,
favorite_sort_order: i.favorite_sort_order ?? 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 })),
})),
is_favorite: ps.is_favorite ?? false,
favorite_sort_order: ps.favorite_sort_order ?? 0,
})),
}
}
async function submit() {
if (!imageFile) {
onSave(buildBody())
return
}
if (!isNew) {
onSave(buildBody())
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) }
} else {
setUploading(true)
try {
const res = await client.post('/api/products/', buildBody())
const newId = res.data.id
const fd = new FormData(); fd.append('file', imageFile)
await client.post(`/api/products/${newId}/image`, fd)
qc.invalidateQueries({ queryKey: ['products-all'] })
onClose()
} catch { toast.error('Σφάλμα αποθήκευσης') }
finally { setUploading(false) }
}
}
const isNew = !product.id
const canSave = form.name.trim() && form.base_price
const favCount = buildFavoritesList(form).length
const tabs = [
{ key: 'favorites', label: 'Αγαπημένα', count: favCount, isFavTab: true },
{ key: 'quick', label: 'Γρήγορες', count: form.quick_options.length },
{ 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 },
]
const favList = buildFavoritesList(form)
return (
{/* ── Header ── */}
{isNew ? 'Νέο προϊόν' : `Επεξεργασία — ${product.name}`}
{/* ── Body: left/right split ── */}
{/* LEFT: product info */}
Στοιχεία προϊόντος
setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
setField('base_price', v)} className="w-full" />
{/* Availability toggle */}
{/* Image upload */}
{product.image_url && (

)}
{imageFile && (
{imageFile.name}
)}
{isNew && imageFile && (
Η εικόνα θα ανέβει μαζί με την αποθήκευση.
)}
{/* RIGHT: tabs */}
{/* Tab bar */}
{tabs.map(tab => {
if (tab.isAdd) return (
)
const isActive = activeTab === tab.key
return (
)
})}
{/* Tab content */}
{/* ── Favorites tab ── */}
{activeTab === 'favorites' && (
Αγαπημένα — εμφανίζονται πρώτα στον σερβιτόρο. Σημειώστε ως αγαπημένο οποιοδήποτε στοιχείο από τις άλλες καρτέλες. Εδώ μπορείτε να τα αναδιατάξετε.
{favList.length === 0 && (
Δεν υπάρχουν αγαπημένα. Χρησιμοποιήστε το σε γρήγορες επιλογές, υλικά, έξτρα ή προτιμήσεις.
)}
{favList.map((fav, fi) => {
const label = getItemLabel(form, fav.type, fav.idx)
const typeLabel = getItemTypeLabel(fav.type)
return (
moveFavorite(favList, fi, -1)}
onDown={() => moveFavorite(favList, fi, 1)}
disableUp={fi === 0}
disableDown={fi === favList.length - 1}
/>
)
})}
)}
{/* ── Quick Options tab ── */}
{activeTab === 'quick' && (
Γρήγορες επιλογές — χωρίς υπο-επιλογές.
{!form.quick_options.length &&
Δεν υπάρχουν γρήγορες επιλογές.
}
{form.quick_options.map((q, i) => (
))}
)}
{/* ── Ingredients tab ── */}
{activeTab === 'ingredients' && (
Υλικά που ο πελάτης μπορεί να αφαιρέσει.
{!form.ingredients.length &&
Δεν υπάρχουν υλικά.
}
{form.ingredients.map((ing, i) => (
))}
)}
{/* ── Extras tab ── */}
{activeTab === 'options' && (
Έξτρα (checkbox). Κάθε extra μπορεί να έχει υπο-επιλογές.
{!form.options.length &&
Δεν υπάρχουν extras.
}
{form.options.map((opt, i) => (
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 />
toggleFavorite('pref', si)} />
● = προεπιλογή (κλικ ξανά για αποεπιλογή) · ⊘ = απενεργοποιεί το κοινό υπο-σύνολο
{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)}
/>
))}
Κοινό υπο-σύνολο
Εμφανίζεται για όλες τις επιλογές εκτός αυτών με ⊘
{!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 && (
)}
)
}