1557 lines
82 KiB
JavaScript
1557 lines
82 KiB
JavaScript
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 (
|
||
<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>
|
||
)
|
||
}
|
||
|
||
function IconBase({ className = '', viewBox, strokeWidth = '1.5', children }) {
|
||
return (
|
||
<svg
|
||
aria-hidden="true"
|
||
viewBox={viewBox}
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
className={`inline-block shrink-0 ${className}`}
|
||
stroke="currentColor"
|
||
strokeWidth={strokeWidth}
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
{children}
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function AddIcon({ className = '' }) {
|
||
return (
|
||
<IconBase className={className} viewBox="0 0 24 24">
|
||
<path d="M15 12H9" />
|
||
<path d="M12 9V15" />
|
||
<path d="M22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C21.5093 4.43821 21.8356 5.80655 21.9449 8" />
|
||
</IconBase>
|
||
)
|
||
}
|
||
|
||
function MoveUpIcon({ className = '' }) {
|
||
return (
|
||
<IconBase className={className} viewBox="-0.5 0 25 25">
|
||
<path d="M12 22.4199C17.5228 22.4199 22 17.9428 22 12.4199C22 6.89707 17.5228 2.41992 12 2.41992C6.47715 2.41992 2 6.89707 2 12.4199C2 17.9428 6.47715 22.4199 12 22.4199Z" />
|
||
<path d="M8 13.8599L10.87 10.8C11.0125 10.6416 11.1868 10.5149 11.3815 10.4282C11.5761 10.3415 11.7869 10.2966 12 10.2966C12.2131 10.2966 12.4239 10.3415 12.6185 10.4282C12.8132 10.5149 12.9875 10.6416 13.13 10.8L16 13.8599" />
|
||
</IconBase>
|
||
)
|
||
}
|
||
|
||
function MoveDownIcon({ className = '' }) {
|
||
return (
|
||
<IconBase className={className} viewBox="-0.5 0 25 25">
|
||
<path d="M12 22.4199C17.5228 22.4199 22 17.9428 22 12.4199C22 6.89707 17.5228 2.41992 12 2.41992C6.47715 2.41992 2 6.89707 2 12.4199C2 17.9428 6.47715 22.4199 12 22.4199Z" />
|
||
<path d="M16 10.99L13.13 14.05C12.9858 14.2058 12.811 14.3298 12.6166 14.4148C12.4221 14.4998 12.2122 14.5437 12 14.5437C11.7878 14.5437 11.5779 14.4998 11.3834 14.4148C11.189 14.3298 11.0142 14.2058 10.87 14.05L8 10.99" />
|
||
</IconBase>
|
||
)
|
||
}
|
||
|
||
function EditIcon({ className = '' }) {
|
||
return (
|
||
<IconBase className={className} viewBox="0 0 24 24">
|
||
<path d="M18 10L14 6M18 10L21 7L17 3L14 6M18 10L17 11M14 6L8 12V16H12L14.5 13.5M20 14V20H12M10 4L4 4L4 20H7" />
|
||
</IconBase>
|
||
)
|
||
}
|
||
|
||
function DeleteIcon({ className = '' }) {
|
||
return (
|
||
<IconBase className={className} viewBox="0 0 24 24" strokeWidth="2">
|
||
<path d="M10 11V17" />
|
||
<path d="M14 11V17" />
|
||
<path d="M4 7H20" />
|
||
<path d="M6 7H12H18V18C18 19.6569 16.6569 21 15 21H9C7.34315 21 6 19.6569 6 18V7Z" />
|
||
<path d="M9 5C9 3.89543 9.89543 3 11 3H13C14.1046 3 15 3.89543 15 5V7H9V5Z" />
|
||
</IconBase>
|
||
)
|
||
}
|
||
|
||
function HeartIcon({ filled, className = '' }) {
|
||
return (
|
||
<svg aria-hidden="true" viewBox="0 0 24 24" className={`inline-block shrink-0 ${className}`}
|
||
fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.8"
|
||
strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function ReorderBtns({ onUp, onDown, disableUp, disableDown }) {
|
||
return (
|
||
<div className="flex flex-col shrink-0">
|
||
<button type="button" onClick={onUp} disabled={disableUp}
|
||
className="text-gray-400 hover:text-gray-600 disabled:opacity-20 leading-none px-1 py-0.5 text-xs">▲</button>
|
||
<button type="button" onClick={onDown} disabled={disableDown}
|
||
className="text-gray-400 hover:text-gray-600 disabled:opacity-20 leading-none px-1 py-0.5 text-xs">▼</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DefaultBtn({ isDefault, onClick, title }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
title={title ?? (isDefault ? 'Κλικ για αποεπιλογή' : 'Ορισμός ως προεπιλογή')}
|
||
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0 transition-all ${
|
||
isDefault
|
||
? 'border-primary-600 bg-primary-600'
|
||
: 'border-gray-300 bg-white hover:border-primary-400'
|
||
}`}
|
||
>
|
||
{isDefault && <span className="w-2.5 h-2.5 rounded-full bg-white block" />}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function FavoriteBtn({ isFavorite, onClick, title }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
title={title ?? (isFavorite ? 'Αφαίρεση από αγαπημένα' : 'Προσθήκη στα αγαπημένα')}
|
||
className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 transition-all ${
|
||
isFavorite
|
||
? 'text-rose-500 hover:text-rose-400'
|
||
: 'text-gray-300 hover:text-rose-400'
|
||
}`}
|
||
>
|
||
<HeartIcon filled={isFavorite} className="w-4 h-4" />
|
||
</button>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className={`flex items-center border border-gray-300 rounded-lg overflow-hidden h-10 ${className}`}>
|
||
<button type="button" onClick={dec}
|
||
className="px-2 h-full text-gray-500 hover:bg-gray-100 border-r border-gray-300 text-sm font-bold shrink-0">−</button>
|
||
<input
|
||
type="number" step="0.10" value={value}
|
||
onChange={e => onChange(e.target.value)}
|
||
placeholder={placeholder ?? '0.00'}
|
||
className="flex-1 min-w-0 text-center text-sm outline-none bg-transparent px-1"
|
||
/>
|
||
<button type="button" onClick={inc}
|
||
className="px-2 h-full text-gray-500 hover:bg-gray-100 border-l border-gray-300 text-sm font-bold shrink-0">+</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="border-t border-gray-100 bg-indigo-50/40 px-3 py-2 space-y-2">
|
||
<p className="text-xs text-indigo-500 font-medium mb-1">
|
||
Υπο-επιλογές του «{parentLabel || '…'}» — εμφανίζονται μόνο αν επιλεγεί
|
||
</p>
|
||
{subChoices.map((sc, sci) => (
|
||
<div key={sci} className="flex items-center gap-2 ml-4">
|
||
<ReorderBtns
|
||
onUp={() => onMove(sci, -1)} onDown={() => onMove(sci, 1)}
|
||
disableUp={sci === 0} disableDown={sci === subChoices.length - 1}
|
||
/>
|
||
<DefaultBtn isDefault={sc.is_default} onClick={() => onToggleDefault(sci)} />
|
||
<input className="input flex-1 text-sm" placeholder="π.χ. Καραμέλα"
|
||
value={sc.name} onChange={e => onChange(sci, 'name', e.target.value)} />
|
||
<PriceInput value={sc.extra_cost} onChange={v => onChange(sci, 'extra_cost', v)}
|
||
allowNegative className="w-28 text-sm" />
|
||
<button onClick={() => onRemove(sci)} className="btn btn-danger px-2 min-h-0 h-9 text-sm shrink-0">✕</button>
|
||
</div>
|
||
))}
|
||
<button onClick={onAdd} className="ml-4 btn btn-secondary text-xs px-2 py-1 min-h-0 h-7">
|
||
+ Υπο-επιλογή
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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_<id>' → 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 (
|
||
<div className="flex gap-6 h-full">
|
||
{/* Left: Categories */}
|
||
<aside className="w-96 shrink-0 space-y-1 overflow-y-auto">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="font-semibold text-gray-700">Κατηγορίες</h2>
|
||
<button
|
||
onClick={() => setEditCat({ _isNew: true })}
|
||
className="btn btn-primary px-2 py-1 min-h-0 h-8 text-white"
|
||
title="Προσθήκη κατηγορίας"
|
||
aria-label="Προσθήκη κατηγορίας"
|
||
>
|
||
<AddIcon className={categoryIconClass} />
|
||
</button>
|
||
</div>
|
||
<button onClick={() => setSelectedCat(null)}
|
||
className={`w-full text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${!selectedCat ? 'bg-primary-700 text-white' : 'hover:bg-gray-100 text-gray-700'}`}>
|
||
Όλα
|
||
</button>
|
||
|
||
{topLevelCats.map((cat, idx) => {
|
||
const subs = categories.filter(c => c.parent_id === cat.id).sort((a, b) => a.sort_order - b.sort_order)
|
||
const subList = buildSubList(cat, subs)
|
||
const isParentActive = selectedCat === cat.id
|
||
return (
|
||
<div key={cat.id}>
|
||
{/* Top-level category row */}
|
||
<div className={`flex items-center gap-1 rounded-xl ${isParentActive ? 'bg-primary-700 text-white' : 'hover:bg-gray-100'}`}>
|
||
{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>
|
||
{/* Add sub-category button */}
|
||
<button
|
||
onClick={() => setEditCat({ _isNew: true, parent_id: cat.id })}
|
||
className={`${categoryActionBtnClass} text-green-600 hover:text-green-700`}
|
||
title="Προσθήκη υποκατηγορίας"
|
||
>
|
||
<AddIcon className={categoryIconClass} />
|
||
</button>
|
||
<button onClick={() => moveCat(cat, -1)} disabled={idx === 0} className={categoryMoveBtnClass} title="Μετακίνηση πάνω">
|
||
<MoveUpIcon className={categoryIconClass} />
|
||
</button>
|
||
<button onClick={() => moveCat(cat, 1)} disabled={idx === topLevelCats.length - 1} className={categoryMoveBtnClass} title="Μετακίνηση κάτω">
|
||
<MoveDownIcon className={categoryIconClass} />
|
||
</button>
|
||
<button onClick={() => setEditCat(cat)} className={categoryEditBtnClass} title="Επεξεργασία">
|
||
<EditIcon className={categoryIconClass} />
|
||
</button>
|
||
<button onClick={() => setConfirmDelete({ type: 'category', id: cat.id })} className={`${categoryDeleteBtnClass} mr-1`} title="Διαγραφή">
|
||
<DeleteIcon className={categoryIconClass} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Sub-category rows (indented), interleaved with General group */}
|
||
{subList.map((row, subIdx) => {
|
||
if (row._isGeneral) {
|
||
// Virtual "General" row — products directly on the parent
|
||
const isGeneralActive = selectedCat === `__general_${cat.id}`
|
||
return (
|
||
<div key="__general__" className={`flex items-center gap-1 ml-4 rounded-xl ${isGeneralActive ? 'bg-primary-600 text-white' : 'hover:bg-gray-100'}`}>
|
||
<span className="w-1.5 h-1.5 rounded-full ml-2 shrink-0 bg-gray-300" />
|
||
<button
|
||
onClick={() => setSelectedCat(`__general_${cat.id}`)}
|
||
className={`flex-1 text-left px-2 py-2 text-xs italic truncate ${selectedCat === `__general_${cat.id}` ? 'font-semibold' : 'text-gray-400'}`}
|
||
>Γενικά</button>
|
||
<button onClick={() => moveGeneral(cat, -1)} disabled={subIdx === 0} className={`${categoryMoveBtnClass} opacity-60`} title="Μετακίνηση Γενικά πάνω">
|
||
<MoveUpIcon className={categoryIconClass} />
|
||
</button>
|
||
<button onClick={() => moveGeneral(cat, 1)} disabled={subIdx === subList.length - 1} className={`${categoryMoveBtnClass} opacity-60 mr-1`} title="Μετακίνηση Γενικά κάτω">
|
||
<MoveDownIcon className={categoryIconClass} />
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
const isSubActive = selectedCat === row.id
|
||
return (
|
||
<div key={row.id} className={`flex items-center gap-1 ml-4 rounded-xl ${isSubActive ? 'bg-primary-600 text-white' : 'hover:bg-gray-100'}`}>
|
||
{row.color
|
||
? <span className="w-1.5 h-1.5 rounded-full ml-2 shrink-0" style={{ background: row.color }} />
|
||
: <span className="w-1.5 h-1.5 rounded-full ml-2 shrink-0 bg-gray-400" />
|
||
}
|
||
<button onClick={() => setSelectedCat(row.id)} className="flex-1 text-left px-2 py-2 text-xs font-medium truncate">{row.name}</button>
|
||
{/* Auto-expanded toggle */}
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleAutoExpanded.mutate({ id: row.id, auto_expanded: !row.auto_expanded })}
|
||
title={row.auto_expanded ? 'Auto-expanded: ON — κλικ για απενεργοποίηση' : 'Auto-expanded: OFF — κλικ για ενεργοποίηση'}
|
||
className={`px-1.5 py-0.5 rounded text-xs font-bold shrink-0 transition-colors ${
|
||
row.auto_expanded
|
||
? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200'
|
||
: 'bg-gray-100 text-gray-400 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
↕
|
||
</button>
|
||
<button onClick={() => moveSubcat(cat, row, -1)} disabled={subIdx === 0} className={categoryMoveBtnClass} title="Μετακίνηση πάνω">
|
||
<MoveUpIcon className={categoryIconClass} />
|
||
</button>
|
||
<button onClick={() => moveSubcat(cat, row, 1)} disabled={subIdx === subList.length - 1} className={categoryMoveBtnClass} title="Μετακίνηση κάτω">
|
||
<MoveDownIcon className={categoryIconClass} />
|
||
</button>
|
||
<button onClick={() => setEditCat(row)} className={categoryEditBtnClass} title="Επεξεργασία">
|
||
<EditIcon className={categoryIconClass} />
|
||
</button>
|
||
<button onClick={() => setConfirmDelete({ type: 'category', id: row.id })} className={`${categoryDeleteBtnClass} mr-1`} title="Διαγραφή">
|
||
<DeleteIcon className={categoryIconClass} />
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
})}
|
||
</aside>
|
||
|
||
{/* Right: Products */}
|
||
<div className="flex-1 space-y-4 min-w-0">
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<h2 className="font-semibold text-gray-700 flex-1">
|
||
{isGeneralSel
|
||
? (() => {
|
||
const parent = categories.find(c => c.id === generalParentId)
|
||
return `Προϊόντα — ${parent?.name ?? '?'} / Γενικά`
|
||
})()
|
||
: selectedCat
|
||
? (() => {
|
||
const cat = categories.find(c => c.id === selectedCat)
|
||
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}`
|
||
})()
|
||
: 'Προϊόντα'
|
||
}
|
||
</h2>
|
||
<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>
|
||
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||
<input type="checkbox" checked={showArchived} onChange={e => setShowArchived(e.target.checked)} className="accent-primary-700" />
|
||
Αρχειοθετημένα
|
||
</label>
|
||
<select value={sortMode} onChange={e => setSortMode(e.target.value)} className="input text-sm py-1 h-9 min-h-0 w-auto pr-8">
|
||
<option value="custom">Σειρά: Προσαρμοσμένη</option>
|
||
<option value="name">Σειρά: Αλφαβητικά</option>
|
||
<option value="price">Σειρά: Τιμή</option>
|
||
</select>
|
||
{!multiSelect && <button onClick={() => setMultiSelect(true)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">Πολλαπλή επιλογή</button>}
|
||
{multiSelect && (
|
||
<div className="flex gap-2 flex-wrap">
|
||
<span className="text-sm text-gray-500 self-center">{selected.size} επιλεγμένα</span>
|
||
<button onClick={selectAll} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8">Όλα</button>
|
||
<button onClick={clearSelect} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8">Καθαρισμός</button>
|
||
<button onClick={() => bulkAction('available')} disabled={!selected.size} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8 text-green-600">Διαθέσιμο</button>
|
||
<button onClick={() => bulkAction('unavailable')} disabled={!selected.size} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8 text-amber-600">Μη διαθέσιμο</button>
|
||
<button onClick={() => bulkAction('archive')} disabled={!selected.size} className="btn btn-danger text-xs px-2 py-1 min-h-0 h-8">Αρχειοθέτηση</button>
|
||
<button onClick={exitMultiSelect} className="btn btn-secondary text-xs px-2 py-1 min-h-0 h-8">Ακύρωση</button>
|
||
</div>
|
||
)}
|
||
{!multiSelect && <button onClick={() => setEditProduct({ ...EMPTY_PRODUCT })} className="btn btn-primary">+ Νέο προϊόν</button>}
|
||
</div>
|
||
|
||
{products.length === 0 && <p className="text-center text-gray-400 py-16">Δεν υπάρχουν προϊόντα.</p>}
|
||
|
||
<div className="space-y-3">
|
||
{products.map((p, idx) => {
|
||
const isArchived = p.lifecycle_status === 'archived'
|
||
return (
|
||
<div key={p.id}
|
||
onClick={multiSelect ? () => 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 && (
|
||
<input type="checkbox" checked={selected.has(p.id)} onChange={() => toggleSelect(p.id)}
|
||
onClick={e => e.stopPropagation()} className="w-4 h-4 accent-primary-700 shrink-0" />
|
||
)}
|
||
{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 flex items-center gap-2">
|
||
{p.name}
|
||
{isArchived && (
|
||
<span className="text-xs bg-gray-200 text-gray-600 px-1.5 py-0.5 rounded font-normal">Αρχείο</span>
|
||
)}
|
||
{!isArchived && !p.is_available && (
|
||
<span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-normal">Ανενεργό</span>
|
||
)}
|
||
</p>
|
||
<p className="text-sm text-gray-500">
|
||
{(() => {
|
||
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)}`}
|
||
</p>
|
||
</div>
|
||
{!multiSelect && (
|
||
<>
|
||
{/* Availability toggle — only for non-archived products */}
|
||
{!isArchived && (
|
||
<button
|
||
onClick={e => { e.stopPropagation(); toggleAvail.mutate({ id: p.id, is_available: !p.is_available }) }}
|
||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors shrink-0 ${
|
||
p.is_available
|
||
? 'bg-green-50 border-green-300 text-green-700 hover:bg-green-100'
|
||
: 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
<span className={`w-2 h-2 rounded-full ${p.is_available ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||
{p.is_available ? 'Διαθέσιμο' : 'Ανενεργό'}
|
||
</button>
|
||
)}
|
||
<button onClick={() => setEditProduct(p)} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9 shrink-0">Επεξεργασία</button>
|
||
{/* Archive/delete button — context-sensitive */}
|
||
{isArchived ? (
|
||
<button
|
||
onClick={() => setConfirmDelete({ type: 'product-hard', id: p.id })}
|
||
className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9 shrink-0"
|
||
title="Οριστική διαγραφή"
|
||
>
|
||
Διαγραφή
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => setConfirmDelete({ type: 'product-archive', id: p.id })}
|
||
className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9 shrink-0"
|
||
title="Αρχειοθέτηση (διατηρείται για ιστορικό)"
|
||
>
|
||
Αρχειοθέτηση
|
||
</button>
|
||
)}
|
||
{sortMode === 'custom' && !isArchived && (
|
||
<div className="flex flex-col gap-0.5 shrink-0">
|
||
<button onClick={() => moveProd(p, -1)} disabled={idx === 0} className="text-gray-400 hover:text-gray-600 disabled:opacity-20 text-xs leading-none">▲</button>
|
||
<button onClick={() => moveProd(p, 1)} disabled={idx === products.length - 1} className="text-gray-400 hover:text-gray-600 disabled:opacity-20 text-xs leading-none">▼</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{editCat !== null && (
|
||
<CategoryFormModal
|
||
cat={editCat}
|
||
parentName={editCat.parent_id ? categories.find(c => 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 && (
|
||
<ProductFormModal
|
||
product={editProduct} categories={categories} printers={printers}
|
||
onSave={b => saveProduct.mutate(b)}
|
||
onCopy={formData => setEditProduct({ ...EMPTY_PRODUCT, ...formData, id: undefined, image_url: undefined, name: formData.name + ' (αντίγραφο)' })}
|
||
onClose={() => setEditProduct(null)}
|
||
/>
|
||
)}
|
||
{confirmDelete && (
|
||
<ConfirmModal
|
||
title="Επιβεβαίωση"
|
||
message={
|
||
confirmDelete.type === 'category'
|
||
? 'Η κατηγορία θα διαγραφεί.'
|
||
: confirmDelete.type === 'bulk-archive'
|
||
? `${confirmDelete.ids.length} προϊόντα θα αρχειοθετηθούν (αν υπάρχουν σε παραγγελίες) ή θα διαγραφούν.`
|
||
: confirmDelete.type === 'product-archive'
|
||
? 'Το προϊόν θα αρχειοθετηθεί. Αν δεν υπάρχει σε παλαιές παραγγελίες θα διαγραφεί οριστικά, αλλιώς θα κρατηθεί για ιστορικό.'
|
||
: 'Οριστική διαγραφή. Το προϊόν θα χαθεί μόνιμα.'
|
||
}
|
||
confirmLabel={confirmDelete.type === 'category' ? 'Επιβεβαίωση' : confirmDelete.type === 'product-hard' ? 'Διαγραφή' : 'Αρχειοθέτηση'}
|
||
confirmClass="btn-danger"
|
||
onConfirm={handleConfirmDelete}
|
||
onCancel={() => setConfirmDelete(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<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">{title}</h2>
|
||
{isSub && parentName && (
|
||
<p className="text-xs text-gray-400 -mt-2">Κατηγορία: <span className="font-medium text-gray-600">{parentName}</span></p>
|
||
)}
|
||
<div>
|
||
<label className="label">Όνομα</label>
|
||
<input className="input" value={name} onChange={e => setName(e.target.value)} autoFocus />
|
||
</div>
|
||
<div>
|
||
<label className="label">Χρώμα</label>
|
||
<ColorPicker value={color} onChange={setColor} />
|
||
</div>
|
||
<div className="flex gap-3 pt-2">
|
||
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
|
||
<button onClick={() => onSave(name, color || null)} disabled={!name.trim()} className="flex-1 btn btn-primary">Αποθήκευση</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── 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 (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-2xl shadow-2xl flex flex-col overflow-hidden" style={{ width: '90vw', height: '92vh' }}>
|
||
|
||
{/* ── Header ── */}
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
|
||
<h2 className="font-bold text-gray-800 text-lg">
|
||
{isNew ? 'Νέο προϊόν' : `Επεξεργασία — ${product.name}`}
|
||
</h2>
|
||
<button onClick={onClose} className="w-8 h-8 rounded-full hover:bg-gray-100 flex items-center justify-center text-gray-500 text-lg">✕</button>
|
||
</div>
|
||
|
||
{/* ── Body: left/right split ── */}
|
||
<div className="flex-1 flex overflow-hidden">
|
||
|
||
{/* LEFT: product info */}
|
||
<div className="w-80 shrink-0 border-r border-gray-100 bg-gray-50/50 px-5 py-5 flex flex-col gap-3">
|
||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest">Στοιχεία προϊόντος</p>
|
||
|
||
<div>
|
||
<label className="label">Όνομα *</label>
|
||
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
|
||
</div>
|
||
|
||
<div>
|
||
<label className="label">Τιμή βάσης (€) *</label>
|
||
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
|
||
</div>
|
||
|
||
<div>
|
||
<label className="label">Κατηγορία</label>
|
||
<select className="input" value={form.category_id} onChange={e => setField('category_id', e.target.value)}>
|
||
<option value="">— Χωρίς κατηγορία —</option>
|
||
{categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order).flatMap(parent => {
|
||
const subs = categories.filter(c => c.parent_id === parent.id).sort((a, b) => a.sort_order - b.sort_order)
|
||
return [
|
||
<option key={parent.id} value={parent.id}>{parent.name}</option>,
|
||
...subs.map(s => <option key={s.id} value={s.id}> ↳ {s.name}</option>),
|
||
]
|
||
})}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="label">Ζώνη εκτυπωτή</label>
|
||
<select className="input" value={form.printer_zone_id} onChange={e => setField('printer_zone_id', e.target.value)}>
|
||
<option value="">— Χωρίς εκτυπωτή —</option>
|
||
{printers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Availability toggle */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setField('is_available', !form.is_available)}
|
||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||
form.is_available
|
||
? 'bg-green-50 border-green-300 text-green-700 hover:bg-green-100'
|
||
: 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
<span className={`w-2.5 h-2.5 rounded-full ${form.is_available ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||
{form.is_available ? 'Διαθέσιμο' : 'Μη διαθέσιμο'}
|
||
</button>
|
||
|
||
{/* Image upload */}
|
||
<div>
|
||
<label className="label">Εικόνα προϊόντος</label>
|
||
{product.image_url && (
|
||
<img src={`${import.meta.env.VITE_API_URL || ''}${product.image_url}`}
|
||
className="w-16 h-16 rounded-xl object-cover border border-gray-200 mb-2" alt="" />
|
||
)}
|
||
{imageFile && (
|
||
<div className="text-xs text-primary-700 font-medium mb-1 break-all">{imageFile.name}</div>
|
||
)}
|
||
<label className="cursor-pointer inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-sm text-gray-600 transition-colors">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||
{imageFile ? 'Αλλαγή εικόνας' : 'Επιλογή εικόνας'}
|
||
<input type="file" accept="image/*" className="sr-only"
|
||
onChange={e => setImageFile(e.target.files[0] ?? null)} />
|
||
</label>
|
||
{isNew && imageFile && (
|
||
<p className="text-xs text-gray-400 mt-1">Η εικόνα θα ανέβει μαζί με την αποθήκευση.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* RIGHT: tabs */}
|
||
<div className="flex-1 flex flex-col overflow-hidden">
|
||
{/* Tab bar */}
|
||
<div className="flex border-b border-gray-200 overflow-x-auto shrink-0 bg-white">
|
||
{tabs.map(tab => {
|
||
if (tab.isAdd) return (
|
||
<button key="__add_pref__" onClick={addPrefSet}
|
||
className="px-4 py-3 text-sm font-medium text-primary-600 hover:bg-primary-50 whitespace-nowrap border-b-2 border-transparent transition-colors">
|
||
{tab.label}
|
||
</button>
|
||
)
|
||
const isActive = activeTab === tab.key
|
||
return (
|
||
<button key={String(tab.key)} onClick={() => setActiveTab(tab.key)}
|
||
className={`px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors flex items-center gap-1.5 ${
|
||
isActive ? 'border-primary-600 text-primary-700 bg-primary-50/50' : 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||
}`}>
|
||
{tab.isFavTab && <HeartIcon filled={favCount > 0} className={`w-3.5 h-3.5 ${favCount > 0 ? 'text-rose-500' : 'text-gray-400'}`} />}
|
||
{tab.label}
|
||
{tab.count > 0 && (
|
||
<span className={`text-xs px-1.5 py-0.5 rounded-full font-mono ${isActive ? 'bg-primary-100 text-primary-700' : 'bg-gray-100 text-gray-500'}`}>
|
||
{tab.count}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Tab content */}
|
||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||
|
||
{/* ── Favorites tab ── */}
|
||
{activeTab === 'favorites' && (
|
||
<div>
|
||
<p className="text-sm text-gray-500 mb-4">
|
||
Αγαπημένα — εμφανίζονται πρώτα στον σερβιτόρο. Σημειώστε ως αγαπημένο οποιοδήποτε στοιχείο από τις άλλες καρτέλες. Εδώ μπορείτε να τα αναδιατάξετε.
|
||
</p>
|
||
{favList.length === 0 && (
|
||
<p className="text-sm text-gray-400 text-center py-12">
|
||
Δεν υπάρχουν αγαπημένα. Χρησιμοποιήστε το <HeartIcon className="w-4 h-4 inline text-gray-400" /> σε γρήγορες επιλογές, υλικά, έξτρα ή προτιμήσεις.
|
||
</p>
|
||
)}
|
||
<div className="space-y-2">
|
||
{favList.map((fav, fi) => {
|
||
const label = getItemLabel(form, fav.type, fav.idx)
|
||
const typeLabel = getItemTypeLabel(fav.type)
|
||
return (
|
||
<div key={`${fav.type}-${fav.idx}`} className="flex items-center gap-3 border border-rose-100 bg-rose-50/30 rounded-xl p-3">
|
||
<ReorderBtns
|
||
onUp={() => moveFavorite(favList, fi, -1)}
|
||
onDown={() => moveFavorite(favList, fi, 1)}
|
||
disableUp={fi === 0}
|
||
disableDown={fi === favList.length - 1}
|
||
/>
|
||
<HeartIcon filled className="w-4 h-4 text-rose-400 shrink-0" />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-800 truncate">{label}</p>
|
||
<p className="text-xs text-gray-400">{typeLabel}</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleFavorite(fav.type, fav.idx)}
|
||
className="text-xs text-gray-400 hover:text-red-500 transition-colors px-2 py-1 rounded hover:bg-red-50"
|
||
>
|
||
Αφαίρεση
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Quick Options tab ── */}
|
||
{activeTab === 'quick' && (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<p className="text-sm text-gray-500">Γρήγορες επιλογές — χωρίς υπο-επιλογές.</p>
|
||
<button onClick={addQuickOption} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Επιλογή</button>
|
||
</div>
|
||
{!form.quick_options.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν γρήγορες επιλογές.</p>}
|
||
<div className="space-y-2">
|
||
{form.quick_options.map((q, i) => (
|
||
<div key={i} className={`flex gap-2 items-center border rounded-xl p-3 bg-white ${q.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
|
||
<ReorderBtns onUp={() => moveQuickOption(i, -1)} onDown={() => moveQuickOption(i, 1)}
|
||
disableUp={i === 0} disableDown={i === form.quick_options.length - 1} />
|
||
<FavoriteBtn isFavorite={q.is_favorite} onClick={() => toggleFavorite('quick', i)} />
|
||
<input className="input flex-1" placeholder="Όνομα (π.χ. Extra Bacon)"
|
||
value={q.name} onChange={e => setQuickOption(i, 'name', e.target.value)} />
|
||
<PriceInput value={q.price} onChange={v => setQuickOption(i, 'price', v)}
|
||
className="w-32" placeholder="0.00" />
|
||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer shrink-0 select-none">
|
||
<input type="checkbox" checked={q.allow_multiple}
|
||
onChange={e => setQuickOption(i, 'allow_multiple', e.target.checked)}
|
||
className="accent-primary-700 w-4 h-4" />
|
||
Πολλαπλά
|
||
</label>
|
||
<button onClick={() => removeQuickOption(i)} className="btn btn-danger px-3 min-h-0 h-10">✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Ingredients tab ── */}
|
||
{activeTab === 'ingredients' && (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<p className="text-sm text-gray-500">Υλικά που ο πελάτης μπορεί να αφαιρέσει.</p>
|
||
<button onClick={addIngredient} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Υλικό</button>
|
||
</div>
|
||
{!form.ingredients.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν υλικά.</p>}
|
||
<div className="space-y-2">
|
||
{form.ingredients.map((ing, i) => (
|
||
<div key={i} className={`flex gap-2 items-center border rounded-xl p-3 bg-white ${ing.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
|
||
<ReorderBtns onUp={() => moveIngredient(i, -1)} onDown={() => moveIngredient(i, 1)}
|
||
disableUp={i === 0} disableDown={i === form.ingredients.length - 1} />
|
||
<FavoriteBtn isFavorite={ing.is_favorite} onClick={() => toggleFavorite('ingredient', i)} />
|
||
<input className="input flex-1" placeholder="Όνομα υλικού" value={ing.name}
|
||
onChange={e => setIngredient(i, 'name', e.target.value)} />
|
||
<PriceInput value={ing.extra_cost} onChange={v => setIngredient(i, 'extra_cost', v)}
|
||
allowNegative className="w-32" />
|
||
<button onClick={() => removeIngredient(i)} className="btn btn-danger px-3 min-h-0 h-10">✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Extras tab ── */}
|
||
{activeTab === 'options' && (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<p className="text-sm text-gray-500">Έξτρα (checkbox). Κάθε extra μπορεί να έχει υπο-επιλογές.</p>
|
||
<button onClick={addOption} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Έξτρα</button>
|
||
</div>
|
||
{!form.options.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν extras.</p>}
|
||
<div className="space-y-3">
|
||
{form.options.map((opt, i) => (
|
||
<div key={i} className={`border rounded-xl overflow-hidden ${opt.is_favorite ? 'border-rose-200' : 'border-gray-200'}`}>
|
||
<div className="flex gap-2 items-center p-3 bg-white flex-wrap">
|
||
<ReorderBtns onUp={() => moveOption(i, -1)} onDown={() => moveOption(i, 1)}
|
||
disableUp={i === 0} disableDown={i === form.options.length - 1} />
|
||
<FavoriteBtn isFavorite={opt.is_favorite} onClick={() => toggleFavorite('option', i)} />
|
||
<input className="input flex-1 min-w-40" placeholder="Όνομα extra (π.χ. Κανέλα)"
|
||
value={opt.name} onChange={e => setOption(i, 'name', e.target.value)} />
|
||
<PriceInput value={opt.extra_cost} onChange={v => setOption(i, 'extra_cost', v)}
|
||
allowNegative className="w-32" />
|
||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer shrink-0 select-none">
|
||
<input type="checkbox" checked={opt.allow_multiple}
|
||
onChange={e => setOption(i, 'allow_multiple', e.target.checked)}
|
||
className="accent-primary-700 w-4 h-4" />
|
||
Πολλαπλά
|
||
</label>
|
||
<button onClick={() => addOptionSubChoice(i)}
|
||
className="btn btn-secondary text-xs px-2 min-h-0 h-9 shrink-0 whitespace-nowrap">
|
||
+ Υπο-επιλογές
|
||
</button>
|
||
<button onClick={() => removeOption(i)} className="btn btn-danger px-3 min-h-0 h-10">✕</button>
|
||
</div>
|
||
<SubChoiceRows
|
||
subChoices={opt.sub_choices}
|
||
parentLabel={opt.name}
|
||
onMove={(sci, dir) => 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)}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 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 (
|
||
<div>
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<input className="input flex-1 font-semibold text-base"
|
||
placeholder="Όνομα προτίμησης (π.χ. Ζάχαρη)" value={ps.name}
|
||
onChange={e => setPrefSetField(si, 'name', e.target.value)} autoFocus />
|
||
<FavoriteBtn isFavorite={ps.is_favorite} onClick={() => toggleFavorite('pref', si)} />
|
||
<button onClick={() => removePrefSet(si)} className="btn btn-danger px-3 min-h-0 h-10 shrink-0">Διαγραφή</button>
|
||
</div>
|
||
|
||
<p className="text-xs text-gray-400 mb-3">
|
||
● = προεπιλογή (κλικ ξανά για αποεπιλογή) · ⊘ = απενεργοποιεί το κοινό υπο-σύνολο
|
||
</p>
|
||
|
||
<div className="space-y-3 mb-5">
|
||
{ps.choices.map((ch, ci) => (
|
||
<div key={ci} className="border border-gray-200 rounded-xl overflow-hidden">
|
||
<div className="flex items-center gap-2 p-3 bg-white">
|
||
<ReorderBtns onUp={() => moveChoice(si, ci, -1)} onDown={() => moveChoice(si, ci, 1)}
|
||
disableUp={ci === 0} disableDown={ci === ps.choices.length - 1} />
|
||
<DefaultBtn isDefault={ps.default_choice_index === ci} onClick={() => toggleDefaultChoice(si, ci)} />
|
||
<input className="input flex-1" placeholder="Όνομα επιλογής (π.χ. Σκέτος)"
|
||
value={ch.name} onChange={e => setChoice(si, ci, 'name', e.target.value)} />
|
||
<PriceInput value={ch.extra_cost} onChange={v => setChoice(si, ci, 'extra_cost', v)}
|
||
allowNegative className="w-32" />
|
||
{hasSharedSubset && (
|
||
<button type="button"
|
||
onClick={() => setChoice(si, ci, 'disables_subset', !ch.disables_subset)}
|
||
title={ch.disables_subset ? 'Εμφάνιση κοινού υπο-συνόλου' : 'Απόκρυψη κοινού υπο-συνόλου'}
|
||
className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 text-sm transition-colors ${
|
||
ch.disables_subset ? 'bg-red-100 text-red-500' : 'text-gray-300 hover:text-red-400'
|
||
}`}>⊘</button>
|
||
)}
|
||
<button onClick={() => addSubChoice(si, ci)}
|
||
className="btn btn-secondary text-xs px-2 min-h-0 h-9 shrink-0 whitespace-nowrap">
|
||
+ Υπο-επιλογές
|
||
</button>
|
||
<button onClick={() => removeChoice(si, ci)} className="btn btn-danger px-2 min-h-0 h-9 shrink-0">✕</button>
|
||
</div>
|
||
<SubChoiceRows
|
||
subChoices={ch.sub_choices}
|
||
parentLabel={ch.name}
|
||
onMove={(sci, dir) => 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)}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<button onClick={() => addChoice(si)} className="btn btn-secondary text-sm px-4 py-1.5 min-h-0 h-9">
|
||
+ Επιλογή
|
||
</button>
|
||
|
||
<div className="mt-6 border-t border-gray-100 pt-5">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div>
|
||
<p className="text-sm font-semibold text-gray-700">Κοινό υπο-σύνολο</p>
|
||
<p className="text-xs text-gray-400">Εμφανίζεται για όλες τις επιλογές εκτός αυτών με ⊘</p>
|
||
</div>
|
||
{!ps.shared_subset ? (
|
||
<button onClick={() => setSharedSubsetName(si, '')} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">
|
||
+ Κοινό υπο-σύνολο
|
||
</button>
|
||
) : (
|
||
<button onClick={() => setPrefSetField(si, 'shared_subset', null)} className="btn btn-danger text-sm px-3 py-1.5 min-h-0 h-9">
|
||
Αφαίρεση
|
||
</button>
|
||
)}
|
||
</div>
|
||
{ps.shared_subset && (
|
||
<div className="border border-indigo-200 rounded-xl p-4 bg-indigo-50/30 space-y-3">
|
||
<div>
|
||
<label className="label text-xs">Όνομα (π.χ. Είδος ζάχαρης)</label>
|
||
<input className="input text-sm" placeholder="π.χ. Είδος ζάχαρης"
|
||
value={ps.shared_subset.name || ''}
|
||
onChange={e => setSharedSubsetName(si, e.target.value)} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
{(ps.shared_subset.choices || []).map((sc, sci) => (
|
||
<div key={sci} className="flex items-center gap-2">
|
||
<ReorderBtns onUp={() => moveSharedSubsetChoice(si, sci, -1)} onDown={() => moveSharedSubsetChoice(si, sci, 1)}
|
||
disableUp={sci === 0} disableDown={sci === ps.shared_subset.choices.length - 1} />
|
||
<DefaultBtn isDefault={sc.is_default} onClick={() => setSharedSubsetChoice(si, sci, 'is_default', !sc.is_default)} />
|
||
<input className="input flex-1 text-sm" placeholder="π.χ. Λευκή"
|
||
value={sc.name} onChange={e => setSharedSubsetChoice(si, sci, 'name', e.target.value)} />
|
||
<PriceInput value={sc.extra_cost} onChange={v => setSharedSubsetChoice(si, sci, 'extra_cost', v)}
|
||
allowNegative className="w-32 text-sm" />
|
||
<button onClick={() => removeSharedSubsetChoice(si, sci)} className="btn btn-danger px-2 min-h-0 h-9 text-sm shrink-0">✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button onClick={() => addSharedSubsetChoice(si)} className="btn btn-secondary text-xs px-3 py-1 min-h-0 h-7">
|
||
+ Επιλογή υπο-συνόλου
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Footer ── */}
|
||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-100 bg-gray-50/50 shrink-0 gap-3">
|
||
<button onClick={onClose} className="btn btn-secondary px-6">Ακύρωση</button>
|
||
<div className="flex gap-3">
|
||
{!isNew && (
|
||
<button onClick={() => onCopy({ ...form })} className="btn btn-secondary px-6 text-indigo-600 border-indigo-200 hover:bg-indigo-50">
|
||
📋 Αντιγραφή
|
||
</button>
|
||
)}
|
||
<button onClick={submit} disabled={!canSave || uploading} className="btn btn-primary px-8">
|
||
{uploading ? 'Ανέβασμα…' : isNew ? 'Δημιουργία' : 'Αποθήκευση'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|