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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 10:00:14 +03:00
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions

View File

@@ -0,0 +1,38 @@
import { useNavigate } from 'react-router-dom'
import { ChefHat, Phone } from 'lucide-react'
export default function AppsPage() {
const navigate = useNavigate()
return (
<div className="p-8">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 max-w-2xl">
<button
onClick={() => navigate('/kds')}
className="flex flex-col items-center gap-3 p-6 bg-white border border-slate-200 rounded-xl shadow-sm hover:border-sky-300 hover:shadow-md transition-all text-center group"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-sky-50 text-sky-600 group-hover:bg-sky-100 transition-colors">
<ChefHat size={24} />
</div>
<div>
<p className="text-[13px] font-semibold text-slate-800">KDS</p>
<p className="text-[11px] text-slate-400 mt-0.5">Kitchen Display</p>
</div>
</button>
<button
onClick={() => navigate('/apps/phone')}
className="flex flex-col items-center gap-3 p-6 bg-white border border-slate-200 rounded-xl shadow-sm hover:border-emerald-300 hover:shadow-md transition-all text-center group"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 group-hover:bg-emerald-100 transition-colors">
<Phone size={24} />
</div>
<div>
<p className="text-[13px] font-semibold text-slate-800">Τηλέφωνο</p>
<p className="text-[11px] text-slate-400 mt-0.5">Caller ID</p>
</div>
</button>
</div>
</div>
)
}

View File

@@ -132,8 +132,7 @@ export default function ContactsPage() {
{/* Header */}
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, background: 'white' }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Επαφές / Προμηθευτές</h1>
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>{contacts.length} ενεργές επαφές</p>
<p style={{ margin: 0, fontSize: 13, color: '#9ca3af' }}>{contacts.length} ενεργές επαφές</p>
</div>
<button
onClick={() => setModal({ contact: null })}

View File

@@ -247,8 +247,7 @@ export default function CustomersPage() {
<div style={{ padding: '18px 24px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Πελάτες</h1>
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>{activeCount} ενεργοί πελάτες</p>
<p style={{ margin: 0, fontSize: 13, color: '#9ca3af' }}>{activeCount} ενεργοί πελάτες</p>
</div>
<button
onClick={() => setModal({ customer: null })}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,839 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
import ManagerOrderDrawer from './ManagerOrderDrawer'
// ── Constants ─────────────────────────────────────────────────────────────────
const FAV_CAT_ID = '__favorites__'
const SVC_CAT_ID = '__service__'
const DECIMAL_UNITS = new Set(['kg', 'liter', 'gram', 'ml'])
const DECIMAL_UNIT_LABELS = { kg: 'kg', liter: 'L', gram: 'g', ml: 'mL' }
// ── Helpers ───────────────────────────────────────────────────────────────────
function productAllowsQuickAdd(product) {
if (!product.quick_add_enabled) return false
for (const ps of (product.preference_sets || [])) {
if (ps.default_choice_id == null) return false
}
return true
}
function buildQuickAddItem(product) {
const qty = DECIMAL_UNITS.has(product.unit_type) ? 1.0 : 1
const selectedOptions = []
for (const ps of (product.preference_sets || [])) {
const defaultChoice = ps.choices?.find(c => c.id === ps.default_choice_id)
if (defaultChoice) {
selectedOptions.push({ id: defaultChoice.id, name: defaultChoice.name, price_delta: defaultChoice.extra_cost ?? 0 })
}
}
return {
product_id: product.id,
quantity: qty,
unit_type: product.unit_type || 'piece',
unit_price: product.base_price,
notes: '',
selected_options: selectedOptions,
removed_ingredients: [],
}
}
function fmtQty(qty, unitType) {
const label = DECIMAL_UNIT_LABELS[unitType]
if (!label) return `×${qty}`
if (unitType === 'kg' || unitType === 'liter') return `${Number(qty).toFixed(1)}${label}`
return `${qty}${label}`
}
function hexToRgba(hex, alpha) {
if (!hex) return null
const h = hex.replace('#', '')
const r = parseInt(h.substring(0, 2), 16)
const g = parseInt(h.substring(2, 4), 16)
const b = parseInt(h.substring(4, 6), 16)
return `rgba(${r},${g},${b},${alpha})`
}
function buildSections(parent, subcategories, directProducts) {
const sections = []
if (directProducts.length > 0) {
sections.push({ _isGeneral: true, sort_order: parent.general_sort_order, products: directProducts })
}
for (const sub of subcategories) {
sections.push({ ...sub, _isGeneral: false, sort_order: sub.sort_order })
}
return sections.sort((a, b) => a.sort_order - b.sort_order)
}
// ── Product card for the desktop grid ─────────────────────────────────────────
function DesktopProductCard({ product, onOpen, onQuickAdd }) {
const canQuickAdd = productAllowsQuickAdd(product)
const initials = product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
return (
<div
onClick={() => onOpen(product)}
style={{
background: 'white',
border: '1px solid #e2e8f0',
borderRadius: 12,
overflow: 'hidden',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
transition: 'box-shadow 120ms ease, border-color 120ms ease',
position: 'relative',
}}
onMouseEnter={e => { e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)'; e.currentTarget.style.borderColor = '#cbd5e1' }}
onMouseLeave={e => { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.borderColor = '#e2e8f0' }}
>
{/* Thumbnail */}
<div style={{
width: '100%', aspectRatio: '16/9', maxHeight: 80,
background: '#f1f5f9', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{product.image_url
? <img src={product.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 22, fontWeight: 700, color: '#94a3b8', letterSpacing: -1 }}>{initials}</span>
}
</div>
{/* Info */}
<div style={{ padding: '8px 10px 10px', flex: 1, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#1e293b', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{product.name}
</div>
{product.tags?.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
{product.tags.slice(0, 2).map(tag => (
<span key={tag} style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', background: '#f1f5f9', borderRadius: 99, color: '#64748b', border: '1px solid #e2e8f0' }}>{tag}</span>
))}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto' }}>
<span style={{ fontSize: 14, fontWeight: 700, color: '#f59e0b' }}>{Number(product.base_price).toFixed(2)} </span>
{canQuickAdd && (
<button
onClick={e => { e.stopPropagation(); onQuickAdd(product) }}
style={{
width: 30, height: 30, borderRadius: '50%',
background: '#fff7ed', border: '1.5px solid #fed7aa',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#f59e0b',
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.8" strokeLinecap="round"/>
</svg>
</button>
)}
</div>
</div>
</div>
)
}
// ── Service item widget ───────────────────────────────────────────────────────
function ServiceItemWidget({ product, customerCount, onAdd }) {
const [qty, setQty] = useState(0)
const hasPrice = product.base_price != null && product.base_price > 0
return (
<div style={{
background: 'white', border: '1px solid #e2e8f0', borderRadius: 12,
padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#1e293b', flex: 1, minWidth: 0 }}>{product.name}</span>
<span style={{ fontSize: 12, color: hasPrice ? '#f59e0b' : '#94a3b8', fontWeight: 600, flexShrink: 0 }}>
{hasPrice ? `${Number(product.base_price).toFixed(2)}` : 'no charge'}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button onClick={() => setQty(q => Math.max(0, q - 1))}
style={{ width: 32, height: 32, borderRadius: '50%', border: '1px solid #e2e8f0', background: qty === 0 ? '#f8fafc' : 'rgba(245,158,11,0.1)', color: qty === 0 ? '#94a3b8' : '#f59e0b', fontSize: 18, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }}></button>
<button onClick={() => setQty(Math.max(1, customerCount ?? 1))} title={`Ορισμός σε ${customerCount ?? 1}`}
style={{ width: 32, height: 32, borderRadius: '50%', border: '1px solid #e2e8f0', background: 'rgba(245,158,11,0.1)', color: '#f59e0b', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</button>
<button onClick={() => setQty(q => q + 1)}
style={{ width: 32, height: 32, borderRadius: '50%', border: '1px solid #e2e8f0', background: 'rgba(245,158,11,0.1)', color: '#f59e0b', fontSize: 18, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }}>+</button>
<span style={{ fontSize: 20, fontWeight: 800, color: qty === 0 ? '#94a3b8' : '#1e293b', flex: 1, textAlign: 'center' }}>{qty}</span>
<button
disabled={qty === 0}
onClick={() => {
if (qty === 0) return
onAdd({
product_id: product.id,
quantity: qty,
unit_type: 'piece',
unit_price: product.base_price ?? 0,
notes: '',
selected_options: [],
removed_ingredients: [],
is_service_item: true,
})
setQty(0)
}}
style={{
padding: '6px 14px', borderRadius: 8, border: 'none',
background: qty === 0 ? '#f1f5f9' : '#f59e0b',
color: qty === 0 ? '#94a3b8' : '#fff',
fontSize: 12, fontWeight: 700, cursor: qty === 0 ? 'default' : 'pointer',
}}
>ADD</button>
</div>
</div>
)
}
// ── Cart item row ─────────────────────────────────────────────────────────────
function CartItemRow({ item, product, onRemove, onChangeQty, onEdit }) {
const isDecimal = DECIMAL_UNITS.has(product?.unit_type)
const optionSummary = item.selected_options?.length
? item.selected_options.map(o => o.name).join(', ')
: null
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '8px 0', borderBottom: '1px solid #f1f5f9' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#1e293b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{product?.name ?? `#${item.product_id}`}
</div>
{optionSummary && (
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{optionSummary}
</div>
)}
{item.notes && (
<div style={{ fontSize: 11, color: '#f59e0b', marginTop: 1, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.notes}
</div>
)}
</div>
{/* Qty controls */}
{isDecimal ? (
<button onClick={onEdit} style={{ fontSize: 12, fontWeight: 700, color: '#f59e0b', background: '#fff7ed', border: '1px solid #fed7aa', borderRadius: 6, padding: '2px 8px', cursor: 'pointer', whiteSpace: 'nowrap' }}>
{fmtQty(item.quantity, product?.unit_type)}
</button>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
<button onClick={() => onChangeQty(item.quantity - 1)}
style={{ width: 24, height: 24, borderRadius: '50%', border: '1px solid #e2e8f0', background: '#f8fafc', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: item.quantity <= 1 ? '#ef4444' : '#475569', fontSize: 14, fontWeight: 700 }}>
{item.quantity <= 1
? <svg width="10" height="10" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
: ''}
</button>
<span style={{ fontSize: 13, fontWeight: 700, color: '#1e293b', minWidth: 16, textAlign: 'center' }}>{item.quantity}</span>
<button onClick={() => onChangeQty(item.quantity + 1)}
style={{ width: 24, height: 24, borderRadius: '50%', border: '1px solid #e2e8f0', background: '#f8fafc', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#16a34a', fontSize: 14, fontWeight: 700 }}>+</button>
</div>
)}
{/* Price */}
<span style={{ fontSize: 13, fontWeight: 700, color: '#475569', minWidth: 50, textAlign: 'right', flexShrink: 0 }}>
{((item.unit_price ?? 0) * item.quantity + (item.price_adjustment ?? 0) * item.quantity).toFixed(2)}
</span>
{/* Edit + Remove */}
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
<button onClick={onEdit}
style={{ padding: '2px 7px', borderRadius: 6, border: '1px solid #e2e8f0', background: '#f8fafc', color: '#64748b', fontSize: 11, cursor: 'pointer' }}>
Επεξ.
</button>
<button onClick={onRemove}
style={{ width: 24, height: 24, borderRadius: '50%', border: 'none', background: 'none', color: '#94a3b8', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
</button>
</div>
</div>
)
}
// ── Search overlay ────────────────────────────────────────────────────────────
function SearchOverlay({ products, onClose, onOpen }) {
const [query, setQuery] = useState('')
const inputRef = useRef(null)
useEffect(() => {
inputRef.current?.focus()
function onKey(e) { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const activeProducts = products.filter(p => p.lifecycle_status !== 'archived')
const results = query.trim().length === 0
? []
: activeProducts.filter(p => p.name.toLowerCase().includes(query.trim().toLowerCase()))
return (
<>
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 200 }} />
<div style={{
position: 'fixed', top: '15%', left: '50%', transform: 'translateX(-50%)',
zIndex: 201, width: 'min(580px, 90vw)',
background: 'white', borderRadius: 16,
boxShadow: '0 20px 60px rgba(0,0,0,0.18)',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
maxHeight: '60vh',
}}>
{/* Input */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" style={{ color: '#94a3b8', flexShrink: 0 }}>
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2.2"/>
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
<input
ref={inputRef}
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Αναζήτηση προϊόντος…"
style={{ flex: 1, border: 'none', outline: 'none', fontSize: 16, color: '#1e293b', background: 'transparent', fontFamily: 'inherit' }}
/>
<button onClick={onClose} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: '#64748b' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
</button>
</div>
{/* Results */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{query.trim().length === 0 ? (
<p style={{ textAlign: 'center', color: '#94a3b8', padding: '20px', fontSize: 14 }}>Πληκτρολογήστε για αναζήτηση</p>
) : results.length === 0 ? (
<p style={{ textAlign: 'center', color: '#94a3b8', padding: '20px', fontSize: 14 }}>Δεν βρέθηκαν προϊόντα για «{query}»</p>
) : results.map(p => {
const initials = p.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
return (
<button key={p.id} onClick={() => { onOpen(p); onClose() }}
style={{ display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '10px 16px', background: 'none', border: 'none', cursor: 'pointer', borderBottom: '1px solid #f1f5f9', textAlign: 'left' }}
onMouseEnter={e => { e.currentTarget.style.background = '#f8fafc' }}
onMouseLeave={e => { e.currentTarget.style.background = 'none' }}
>
<div style={{ width: 38, height: 38, borderRadius: 8, flexShrink: 0, background: '#f1f5f9', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{p.image_url
? <img src={p.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 12, fontWeight: 700, color: '#94a3b8' }}>{initials}</span>
}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#1e293b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</div>
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 1 }}>{Number(p.base_price).toFixed(2)} </div>
</div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ color: '#cbd5e1', flexShrink: 0 }}>
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
)
})}
</div>
</div>
</>
)
}
// ── Main component ────────────────────────────────────────────────────────────
export default function DesktopOrderingTab({ orderId, tableId, orderStatus }) {
const qc = useQueryClient()
const { data: productsData, isLoading: productsLoading } = useQuery({
queryKey: ['products-ordering'],
queryFn: async () => {
const [prodRes, catRes] = await Promise.all([
client.get('/api/products/'),
client.get('/api/products/categories'),
])
return { products: prodRes.data, categories: catRes.data }
},
staleTime: 5 * 60 * 1000,
})
const products = productsData?.products ?? []
const categories = productsData?.categories ?? []
const serviceProducts = products.filter(p => p.is_service_item)
const regularProducts = products.filter(p => !p.is_service_item || p.category_id)
const topLevel = categories.filter(c => !c.parent_id).sort((a, b) => a.sort_order - b.sort_order)
const initialCatId = topLevel[0]?.id ?? null
const [activeCat, setActiveCat] = useState(null)
const [viewAllOpen, setViewAllOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [expandedSubs, setExpandedSubs] = useState({})
// Drawer for product customization
const [drawerProduct, setDrawerProduct] = useState(null)
const [editItem, setEditItem] = useState(null) // { cartKey, product, drawerState }
// Cart
const [cart, setCart] = useState([])
const [orderNote, setOrderNote] = useState('')
const [sending, setSending] = useState(false)
// Customer count (loaded from order)
const { data: orderData } = useQuery({
queryKey: ['order', orderId],
queryFn: () => client.get(`/api/orders/${orderId}`).then(r => r.data),
enabled: !!orderId,
staleTime: 30_000,
})
const customerCount = orderData?.customer_count ?? null
// Courses settings
const { data: posSettings } = useQuery({
queryKey: ['pos-settings-courses'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 60_000,
})
const coursesEnabled = posSettings?.['orders.courses_enabled']?.value === 'true'
const courses = coursesEnabled ? (() => { try { return JSON.parse(posSettings?.['orders.courses']?.value || '[]') } catch { return [] } })() : []
// Set initial category once products load
useEffect(() => {
if (activeCat === null && topLevel.length > 0) {
const firstId = serviceProducts.length > 0 ? SVC_CAT_ID : topLevel[0]?.id
setActiveCat(firstId ?? null)
}
}, [topLevel.length, serviceProducts.length])
function buildDefaultExpanded(catId) {
const subs = categories.filter(c => c.parent_id === catId)
const state = {}
subs.forEach(s => { if (s.auto_expanded) state[String(s.id)] = true })
return state
}
function selectCategory(id) {
setActiveCat(id)
setViewAllOpen(false)
setExpandedSubs(id === FAV_CAT_ID || id === SVC_CAT_ID ? {} : buildDefaultExpanded(id))
}
const isSvcActive = activeCat === SVC_CAT_ID
const activeParent = categories.find(c => c.id === activeCat)
const subcategories = activeParent
? categories.filter(c => c.parent_id === activeCat).sort((a, b) => a.sort_order - b.sort_order)
: []
const hasSubcats = subcategories.length > 0
const directProducts = regularProducts.filter(p => p.category_id === activeCat)
const sections = hasSubcats ? buildSections(activeParent, subcategories, directProducts) : []
// Cart management
function addToCart(item) {
setCart(prev => {
const { _key: _k, _drawerState: _ds, quantity: _q, ...newCore } = item
const matchIdx = prev.findIndex(existing => {
const { _key, _drawerState, quantity: _eq, ...existCore } = existing
return JSON.stringify(existCore) === JSON.stringify(newCore)
})
if (matchIdx !== -1) {
const next = [...prev]
next[matchIdx] = { ...next[matchIdx], quantity: next[matchIdx].quantity + (item.quantity ?? 1) }
return next
}
return [...prev, { ...item, _key: Date.now() + Math.random() }]
})
}
function handleQuickAdd(product) {
const base = buildQuickAddItem(product)
const item = product.is_service_item ? { ...base, is_service_item: true } : base
addToCart(item)
toast.success(`${product.name} προστέθηκε`, { duration: 800 })
}
function removeFromCart(key) {
setCart(prev => prev.filter(i => i._key !== key))
}
function changeCartQty(key, newQty) {
if (newQty <= 0) removeFromCart(key)
else setCart(prev => prev.map(i => i._key === key ? { ...i, quantity: newQty } : i))
}
function openEditDrawer(cartItem) {
const product = products.find(p => p.id === cartItem.product_id)
if (!product) return
setEditItem({ cartKey: cartItem._key, product, drawerState: cartItem._drawerState })
setDrawerProduct(null)
}
function handleDrawerAdd(item) {
if (editItem) {
setCart(prev => prev.map(i => i._key === editItem.cartKey ? { ...item, _key: i._key } : i))
setEditItem(null)
} else {
const finalItem = drawerProduct?.is_service_item ? { ...item, is_service_item: true } : item
addToCart(finalItem)
setDrawerProduct(null)
}
}
const cartTotal = cart.reduce((s, i) => s + (i.unit_price ?? 0) * i.quantity + (i.price_adjustment ?? 0) * i.quantity, 0)
const isOpen = ['open', 'partially_paid'].includes(orderStatus)
const addItemsMutation = useMutation({
mutationFn: async () => {
let activeOrderId = orderId
if (!activeOrderId) {
const { data: newOrder } = await client.post('/api/orders/', { table_id: tableId })
activeOrderId = newOrder.id
}
const res = await client.post(`/api/orders/${activeOrderId}/items`, {
items: cart.map(({ _key, _drawerState, ...item }) => item),
order_note: orderNote || null,
})
return res.data
},
onSuccess: (data) => {
const printResults = data.print_results ?? []
const allOk = printResults.length === 0 || printResults.every(r => r.success)
if (!allOk) {
toast.error('Παραγγελία αποθηκεύτηκε αλλά κάποιος εκτυπωτής δεν ανταποκρίθηκε')
} else {
toast.success('Παραγγελία εστάλη επιτυχώς')
}
setCart([])
setOrderNote('')
qc.invalidateQueries({ queryKey: ['order', orderId] })
qc.invalidateQueries({ queryKey: ['orders-active'] })
},
onError: (err) => {
toast.error(err?.response?.data?.detail || 'Σφάλμα αποστολής παραγγελίας')
},
})
// Keyboard shortcut for search
useEffect(() => {
function onKey(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); setSearchOpen(true) }
if (e.key === 'Escape') { setDrawerProduct(null); setEditItem(null); setSearchOpen(false) }
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
if (productsLoading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 200, color: '#94a3b8', fontSize: 14 }}>
Φόρτωση προϊόντων
</div>
)
}
if (!isOpen) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 200, color: '#94a3b8', fontSize: 14 }}>
Η παραγγελία δεν είναι ανοιχτή.
</div>
)
}
const activeDrawerProduct = editItem ? editItem.product : drawerProduct
const drawerOpen = !!activeDrawerProduct
return (
<div style={{ display: 'flex', height: '100%', minHeight: 0, gap: 0 }}>
{/* ── LEFT: Category + Product browser ─────────────────────────────── */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, borderRight: '1px solid #e2e8f0' }}>
{/* Toolbar */}
<div style={{ padding: '10px 12px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0, background: '#f8fafc' }}>
<div style={{ flex: 1, display: 'flex', gap: 6, overflowX: 'auto', scrollbarWidth: 'none' }}>
{/* SERVICE chip */}
{serviceProducts.length > 0 && (
<button onClick={() => selectCategory(SVC_CAT_ID)}
style={{
height: 32, padding: '0 14px', borderRadius: 16, border: isSvcActive ? '2px solid #f59e0b' : '1px solid #e2e8f0',
background: isSvcActive ? '#f59e0b' : '#fff7ed', color: isSvcActive ? '#fff' : '#d97706',
fontSize: 12, fontWeight: 700, cursor: 'pointer', whiteSpace: 'nowrap',
display: 'flex', alignItems: 'center', gap: 4,
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 11l19-9-9 19-2-8-8-2z"/>
</svg>
Service
</button>
)}
{topLevel.map(cat => {
const isActive = activeCat === cat.id
const bg = cat.color
? isActive ? cat.color : hexToRgba(cat.color, 0.15)
: isActive ? '#f59e0b' : '#f8fafc'
const color = cat.color
? isActive ? '#fff' : cat.color
: isActive ? '#fff' : '#64748b'
return (
<button key={cat.id} onClick={() => selectCategory(cat.id)}
style={{
height: 32, padding: '0 14px', borderRadius: 16,
border: isActive && cat.color ? `2px solid ${cat.color}` : isActive ? '2px solid #f59e0b' : '1px solid #e2e8f0',
background: bg, color,
fontSize: 12, fontWeight: 700, cursor: 'pointer', whiteSpace: 'nowrap',
}}
>{cat.name}</button>
)
})}
<button onClick={() => setViewAllOpen(true)}
style={{ height: 32, padding: '0 12px', borderRadius: 16, border: '1px solid #e2e8f0', background: '#f8fafc', color: '#64748b', fontSize: 12, fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0 }}>
Όλες
</button>
</div>
<button onClick={() => setSearchOpen(true)}
style={{ height: 32, padding: '0 12px', borderRadius: 16, border: '1px solid #e2e8f0', background: '#f8fafc', color: '#64748b', fontSize: 12, fontWeight: 600, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2.2"/>
<path d="M16.5 16.5L21 21" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
Αναζήτηση
</button>
</div>
{/* Product area */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 14px' }}>
{isSvcActive ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 10 }}>
{serviceProducts.map(p => (
<ServiceItemWidget key={p.id} product={p} customerCount={customerCount} onAdd={item => addToCart(item)} />
))}
</div>
) : !hasSubcats ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10 }}>
{directProducts.map(p => (
<DesktopProductCard key={p.id} product={p} onOpen={setDrawerProduct} onQuickAdd={handleQuickAdd} />
))}
</div>
{directProducts.length === 0 && (
<div style={{ textAlign: 'center', color: '#94a3b8', padding: '40px 0', fontSize: 14 }}>Δεν υπάρχουν προϊόντα</div>
)}
</>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{sections.map(section => {
const key = section._isGeneral ? '__general__' : String(section.id)
const isExpanded = !!expandedSubs[key]
const sectionProducts = section._isGeneral
? section.products
: regularProducts.filter(p => p.category_id === section.id)
if (sectionProducts.length === 0) return null
if (section._isGeneral) {
return (
<div key={key}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10 }}>
{sectionProducts.map(p => (
<DesktopProductCard key={p.id} product={p} onOpen={setDrawerProduct} onQuickAdd={handleQuickAdd} />
))}
</div>
</div>
)
}
const accentColor = section.color ?? activeParent?.color ?? null
return (
<div key={key} style={{ border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden' }}>
<button
onClick={() => setExpandedSubs(prev => ({ ...prev, [key]: !prev[key] }))}
style={{
width: '100%', padding: '10px 14px', background: isExpanded ? '#f8fafc' : 'white',
border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8, textAlign: 'left',
}}
>
{accentColor && <span style={{ width: 10, height: 10, borderRadius: '50%', background: accentColor, flexShrink: 0 }} />}
<span style={{ flex: 1, fontSize: 13, fontWeight: 700, color: '#1e293b' }}>{section.name}</span>
<span style={{ fontSize: 11, color: '#94a3b8', marginRight: 4 }}>{sectionProducts.length}</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${isExpanded ? 180 : 0}deg)`, transition: 'transform 180ms', color: '#94a3b8', flexShrink: 0 }}>
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{isExpanded && (
<div style={{ padding: '10px 12px 12px', background: '#fafbfc', borderTop: '1px solid #f1f5f9' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10 }}>
{sectionProducts.map(p => (
<DesktopProductCard key={p.id} product={p} onOpen={setDrawerProduct} onQuickAdd={handleQuickAdd} />
))}
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>
{/* ── RIGHT: Cart panel or Drawer panel ────────────────────────────── */}
<div style={{ width: 320, flexShrink: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{drawerOpen ? (
<ManagerOrderDrawer
key={activeDrawerProduct.id}
product={activeDrawerProduct}
initialState={editItem?.drawerState}
onClose={() => { setDrawerProduct(null); setEditItem(null) }}
onAdd={handleDrawerAdd}
courses={courses}
/>
) : (
/* Cart */
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#f8fafc' }}>
{/* Cart header */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid #e2e8f0', flexShrink: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#1e293b' }}>Νέα Παραγγελία</div>
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 1 }}>
{cart.length === 0 ? 'Επιλέξτε προϊόντα από τα αριστερά' : `${cart.length} ${cart.length === 1 ? 'προϊόν' : 'προϊόντα'}`}
</div>
</div>
{/* Item list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px' }}>
{cart.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12, padding: '40px 0' }}>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style={{ color: '#e2e8f0' }}>
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4zM3 6h18M16 10a4 4 0 01-8 0" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<p style={{ color: '#cbd5e1', fontSize: 13, textAlign: 'center' }}>Το καλάθι είναι άδειο</p>
</div>
) : (
<div>
{cart.map(item => {
const product = products.find(p => p.id === item.product_id)
return (
<CartItemRow
key={item._key}
item={item}
product={product}
onRemove={() => removeFromCart(item._key)}
onChangeQty={qty => changeCartQty(item._key, qty)}
onEdit={() => openEditDrawer(item)}
/>
)
})}
</div>
)}
</div>
{/* Order note */}
{cart.length > 0 && (
<div style={{ padding: '8px 16px', flexShrink: 0 }}>
<textarea
value={orderNote}
onChange={e => setOrderNote(e.target.value)}
placeholder="Σημείωση παραγγελίας (προαιρετικό)…"
rows={2}
style={{
width: '100%', resize: 'none', padding: '8px 10px',
background: 'white', border: '1px solid #e2e8f0',
borderRadius: 8, fontSize: 12, color: '#1e293b', lineHeight: 1.5,
outline: 'none', boxSizing: 'border-box', fontFamily: 'inherit',
}}
/>
</div>
)}
{/* Cart footer */}
<div style={{ padding: '10px 16px 16px', borderTop: '1px solid #e2e8f0', flexShrink: 0, background: 'white' }}>
{cart.length > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<span style={{ fontSize: 13, color: '#64748b', fontWeight: 600 }}>Σύνολο</span>
<span style={{ fontSize: 18, fontWeight: 800, color: '#1e293b' }}>{cartTotal.toFixed(2)}</span>
</div>
)}
<button
onClick={() => addItemsMutation.mutate()}
disabled={cart.length === 0 || addItemsMutation.isPending}
style={{
width: '100%', height: 44, borderRadius: 22,
background: cart.length === 0 ? '#e2e8f0' : '#f59e0b',
border: 'none', color: cart.length === 0 ? '#94a3b8' : '#fff',
fontSize: 15, fontWeight: 700, fontFamily: 'inherit',
cursor: cart.length === 0 ? 'default' : 'pointer',
transition: 'background 150ms ease',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}
>
{addItemsMutation.isPending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`}
</button>
{cart.length > 0 && (
<button onClick={() => setCart([])}
style={{ width: '100%', marginTop: 6, padding: '6px 0', background: 'none', border: 'none', color: '#94a3b8', fontSize: 12, cursor: 'pointer' }}>
Εκκαθάριση καλαθιού
</button>
)}
</div>
</div>
)}
</div>
{/* View all categories modal */}
{viewAllOpen && (
<>
<div onClick={() => setViewAllOpen(false)} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 200 }} />
<div style={{
position: 'fixed', top: '15%', left: '50%', transform: 'translateX(-50%)',
zIndex: 201, width: 'min(500px, 90vw)',
background: 'white', borderRadius: 16,
boxShadow: '0 20px 60px rgba(0,0,0,0.18)',
overflow: 'hidden',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', borderBottom: '1px solid #e2e8f0' }}>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1e293b' }}>Κατηγορίες</span>
<button onClick={() => setViewAllOpen(false)} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: 18 }}></button>
</div>
<div style={{ padding: '14px 16px', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, maxHeight: '60vh', overflowY: 'auto' }}>
{serviceProducts.length > 0 && (
<button onClick={() => { selectCategory(SVC_CAT_ID); setViewAllOpen(false) }}
style={{ padding: '14px 10px', borderRadius: 12, background: '#fff7ed', border: isSvcActive ? '2px solid #f59e0b' : '1px solid #fed7aa', cursor: 'pointer', fontSize: 13, fontWeight: 700, color: '#d97706', textAlign: 'center' }}>
Service
</button>
)}
{topLevel.map(cat => {
const isActive = activeCat === cat.id
return (
<button key={cat.id} onClick={() => { selectCategory(cat.id); setViewAllOpen(false) }}
style={{
padding: '14px 10px', borderRadius: 12, cursor: 'pointer', fontSize: 13, fontWeight: 700, textAlign: 'center',
background: cat.color || '#f1f5f9',
color: cat.color ? '#fff' : '#475569',
border: isActive ? '2px solid #f59e0b' : '1px solid transparent',
boxShadow: cat.color ? 'inset 0 0 0 100px rgba(0,0,0,0.15)' : 'none',
}}>
{cat.name}
</button>
)
})}
</div>
</div>
</>
)}
{/* Search modal */}
{searchOpen && (
<SearchOverlay
products={products}
onClose={() => setSearchOpen(false)}
onOpen={p => { setDrawerProduct(p); setSearchOpen(false) }}
/>
)}
</div>
)
}

View File

@@ -320,8 +320,7 @@ export default function ExpensesPage() {
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Έξοδα</h1>
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
<p style={{ margin: 0, fontSize: 13, color: '#9ca3af' }}>
{pendingCount} εκκρεμή · Σύνολο οφειλών: <strong style={{ color: totalDue > 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalDue)}</strong>
</p>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -89,6 +89,7 @@ export default function LoginPage() {
const [loadingInit, setLoadingInit] = useState(true)
const [selectedManager, setSelectedManager] = useState(null)
const [manualMode, setManualMode] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [pin, setPin] = useState('')
@@ -125,6 +126,10 @@ export default function LoginPage() {
const autofill = settings?.autofill_username !== false
const singleManager = managers.length === 1 ? managers[0] : null
// Suppressed in manual mode so the free-text input is shown instead
const effectiveUsername = manualMode ? null
: (selectedManager?.username || (autofill && singleManager ? singleManager.username : null))
// Auto-login: none method + single manager → attempt exactly once, skip if manual logout
useEffect(() => {
if (!loadingInit && loginMethod === 'none' && singleManager && !manualLogout && !autoLoginAttempted.current) {
@@ -141,11 +146,10 @@ export default function LoginPage() {
setLoading(true)
try {
const { data } = await client.post('/api/auth/login-no-auth', { username: uname })
const role = data.user.role
if (role !== 'manager' && role !== 'sysadmin') { setLoading(false); return }
if (!data.user.perm_access_dashboard) { setLoading(false); return }
clearManualLogoutFlag()
login(data.user, data.access_token)
navigate('/operations', { replace: true })
navigate('/dashboard', { replace: true })
} catch {
setLoading(false)
}
@@ -153,7 +157,7 @@ export default function LoginPage() {
async function handleSubmit(e) {
e?.preventDefault()
const uname = selectedManager?.username || (autofill && singleManager ? singleManager.username : username.trim())
const uname = effectiveUsername || username.trim()
if (!uname) return
if (loginMethod === 'password' && !password) return
if (loginMethod === 'pin' && pin.length < 4) return
@@ -166,8 +170,7 @@ export default function LoginPage() {
else if (loginMethod === 'pin') body.pin = pin
const { data } = await client.post('/api/auth/login', body)
const role = data.user.role
if (role !== 'manager' && role !== 'sysadmin') {
if (!data.user.perm_access_dashboard) {
setError('This account does not have manager access.')
setPin('')
setPassword('')
@@ -175,7 +178,7 @@ export default function LoginPage() {
}
clearManualLogoutFlag()
login(data.user, data.access_token)
navigate('/operations', { replace: true })
navigate('/dashboard', { replace: true })
} catch (err) {
setError(err.response?.data?.detail || 'Invalid credentials')
setPin('')
@@ -191,7 +194,7 @@ export default function LoginPage() {
}, [pin])
// Show spinner while loading OR while auto-login is in progress (none mode, not manual logout)
if (loadingInit || (loginMethod === 'none' && singleManager && !manualLogout)) {
if (loadingInit || (loginMethod === 'none' && singleManager && !manualLogout && !manualMode)) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
@@ -199,10 +202,6 @@ export default function LoginPage() {
)
}
// Determine effective username to show
const effectiveUsername = selectedManager?.username
|| (autofill && singleManager ? singleManager.username : null)
// Multi-manager + none method: show picker first
const needsPicker = loginMethod === 'none' && managers.length > 1 && !selectedManager
@@ -229,56 +228,74 @@ export default function LoginPage() {
</p>
</div>
{/* Username — show if not autofilled */}
{!effectiveUsername && (
<>
{managers.length > 1 ? (
<div className="space-y-2">
{managers.map(m => (
<button
key={m.id}
type="button"
onClick={() => setUsername(m.username)}
className={`w-full flex items-center gap-3 rounded-xl border p-3 text-left transition ${
username === m.username
? 'border-sky-400 bg-sky-50'
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}`}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
{(m.full_name || m.username).charAt(0).toUpperCase()}
</div>
<div>
<p className="text-[13px] font-medium text-slate-800">{m.full_name || m.username}</p>
<p className="text-[11px] text-slate-400">{m.username}</p>
</div>
</button>
))}
{/* Username section */}
{manualMode ? (
<LabelledInput
icon={User}
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="off"
autoFocus
/>
) : effectiveUsername ? (
<div className="space-y-1">
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
{effectiveUsername.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-[13px] font-medium text-slate-700">{effectiveUsername}</p>
<p className="text-[11px] text-slate-400">Manager</p>
</div>
) : (
<LabelledInput
icon={User}
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="off"
autoFocus
/>
)}
</>
)}
{/* Show who we're logging in as when autofilled */}
{effectiveUsername && (
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
{effectiveUsername.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-[13px] font-medium text-slate-700">{effectiveUsername}</p>
<p className="text-[11px] text-slate-400">Manager</p>
</div>
<button
type="button"
onClick={() => { setManualMode(true); setSelectedManager(null); setUsername(''); setPassword(''); setPin(''); setError('') }}
className="text-[11px] text-slate-400 hover:text-sky-600 transition-colors w-full text-right pr-1"
>
Sign in as different account
</button>
</div>
) : managers.length > 0 ? (
<div className="space-y-2">
{managers.map(m => (
<button
key={m.id}
type="button"
onClick={() => setUsername(m.username)}
className={`w-full flex items-center gap-3 rounded-xl border p-3 text-left transition ${
username === m.username
? 'border-sky-400 bg-sky-50'
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}`}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-sky-100 text-sky-700 font-semibold text-[12px] flex-shrink-0">
{(m.full_name || m.username).charAt(0).toUpperCase()}
</div>
<div>
<p className="text-[13px] font-medium text-slate-800">{m.full_name || m.username}</p>
<p className="text-[11px] text-slate-400">{m.username}</p>
</div>
</button>
))}
<button
type="button"
onClick={() => { setManualMode(true); setUsername(''); setPassword(''); setPin(''); setError('') }}
className="text-[11px] text-slate-400 hover:text-sky-600 transition-colors w-full text-right pr-1"
>
Sign in as different account
</button>
</div>
) : (
<LabelledInput
icon={User}
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="off"
autoFocus
/>
)}
{/* Password input */}
@@ -333,6 +350,7 @@ export default function LoginPage() {
{loading && loginMethod === 'pin' && (
<p className="text-center text-[13px] text-slate-400">Verifying</p>
)}
</form>
)}
</div>

View File

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

View File

@@ -0,0 +1,43 @@
import { useState } from 'react'
import { Percent, Tag, Gift, Users } from 'lucide-react'
import { TabGroup, TabCard } from '../../ui/Tabs'
import PriceGroupsTab from './pricing/PriceGroupsTab'
import ModifiersTab from './pricing/ModifiersTab'
import DealsTab from './pricing/DealsTab'
import DiscountSettingsTab from './pricing/DiscountSettingsTab'
const TABS = [
{ id: 'groups', label: 'Ομάδες Τιμών', icon: Tag },
{ id: 'modifiers', label: 'Τροποποιητές Τιμής', icon: Percent },
{ id: 'deals', label: 'Προσφορές', icon: Gift },
{ id: 'discounts', label: 'Ρυθμίσεις Εκπτώσεων', icon: Users },
]
export default function PricingPage() {
const [tab, setTab] = useState('groups')
return (
<div className="flex flex-col h-full min-h-0 p-6 gap-4">
{/* Page header */}
<div className="flex items-center gap-3 shrink-0">
<div className="w-9 h-9 rounded-xl bg-sky-100 flex items-center justify-center shrink-0">
<Percent className="w-5 h-5 text-sky-600" />
</div>
<div>
<h1 className="text-[15px] font-semibold text-slate-900">Προσφορές &amp; Τιμές</h1>
<p className="text-[12px] text-slate-500">Δυναμικές τιμές, τροποποιητές, προσφορές και εκπτώσεις</p>
</div>
</div>
<TabCard className="flex-1">
<TabGroup tabs={TABS} active={tab} onChange={setTab} />
<div className="flex-1 overflow-y-auto">
{tab === 'groups' && <PriceGroupsTab />}
{tab === 'modifiers' && <ModifiersTab />}
{tab === 'deals' && <DealsTab />}
{tab === 'discounts' && <DiscountSettingsTab />}
</div>
</TabCard>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useState, useEffect, useRef } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../../api/client'
@@ -76,7 +76,7 @@ function PriceInput({ value, onChange, placeholder, className = '', allowNegativ
)
}
function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove, onAdd, parentLabel }) {
function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove, onAdd, parentLabel, showMultiple = false }) {
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">
@@ -84,14 +84,28 @@ function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove
Υπο-επιλογές του «{parentLabel || '…'}»
</p>
{subChoices.map((sc, sci) => (
<div key={sci} className="flex items-center gap-2 ml-4">
<div key={sci} className="flex items-center gap-2 ml-4 flex-wrap">
<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="π.χ. Καραμέλα"
<input className="input flex-1 min-w-28 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" />
{showMultiple && (
<label className="flex items-center gap-1 text-xs text-gray-600 cursor-pointer shrink-0 select-none">
<input type="checkbox" checked={sc.allow_multiple ?? false}
onChange={e => onChange(sci, 'allow_multiple', e.target.checked)}
className="accent-primary-700 w-3.5 h-3.5" />
Πολλαπλά
</label>
)}
<label className="flex items-center gap-1 text-xs cursor-pointer shrink-0 select-none" style={{ color: sc.is_compact ? '#7c3aed' : '#6b7280' }}>
<input type="checkbox" checked={sc.is_compact ?? false}
onChange={e => onChange(sci, 'is_compact', e.target.checked)}
className="w-3.5 h-3.5" style={{ accentColor: '#7c3aed' }} />
Compact
</label>
<button onClick={() => onRemove(sci)} className="btn btn-danger px-2 min-h-0 h-9 text-sm shrink-0"></button>
</div>
))}
@@ -102,6 +116,40 @@ function SubChoiceRows({ subChoices, onMove, onToggleDefault, onChange, onRemove
)
}
// ── Group helpers ─────────────────────────────────────────────────────────────
function GroupHeader({ group, onRename, onDelete, collapsed, onToggle }) {
const [editing, setEditing] = useState(false)
const [val, setVal] = useState(group.name)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 8 }}>
<button type="button" onClick={onToggle}
className="flex items-center gap-2 flex-1 px-3 py-2 rounded-lg bg-indigo-50 border border-indigo-200 text-indigo-700 font-semibold text-sm text-left hover:bg-indigo-100 transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 180ms', flexShrink: 0 }}>
<path d="M6 9L12 15L18 9" strokeLinecap="round"/>
</svg>
{editing ? (
<input
className="input flex-1 text-sm py-0 h-6 min-w-0"
value={val}
onChange={e => setVal(e.target.value)}
onBlur={() => { onRename(val); setEditing(false) }}
onKeyDown={e => { if (e.key === 'Enter') e.target.blur() }}
onClick={e => e.stopPropagation()}
autoFocus
/>
) : (
<span className="flex-1 truncate">{group.name || '(αχρησιμοποίητη ομάδα)'}</span>
)}
</button>
<button type="button" onClick={e => { e.stopPropagation(); setVal(group.name); setEditing(true) }}
className="w-7 h-7 rounded text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 flex items-center justify-center text-xs"></button>
<button type="button" onClick={onDelete}
className="w-7 h-7 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 flex items-center justify-center text-xs"></button>
</div>
)
}
// ── Form builder ──────────────────────────────────────────────────────────────
function buildFormFromProduct(product) {
return {
@@ -109,9 +157,10 @@ function buildFormFromProduct(product) {
description: product.description || '',
category_id: product.category_id ?? '',
base_price: product.base_price ?? '',
unit_type: product.unit_type ?? 'piece',
is_available: product.is_available ?? true,
lifecycle_status: product.lifecycle_status ?? 'active',
printer_zone_id: product.printer_zone_id ?? '',
prep_zone_ids: product.prep_zone_ids ?? [],
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,
@@ -119,25 +168,32 @@ function buildFormFromProduct(product) {
})) ?? [],
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 })) ?? [],
multi_select: o.multi_select ?? false, is_compact: o.is_compact ?? false,
sub_choices: o.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false, allow_multiple: s.allow_multiple ?? false, is_compact: s.is_compact ?? false })) ?? [],
is_favorite: o.is_favorite ?? false, favorite_sort_order: o.favorite_sort_order ?? 0,
group_id: o.group_id ?? null,
})) ?? [],
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,
is_compact: i.is_compact ?? false, group_id: i.group_id ?? null,
})) ?? [],
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 })) ?? [],
is_compact: c.is_compact ?? false,
sub_choices: c.sub_choices?.map(s => ({ name: s.name, extra_cost: s.extra_cost ?? 0, is_default: s.is_default ?? false, is_compact: s.is_compact ?? false })) ?? [],
})) ?? [],
group_id: ps.group_id ?? null,
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,
allow_multi_select: ps.allow_multi_select ?? false,
allow_choice_quantity: ps.allow_choice_quantity ?? false,
})) ?? [],
digital_visible: product.digital_visible ?? true,
digital_available: product.digital_available ?? true,
@@ -152,6 +208,18 @@ function buildFormFromProduct(product) {
cost_breakdown: product.cost_breakdown?.length
? product.cost_breakdown.map(e => ({ label: e.label, amount: e.amount }))
: [],
// Quick Add
quick_add_enabled: product.quick_add_enabled ?? true,
// Tags
tags: product.tags ?? [],
tagsInput: '',
// Service items
is_service_item: product.is_service_item ?? false,
// Fiscal printer (ΦΗΜ)
fiscal_name: product.fiscal_name ?? '',
fiscal_vat_group_id: product.fiscal_vat_group_id ?? null,
// Modifier groups (folders) — keyed by _tempId in form, id from server
modifier_groups: (product.modifier_groups || []).map(g => ({ _tempId: g.id, id: g.id, modifier_type: g.modifier_type, name: g.name, sort_order: g.sort_order })),
}
}
@@ -189,7 +257,7 @@ function getItemTypeLabel(type) {
}
// ── Main modal ────────────────────────────────────────────────────────────────
export default function ProductFormModal({ product, categories, printers, onSave, onCopy, onClose }) {
export default function ProductFormModal({ product, categories, printers, prepZones = [], onSave, onCopy, onClose }) {
const [form, setForm] = useState(() => buildFormFromProduct(product))
const [activeTab, setActiveTab] = useState('favorites')
const [leftTab, setLeftTab] = useState('info')
@@ -197,6 +265,17 @@ export default function ProductFormModal({ product, categories, printers, onSave
const [uploading, setUploading] = useState(false)
const qc = useQueryClient()
// Fetch fiscal VAT groups from settings (needed for the Ταμειακή tab dropdown)
const { data: settingsData } = useQuery({
queryKey: ['pos-settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 60_000,
})
const fiscalEnabled = settingsData?.['fiscal.enabled']?.value === 'true'
const vatGroups = (() => {
try { return JSON.parse(settingsData?.['fiscal.vat_groups']?.value || '[]') } catch { return [] }
})()
useEffect(() => {
function onKey(e) { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
@@ -251,14 +330,14 @@ export default function ProductFormModal({ product, categories, printers, onSave
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 addOption() { setForm(f => ({ ...f, options: [...f.options, { name: '', extra_cost: 0, allow_multiple: false, multi_select: false, is_compact: 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 }] }
idx !== oi ? o : { ...o, sub_choices: [...(o.sub_choices || []), { name: '', extra_cost: 0, is_default: false, allow_multiple: false, is_compact: false }] }
)}))
}
function removeOptionSubChoice(oi, sci) {
@@ -287,14 +366,14 @@ export default function ProductFormModal({ product, categories, printers, onSave
}
// Ingredients
function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '', extra_cost: 0, is_favorite: false, favorite_sort_order: 0 }] })) }
function addIngredient() { setForm(f => ({ ...f, ingredients: [...f.ingredients, { name: '', extra_cost: 0, is_favorite: false, favorite_sort_order: 0, is_compact: false }] })) }
function removeIngredient(i) { setForm(f => ({ ...f, ingredients: f.ingredients.filter((_, idx) => idx !== i) })) }
function setIngredient(i, k, v) { setForm(f => ({ ...f, ingredients: f.ingredients.map((ing, idx) => idx === i ? { ...ing, [k]: v } : ing) })) }
function moveIngredient(i, dir) { setForm(f => ({ ...f, ingredients: moveItem(f.ingredients, i, dir) })) }
// Preference sets
function addPrefSet() {
setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', default_choice_index: -1, choices: [], shared_subset: null, is_favorite: false, favorite_sort_order: 0 }] }))
setForm(f => ({ ...f, preference_sets: [...f.preference_sets, { name: '', default_choice_index: -1, choices: [], shared_subset: null, is_favorite: false, favorite_sort_order: 0, allow_multi_select: false, allow_choice_quantity: false }] }))
setActiveTab(form.preference_sets.length)
}
function removePrefSet(si) {
@@ -306,7 +385,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
}
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
idx === si ? { ...ps, choices: [...ps.choices, { name: '', extra_cost: 0, disables_subset: false, is_compact: false, sub_choices: [] }] } : ps
)}))
}
function removeChoice(si, ci) {
@@ -341,7 +420,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
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 }] }
cidx !== ci ? ch : { ...ch, sub_choices: [...(ch.sub_choices || []), { name: '', extra_cost: 0, is_default: false, is_compact: false }] }
)}
)}))
}
@@ -405,27 +484,60 @@ export default function ProductFormModal({ product, categories, printers, onSave
)}))
}
// Modifier groups
const _nextTempId = useRef(-1)
function addModifierGroup(modifier_type) {
const _tempId = _nextTempId.current--
setForm(f => ({ ...f, modifier_groups: [...f.modifier_groups, { _tempId, id: null, modifier_type, name: 'Νέα ομάδα', sort_order: f.modifier_groups.length }] }))
}
function renameModifierGroup(_tempId, name) {
setForm(f => ({ ...f, modifier_groups: f.modifier_groups.map(g => g._tempId === _tempId ? { ...g, name } : g) }))
}
function deleteModifierGroup(_tempId) {
setForm(f => {
const next = { ...f, modifier_groups: f.modifier_groups.filter(g => g._tempId !== _tempId) }
// Un-assign all items that belonged to this group
next.options = f.options.map(o => o.group_id === _tempId ? { ...o, group_id: null } : o)
next.ingredients = f.ingredients.map(i => i.group_id === _tempId ? { ...i, group_id: null } : i)
next.preference_sets = f.preference_sets.map(ps => ps.group_id === _tempId ? { ...ps, group_id: null } : ps)
return next
})
}
function setItemGroup(listKey, idx, _tempId) {
setForm(f => ({ ...f, [listKey]: f[listKey].map((item, i) => i === idx ? { ...item, group_id: _tempId } : item) }))
}
const [collapsedGroups, setCollapsedGroups] = useState({})
function toggleGroupCollapsed(_tempId) {
setCollapsedGroups(s => ({ ...s, [_tempId]: !s[_tempId] }))
}
function buildBody() {
return {
name: form.name,
description: form.description || null,
category_id: form.category_id ? Number(form.category_id) : null,
base_price: parseFloat(form.base_price),
base_price: form.base_price !== '' && form.base_price !== null && form.base_price !== undefined ? parseFloat(form.base_price) : 0,
unit_type: form.unit_type || 'piece',
is_available: form.is_available,
lifecycle_status: form.lifecycle_status,
printer_zone_id: form.printer_zone_id ? Number(form.printer_zone_id) : null,
prep_zone_ids: form.prep_zone_ids,
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, is_compact: q.is_compact ?? false,
})),
modifier_groups: form.modifier_groups.map((g, gi) => ({ modifier_type: g.modifier_type, name: g.name, sort_order: gi })),
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 })),
multi_select: o.multi_select ?? false, is_compact: o.is_compact ?? false,
sub_choices: (o.sub_choices || []).map(s => ({ name: s.name, extra_cost: parseFloat(s.extra_cost) || 0, is_default: s.is_default ?? false, allow_multiple: s.allow_multiple ?? false, is_compact: s.is_compact ?? false })),
is_favorite: o.is_favorite ?? false, favorite_sort_order: o.favorite_sort_order ?? 0,
group_id: o.group_id != null ? form.modifier_groups.findIndex(g => g._tempId === o.group_id) : null,
})),
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,
is_compact: i.is_compact ?? false,
group_id: i.group_id != null ? form.modifier_groups.findIndex(g => g._tempId === i.group_id) : null,
})),
preference_sets: form.preference_sets.map(ps => ({
name: ps.name,
@@ -436,9 +548,13 @@ export default function ProductFormModal({ product, categories, printers, onSave
} : 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_compact: c.is_compact ?? 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_compact: s.is_compact ?? false })),
})),
is_favorite: ps.is_favorite ?? false, favorite_sort_order: ps.favorite_sort_order ?? 0,
group_id: ps.group_id != null ? form.modifier_groups.findIndex(g => g._tempId === ps.group_id) : null,
allow_multi_select: ps.allow_multi_select ?? false,
allow_choice_quantity: ps.allow_choice_quantity ?? false,
})),
digital_visible: form.digital_visible,
digital_available: form.digital_available,
@@ -452,6 +568,15 @@ export default function ProductFormModal({ product, categories, printers, onSave
cost_breakdown: form.cost_mode === 'detailed' && form.cost_breakdown.length > 0
? form.cost_breakdown.map(e => ({ label: e.label, amount: parseFloat(e.amount) || 0 }))
: null,
// Quick Add
quick_add_enabled: form.quick_add_enabled,
// Tags
tags: form.tags || [],
// Service items
is_service_item: form.is_service_item ?? false,
// Fiscal printer (ΦΗΜ)
fiscal_name: form.fiscal_name?.trim() || null,
fiscal_vat_group_id: form.fiscal_vat_group_id ? parseInt(form.fiscal_vat_group_id, 10) : null,
}
}
@@ -481,8 +606,9 @@ export default function ProductFormModal({ product, categories, printers, onSave
}
const isNew = !product.id
const canSave = form.name.trim() && form.base_price
const canSave = form.name.trim() && (form.is_service_item || (form.base_price !== '' && form.base_price !== null && form.base_price !== undefined))
const favCount = buildFavoritesList(form).length
const prefGroups = form.modifier_groups.filter(g => g.modifier_type === 'preference')
const tabs = [
{ key: 'favorites', label: 'Αγαπημένα', count: favCount, isFavTab: true },
{ key: 'quick', label: 'Γρήγορες', count: form.quick_options.length },
@@ -490,6 +616,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
{ 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 },
{ key: '__add_pref_group__', label: ' Ομάδα', isAddGroup: true },
]
const favList = buildFavoritesList(form)
@@ -508,14 +635,17 @@ export default function ProductFormModal({ product, categories, printers, onSave
{/* Body */}
<div className="flex-1 flex overflow-hidden">
{/* LEFT: tabbed product info — 30% width */}
<div className="shrink-0 border-r border-gray-100 bg-gray-50/50 flex flex-col overflow-hidden" style={{ width: '25%' }}>
{/* LEFT: tabbed product info — 25% normally, 100% for service items */}
<div className="shrink-0 border-r border-gray-100 bg-gray-50/50 flex flex-col overflow-hidden" style={{ width: form.is_service_item ? '100%' : '25%' }}>
{/* Left tab bar */}
<div className="flex border-b border-gray-200 shrink-0 bg-white">
{[
{ key: 'info', label: 'Πληροφορίες' },
{ key: 'digital', label: 'Digital Menu' },
{ key: 'cost', label: 'Κόστος' },
...(!form.is_service_item ? [
{ key: 'digital', label: 'Digital Menu' },
{ key: 'cost', label: 'Κόστος' },
...(fiscalEnabled ? [{ key: 'fiscal', label: 'Ταμειακή' }] : []),
] : []),
].map(t => (
<button key={t.key} onClick={() => setLeftTab(t.key)}
className={`flex-1 px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
@@ -533,12 +663,36 @@ export default function ProductFormModal({ product, categories, printers, onSave
{/* Tab 1: Main Info */}
{leftTab === 'info' && (<>
<div>
<label className="label">Όνομα *</label>
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder="π.χ. Espresso" />
</div>
{/* Service Item toggle */}
<button
type="button"
onClick={() => setField('is_service_item', !form.is_service_item)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors w-full ${
form.is_service_item
? 'bg-amber-50 border-amber-400 text-amber-800'
: 'bg-gray-100 border-gray-300 text-gray-500 hover:bg-gray-200'
}`}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 11l19-9-9 19-2-8-8-2z"/>
</svg>
{form.is_service_item ? 'Service Item' : 'Κανονικό προϊόν'}
</button>
{form.is_service_item && (
<p className="text-xs text-amber-700 -mt-1 px-1">
{form.base_price && parseFloat(form.base_price) > 0
? 'Τυπώνεται σε ξεχωριστή ενότητα SERVICE. Καταγράφεται στα έσοδα.'
: 'Τυπώνεται μία φορά — δεν αποθηκεύεται στην παραγγελία, δεν καταγράφεται στα έσοδα.'}
</p>
)}
<div>
<label className="label">Όνομα *</label>
<input className="input" value={form.name} onChange={e => setField('name', e.target.value)} autoFocus placeholder={form.is_service_item ? 'π.χ. Σερβίτσιο' : 'π.χ. Espresso'} />
</div>
{!form.is_service_item && <div>
<label className="label">Περιγραφή <span className="text-gray-400 font-normal normal-case">(προαιρετική)</span></label>
<textarea
className="input resize-none"
@@ -549,15 +703,68 @@ export default function ProductFormModal({ product, categories, printers, onSave
style={{ lineHeight: 1.5, fontSize: 13 }}
/>
<p className="text-xs text-gray-400 mt-1">Χρήσιμο για ψηφιακό μενού ή ενημέρωση σερβιτόρων.</p>
</div>
</div>}
{!form.is_service_item && <div>
<label className="label">Tags <span className="text-gray-400 font-normal normal-case">(προαιρετικά)</span></label>
<div className="flex flex-wrap gap-1.5 mb-1.5">
{(form.tags || []).map((tag, ti) => (
<span key={ti} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-xs font-medium text-indigo-700">
{tag}
<button type="button" onClick={() => setField('tags', form.tags.filter((_, i) => i !== ti))}
className="hover:text-indigo-900 leading-none">×</button>
</span>
))}
</div>
<input
className="input text-sm"
value={form.tagsInput || ''}
onChange={e => setField('tagsInput', e.target.value)}
onKeyDown={e => {
if ((e.key === 'Enter' || e.key === ',') && form.tagsInput?.trim()) {
e.preventDefault()
const tag = form.tagsInput.trim().toLowerCase().replace(/,/g, '')
if (tag && !(form.tags || []).includes(tag)) setField('tags', [...(form.tags || []), tag])
setField('tagsInput', '')
}
}}
onBlur={() => {
if (form.tagsInput?.trim()) {
const tag = form.tagsInput.trim().toLowerCase()
if (!(form.tags || []).includes(tag)) setField('tags', [...(form.tags || []), tag])
setField('tagsInput', '')
}
}}
placeholder="π.χ. vegan, gluten-free — Enter ή κόμμα"
/>
<p className="text-xs text-gray-400 mt-1">Πάτα Enter ή κόμμα για να προσθέσεις tag.</p>
</div>}
{!form.is_service_item && <div>
<label className="label">Μονάδα μέτρησης</label>
<select className="input" value={form.unit_type} onChange={e => setField('unit_type', e.target.value)}>
<option value="piece">Τεμάχιο (pc)</option>
<option value="portion">Μερίδα (portion)</option>
<option value="kg">Κιλό (kg)</option>
<option value="liter">Λίτρο (L)</option>
<option value="gram">Γραμμάριο (g)</option>
<option value="ml">Χιλιοστόλιτρο (mL)</option>
</select>
{(form.unit_type === 'kg' || form.unit_type === 'liter' || form.unit_type === 'gram' || form.unit_type === 'ml') && (
<p className="text-xs text-indigo-600 mt-1">Η ποσότητα θα εισάγεται ως δεκαδικός αριθμός στην εφαρμογή σερβιτόρων.</p>
)}
</div>}
<div>
<label className="label">Βασική Τιμή () *</label>
<label className="label">{form.is_service_item ? 'Τιμή (€) — προαιρετική' : 'Βασική Τιμή (€) *'}</label>
<PriceInput value={form.base_price} onChange={v => setField('base_price', v)} className="w-full" />
</div>
<div>
<label className="label">Κατηγορία</label>
<label className="label">
Κατηγορία
{form.is_service_item && <span className="text-gray-400 font-normal normal-case ml-1">(προαιρετική εμφανίζεται και εκεί)</span>}
</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 => {
@@ -571,23 +778,56 @@ export default function ProductFormModal({ product, categories, printers, onSave
</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>
<label className="label">Ζώνη Προετοιμασίας</label>
<div className="flex flex-col gap-1.5 mt-1">
{prepZones.length === 0 ? (
<p className="text-xs text-gray-400 italic">Δεν υπάρχουν ζώνες. <a href="/management/prep-zones" className="underline text-primary-600">Δημιουργήστε μια ζώνη</a>.</p>
) : (
prepZones.map(z => {
const selected = form.prep_zone_ids.includes(z.id)
return (
<label key={z.id} className={`flex items-center gap-2.5 px-3 py-2 rounded-lg border cursor-pointer transition-colors select-none text-sm ${selected ? 'border-primary-300 bg-primary-50 text-primary-700' : 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'}`}>
<input
type="checkbox"
checked={selected}
onChange={() => {
const ids = form.prep_zone_ids.includes(z.id)
? form.prep_zone_ids.filter(id => id !== z.id)
: [...form.prep_zone_ids, z.id]
setField('prep_zone_ids', ids)
}}
className="w-3.5 h-3.5 accent-primary-600 shrink-0"
/>
<span className="font-medium">{z.name}</span>
{z.description && <span className="text-xs text-gray-400 ml-1"> {z.description}</span>}
</label>
)
})
)}
</div>
</div>
<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>
{!form.is_service_item && <div className="flex items-center gap-3 flex-wrap">
<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>
<div>
<button type="button" onClick={() => setField('quick_add_enabled', !form.quick_add_enabled)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
form.quick_add_enabled ? 'bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-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.quick_add_enabled ? 'bg-blue-500' : 'bg-gray-400'}`} />
{form.quick_add_enabled ? 'Quick Add: Ενεργό' : 'Quick Add: Ανενεργό'}
</button>
</div>}
{!form.is_service_item && <div>
<label className="label">Εικόνα προϊόντος</label>
{product.image_url && (
<img src={product.image_url}
@@ -599,7 +839,7 @@ export default function ProductFormModal({ product, categories, printers, onSave
{imageFile ? 'Αλλαγή εικόνας' : 'Επιλογή εικόνας'}
<input type="file" accept="image/*" className="sr-only" onChange={e => setImageFile(e.target.files[0] ?? null)} />
</label>
</div>
</div>}
</>)}
{/* Tab 2: Digital Menu */}
@@ -761,13 +1001,69 @@ export default function ProductFormModal({ product, categories, printers, onSave
</div>
)}
{/* Tab 4: Fiscal (ΦΗΜ) — only shown when fiscal.enabled = true */}
{leftTab === 'fiscal' && (
<div className="space-y-4">
<div className="rounded-lg bg-blue-50 border border-blue-200 px-3 py-2 text-xs text-blue-800">
Τα πεδία αυτά χρησιμοποιούνται κατά την αυτόματη αποστολή αποδείξεων στη ΦΗΜ.
</div>
<div>
<label className="label">Όνομα ΦΗΜ <span className="text-gray-400 font-normal normal-case">(προαιρετικό)</span></label>
<input
className="input font-mono uppercase"
placeholder={form.name?.toUpperCase() || 'ΧΡΗΣΙΜΟΠΟΙΕΊ ΤΟ ΚΑΝΟΝΙΚΟ ΟΝΟΜΑ'}
value={form.fiscal_name}
maxLength={30}
onChange={e => setField('fiscal_name', e.target.value.toUpperCase())}
/>
<p className="text-xs text-gray-400 mt-1">
Σύντομο όνομα για την απόδειξη. Αν αφεθεί κενό, χρησιμοποιείται το κανονικό όνομα.
</p>
</div>
<div>
<label className="label">Ομάδα ΦΠΑ ΦΗΜ *</label>
{vatGroups.length === 0 ? (
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
Δεν έχουν οριστεί ομάδες ΦΠΑ. Πηγαίνετε στις <strong>Ρυθμίσεις Ταμειακή</strong> για να τις ρυθμίσετε.
</p>
) : (
<select
className="input"
value={form.fiscal_vat_group_id ?? ''}
onChange={e => setField('fiscal_vat_group_id', e.target.value ? parseInt(e.target.value, 10) : null)}
>
<option value=""> Χωρίς ανάθεση </option>
{vatGroups.map(g => (
<option key={g.machine_id} value={g.machine_id}>
{g.machine_id} {g.friendly_name}
</option>
))}
</select>
)}
{!form.fiscal_vat_group_id && (
<p className="text-xs text-red-500 mt-1">
Απαιτείται για πληρωμή με ΦΗΜ ενεργή.
</p>
)}
</div>
</div>
)}
</div>
</div>
{/* RIGHT: ingredient/option tabs — 70% */}
<div className="flex flex-col overflow-hidden" style={{ width: '75%' }}>
{/* RIGHT: ingredient/option tabs — 70% (hidden for service items) */}
<div className="flex flex-col overflow-hidden" style={{ width: '75%', display: form.is_service_item ? 'none' : undefined }}>
<div className="flex border-b border-gray-200 overflow-x-auto shrink-0 bg-white">
{tabs.map(tab => {
if (tab.isAddGroup) return (
<button key="__add_pref_group__" onClick={() => addModifierGroup('preference')}
className="px-4 py-3 text-sm font-medium text-indigo-600 hover:bg-indigo-50 whitespace-nowrap border-b-2 border-transparent transition-colors">
{tab.label}
</button>
)
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">
@@ -849,63 +1145,142 @@ export default function ProductFormModal({ product, categories, printers, onSave
)}
{/* Ingredients */}
{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>
{activeTab === 'ingredients' && (() => {
const ingGroups = form.modifier_groups.filter(g => g.modifier_type === 'ingredient')
const ungrouped = form.ingredients.map((ing, i) => ({ ing, i })).filter(({ ing }) => ing.group_id == null)
const renderIngRow = (ing, i) => (
<div key={i} className={`flex gap-2 items-center border rounded-xl p-3 bg-white flex-wrap ${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 min-w-40" 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" />
<label className="flex items-center gap-1.5 text-sm cursor-pointer shrink-0 select-none" style={{ color: ing.is_compact ? '#7c3aed' : '#6b7280' }}>
<input type="checkbox" checked={ing.is_compact ?? false} onChange={e => setIngredient(i, 'is_compact', e.target.checked)} className="w-4 h-4" style={{ accentColor: '#7c3aed' }} />
Compact
</label>
{ingGroups.length > 0 && (
<select className="input text-xs px-2 h-9 shrink-0" style={{ width: 120 }}
value={form.ingredients[i].group_id ?? ''}
onChange={e => setItemGroup('ingredients', i, e.target.value === '' ? null : Number(e.target.value))}>
<option value=""> χωρίς ομάδα </option>
{ingGroups.map(g => <option key={g._tempId} value={g._tempId}>{g.name}</option>)}
</select>
)}
<button onClick={() => removeIngredient(i)} className="btn btn-danger px-3 min-h-0 h-10"></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>
)
return (
<div>
<div className="flex items-center justify-between mb-3 gap-2 flex-wrap">
<p className="text-sm text-gray-500">Υλικά που ο πελάτης μπορεί να αφαιρέσει.</p>
<div className="flex gap-2">
<button onClick={() => addModifierGroup('ingredient')} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Ομάδα</button>
<button onClick={addIngredient} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Υλικό</button>
</div>
))}
</div>
{!form.ingredients.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν υλικά.</p>}
<div className="space-y-2">
{ungrouped.map(({ ing, i }) => renderIngRow(ing, i))}
{ingGroups.map(g => {
const members = form.ingredients.map((ing, i) => ({ ing, i })).filter(({ ing }) => ing.group_id === g._tempId)
const collapsed = collapsedGroups[g._tempId]
return (
<div key={g._tempId}>
<GroupHeader group={g} collapsed={collapsed} onToggle={() => toggleGroupCollapsed(g._tempId)}
onRename={name => renameModifierGroup(g._tempId, name)}
onDelete={() => deleteModifierGroup(g._tempId)} />
{!collapsed && (
<div className="ml-4 border-l-2 border-indigo-100 pl-3 mt-1 space-y-2">
{members.length === 0 && <p className="text-xs text-gray-400 py-2">Χωρίς υλικά αλλάξτε ομάδα από κάθε υλικό.</p>}
{members.map(({ ing, i }) => renderIngRow(ing, i))}
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)}
)
})()}
{/* Options/Extras */}
{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>
{activeTab === 'options' && (() => {
const optGroups = form.modifier_groups.filter(g => g.modifier_type === 'option')
const ungrouped = form.options.map((opt, i) => ({ opt, i })).filter(({ opt }) => opt.group_id == null)
const renderOptRow = (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="π.χ. Κανέλα" 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>
<label className="flex items-center gap-1.5 text-sm cursor-pointer shrink-0 select-none" style={{ color: opt.multi_select ? '#0891b2' : '#6b7280' }}>
<input type="checkbox" checked={opt.multi_select ?? false} onChange={e => setOption(i, 'multi_select', e.target.checked)} className="w-4 h-4" style={{ accentColor: '#0891b2' }} />
Multi-select
</label>
<label className="flex items-center gap-1.5 text-sm cursor-pointer shrink-0 select-none" style={{ color: opt.is_compact ? '#7c3aed' : '#6b7280' }}>
<input type="checkbox" checked={opt.is_compact ?? false} onChange={e => setOption(i, 'is_compact', e.target.checked)} className="w-4 h-4" style={{ accentColor: '#7c3aed' }} />
Compact
</label>
{optGroups.length > 0 && (
<select className="input text-xs px-2 h-9 shrink-0" style={{ width: 120 }}
value={form.options[i].group_id ?? ''}
onChange={e => setItemGroup('options', i, e.target.value === '' ? null : Number(e.target.value))}>
<option value=""> χωρίς ομάδα </option>
{optGroups.map(g => <option key={g._tempId} value={g._tempId}>{g.name}</option>)}
</select>
)}
<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}
showMultiple={opt.multi_select}
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>
{!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="π.χ. Κανέλα" 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)} />
)
return (
<div>
<div className="flex items-center justify-between mb-3 gap-2 flex-wrap">
<p className="text-sm text-gray-500">Έξτρα (checkbox). Κάθε extra μπορεί να έχει υπο-επιλογές.</p>
<div className="flex gap-2">
<button onClick={() => addModifierGroup('option')} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Ομάδα</button>
<button onClick={addOption} className="btn btn-secondary text-sm px-3 py-1.5 min-h-0 h-9">+ Έξτρα</button>
</div>
))}
</div>
{!form.options.length && <p className="text-sm text-gray-400 text-center py-8">Δεν υπάρχουν extras.</p>}
<div className="space-y-3">
{ungrouped.map(({ opt, i }) => renderOptRow(opt, i))}
{optGroups.map(g => {
const members = form.options.map((opt, i) => ({ opt, i })).filter(({ opt }) => opt.group_id === g._tempId)
const collapsed = collapsedGroups[g._tempId]
return (
<div key={g._tempId}>
<GroupHeader group={g} collapsed={collapsed} onToggle={() => toggleGroupCollapsed(g._tempId)}
onRename={name => renameModifierGroup(g._tempId, name)}
onDelete={() => deleteModifierGroup(g._tempId)} />
{!collapsed && (
<div className="ml-4 border-l-2 border-indigo-100 pl-3 mt-1 space-y-3">
{members.length === 0 && <p className="text-xs text-gray-400 py-2">Χωρίς extras αλλάξτε ομάδα από κάθε extra.</p>}
{members.map(({ opt, i }) => renderOptRow(opt, i))}
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)}
)
})()}
{/* Preference set tab */}
{typeof activeTab === 'number' && form.preference_sets[activeTab] && (() => {
@@ -914,22 +1289,65 @@ export default function ProductFormModal({ product, categories, printers, onSave
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}
<div className="flex items-center gap-3 mb-4 flex-wrap">
<input className="input flex-1 font-semibold text-base min-w-32" placeholder="π.χ. Ζάχαρη" value={ps.name}
onChange={e => setPrefSetField(si, 'name', e.target.value)} autoFocus />
{prefGroups.length > 0 && (
<select className="input text-xs px-2 h-9 shrink-0" style={{ width: 140 }}
value={ps.group_id ?? ''}
onChange={e => setPrefSetField(si, 'group_id', e.target.value === '' ? null : Number(e.target.value))}>
<option value=""> χωρίς ομάδα </option>
{prefGroups.map(g => <option key={g._tempId} value={g._tempId}>{g.name}</option>)}
</select>
)}
<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>
{/* Pref groups management (only show if any exist) */}
{prefGroups.length > 0 && (
<div className="mb-4 p-3 bg-indigo-50/50 border border-indigo-100 rounded-xl space-y-1">
<p className="text-xs font-semibold text-indigo-600 mb-2">Ομάδες προτιμήσεων</p>
{prefGroups.map(g => (
<GroupHeader key={g._tempId} group={g}
collapsed={collapsedGroups[g._tempId]}
onToggle={() => toggleGroupCollapsed(g._tempId)}
onRename={name => renameModifierGroup(g._tempId, name)}
onDelete={() => deleteModifierGroup(g._tempId)} />
))}
</div>
)}
<div className="flex items-center gap-5 mb-3 flex-wrap">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" checked={ps.allow_multi_select ?? false}
onChange={e => setPrefSetField(si, 'allow_multi_select', e.target.checked)}
className="w-4 h-4" style={{ accentColor: '#6366f1' }} />
<span className="text-sm font-medium" style={{ color: ps.allow_multi_select ? '#6366f1' : '#6b7280' }}>
Πολλαπλή επιλογή
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" checked={ps.allow_choice_quantity ?? false}
onChange={e => setPrefSetField(si, 'allow_choice_quantity', e.target.checked)}
className="w-4 h-4" style={{ accentColor: '#6366f1' }} />
<span className="text-sm font-medium" style={{ color: ps.allow_choice_quantity ? '#6366f1' : '#6b7280' }}>
Ποσότητα ανά επιλογή
</span>
</label>
</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">
<div className="flex items-center gap-2 p-3 bg-white flex-wrap">
<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)} />
<input className="input flex-1 min-w-32" 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" />
<label className="flex items-center gap-1 text-xs cursor-pointer shrink-0 select-none" style={{ color: ch.is_compact ? '#7c3aed' : '#6b7280' }}>
<input type="checkbox" checked={ch.is_compact ?? false} onChange={e => setChoice(si, ci, 'is_compact', e.target.checked)} className="w-3.5 h-3.5" style={{ accentColor: '#7c3aed' }} />
Compact
</label>
{hasSharedSubset && (
<button type="button" onClick={() => setChoice(si, ci, 'disables_subset', !ch.disables_subset)}
className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 text-sm ${ch.disables_subset ? 'bg-red-100 text-red-500' : 'text-gray-300 hover:text-red-400'}`}></button>

View File

@@ -0,0 +1,9 @@
import ProductsTab from '../Management/ProductsTab'
export default function ProductsPage() {
return (
<div className="flex flex-col h-full min-h-0">
<ProductsTab />
</div>
)
}

View File

@@ -40,7 +40,8 @@ function Icon({ name, size = 14, className = '' }) {
// ── Helpers ───────────────────────────────────────────────────────────────────
const EMPTY_PRODUCT = {
name: '', category_id: '', base_price: '', is_available: true, lifecycle_status: 'active',
printer_zone_id: '', description: '', quick_options: [], options: [], ingredients: [], preference_sets: [],
description: '', quick_options: [], options: [], ingredients: [], preference_sets: [],
is_service_item: false,
}
function catColor(cat) { return cat?.color || '#94a3b8' }
@@ -175,6 +176,15 @@ function CategoryPanel({ categories, products, selectedCat, onSelect, onEdit, on
{/* Tree */}
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 24px' }}>
{/* Service items — permanent, always first */}
<CatRow
label="Service"
count={products.filter(p => p.is_service_item && p.lifecycle_status !== 'archived').length}
isSelected={selectedCat === '__service__'}
onClick={() => onSelect('__service__')}
dotColor="#f59e0b"
isService
/>
{/* All */}
<CatRow
label="Όλα τα προϊόντα" count={totalActive}
@@ -277,7 +287,7 @@ function CategoryPanel({ categories, products, selectedCat, onSelect, onEdit, on
)
}
function CatRow({ label, count, isSelected, onClick, dotColor, dotIsGradient, isParent, isOpen, onToggle, onEdit, onDelete, onAddChild, onReparent, depth = 0, showDrag }) {
function CatRow({ label, count, isSelected, onClick, dotColor, dotIsGradient, isParent, isOpen, onToggle, onEdit, onDelete, onAddChild, onReparent, depth = 0, showDrag, isService }) {
const bg = isSelected ? '#eff6ff' : 'transparent'
const accent = isSelected ? '#3b82f6' : 'transparent'
return (
@@ -313,14 +323,20 @@ function CatRow({ label, count, isSelected, onClick, dotColor, dotIsGradient, is
<span style={{ width: 15, flexShrink: 0 }} />
)}
{/* Color dot */}
<span style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
...(dotIsGradient ? { background: dotColor } : { backgroundColor: dotColor }),
}} />
{/* Color dot or service icon */}
{isService ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<path d="M3 11l19-9-9 19-2-8-8-2z"/>
</svg>
) : (
<span style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
...(dotIsGradient ? { background: dotColor } : { backgroundColor: dotColor }),
}} />
)}
{/* Name */}
<span style={{ flex: 1, fontSize: 13.5, fontWeight: isSelected ? 600 : 500, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
<span style={{ flex: 1, fontSize: 13.5, fontWeight: isSelected ? 600 : 500, color: isService ? '#b45309' : '#111827', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
{label}
</span>
@@ -439,9 +455,9 @@ function CatFooterRow({ icon, label, count, isSelected, onClick }) {
}
// ── Products Panel Header ─────────────────────────────────────────────────────
function ProductsHeader({ title, visibleCount, totalCount, search, setSearch, view, setView, zoneFilter, setZoneFilter, priceMin, setPriceMin, priceMax, setPriceMax, dataQualityFilter, setDataQualityFilter, showUnavailable, setShowUnavailable, showArchived, setShowArchived, zones, onNew }) {
function ProductsHeader({ title, visibleCount, totalCount, search, setSearch, view, setView, priceMin, setPriceMin, priceMax, setPriceMax, dataQualityFilter, setDataQualityFilter, showUnavailable, setShowUnavailable, showArchived, setShowArchived, onNew }) {
const [filtersOpen, setFiltersOpen] = useState(false)
const hasFilter = zoneFilter !== 'all' || priceMin !== '' || priceMax !== '' || dataQualityFilter !== 'all' || showUnavailable || showArchived
const hasFilter = priceMin !== '' || priceMax !== '' || dataQualityFilter !== 'all' || showUnavailable || showArchived
function clearFilters() {
setZoneFilter('all')
@@ -526,10 +542,6 @@ function ProductsHeader({ title, visibleCount, totalCount, search, setSearch, vi
borderRadius: 10, boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end',
}}>
<FilterGroup label="Ζώνη εκτύπωσης">
<SegControl value={zoneFilter} onChange={setZoneFilter}
options={[{ v: 'all', l: 'Όλες' }, ...zones.map(z => ({ v: z, l: z }))]} />
</FilterGroup>
<FilterGroup label="Τιμή €">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input type="number" placeholder="από" value={priceMin} onChange={e => setPriceMin(e.target.value)}
@@ -636,7 +648,7 @@ function SubCatBar({ parentCat, subCats, products, subCatFilter, setSubCatFilter
}
// ── Bulk bar ──────────────────────────────────────────────────────────────────
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrinterZone, onMoveToCategory }) {
function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrepZone, onMoveToCategory }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '12px 24px 0', padding: '10px 14px', background: '#111827', color: '#fff', borderRadius: 10, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
@@ -660,12 +672,12 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrinterZo
</button>
))}
<div style={{ width: 1, background: 'rgba(255,255,255,0.15)', margin: '4px 2px' }} />
<button onClick={onSetPrinterZone}
<button onClick={onSetPrepZone}
style={{ border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: 'rgba(255,255,255,0.85)', padding: '6px 10px', borderRadius: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
className="hover:bg-white/10"
>
<Icon name="folder" size={13} />
Ζώνη εκτύπωσης
Ζώνη Προετοιμασίας
</button>
<button onClick={onMoveToCategory}
style={{ border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: 'rgba(255,255,255,0.85)', padding: '6px 10px', borderRadius: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
@@ -679,38 +691,43 @@ function BulkBar({ count, onClear, onAvail, onUnavail, onArchive, onSetPrinterZo
)
}
// ── Bulk: Set Printer Zone modal ───────────────────────────────────────────────
function BulkSetPrinterZoneModal({ count, printers, onClose, onConfirm }) {
const zones = [...new Map(printers.map(p => [p.id, p])).values()]
const [selectedId, setSelectedId] = useState(zones[0]?.id ?? '')
// ── Bulk: Set Prep Zone modal ───────────────────────────────────────────────
function BulkSetPrepZoneModal({ count, prepZones, onClose, onConfirm }) {
const [selectedIds, setSelectedIds] = useState([])
const toggle = (id) => setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 60, padding: 16 }}>
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', width: '100%', maxWidth: 380, padding: 24 }}>
<h2 style={{ margin: '0 0 4px', fontWeight: 700, fontSize: 16, color: '#111827' }}>Ζώνη εκτύπωσης</h2>
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.18)', width: '100%', maxWidth: 420, padding: 24 }}>
<h2 style={{ margin: '0 0 4px', fontWeight: 700, fontSize: 16, color: '#111827' }}>Ζώνες Προετοιμασίας</h2>
<p style={{ margin: '0 0 18px', fontSize: 13, color: '#6b7280' }}>
Θα οριστεί σε <strong style={{ color: '#111827' }}>{count}</strong> προϊόντα.
</p>
{zones.length === 0 ? (
<p style={{ fontSize: 13, color: '#ef4444', marginBottom: 18 }}>Δεν βρέθηκαν εκτυπωτές.</p>
{prepZones.length === 0 ? (
<p style={{ fontSize: 13, color: '#ef4444', marginBottom: 18 }}>Δεν βρέθηκαν ζώνες προετοιμασίας.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
{zones.map(z => (
<label key={z.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', border: `1.5px solid ${selectedId === z.id ? '#3b82f6' : '#e5e7eb'}`, borderRadius: 10, cursor: 'pointer', background: selectedId === z.id ? '#eff6ff' : '#fff' }}>
<input type="radio" name="zone" value={z.id} checked={selectedId === z.id} onChange={() => setSelectedId(z.id)} style={{ display: 'none' }} />
<span style={{ width: 16, height: 16, borderRadius: '50%', border: `2px solid ${selectedId === z.id ? '#3b82f6' : '#d1d5db'}`, background: selectedId === z.id ? '#3b82f6' : '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{selectedId === z.id && <span style={{ width: 6, height: 6, borderRadius: '50%', background: '#fff' }} />}
</span>
<span style={{ fontSize: 14, fontWeight: 500, color: '#111827' }}>{z.name}</span>
</label>
))}
{prepZones.map(z => {
const on = selectedIds.includes(z.id)
return (
<label key={z.id} onClick={() => toggle(z.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', border: `1.5px solid ${on ? '#3b82f6' : '#e5e7eb'}`, borderRadius: 10, cursor: 'pointer', background: on ? '#eff6ff' : '#fff' }}>
<span style={{ width: 16, height: 16, borderRadius: 4, border: `2px solid ${on ? '#3b82f6' : '#d1d5db'}`, background: on ? '#3b82f6' : '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{on && <span style={{ width: 8, height: 8, background: '#fff', borderRadius: 2, display: 'block' }} />}
</span>
<div>
<span style={{ fontSize: 14, fontWeight: 500, color: '#111827' }}>{z.name}</span>
{z.description && <span style={{ fontSize: 12, color: '#6b7280', marginLeft: 8 }}>{z.description}</span>}
</div>
</label>
)
})}
</div>
)}
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={onClose} style={{ flex: 1, padding: '9px 0', border: '1px solid #e5e7eb', background: '#fff', borderRadius: 8, fontSize: 14, cursor: 'pointer', color: '#374151' }}>Άκυρο</button>
<button
onClick={() => onConfirm(selectedId)}
disabled={!selectedId}
style={{ flex: 1, padding: '9px 0', border: 'none', background: selectedId ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: selectedId ? 'pointer' : 'not-allowed' }}
onClick={() => onConfirm(selectedIds)}
disabled={!selectedIds.length}
style={{ flex: 1, padding: '9px 0', border: 'none', background: selectedIds.length ? '#111827' : '#d1d5db', color: '#fff', borderRadius: 8, fontSize: 14, fontWeight: 600, cursor: selectedIds.length ? 'pointer' : 'not-allowed' }}
>
Εφαρμογή
</button>
@@ -829,6 +846,11 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
Αρχείο
</span>
)}
{p.is_service_item && !isArchived && (
<span style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', background: '#fef3c7', color: '#b45309', padding: '1px 6px', borderRadius: 4, flexShrink: 0 }}>
Service
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: '#6b7280' }}>
{displayCat && (
@@ -837,11 +859,11 @@ function ProductRow({ p, cat, parentCat, selected, onToggleSelect, onEdit, onArc
{displayCat.name}
</span>
)}
{p.printer_zone_name && (
<span style={{ background: '#eff6ff', color: '#1d4ed8', padding: '1px 6px', borderRadius: 4, fontSize: 11 }}>
{p.printer_zone_name}
{p.prep_zone_names?.map(name => (
<span key={name} style={{ background: '#eff6ff', color: '#1d4ed8', padding: '1px 6px', borderRadius: 4, fontSize: 11 }}>
{name}
</span>
)}
))}
</div>
</div>
@@ -997,11 +1019,13 @@ function ProductCard({ p, cat, parentCat, selected, onToggleSelect, onEdit, onAr
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: 15, color: '#111827' }}>
{parseFloat(p.base_price).toFixed(2)}
</span>
{p.printer_zone_name && (
<span style={{ background: '#eff6ff', color: '#1d4ed8', padding: '1px 6px', borderRadius: 4, fontSize: 11 }}>
{p.printer_zone_name}
</span>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{p.prep_zone_names?.map(name => (
<span key={name} style={{ background: '#eff6ff', color: '#1d4ed8', padding: '1px 6px', borderRadius: 4, fontSize: 11 }}>
{name}
</span>
))}
</div>
</div>
</div>
</div>
@@ -1052,7 +1076,6 @@ export default function ProductsTab() {
const [confirmDelete, setConfirmDelete] = useState(null)
const [search, setSearch] = useState('')
const [view, setView] = useState('list')
const [zoneFilter, setZoneFilter] = useState('all')
const [priceMin, setPriceMin] = useState('')
const [priceMax, setPriceMax] = useState('')
const [dataQualityFilter, setDataQualityFilter] = useState('all')
@@ -1062,7 +1085,7 @@ export default function ProductsTab() {
const [collapsedGroups, setCollapsedGroups] = useState(new Set())
// local override of group display order (array of group keys), reset when scope changes
const [localGroupOrder, setLocalGroupOrder] = useState(null)
const [bulkPrinterZoneModal, setBulkPrinterZoneModal] = useState(false)
const [bulkPrepZoneModal, setBulkPrepZoneModal] = useState(false)
const [bulkMoveCatModal, setBulkMoveCatModal] = useState(false)
// drag state for group headers
@@ -1087,11 +1110,15 @@ export default function ProductsTab() {
staleTime: 60_000,
})
const printers = statusData?.printers ?? []
const zones = [...new Set(printers.map(p => p.name))]
const { data: prepZones = [] } = useQuery({
queryKey: ['prep-zones'],
queryFn: () => client.get('/api/prep-zones').then(r => r.data),
})
const products = allProducts.map(p => ({
...p,
printer_zone_name: p.printer_zone_id ? printers.find(pr => pr.id === p.printer_zone_id)?.name : null,
prep_zone_names: (p.prep_zone_ids || []).map(id => prepZones.find(z => z.id === id)?.name).filter(Boolean),
}))
const invalidate = () => {
@@ -1191,8 +1218,10 @@ export default function ProductsTab() {
if (selectedCat === '__archived__') {
list = list.filter(p => p.lifecycle_status === 'archived')
} else if (selectedCat === '__service__') {
list = list.filter(p => p.is_service_item && p.lifecycle_status !== 'archived')
} else if (selectedCat === '__uncategorized__') {
list = list.filter(p => !p.category_id && p.lifecycle_status !== 'archived')
list = list.filter(p => !p.category_id && !p.is_service_item && p.lifecycle_status !== 'archived')
} else if (selectedCat) {
if (isParentScope) {
const ids = new Set([selectedCat, ...subCats.map(s => s.id)])
@@ -1202,6 +1231,8 @@ export default function ProductsTab() {
}
if (!showArchived) list = list.filter(p => p.lifecycle_status !== 'archived')
} else {
// "All products" — exclude service items (they have their own section)
list = list.filter(p => !p.is_service_item)
if (!showArchived) list = list.filter(p => p.lifecycle_status !== 'archived')
}
@@ -1215,8 +1246,6 @@ export default function ProductsTab() {
list = list.filter(p => p.name.toLowerCase().includes(q))
}
if (zoneFilter !== 'all') list = list.filter(p => p.printer_zone_name === zoneFilter)
const pmin = priceMin === '' ? -Infinity : Number(priceMin)
const pmax = priceMax === '' ? Infinity : Number(priceMax)
list = list.filter(p => p.base_price >= pmin && p.base_price <= pmax)
@@ -1289,12 +1318,12 @@ export default function ProductsTab() {
setConfirmDelete(null); invalidate(); clearSelect()
}
async function handleBulkSetPrinterZone(printerId) {
async function handleBulkSetPrepZone(prepZoneIds) {
const ids = [...selected]
setBulkPrinterZoneModal(false)
setBulkPrepZoneModal(false)
try {
await Promise.all(ids.map(id => client.put(`/api/products/${id}`, { printer_zone_id: printerId })))
toast.success(`Ζώνη εκτύπωσης ορίστηκε σε ${ids.length} προϊόντα`)
await client.post('/api/products/bulk-prep-zones', { product_ids: ids, prep_zone_ids: prepZoneIds })
toast.success(`Ζώνες προετοιμασίας ορίστηκαν σε ${ids.length} προϊόντα`)
invalidate(); clearSelect()
} catch { toast.error('Σφάλμα') }
}
@@ -1420,6 +1449,7 @@ export default function ProductsTab() {
function getTitle() {
if (selectedCat === null) return 'Όλα τα προϊόντα'
if (selectedCat === '__archived__') return 'Αρχειοθετημένα'
if (selectedCat === '__service__') return 'Service'
if (selectedCat === '__uncategorized__') return 'Χωρίς κατηγορία'
const cat = categories.find(c => c.id === selectedCat)
if (!cat) return 'Προϊόντα'
@@ -1456,15 +1486,17 @@ export default function ProductsTab() {
totalCount={totalActive}
search={search} setSearch={setSearch}
view={view} setView={setView}
zoneFilter={zoneFilter} setZoneFilter={setZoneFilter}
priceMin={priceMin} setPriceMin={setPriceMin}
priceMax={priceMax} setPriceMax={setPriceMax}
dataQualityFilter={dataQualityFilter} setDataQualityFilter={setDataQualityFilter}
showUnavailable={showUnavailable} setShowUnavailable={setShowUnavailable}
showArchived={showArchived} setShowArchived={setShowArchived}
zones={zones}
onNew={() => {
const SPECIAL = [null, '__uncategorized__', '__archived__']
if (selectedCat === '__service__') {
setEditProduct({ ...EMPTY_PRODUCT, is_service_item: true, base_price: 0 })
return
}
const SPECIAL = [null, '__uncategorized__', '__archived__', '__service__']
const presetCat = (!SPECIAL.includes(subCatFilter) && subCatFilter)
|| (!SPECIAL.includes(selectedCat) && selectedCat)
|| ''
@@ -1491,7 +1523,7 @@ export default function ProductsTab() {
onAvail={() => bulkAction('available')}
onUnavail={() => bulkAction('unavailable')}
onArchive={() => bulkAction('archive')}
onSetPrinterZone={() => setBulkPrinterZoneModal(true)}
onSetPrepZone={() => setBulkPrepZoneModal(true)}
onMoveToCategory={() => setBulkMoveCatModal(true)}
/>
)}
@@ -1626,6 +1658,7 @@ export default function ProductsTab() {
product={editProduct}
categories={categories}
printers={printers}
prepZones={prepZones}
onSave={b => saveProduct.mutate(b)}
onCopy={formData => setEditProduct({ ...EMPTY_PRODUCT, ...formData, id: undefined, image_url: undefined, name: formData.name + ' (αντίγραφο)' })}
onClose={() => setEditProduct(null)}
@@ -1649,13 +1682,13 @@ export default function ProductsTab() {
/>
)}
{/* Bulk: set printer zone modal */}
{bulkPrinterZoneModal && (
<BulkSetPrinterZoneModal
{/* Bulk: set prep zone modal */}
{bulkPrepZoneModal && (
<BulkSetPrepZoneModal
count={selected.size}
printers={printers}
onClose={() => setBulkPrinterZoneModal(false)}
onConfirm={handleBulkSetPrinterZone}
prepZones={prepZones}
onClose={() => setBulkPrepZoneModal(false)}
onConfirm={handleBulkSetPrepZone}
/>
)}

View File

@@ -0,0 +1 @@
export { default } from '../SchedulePage'

View File

@@ -0,0 +1,9 @@
import StaffTab from '../StaffTab'
export default function StaffPage() {
return (
<div className="flex flex-col h-full min-h-0">
<StaffTab />
</div>
)
}

View File

@@ -0,0 +1,9 @@
import TablesConfigTab from '../TablesConfigTab'
export default function TablesConfigPage() {
return (
<div className="flex flex-col h-full min-h-0">
<TablesConfigTab />
</div>
)
}

View File

@@ -0,0 +1,687 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Gift, Power, Pencil, Trash2, X, Info } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import Button from '../../../ui/Button'
import Modal from '../../../ui/Modal'
import { ConfirmModal } from '../../../ui/Modal'
// ─── API ─────────────────────────────────────────────────────────────────────
const api = {
list: () => client.get('/api/pricing/deals').then(r => r.data),
create: body => client.post('/api/pricing/deals', body).then(r => r.data),
update: (id,b) => client.put(`/api/pricing/deals/${id}`, b).then(r => r.data),
toggle: id => client.patch(`/api/pricing/deals/${id}/toggle`).then(r => r.data),
remove: id => client.delete(`/api/pricing/deals/${id}`),
modifiers: () => client.get('/api/pricing/modifiers').then(r => r.data),
products: () => client.get('/api/products', { params: { all: true } }).then(r => r.data),
categories:() => client.get('/api/products/categories').then(r => r.data),
}
const COLORS = [
'#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316',
'#eab308','#22c55e','#14b8a6','#0ea5e9','#64748b',
]
const CONDITION_TYPES = [
{ value: 'min_item_quantity', label: 'Ελάχιστη ποσότητα αντικειμένου' },
{ value: 'min_order_value', label: 'Ελάχιστη αξία παραγγελίας' },
{ value: 'time_range', label: 'Ώρα ημέρας' },
{ value: 'day_of_week', label: 'Ημέρα εβδομάδας' },
{ value: 'date_range', label: 'Εύρος ημερομηνιών' },
{ value: 'order_channel', label: 'Κανάλι παραγγελίας' },
{ value: 'price_group_active',label: 'Ομάδα τιμής ενεργή' },
]
const DAYS = ['Δευτέρα','Τρίτη','Τετάρτη','Πέμπτη','Παρασκευή','Σάββατο','Κυριακή']
const CHANNELS = ['pos','online','qr','takeaway']
const ACTION_TYPES = [
{ value: 'apply_modifier', label: 'Εφαρμογή τροποποιητή' },
{ value: 'set_price', label: 'Ορισμός τιμής' },
{ value: 'add_amount', label: '± Ποσό €' },
{ value: 'add_percent', label: '± Ποσοστό %' },
{ value: 'free_item', label: 'Δωρεάν προϊόν' },
{ value: 'free_choice', label: 'Επιλογή δωρεάν προϊόντος' },
]
// ─── Reuse condition editor (simplified for deals) ────────────────────────────
function ConditionRow({ cond, onChange, onRemove, groups }) {
const set = (k, v) => onChange({ ...cond, params: { ...cond.params, [k]: v } })
const setType = t => onChange({ condition_type: t, params: {} })
return (
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
<div className="flex items-center gap-2">
<select className="input flex-1 text-[12px]" value={cond.condition_type}
onChange={e => setType(e.target.value)}>
{CONDITION_TYPES.map(ct => <option key={ct.value} value={ct.value}>{ct.label}</option>)}
</select>
<button type="button" onClick={onRemove}
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
<X className="w-3.5 h-3.5" />
</button>
</div>
{cond.condition_type === 'min_item_quantity' && (
<div>
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη ποσότητα</label>
<input type="number" min={1} className="input w-28 text-[12px]"
value={cond.params.min ?? 1} onChange={e => set('min', Number(e.target.value))} />
</div>
)}
{cond.condition_type === 'min_order_value' && (
<div>
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη αξία ()</label>
<input type="number" min={0} step={0.5} className="input w-28 text-[12px]"
value={cond.params.min ?? 0} onChange={e => set('min', Number(e.target.value))} />
</div>
)}
{cond.condition_type === 'time_range' && (
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
<input type="time" className="input w-full text-[12px]" value={cond.params.from ?? ''}
onChange={e => set('from', e.target.value)} />
</div>
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
<input type="time" className="input w-full text-[12px]" value={cond.params.to ?? ''}
onChange={e => set('to', e.target.value)} />
</div>
</div>
)}
{cond.condition_type === 'day_of_week' && (
<div className="flex flex-wrap gap-1.5">
{DAYS.map((d, i) => (
<button type="button" key={i}
onClick={() => { const days = cond.params.days ?? []; set('days', days.includes(i) ? days.filter(x => x !== i) : [...days, i]) }}
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
(cond.params.days ?? []).includes(i) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{d.slice(0, 3)}</button>
))}
</div>
)}
{cond.condition_type === 'date_range' && (
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
<input type="date" className="input w-full text-[12px]" value={cond.params.from ?? ''}
onChange={e => set('from', e.target.value)} />
</div>
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
<input type="date" className="input w-full text-[12px]" value={cond.params.to ?? ''}
onChange={e => set('to', e.target.value)} />
</div>
</div>
)}
{cond.condition_type === 'order_channel' && (
<div className="flex flex-wrap gap-1.5">
{CHANNELS.map(ch => (
<button type="button" key={ch}
onClick={() => { const channels = cond.params.channels ?? []; set('channels', channels.includes(ch) ? channels.filter(x => x !== ch) : [...channels, ch]) }}
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
(cond.params.channels ?? []).includes(ch) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{ch}</button>
))}
</div>
)}
{cond.condition_type === 'price_group_active' && (
<div>
<select className="input w-full text-[12px]" value={cond.params.price_group_id ?? ''}
onChange={e => set('price_group_id', Number(e.target.value))}>
<option value=""> Επιλογή ομάδας </option>
{(groups ?? []).map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
)}
</div>
)
}
// ─── Shared multi-picker (same logic as ModifiersTab) ─────────────────────────
function MultiPicker({ items, selected, onToggle, labelKey = 'name', valueKey = 'id' }) {
return (
<div className="border border-slate-200 rounded-lg overflow-hidden">
{items.length === 0 && (
<p className="text-[11px] text-slate-400 px-3 py-2">Δεν βρέθηκαν αποτελέσματα</p>
)}
{items.map(item => {
const val = item[valueKey]
const checked = selected.includes(val)
return (
<div key={val} onClick={() => onToggle(val)}
className={`flex items-center gap-2.5 px-3 py-1.5 cursor-pointer transition-colors text-[12px] border-b border-slate-100 last:border-0 ${
checked ? 'bg-sky-50 text-sky-700' : 'hover:bg-slate-50 text-slate-700'
}`}>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
checked ? 'bg-sky-500 border-sky-500' : 'border-slate-300 bg-white'
}`}>
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l3 3 5-6" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
{item[labelKey]}
</div>
)
})}
</div>
)
}
function TagPicker({ selected, onToggle, onAdd, allTags }) {
const [draft, setDraft] = useState('')
const handleAdd = () => {
const tag = draft.trim()
if (tag && !selected.includes(tag)) onAdd(tag)
setDraft('')
}
return (
<div className="space-y-2">
{allTags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{allTags.map(tag => {
const active = selected.includes(tag)
return (
<button type="button" key={tag} onClick={() => onToggle(tag)}
className={`px-2 py-0.5 rounded-full text-[11px] font-medium border transition-colors ${
active ? 'bg-sky-500 border-sky-500 text-white' : 'border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-600'
}`}>{tag}</button>
)
})}
</div>
)}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selected.map(tag => (
<span key={tag} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-100 text-sky-700 text-[11px] font-medium">
{tag}
<button type="button" onClick={() => onToggle(tag)} className="hover:text-sky-900">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<input className="input flex-1 text-[12px]" placeholder="Νέο tag..." value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd() } }} />
<button type="button" onClick={handleAdd}
className="px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 text-[12px] hover:bg-slate-200 transition-colors">
+
</button>
</div>
</div>
)
}
// ─── Deal target row ──────────────────────────────────────────────────────────
function DealTargetRow({ target, onChange, onRemove, products, categories, allTags }) {
const ids = target.target_ids ?? []
const tags = target.target_tags ?? []
const setIds = v => onChange({ ...target, target_ids: v, target_id: v[0] ?? null })
const setTags = v => onChange({ ...target, target_tags: v, target_tag: v[0] ?? null })
const toggleId = id => setIds(ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
const toggleTag = tag => setTags(tags.includes(tag) ? tags.filter(x => x !== tag) : [...tags, tag])
return (
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
<div className="flex items-center gap-2">
<select className="input flex-1 text-[12px]" value={target.target_type}
onChange={e => onChange({ target_type: e.target.value, target_id: null, target_tag: null, target_ids: [], target_tags: [] })}>
<option value="any">Οποιοδήποτε προϊόν</option>
<option value="item">Προϊόντα</option>
<option value="category">Κατηγορίες</option>
<option value="tag">Ετικέτες (tags)</option>
</select>
<button type="button" onClick={onRemove}
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
<X className="w-3.5 h-3.5" />
</button>
</div>
{target.target_type === 'item' && (
<MultiPicker items={products} selected={ids} onToggle={toggleId} />
)}
{target.target_type === 'category' && (
<MultiPicker items={categories} selected={ids} onToggle={toggleId} />
)}
{target.target_type === 'tag' && (
<TagPicker selected={tags} onToggle={toggleTag} onAdd={tag => setTags([...tags, tag])} allTags={allTags} />
)}
</div>
)
}
// ─── Deal form ────────────────────────────────────────────────────────────────
const DEAL_TABS = [
{ key: 'info', label: 'Πληροφορίες' },
{ key: 'trigger', label: 'Trigger & Συνθήκες' },
{ key: 'action', label: 'Ενέργεια' },
]
function DealForm({ initial, onSave, onCancel, saving, modifiers, groups, activeTab, onTabChange }) {
const { data: products = [] } = useQuery({
queryKey: ['products-all-pricing'],
queryFn: () => client.get('/api/products/', { params: { all: true } }).then(r => r.data),
})
const { data: categories = [] } = useQuery({
queryKey: ['categories'],
queryFn: () => client.get('/api/products/categories').then(r => r.data),
staleTime: 60_000,
})
const { data: allTags = [] } = useQuery({
queryKey: ['product-tags'],
queryFn: () => client.get('/api/products/tags').then(r => r.data),
staleTime: 60_000,
})
const normalizeTarget = t => ({
...t,
target_ids: t.target_ids ?? (t.target_id != null ? [t.target_id] : []),
target_tags: t.target_tags ?? (t.target_tag ? [t.target_tag] : []),
})
const blank = {
name: '', description: '', color: COLORS[6], is_active: true, sort_order: 0,
action_type: 'free_item',
action_modifier_id: null, action_value: null,
action_free_item_id: null,
action_free_target_type: 'item', action_free_target_ids: [],
action_free_quantity: 1,
conditions: [], targets: [{ target_type: 'any', target_id: null, target_tag: null, target_ids: [], target_tags: [] }],
}
const [f, setF] = useState(initial ? {
...blank, ...initial,
conditions: initial.conditions ?? [],
targets: (initial.targets ?? [{ target_type: 'any', target_id: null, target_tag: null, target_ids: [], target_tags: [] }]).map(normalizeTarget),
action_free_target_ids: initial.action_free_target_ids ?? [],
} : blank)
const set = (k, v) => setF(p => ({ ...p, [k]: v }))
const addCond = () => set('conditions', [...f.conditions, { condition_type: 'min_item_quantity', params: { min: 1 } }])
const updCond = (i, c) => set('conditions', f.conditions.map((x, j) => j === i ? c : x))
const remCond = i => set('conditions', f.conditions.filter((_, j) => j !== i))
const addTarget = () => set('targets', [...f.targets, { target_type: 'any', target_id: null, target_tag: null, target_ids: [], target_tags: [] }])
const updTarget = (i, t) => set('targets', f.targets.map((x, j) => j === i ? t : x))
const remTarget = i => set('targets', f.targets.filter((_, j) => j !== i))
const toggleFreeId = id => {
const ids = f.action_free_target_ids ?? []
set('action_free_target_ids', ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
}
function submit(e) {
e.preventDefault()
if (!f.name.trim()) return toast.error('Απαιτείται όνομα')
onSave({
...f,
action_free_target_ids: f.action_free_target_ids?.length ? f.action_free_target_ids : null,
})
}
return (
<form id="deal-form" onSubmit={submit} className="space-y-5">
{/* ── Info tab ── */}
{activeTab === 'info' && (
<>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-[12px] font-medium text-slate-600 mb-1">Όνομα προσφοράς</label>
<input className="input w-full" value={f.name} onChange={e => set('name', e.target.value)}
placeholder="π.χ. 3+1 Μπύρες" autoFocus />
</div>
<div className="col-span-2">
<label className="block text-[12px] font-medium text-slate-600 mb-1">Περιγραφή</label>
<input className="input w-full" value={f.description ?? ''}
onChange={e => set('description', e.target.value)} />
</div>
</div>
<div className="flex items-center justify-between">
<div>
<label className="block text-[12px] font-medium text-slate-600 mb-1.5">Χρώμα</label>
<div className="flex flex-wrap gap-2">
{COLORS.map(c => (
<button type="button" key={c} onClick={() => set('color', c)}
className={`w-6 h-6 rounded-full transition-transform ${f.color === c ? 'ring-2 ring-offset-1 ring-slate-400 scale-110' : 'hover:scale-105'}`}
style={{ background: c }} />
))}
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<div onClick={() => set('is_active', !f.is_active)}
className={`relative rounded-full transition-colors ${f.is_active ? 'bg-sky-500' : 'bg-slate-200'}`}
style={{ width: 32, height: 18 }}>
<span className={`absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-white shadow transition-transform ${f.is_active ? 'translate-x-3.5' : ''}`} />
</div>
<span className="text-[12px] text-slate-700">Ενεργή</span>
</label>
</div>
</>
)}
{/* ── Trigger & Conditions tab ── */}
{activeTab === 'trigger' && (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">Trigger</span>
<span className="text-[11px] text-slate-400 ml-1.5">Αγορά αυτών</span>
</div>
<button type="button" onClick={addTarget}
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
<Plus className="w-3.5 h-3.5" />Προσθήκη
</button>
</div>
<p className="text-[11px] text-slate-400">
Ποια προϊόντα πρέπει να υπάρχουν στην παραγγελία για να ενεργοποιηθεί η προσφορά;
</p>
{f.targets.map((t, i) => (
<DealTargetRow key={i} target={t} onChange={v => updTarget(i, v)} onRemove={() => remTarget(i)}
products={products} categories={categories} allTags={allTags} />
))}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">Επιπλέον Συνθήκες</span>
<span className="text-[11px] text-slate-400 ml-1.5">(προαιρετικά)</span>
</div>
<button type="button" onClick={addCond}
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
<Plus className="w-3.5 h-3.5" />Προσθήκη
</button>
</div>
{f.conditions.length === 0 && (
<p className="text-[12px] text-slate-400 bg-slate-50 rounded-lg px-3 py-2.5">
Χωρίς επιπλέον συνθήκες αρκεί μόνο το trigger.
</p>
)}
{f.conditions.map((c, i) => (
<ConditionRow key={i} cond={c} onChange={v => updCond(i, v)} onRemove={() => remCond(i)} groups={groups} />
))}
</div>
</>
)}
{/* ── Action tab ── */}
{activeTab === 'action' && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{ACTION_TYPES.map(({ value, label }) => (
<button type="button" key={value} onClick={() => set('action_type', value)}
className={`py-2 rounded-lg border text-[12px] font-medium transition-colors text-center ${
f.action_type === value ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{label}</button>
))}
</div>
{f.action_type === 'apply_modifier' && (
<div>
<label className="block text-[11px] text-slate-500 mb-1">Τροποποιητής (χωρίς συνθήκες)</label>
<select className="input w-full text-[12px]" value={f.action_modifier_id ?? ''}
onChange={e => set('action_modifier_id', Number(e.target.value))}>
<option value=""> Επιλογή </option>
{(modifiers ?? []).filter(m => !m.conditions?.length).map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
<p className="text-[11px] text-slate-400 mt-1">Εμφανίζονται μόνο τροποποιητές χωρίς συνθήκες (χειροκίνητοι).</p>
</div>
)}
{(f.action_type === 'set_price' || f.action_type === 'add_amount' || f.action_type === 'add_percent') && (
<div>
<label className="block text-[11px] text-slate-500 mb-1">
{f.action_type === 'set_price' ? 'Νέα τιμή (€)' :
f.action_type === 'add_amount' ? 'Ποσό (€, αρνητικό = έκπτωση)' :
'Ποσοστό (%, αρνητικό = έκπτωση)'}
</label>
<input type="number" step={0.1} className="input w-36 text-[12px]"
value={f.action_value ?? 0} onChange={e => set('action_value', Number(e.target.value))} />
</div>
)}
{f.action_type === 'free_item' && (
<div className="space-y-3">
<div>
<label className="block text-[11px] text-slate-500 mb-1">Δωρεάν προϊόν</label>
<select className="input w-full text-[12px]" value={f.action_free_item_id ?? ''}
onChange={e => set('action_free_item_id', Number(e.target.value))}>
<option value=""> Επιλογή </option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div>
<label className="block text-[11px] text-slate-500 mb-1">Ποσότητα</label>
<input type="number" min={1} className="input w-20 text-[12px]"
value={f.action_free_quantity} onChange={e => set('action_free_quantity', Number(e.target.value))} />
</div>
</div>
)}
{f.action_type === 'free_choice' && (
<div className="space-y-2">
<div className="flex gap-2">
{[['item','Προϊόντα'],['category','Κατηγορία'],['tag','Ετικέτα']].map(([v,l]) => (
<button type="button" key={v} onClick={() => { set('action_free_target_type', v); set('action_free_target_ids', []) }}
className={`px-3 py-1 rounded text-[12px] font-medium border transition-colors ${
f.action_free_target_type === v ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{l}</button>
))}
</div>
{f.action_free_target_type === 'item' && (
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
{products.map(p => (
<label key={p.id} className="flex items-center gap-2 cursor-pointer py-0.5">
<input type="checkbox" className="rounded"
checked={(f.action_free_target_ids ?? []).includes(p.id)}
onChange={() => toggleFreeId(p.id)} />
<span className="text-[12px] text-slate-700">{p.name}</span>
</label>
))}
</div>
)}
{f.action_free_target_type === 'category' && (
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-lg p-2 space-y-1">
{categories.map(c => (
<label key={c.id} className="flex items-center gap-2 cursor-pointer py-0.5">
<input type="checkbox" className="rounded"
checked={(f.action_free_target_ids ?? []).includes(c.id)}
onChange={() => toggleFreeId(c.id)} />
<span className="text-[12px] text-slate-700">{c.name}</span>
</label>
))}
</div>
)}
{f.action_free_target_type === 'tag' && (
<div>
<label className="block text-[11px] text-slate-500 mb-1">Ετικέτες (μία ανά γραμμή)</label>
<textarea className="input w-full text-[12px] font-mono" rows={3}
value={(f.action_free_target_ids ?? []).join('\n')}
onChange={e => set('action_free_target_ids', e.target.value.split('\n').map(s => s.trim()).filter(Boolean))} />
</div>
)}
<div>
<label className="block text-[11px] text-slate-500 mb-1">Ποσότητα δωρεάν</label>
<input type="number" min={1} className="input w-20 text-[12px]"
value={f.action_free_quantity} onChange={e => set('action_free_quantity', Number(e.target.value))} />
</div>
</div>
)}
</div>
)}
</form>
)
}
// ─── Deal card ────────────────────────────────────────────────────────────────
function DealCard({ deal, onEdit, onDelete }) {
const qc = useQueryClient()
const toggle = useMutation({
mutationFn: () => api.toggle(deal.id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['pricing-deals'] }),
onError: () => toast.error('Σφάλμα'),
})
const actionLabel = ACTION_TYPES.find(a => a.value === deal.action_type)?.label ?? deal.action_type
return (
<div className={`bg-white rounded-xl border shadow-sm p-4 flex items-start gap-3 transition-opacity ${deal.is_active ? 'border-slate-200' : 'border-slate-100 opacity-60'}`}>
<div className="w-3 h-3 rounded-full mt-1 shrink-0" style={{ background: deal.color ?? '#22c55e' }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-semibold text-slate-800">{deal.name}</span>
<span className={`text-[11px] font-medium px-1.5 py-0.5 rounded-full ${
deal.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'
}`}>{deal.is_active ? 'Ενεργή' : 'Ανενεργή'}</span>
<span className="text-[11px] text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded-full">{actionLabel}</span>
</div>
{deal.description && <p className="text-[12px] text-slate-500 mt-0.5">{deal.description}</p>}
<p className="text-[11px] text-slate-400 mt-1">
{deal.conditions?.length ?? 0} συνθήκες · {deal.targets?.length ?? 0} targets
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => toggle.mutate()}
className={`p-1.5 rounded-lg transition-colors ${
deal.is_active ? 'text-emerald-600 hover:bg-emerald-50' : 'text-slate-400 hover:bg-slate-100'
}`}>
<Power className="w-4 h-4" />
</button>
<button onClick={() => onEdit(deal)}
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
<Pencil className="w-4 h-4" />
</button>
<button onClick={() => onDelete(deal)}
className="p-1.5 rounded-lg text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)
}
// ─── Main tab ─────────────────────────────────────────────────────────────────
export default function DealsTab() {
const qc = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState(null)
const [deleting, setDeleting] = useState(null)
const [formTab, setFormTab] = useState('info')
const { data: deals = [], isLoading } = useQuery({ queryKey: ['pricing-deals'], queryFn: api.list })
const { data: modifiers = [] } = useQuery({ queryKey: ['pricing-modifiers'], queryFn: api.modifiers })
const { data: groups = [] } = useQuery({ queryKey: ['pricing-groups'], queryFn: () => client.get('/api/pricing/groups').then(r => r.data) })
const create = useMutation({
mutationFn: api.create,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-deals'] }); setShowForm(false); toast.success('Δημιουργήθηκε') },
onError: () => toast.error('Σφάλμα'),
})
const update = useMutation({
mutationFn: ({ id, body }) => api.update(id, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-deals'] }); setEditing(null); toast.success('Αποθηκεύτηκε') },
onError: () => toast.error('Σφάλμα'),
})
const remove = useMutation({
mutationFn: api.remove,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-deals'] }); setDeleting(null); toast.success('Διαγράφηκε') },
onError: () => toast.error('Σφάλμα'),
})
const formProps = { modifiers, groups }
return (
<div className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-[14px] font-semibold text-slate-800">Προσφορές</h2>
<p className="text-[12px] text-slate-500 mt-0.5">
Αυτόματες δράσεις που ενεργοποιούνται όταν ο σερβιτόρος προσθέτει συγκεκριμένα προϊόντα στην παραγγελία.
</p>
</div>
<Button variant="primary" onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-1.5" />Νέα Προσφορά
</Button>
</div>
<div className="flex items-start gap-3 bg-sky-50 border border-sky-200 rounded-lg px-4 py-3">
<Info className="w-4 h-4 text-sky-500 shrink-0 mt-0.5" />
<p className="text-[12px] text-sky-700">
Όταν μια προσφορά ενεργοποιηθεί, ο σερβιτόρος λαμβάνει prompt στην εφαρμογή και επιλέγει αν θα την εφαρμόσει.
Τίποτα δεν εφαρμόζεται αυτόματα χωρίς επιβεβαίωση.
</p>
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<div className="w-5 h-5 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
</div>
) : deals.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
<Gift className="w-10 h-10 mb-3 opacity-30" />
<p className="text-[13px]">Δεν υπάρχουν προσφορές</p>
<p className="text-[12px] mt-1">π.χ. «Αγόρασε 3 μπύρες, πάρε 1 δωρεάν»</p>
</div>
) : (
<div className="space-y-3">
{deals.map(d => (
<DealCard key={d.id} deal={d} onEdit={d => { setEditing(d); setFormTab('info') }} onDelete={setDeleting} />
))}
</div>
)}
{showForm && (
<Modal title="Νέα Προσφορά" onClose={() => { setShowForm(false); setFormTab('info') }}
maxWidth="max-w-xl" tabs={DEAL_TABS} activeTab={formTab} onTabChange={setFormTab}
footer={<>
<Button type="button" variant="secondary" onClick={() => { setShowForm(false); setFormTab('info') }}>Ακύρωση</Button>
<Button type="submit" form="deal-form" variant="primary" disabled={create.isPending}>
{create.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</>}>
<DealForm onSave={body => create.mutate(body)} onCancel={() => { setShowForm(false); setFormTab('info') }}
saving={create.isPending} {...formProps} activeTab={formTab} onTabChange={setFormTab} />
</Modal>
)}
{editing && (
<Modal title="Επεξεργασία Προσφοράς" onClose={() => { setEditing(null); setFormTab('info') }}
maxWidth="max-w-xl" tabs={DEAL_TABS} activeTab={formTab} onTabChange={setFormTab}
footer={<>
<Button type="button" variant="secondary" onClick={() => { setEditing(null); setFormTab('info') }}>Ακύρωση</Button>
<Button type="submit" form="deal-form" variant="primary" disabled={update.isPending}>
{update.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</>}>
<DealForm initial={editing} onSave={body => update.mutate({ id: editing.id, body })}
onCancel={() => { setEditing(null); setFormTab('info') }} saving={update.isPending}
{...formProps} activeTab={formTab} onTabChange={setFormTab} />
</Modal>
)}
{deleting && (
<ConfirmModal title="Διαγραφή Προσφοράς"
message={`Θέλετε να διαγράψετε την προσφορά "${deleting.name}";`}
confirmLabel="Διαγραφή"
onConfirm={() => remove.mutate(deleting.id)}
onCancel={() => setDeleting(null)} />
)}
</div>
)
}

View File

@@ -0,0 +1,265 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Users, ShieldCheck, Save } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import Button from '../../../ui/Button'
// ─── API ─────────────────────────────────────────────────────────────────────
const api = {
globalGet: () => client.get('/api/pricing/discount-settings/global').then(r => r.data),
globalPut: body => client.put('/api/pricing/discount-settings/global', body).then(r => r.data),
waiterGet: id => client.get(`/api/pricing/discount-settings/${id}`).then(r => r.data),
waiterPut: (id,b) => client.put(`/api/pricing/discount-settings/${id}`, b).then(r => r.data),
staff: () => client.get('/api/waiters/').then(r => r.data),
}
// ─── Toggle helpers ───────────────────────────────────────────────────────────
function Toggle({ value, onChange }) {
return (
<div onClick={() => onChange(!value)}
className={`relative rounded-full cursor-pointer transition-colors shrink-0 ${value ? 'bg-sky-500' : 'bg-slate-200'}`}
style={{ width: 36, height: 20 }}>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${value ? 'translate-x-4' : ''}`} />
</div>
)
}
// ─── Numeric field ────────────────────────────────────────────────────────────
function NumField({ label, hint, value, onChange, step = 1, min = 0, unit = '' }) {
return (
<div className="space-y-1">
<label className="block text-[12px] font-medium text-slate-600">{label}</label>
{hint && <p className="text-[11px] text-slate-400">{hint}</p>}
<div className="flex items-center gap-1.5">
<input
type="number" min={min} step={step}
className="input w-28 text-[12px]"
placeholder="—"
value={value ?? ''}
onChange={e => onChange(e.target.value === '' ? null : Number(e.target.value))}
/>
{unit && <span className="text-[12px] text-slate-500">{unit}</span>}
{value !== null && value !== undefined && (
<button type="button" onClick={() => onChange(null)}
className="text-[11px] text-slate-400 hover:text-slate-600 underline ml-1">
Αφαίρεση
</button>
)}
</div>
</div>
)
}
// ─── Global settings panel ────────────────────────────────────────────────────
function GlobalPanel() {
const qc = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['discount-global'], queryFn: api.globalGet })
const blankGlobal = {
enabled: false,
max_total_value_workday: null,
max_total_value_shift: null,
max_items_per_shift: null,
}
const [f, setF] = useState(null)
const current = f ?? (data ? { ...blankGlobal, ...data } : blankGlobal)
const set = (k, v) => setF(p => ({ ...(p ?? current), [k]: v }))
const save = useMutation({
mutationFn: () => api.globalPut(current),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['discount-global'] }); setF(null); toast.success('Αποθηκεύτηκε') },
onError: () => toast.error('Σφάλμα'),
})
if (isLoading) return <div className="h-24 flex items-center justify-center"><div className="w-4 h-4 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" /></div>
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 space-y-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-sky-100 flex items-center justify-center shrink-0">
<ShieldCheck className="w-4 h-4 text-sky-600" />
</div>
<div>
<h3 className="text-[13px] font-semibold text-slate-800">Καθολικές Ρυθμίσεις</h3>
<p className="text-[11px] text-slate-500">Εφαρμόζονται σε όλους τους σερβιτόρους ανεξαρτήτως ατομικών ρυθμίσεων</p>
</div>
</div>
{/* Master enable */}
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<div>
<span className="text-[13px] font-medium text-slate-700">Εκπτώσεις Ενεργές</span>
<p className="text-[11px] text-slate-400 mt-0.5">Αν είναι ανενεργό, κανένας σερβιτόρος δεν μπορεί να εφαρμόσει εκπτώσεις</p>
</div>
<Toggle value={current.enabled ?? false} onChange={v => set('enabled', v)} />
</div>
{/* Limits */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<NumField label="Μέγιστο ανά εργάσιμη ημέρα" hint="Ολόκληρο το κατάστημα"
value={current.max_total_value_workday} onChange={v => set('max_total_value_workday', v)} step={0.5} unit="€" />
<NumField label="Μέγιστο ανά βάρδια" hint="Όλες οι βάρδιες μαζί"
value={current.max_total_value_shift} onChange={v => set('max_total_value_shift', v)} step={0.5} unit="€" />
<NumField label="Μέγιστα αντικείμενα/βάρδια" hint="Σύνολο αντικειμένων με έκπτωση"
value={current.max_items_per_shift} onChange={v => set('max_items_per_shift', v)} unit="τεμ." />
</div>
<div className="flex justify-end">
<Button variant="primary" onClick={() => save.mutate()} disabled={save.isPending}>
<Save className="w-3.5 h-3.5 mr-1.5" />{save.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</div>
</div>
)
}
// ─── Per-waiter settings panel ────────────────────────────────────────────────
const WAITER_BLANK = {
can_apply_discounts: false,
max_discount_percent: null,
max_discount_amount: null,
max_total_value_shift: null,
max_total_value_workday: null,
max_items_per_shift: null,
max_items_per_workday: null,
max_items_per_order: null,
}
function WaiterPanel({ waiters }) {
const qc = useQueryClient()
const [selectedId, setSelectedId] = useState(waiters[0]?.id ?? null)
const [dirty, setDirty] = useState(null)
const { data, isLoading } = useQuery({
queryKey: ['discount-waiter', selectedId],
queryFn: () => api.waiterGet(selectedId),
enabled: !!selectedId,
})
const current = dirty ?? (data ? { ...WAITER_BLANK, ...data } : WAITER_BLANK)
const set = (k, v) => setDirty(p => ({ ...(p ?? current), [k]: v }))
const save = useMutation({
mutationFn: () => api.waiterPut(selectedId, current),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['discount-waiter', selectedId] }); setDirty(null); toast.success('Αποθηκεύτηκε') },
onError: () => toast.error('Σφάλμα'),
})
const selectedWaiter = waiters.find(w => w.id === selectedId)
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 space-y-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-indigo-100 flex items-center justify-center shrink-0">
<Users className="w-4 h-4 text-indigo-600" />
</div>
<div>
<h3 className="text-[13px] font-semibold text-slate-800">Ανά Σερβιτόρο</h3>
<p className="text-[11px] text-slate-500">Ατομικά όρια ισχύουν παράλληλα με τα καθολικά</p>
</div>
</div>
{/* Waiter picker */}
<div className="flex flex-wrap gap-2">
{waiters.map(w => (
<button key={w.id} type="button"
onClick={() => { setSelectedId(w.id); setDirty(null) }}
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium border transition-colors ${
w.id === selectedId ? 'border-indigo-500 bg-indigo-50 text-indigo-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'
}`}>
{w.nickname || w.full_name || w.username}
</button>
))}
</div>
{!selectedId ? (
<p className="text-[12px] text-slate-400 text-center py-8">Επιλέξτε σερβιτόρο</p>
) : isLoading ? (
<div className="h-24 flex items-center justify-center"><div className="w-4 h-4 rounded-full border-2 border-indigo-400 border-t-transparent animate-spin" /></div>
) : (
<div className="space-y-5">
{/* Can apply toggle */}
<div className="flex items-center justify-between py-2 border-b border-slate-100">
<div>
<span className="text-[13px] font-medium text-slate-700">
{selectedWaiter?.nickname || selectedWaiter?.full_name || selectedWaiter?.username} Δικαίωμα εκπτώσεων
</span>
<p className="text-[11px] text-slate-400 mt-0.5">Απενεργοποίηση κλειδώνει τον σερβιτόρο εντελώς</p>
</div>
<Toggle value={current.can_apply_discounts} onChange={v => set('can_apply_discounts', v)} />
</div>
{/* Per-discount limits */}
<div>
<p className="text-[11px] font-semibold text-slate-500 uppercase tracking-wide mb-3">Ανά Έκπτωση</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<NumField label="Μέγιστο ποσοστό %" hint="Μέγιστη έκπτωση σε % ανά εφαρμογή"
value={current.max_discount_percent} onChange={v => set('max_discount_percent', v)} step={5} unit="%" />
<NumField label="Μέγιστο ποσό €" hint="Μέγιστο € που αφαιρείται ανά εφαρμογή"
value={current.max_discount_amount} onChange={v => set('max_discount_amount', v)} step={0.5} unit="€" />
</div>
</div>
{/* Scope limits */}
<div>
<p className="text-[11px] font-semibold text-slate-500 uppercase tracking-wide mb-3">Συνολικά Όρια</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<NumField label="Αξία/Βάρδια" hint="Συνολική αξία εκπτώσεων ανά βάρδια"
value={current.max_total_value_shift} onChange={v => set('max_total_value_shift', v)} step={0.5} unit="€" />
<NumField label="Αξία/Εργάσιμη ημέρα" hint="Συνολική αξία εκπτώσεων ανά ημέρα"
value={current.max_total_value_workday} onChange={v => set('max_total_value_workday', v)} step={0.5} unit="€" />
<NumField label="Αντικείμενα/Βάρδια"
value={current.max_items_per_shift} onChange={v => set('max_items_per_shift', v)} unit="τεμ." />
<NumField label="Αντικείμενα/Εργάσιμη ημέρα"
value={current.max_items_per_workday} onChange={v => set('max_items_per_workday', v)} unit="τεμ." />
<NumField label="Αντικείμενα/Παραγγελία" hint="Μέγιστος αριθμός αντικειμένων με έκπτωση ανά παραγγελία"
value={current.max_items_per_order} onChange={v => set('max_items_per_order', v)} unit="τεμ." />
</div>
</div>
<div className="flex justify-end">
<Button variant="primary" onClick={() => save.mutate()} disabled={save.isPending}>
<Save className="w-3.5 h-3.5 mr-1.5" />{save.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</div>
</div>
)}
</div>
)
}
// ─── Main tab ─────────────────────────────────────────────────────────────────
export default function DiscountSettingsTab() {
const { data: staff = [] } = useQuery({ queryKey: ['waiters'], queryFn: api.staff })
const waiters = staff.filter(u => u.is_active !== false)
return (
<div className="p-6 space-y-5">
<div>
<h2 className="text-[14px] font-semibold text-slate-800">Ρυθμίσεις Εκπτώσεων</h2>
<p className="text-[12px] text-slate-500 mt-0.5">
Ελέγξτε ποιοι σερβιτόροι μπορούν να εφαρμόζουν εκπτώσεις και ποια είναι τα όρια ανά βάρδια ή εργάσιμη ημέρα.
</p>
</div>
<GlobalPanel />
{waiters.length > 0
? <WaiterPanel waiters={waiters} />
: (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
<Users className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p className="text-[13px]">Δεν βρέθηκαν ενεργοί σερβιτόροι</p>
</div>
)
}
</div>
)
}

View File

@@ -0,0 +1,810 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Plus, Percent, Power, Pencil, Trash2, Star, Layers,
GripVertical, ChevronDown, ChevronUp, X, Info,
} from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import Button from '../../../ui/Button'
import Modal from '../../../ui/Modal'
import { ConfirmModal } from '../../../ui/Modal'
// ─── API ─────────────────────────────────────────────────────────────────────
const api = {
list: (params) => client.get('/api/pricing/modifiers', { params }).then(r => r.data),
create: body => client.post('/api/pricing/modifiers', body).then(r => r.data),
update: (id, b) => client.put(`/api/pricing/modifiers/${id}`, b).then(r => r.data),
toggle: id => client.patch(`/api/pricing/modifiers/${id}/toggle`).then(r => r.data),
reorder: items => client.patch('/api/pricing/modifiers/reorder', { items }),
remove: id => client.delete(`/api/pricing/modifiers/${id}`),
groups: () => client.get('/api/pricing/groups').then(r => r.data),
products: () => client.get('/api/products', { params: { all: true } }).then(r => r.data),
categories: () => client.get('/api/products/categories').then(r => r.data),
prepZones: () => client.get('/api/prep-zones').then(r => r.data),
}
// ─── Constants ───────────────────────────────────────────────────────────────
const COLORS = [
'#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316',
'#eab308','#22c55e','#14b8a6','#0ea5e9','#64748b',
]
const CONDITION_TYPES = [
{ value: 'time_range', label: 'Ώρα ημέρας (εύρος)' },
{ value: 'date_range', label: 'Εύρος ημερομηνιών' },
{ value: 'specific_date', label: 'Συγκεκριμένη ημερομηνία' },
{ value: 'day_of_week', label: 'Ημέρα εβδομάδας' },
{ value: 'min_item_quantity', label: 'Ελάχιστη ποσότητα προϊόντος' },
{ value: 'min_category_qty', label: 'Ελάχιστη ποσότητα κατηγορίας' },
{ value: 'min_order_value', label: 'Ελάχιστη αξία παραγγελίας' },
{ value: 'order_channel', label: 'Κανάλι παραγγελίας' },
{ value: 'price_group_active', label: 'Ομάδα τιμής ενεργή' },
{ value: 'user_tier', label: 'Επίπεδο πελάτη (placeholder)' },
{ value: 'low_stock', label: 'Χαμηλό απόθεμα (placeholder)' },
]
const DAYS = ['Δευτέρα','Τρίτη','Τετάρτη','Πέμπτη','Παρασκευή','Σάββατο','Κυριακή']
const CHANNELS = ['pos','online','qr','takeaway']
const ROUND_OPTIONS = [
{ value: '', label: 'Χωρίς στρογγυλοποίηση' },
{ value: '0.05', label: 'Στο κοντινότερο €0,05' },
{ value: '0.10', label: 'Στο κοντινότερο €0,10' },
{ value: '0.20', label: 'Στο κοντινότερο €0,20' },
{ value: '0.50', label: 'Στο κοντινότερο €0,50' },
{ value: 'x.99', label: 'Σε x,99 (π.χ. 4,99)' },
{ value: 'x.00', label: 'Σε x,00 (στρογγυλό)' },
]
// ─── Condition editor ─────────────────────────────────────────────────────────
function ConditionEditor({ cond, onChange, onRemove, groups, categories, prepZones }) {
const set = (k, v) => onChange({ ...cond, params: { ...cond.params, [k]: v } })
const setType = t => onChange({ condition_type: t, params: {} })
return (
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
<div className="flex items-center gap-2">
<select className="input flex-1 text-[12px]" value={cond.condition_type}
onChange={e => setType(e.target.value)}>
{CONDITION_TYPES.map(ct => (
<option key={ct.value} value={ct.value}>{ct.label}</option>
))}
</select>
<button type="button" onClick={onRemove}
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
<X className="w-3.5 h-3.5" />
</button>
</div>
{/* Params per type */}
{cond.condition_type === 'time_range' && (
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
<input type="time" className="input w-full text-[12px]" value={cond.params.from ?? ''}
onChange={e => set('from', e.target.value)} />
</div>
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
<input type="time" className="input w-full text-[12px]" value={cond.params.to ?? ''}
onChange={e => set('to', e.target.value)} />
</div>
</div>
)}
{cond.condition_type === 'date_range' && (
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Από</label>
<input type="date" className="input w-full text-[12px]" value={cond.params.from ?? ''}
onChange={e => set('from', e.target.value)} />
</div>
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Έως</label>
<input type="date" className="input w-full text-[12px]" value={cond.params.to ?? ''}
onChange={e => set('to', e.target.value)} />
</div>
</div>
)}
{cond.condition_type === 'specific_date' && (
<div>
<label className="block text-[11px] text-slate-500 mb-0.5">Ημερομηνίες (μία ανά γραμμή)</label>
<textarea className="input w-full text-[12px] font-mono" rows={3}
placeholder="YYYY-MM-DD"
value={(cond.params.dates ?? []).join('\n')}
onChange={e => set('dates', e.target.value.split('\n').map(s => s.trim()).filter(Boolean))} />
</div>
)}
{cond.condition_type === 'day_of_week' && (
<div className="flex flex-wrap gap-1.5">
{DAYS.map((d, i) => (
<button type="button" key={i}
onClick={() => {
const days = cond.params.days ?? []
set('days', days.includes(i) ? days.filter(x => x !== i) : [...days, i])
}}
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
(cond.params.days ?? []).includes(i) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{d.slice(0, 3)}</button>
))}
</div>
)}
{cond.condition_type === 'min_item_quantity' && (
<div>
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη ποσότητα</label>
<input type="number" min={1} className="input w-32 text-[12px]"
value={cond.params.min ?? 1} onChange={e => set('min', Number(e.target.value))} />
</div>
)}
{cond.condition_type === 'min_category_qty' && (
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Κατηγορία</label>
<select className="input w-full text-[12px]" value={cond.params.category_id ?? ''}
onChange={e => set('category_id', Number(e.target.value))}>
<option value=""> Επιλογή </option>
{(categories ?? []).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="w-24">
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχ.</label>
<input type="number" min={1} className="input w-full text-[12px]"
value={cond.params.min ?? 1} onChange={e => set('min', Number(e.target.value))} />
</div>
</div>
)}
{cond.condition_type === 'min_order_value' && (
<div>
<label className="block text-[11px] text-slate-500 mb-0.5">Ελάχιστη αξία παραγγελίας ()</label>
<input type="number" min={0} step={0.5} className="input w-32 text-[12px]"
value={cond.params.min ?? 0} onChange={e => set('min', Number(e.target.value))} />
</div>
)}
{cond.condition_type === 'order_channel' && (
<div className="flex flex-wrap gap-1.5">
{CHANNELS.map(ch => (
<button type="button" key={ch}
onClick={() => {
const channels = cond.params.channels ?? []
set('channels', channels.includes(ch) ? channels.filter(x => x !== ch) : [...channels, ch])
}}
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
(cond.params.channels ?? []).includes(ch) ? 'bg-sky-100 text-sky-700' : 'bg-white border border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{ch}</button>
))}
</div>
)}
{cond.condition_type === 'price_group_active' && (
<div>
<label className="block text-[11px] text-slate-500 mb-0.5">Ομάδα τιμής</label>
<select className="input w-full text-[12px]" value={cond.params.price_group_id ?? ''}
onChange={e => set('price_group_id', Number(e.target.value))}>
<option value=""> Επιλογή </option>
{(groups ?? []).map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
)}
{(cond.condition_type === 'user_tier' || cond.condition_type === 'low_stock') && (
<p className="text-[11px] text-amber-600 bg-amber-50 rounded px-2 py-1 flex items-center gap-1.5">
<Info className="w-3.5 h-3.5 shrink-0" />
Placeholder θα ενεργοποιηθεί σε μελλοντική έκδοση
</p>
)}
</div>
)
}
// ─── Shared multi-picker ──────────────────────────────────────────────────────
function MultiPicker({ items, selected, onToggle, labelKey = 'name', valueKey = 'id', placeholder = 'Επιλέξτε...' }) {
return (
<div className="border border-slate-200 rounded-lg overflow-hidden">
{items.length === 0 && (
<p className="text-[11px] text-slate-400 px-3 py-2">{placeholder}</p>
)}
{items.map(item => {
const val = item[valueKey]
const checked = selected.includes(val)
return (
<div key={val} onClick={() => onToggle(val)}
className={`flex items-center gap-2.5 px-3 py-1.5 cursor-pointer transition-colors text-[12px] border-b border-slate-100 last:border-0 ${
checked ? 'bg-sky-50 text-sky-700' : 'hover:bg-slate-50 text-slate-700'
}`}>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
checked ? 'bg-sky-500 border-sky-500' : 'border-slate-300 bg-white'
}`}>
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l3 3 5-6" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
{item[labelKey]}
</div>
)
})}
</div>
)
}
function TagPicker({ selected, onToggle, onAdd, allTags }) {
const [draft, setDraft] = useState('')
const handleAdd = () => {
const tag = draft.trim()
if (tag && !selected.includes(tag)) onAdd(tag)
setDraft('')
}
return (
<div className="space-y-2">
{/* Quick-select existing tags */}
{allTags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{allTags.map(tag => {
const active = selected.includes(tag)
return (
<button type="button" key={tag} onClick={() => onToggle(tag)}
className={`px-2 py-0.5 rounded-full text-[11px] font-medium border transition-colors ${
active ? 'bg-sky-500 border-sky-500 text-white' : 'border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-600'
}`}>{tag}</button>
)
})}
</div>
)}
{/* Selected chips */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selected.map(tag => (
<span key={tag} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-100 text-sky-700 text-[11px] font-medium">
{tag}
<button type="button" onClick={() => onToggle(tag)} className="hover:text-sky-900">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{/* Manual entry */}
<div className="flex gap-2">
<input className="input flex-1 text-[12px]" placeholder="Νέο tag..." value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd() } }} />
<button type="button" onClick={handleAdd}
className="px-3 py-1.5 rounded-lg bg-slate-100 text-slate-600 text-[12px] hover:bg-slate-200 transition-colors">
+
</button>
</div>
</div>
)
}
// ─── Target editor ────────────────────────────────────────────────────────────
function TargetEditor({ target, onChange, onRemove, products, categories, prepZones, allTags }) {
const ids = target.target_ids ?? []
const tags = target.target_tags ?? []
const setIds = v => onChange({ ...target, target_ids: v, target_id: v[0] ?? null })
const setTags = v => onChange({ ...target, target_tags: v, target_tag: v[0] ?? null })
const toggleId = id => setIds(ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
const toggleTag = tag => setTags(tags.includes(tag) ? tags.filter(x => x !== tag) : [...tags, tag])
return (
<div className="border border-slate-200 rounded-lg p-3 space-y-2 bg-slate-50">
<div className="flex items-center gap-2">
<select className="input flex-1 text-[12px]" value={target.target_type}
onChange={e => onChange({ target_type: e.target.value, target_id: null, target_tag: null, target_ids: [], target_tags: [] })}>
<option value="all">Όλα τα προϊόντα</option>
<option value="item">Προϊόντα</option>
<option value="category">Κατηγορίες</option>
<option value="prep_zone">Ζώνες ετοιμασίας</option>
<option value="tag">Ετικέτες (tags)</option>
</select>
<button type="button" onClick={onRemove}
className="p-1.5 rounded text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-colors">
<X className="w-3.5 h-3.5" />
</button>
</div>
{target.target_type === 'item' && (
<MultiPicker items={products} selected={ids} onToggle={toggleId} />
)}
{target.target_type === 'category' && (
<MultiPicker items={categories} selected={ids} onToggle={toggleId} />
)}
{target.target_type === 'prep_zone' && (
<MultiPicker items={prepZones} selected={ids} onToggle={toggleId} />
)}
{target.target_type === 'tag' && (
<TagPicker selected={tags} onToggle={toggleTag} onAdd={tag => setTags([...tags, tag])} allTags={allTags} />
)}
</div>
)
}
// ─── Modifier form ────────────────────────────────────────────────────────────
const MODIFIER_TABS = [
{ key: 'info', label: 'Πληροφορίες' },
{ key: 'target', label: 'Στόχος' },
{ key: 'conditions', label: 'Συνθήκες' },
{ key: 'price', label: 'Τιμή' },
]
function ModifierForm({ initial, onSave, onCancel, saving, groups, activeTab, onTabChange }) {
const { data: products = [] } = useQuery({
queryKey: ['products-all-pricing'],
queryFn: () => client.get('/api/products/', { params: { all: true } }).then(r => r.data),
})
const { data: categories = [] } = useQuery({
queryKey: ['categories'],
queryFn: () => client.get('/api/products/categories').then(r => r.data),
staleTime: 60_000,
})
const { data: prepZones = [] } = useQuery({
queryKey: ['prep-zones'],
queryFn: () => client.get('/api/prep-zones').then(r => r.data),
staleTime: 60_000,
})
const { data: allTags = [] } = useQuery({
queryKey: ['product-tags'],
queryFn: () => client.get('/api/products/tags').then(r => r.data),
staleTime: 60_000,
})
const normalizeTarget = t => ({
...t,
target_ids: t.target_ids ?? (t.target_id != null ? [t.target_id] : []),
target_tags: t.target_tags ?? (t.target_tag ? [t.target_tag] : []),
})
const blank = {
name: '', description: '', color: COLORS[0], is_active: true, is_favorite: false,
allow_stack: false, sort_order: 0, scope: 'global', item_id: null,
action_type: 'add_percent', action_value: -10, round_to: '',
conditions: [], targets: [{ target_type: 'all', target_id: null, target_tag: null, target_ids: [], target_tags: [] }],
}
const [f, setF] = useState(initial ? {
...initial,
action_value: initial.action_value ?? 0,
round_to: initial.round_to ?? '',
conditions: initial.conditions ?? [],
targets: (initial.targets ?? []).map(normalizeTarget),
} : blank)
const set = (k, v) => setF(p => ({ ...p, [k]: v }))
const addCondition = () => set('conditions', [...f.conditions, { condition_type: 'time_range', params: {} }])
const updateCondition = (i, c) => set('conditions', f.conditions.map((x, j) => j === i ? c : x))
const removeCondition = i => set('conditions', f.conditions.filter((_, j) => j !== i))
const addTarget = () => set('targets', [...f.targets, { target_type: 'all', target_id: null, target_tag: null, target_ids: [], target_tags: [] }])
const updateTarget = (i, t) => set('targets', f.targets.map((x, j) => j === i ? t : x))
const removeTarget = i => set('targets', f.targets.filter((_, j) => j !== i))
function submit(e) {
e.preventDefault()
if (!f.name.trim()) return toast.error('Απαιτείται όνομα')
if (f.scope === 'item' && !f.item_id) return toast.error('Επιλέξτε προϊόν για item-scope')
onSave({ ...f, round_to: f.round_to || null })
}
const actionLabel = f.action_type === 'set' ? 'Νέα τιμή (€)' :
f.action_type === 'add_amount' ? 'Ποσό (€, αρνητικό = έκπτωση)' :
'Ποσοστό (%, αρνητικό = έκπτωση)'
return (
<form id="modifier-form" onSubmit={submit} className="space-y-5">
{/* ── Info tab ── */}
{activeTab === 'info' && (
<>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-[12px] font-medium text-slate-600 mb-1">Όνομα</label>
<input className="input w-full" value={f.name} onChange={e => set('name', e.target.value)}
placeholder="π.χ. Happy Hour -10%" autoFocus />
</div>
<div className="col-span-2">
<label className="block text-[12px] font-medium text-slate-600 mb-1">Περιγραφή (προαιρετική)</label>
<input className="input w-full" value={f.description ?? ''} onChange={e => set('description', e.target.value)} />
</div>
</div>
<div>
<label className="block text-[12px] font-medium text-slate-600 mb-1.5">Χρώμα</label>
<div className="flex flex-wrap gap-2">
{COLORS.map(c => (
<button type="button" key={c} onClick={() => set('color', c)}
className={`w-6 h-6 rounded-full transition-transform ${f.color === c ? 'ring-2 ring-offset-1 ring-slate-400 scale-110' : 'hover:scale-105'}`}
style={{ background: c }} />
))}
</div>
</div>
<div className="flex flex-wrap gap-4">
{[
{ key: 'is_active', label: 'Ενεργός' },
{ key: 'is_favorite', label: 'Αγαπημένος (dashboard)' },
{ key: 'allow_stack', label: 'Επιτρέπει συσσώρευση' },
].map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 cursor-pointer select-none">
<div onClick={() => set(key, !f[key])}
className={`relative rounded-full transition-colors ${f[key] ? 'bg-sky-500' : 'bg-slate-200'}`}
style={{ height: '18px', width: '32px' }}>
<span className={`absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-white shadow transition-transform ${f[key] ? 'translate-x-3.5' : ''}`} />
</div>
<span className="text-[12px] text-slate-700">{label}</span>
</label>
))}
</div>
</>
)}
{/* ── Target tab ── */}
{activeTab === 'target' && (
<>
<div>
<label className="block text-[12px] font-medium text-slate-600 mb-1">Εμβέλεια</label>
<div className="flex gap-2">
{[['global','Καθολικός (με στόχους)'],['item','Συγκεκριμένο προϊόν']].map(([v, l]) => (
<button type="button" key={v} onClick={() => set('scope', v)}
className={`flex-1 py-2 rounded-lg border text-[12px] font-medium transition-colors ${
f.scope === v ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{l}</button>
))}
</div>
{f.scope === 'item' && (
<select className="input w-full mt-2 text-[13px]" value={f.item_id ?? ''}
onChange={e => set('item_id', Number(e.target.value))}>
<option value=""> Επιλογή προϊόντος </option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
)}
</div>
{f.scope === 'global' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">
Στόχοι <span className="font-normal text-slate-400 normal-case ml-1">(OR αρκεί ένας)</span>
</span>
<button type="button" onClick={addTarget}
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
<Plus className="w-3.5 h-3.5" />Προσθήκη
</button>
</div>
{f.targets.length === 0 && (
<p className="text-[12px] text-amber-600 bg-amber-50 rounded-lg px-3 py-2.5">
Χωρίς στόχο ο τροποποιητής δεν θα εφαρμοστεί σε κανένα προϊόν.
</p>
)}
{f.targets.map((t, i) => (
<TargetEditor key={i} target={t}
onChange={v => updateTarget(i, v)}
onRemove={() => removeTarget(i)}
products={products} categories={categories} prepZones={prepZones} allTags={allTags} />
))}
</div>
)}
</>
)}
{/* ── Conditions tab ── */}
{activeTab === 'conditions' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">Συνθήκες</span>
<span className="text-[11px] text-slate-400 ml-1.5">AND όλες πρέπει να ισχύουν</span>
</div>
<button type="button" onClick={addCondition}
className="text-[12px] text-sky-600 hover:text-sky-700 flex items-center gap-1">
<Plus className="w-3.5 h-3.5" />Προσθήκη
</button>
</div>
{f.conditions.length === 0 && (
<p className="text-[12px] text-slate-400 bg-slate-50 rounded-lg px-3 py-2.5">
Χωρίς συνθήκες ο τροποποιητής εφαρμόζεται χειροκίνητα μέσω ενεργοποίησης.
</p>
)}
{f.conditions.map((c, i) => (
<ConditionEditor key={i} cond={c}
onChange={v => updateCondition(i, v)}
onRemove={() => removeCondition(i)}
groups={groups} categories={categories} prepZones={prepZones} />
))}
</div>
)}
{/* ── Price Modifier tab ── */}
{activeTab === 'price' && (
<div className="space-y-3">
<div className="flex gap-2">
{[['set','Ορισμός τιμής'],['add_amount','± Ποσό €'],['add_percent','± Ποσοστό %']].map(([v,l]) => (
<button type="button" key={v} onClick={() => set('action_type', v)}
className={`flex-1 py-1.5 rounded-lg border text-[12px] font-medium transition-colors ${
f.action_type === v ? 'border-sky-500 bg-sky-50 text-sky-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`}>{l}</button>
))}
</div>
<div className="flex gap-3 items-end">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">{actionLabel}</label>
<input type="number" step={f.action_type === 'set' ? 0.01 : f.action_type === 'add_amount' ? 0.1 : 1}
className="input w-full" value={f.action_value}
onChange={e => set('action_value', Number(e.target.value))} />
</div>
{f.action_type === 'add_percent' && (
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-0.5">Στρογγυλοποίηση (προαιρ.)</label>
<select className="input w-full text-[12px]" value={f.round_to ?? ''}
onChange={e => set('round_to', e.target.value)}>
{ROUND_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
)}
</div>
<p className="text-[11px] text-slate-400">
Το σύστημα στρογγυλοποιεί πάντα την τελική τιμή στο κοντινότερο 0,10 ανεξάρτητα από την παραπάνω επιλογή.
</p>
</div>
)}
</form>
)
}
// ─── Modifier card ────────────────────────────────────────────────────────────
function ModifierCard({ modifier, onEdit, onDelete, dragHandleProps }) {
const qc = useQueryClient()
const [expanded, setExpanded] = useState(false)
const toggle = useMutation({
mutationFn: () => api.toggle(modifier.id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }),
onError: () => toast.error('Σφάλμα'),
})
const actionText = modifier.action_type === 'set'
? `= €${modifier.action_value}`
: modifier.action_type === 'add_amount'
? `${modifier.action_value >= 0 ? '+' : ''}${modifier.action_value}`
: `${modifier.action_value >= 0 ? '+' : ''}${modifier.action_value}%`
const condCount = modifier.conditions?.length ?? 0
const targetSummary = modifier.scope === 'item'
? 'item-scope'
: modifier.targets?.length === 1 && modifier.targets[0]?.target_type === 'all'
? 'Όλα τα προϊόντα'
: `${modifier.targets?.length ?? 0} στόχοι`
return (
<div className={`bg-white rounded-xl border shadow-sm transition-opacity ${modifier.is_active ? 'border-slate-200' : 'border-slate-100 opacity-60'}`}>
<div className="flex items-center gap-2 px-3 py-3">
{/* Drag handle */}
<div {...dragHandleProps} className="cursor-grab text-slate-300 hover:text-slate-400 shrink-0">
<GripVertical className="w-4 h-4" />
</div>
{/* Color bar */}
<div className="w-1 h-8 rounded-full shrink-0" style={{ background: modifier.color ?? '#64748b' }} />
{/* Name + badges */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[13px] font-semibold text-slate-800 truncate">{modifier.name}</span>
{modifier.is_favorite && <Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" />}
{modifier.allow_stack && <Layers className="w-3 h-3 text-sky-400 shrink-0" title="Stackable" />}
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full ${
modifier.action_value < 0 ? 'bg-emerald-100 text-emerald-700' : 'bg-sky-100 text-sky-700'
}`}>{actionText}</span>
<span className="text-[10px] text-slate-400">{condCount === 0 ? 'χειροκίνητος' : `${condCount} συνθ.`} · {targetSummary}</span>
</div>
{modifier.description && <p className="text-[11px] text-slate-400 truncate mt-0.5">{modifier.description}</p>}
</div>
{/* Actions */}
<div className="flex items-center gap-0.5 shrink-0">
<button onClick={() => setExpanded(e => !e)}
className="p-1.5 rounded text-slate-400 hover:bg-slate-100 transition-colors">
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
<button onClick={() => toggle.mutate()}
className={`p-1.5 rounded transition-colors ${
modifier.is_active ? 'text-emerald-600 hover:bg-emerald-50' : 'text-slate-400 hover:bg-slate-100'
}`} title={modifier.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}>
<Power className="w-3.5 h-3.5" />
</button>
<button onClick={() => onEdit(modifier)}
className="p-1.5 rounded text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => onDelete(modifier)}
className="p-1.5 rounded text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Expanded detail */}
{expanded && (
<div className="border-t border-slate-100 px-4 py-3 text-[12px] text-slate-500 space-y-1.5">
{modifier.conditions?.length === 0 && <p className="italic">Χωρίς αυτόματες συνθήκες μόνο χειροκίνητη ενεργοποίηση</p>}
{modifier.conditions?.map((c, i) => (
<div key={i} className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-sky-400 shrink-0" />
<span>{CONDITION_TYPES.find(x => x.value === c.condition_type)?.label ?? c.condition_type}</span>
<span className="text-slate-400 font-mono text-[11px]">{JSON.stringify(c.params)}</span>
</div>
))}
</div>
)}
</div>
)
}
// ─── Main tab ─────────────────────────────────────────────────────────────────
export default function ModifiersTab() {
const qc = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState(null)
const [deleting, setDeleting] = useState(null)
const [formTab, setFormTab] = useState('info')
const [scopeFilter, setScopeFilter] = useState('all')
const [items, setItems] = useState(null)
const [dragging, setDragging] = useState(null)
const { data: rawModifiers = [], isLoading } = useQuery({
queryKey: ['pricing-modifiers'],
queryFn: () => api.list(),
})
const modifiers = items ?? rawModifiers
const { data: groups = [] } = useQuery({ queryKey: ['pricing-groups'], queryFn: api.groups, staleTime: 30_000 })
const create = useMutation({
mutationFn: api.create,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }); setItems(null); setShowForm(false); toast.success('Δημιουργήθηκε') },
onError: () => toast.error('Σφάλμα'),
})
const update = useMutation({
mutationFn: ({ id, body }) => api.update(id, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }); setItems(null); setEditing(null); toast.success('Αποθηκεύτηκε') },
onError: () => toast.error('Σφάλμα'),
})
const remove = useMutation({
mutationFn: api.remove,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-modifiers'] }); setItems(null); setDeleting(null); toast.success('Διαγράφηκε') },
onError: () => toast.error('Σφάλμα'),
})
const reorder = useMutation({
mutationFn: api.reorder,
onError: () => { setItems(null); toast.error('Σφάλμα αναδιάταξης') },
})
// Simple drag-to-reorder (no extra library — HTML5 DnD)
function handleDragStart(e, id) {
setDragging(id)
e.dataTransfer.effectAllowed = 'move'
}
function handleDragOver(e, id) {
e.preventDefault()
if (dragging == null || dragging === id) return
const from = modifiers.findIndex(m => m.id === dragging)
const to = modifiers.findIndex(m => m.id === id)
if (from === -1 || to === -1) return
const reordered = [...modifiers]
const [moved] = reordered.splice(from, 1)
reordered.splice(to, 0, moved)
setItems(reordered)
}
function handleDrop() {
setDragging(null)
const payload = (items ?? modifiers).map((m, i) => ({ id: m.id, sort_order: i }))
reorder.mutate(payload)
}
const filtered = scopeFilter === 'all' ? modifiers : modifiers.filter(m => m.scope === scopeFilter)
return (
<div className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-[14px] font-semibold text-slate-800">Τροποποιητές Τιμής</h2>
<p className="text-[12px] text-slate-500 mt-0.5">
Κανόνες που αλλάζουν τιμές αυτόματα ή χειροκίνητα. Η σειρά καθορίζει προτεραιότητα.
</p>
</div>
<Button variant="primary" onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-1.5" />Νέος Τροποποιητής
</Button>
</div>
{/* Filter pills */}
<div className="flex gap-2">
{[['all','Όλοι'],['global','Καθολικοί'],['item','Item-scope']].map(([v,l]) => (
<button key={v} onClick={() => setScopeFilter(v)}
className={`px-3 py-1 rounded-full text-[12px] font-medium transition-colors ${
scopeFilter === v ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}>{l}</button>
))}
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<div className="w-5 h-5 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
<Percent className="w-10 h-10 mb-3 opacity-30" />
<p className="text-[13px]">Δεν υπάρχουν τροποποιητές</p>
<p className="text-[12px] mt-1">Δημιουργήστε έναν για Happy Hour, εποχιακές τιμές κ.λπ.</p>
</div>
) : (
<div className="space-y-2" onDragOver={e => e.preventDefault()} onDrop={handleDrop}>
{filtered.map(m => (
<div key={m.id} draggable
onDragStart={e => handleDragStart(e, m.id)}
onDragOver={e => handleDragOver(e, m.id)}
className={dragging === m.id ? 'opacity-50' : ''}>
<ModifierCard modifier={m} onEdit={m => { setEditing(m); setFormTab('info') }} onDelete={setDeleting}
dragHandleProps={{
onMouseDown: () => {},
title: 'Σύρετε για αναδιάταξη',
}} />
</div>
))}
</div>
)}
{/* Create */}
{showForm && (
<Modal title="Νέος Τροποποιητής Τιμής" onClose={() => { setShowForm(false); setFormTab('info') }}
maxWidth="max-w-xl" tabs={MODIFIER_TABS} activeTab={formTab} onTabChange={setFormTab}
footer={<>
<Button type="button" variant="secondary" onClick={() => { setShowForm(false); setFormTab('info') }}>Ακύρωση</Button>
<Button type="submit" form="modifier-form" variant="primary" disabled={create.isPending}>
{create.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</>}>
<ModifierForm onSave={body => create.mutate(body)} onCancel={() => { setShowForm(false); setFormTab('info') }}
saving={create.isPending} groups={groups} activeTab={formTab} onTabChange={setFormTab} />
</Modal>
)}
{/* Edit */}
{editing && (
<Modal title="Επεξεργασία Τροποποιητή" onClose={() => { setEditing(null); setFormTab('info') }}
maxWidth="max-w-xl" tabs={MODIFIER_TABS} activeTab={formTab} onTabChange={setFormTab}
footer={<>
<Button type="button" variant="secondary" onClick={() => { setEditing(null); setFormTab('info') }}>Ακύρωση</Button>
<Button type="submit" form="modifier-form" variant="primary" disabled={update.isPending}>
{update.isPending ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</>}>
<ModifierForm initial={editing} onSave={body => update.mutate({ id: editing.id, body })}
onCancel={() => { setEditing(null); setFormTab('info') }} saving={update.isPending}
groups={groups} activeTab={formTab} onTabChange={setFormTab} />
</Modal>
)}
{/* Delete */}
{deleting && (
<ConfirmModal title="Διαγραφή Τροποποιητή"
message={`Θέλετε να διαγράψετε τον τροποποιητή "${deleting.name}";`}
confirmLabel="Διαγραφή"
onConfirm={() => remove.mutate(deleting.id)}
onCancel={() => setDeleting(null)} />
)}
</div>
)
}

View File

@@ -0,0 +1,280 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Tag, Power, Pencil, Trash2, Clock } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../../api/client'
import Button from '../../../ui/Button'
import Modal from '../../../ui/Modal'
import { ConfirmModal } from '../../../ui/Modal'
const DAYS = ['Δευ', 'Τρι', 'Τετ', 'Πεμ', 'Παρ', 'Σαβ', 'Κυρ']
const api = {
list: () => client.get('/api/pricing/groups').then(r => r.data),
create: body => client.post('/api/pricing/groups', body).then(r => r.data),
update: (id,b) => client.put(`/api/pricing/groups/${id}`, b).then(r => r.data),
toggle: id => client.patch(`/api/pricing/groups/${id}/toggle`).then(r => r.data),
remove: id => client.delete(`/api/pricing/groups/${id}`),
}
const COLORS = [
'#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316',
'#eab308','#22c55e','#14b8a6','#0ea5e9','#64748b',
]
function GroupForm({ initial, onSave, onCancel, saving }) {
const blank = { name: '', description: '', color: COLORS[0], is_active: false,
auto_enable_time: '', auto_disable_time: '', auto_days: [] }
const [f, setF] = useState(initial ? {
...initial,
auto_days: initial.auto_days ?? [],
} : blank)
const set = (k, v) => setF(p => ({ ...p, [k]: v }))
const toggleDay = d => set('auto_days', f.auto_days.includes(d)
? f.auto_days.filter(x => x !== d)
: [...f.auto_days, d])
const hasSchedule = f.auto_enable_time || f.auto_disable_time || f.auto_days.length > 0
function submit(e) {
e.preventDefault()
if (!f.name.trim()) return toast.error('Απαιτείται όνομα')
onSave({
...f,
auto_days: f.auto_days.length ? f.auto_days : null,
auto_enable_time: f.auto_enable_time || null,
auto_disable_time: f.auto_disable_time || null,
})
}
return (
<form onSubmit={submit} className="space-y-4">
{/* Name */}
<div>
<label className="block text-[12px] font-medium text-slate-600 mb-1">Όνομα</label>
<input className="input w-full" value={f.name} onChange={e => set('name', e.target.value)}
placeholder="π.χ. Happy Hour" autoFocus />
</div>
{/* Description */}
<div>
<label className="block text-[12px] font-medium text-slate-600 mb-1">Περιγραφή (προαιρετική)</label>
<input className="input w-full" value={f.description ?? ''} onChange={e => set('description', e.target.value)}
placeholder="Σύντομη περιγραφή..." />
</div>
{/* Color */}
<div>
<label className="block text-[12px] font-medium text-slate-600 mb-2">Χρώμα</label>
<div className="flex flex-wrap gap-2">
{COLORS.map(c => (
<button type="button" key={c} onClick={() => set('color', c)}
className={`w-7 h-7 rounded-full transition-transform ${f.color === c ? 'ring-2 ring-offset-2 ring-slate-400 scale-110' : 'hover:scale-105'}`}
style={{ background: c }} />
))}
<input type="color" value={f.color ?? '#6366f1'} onChange={e => set('color', e.target.value)}
className="w-7 h-7 rounded-full border-0 cursor-pointer p-0.5 bg-transparent" title="Προσαρμοσμένο χρώμα" />
</div>
</div>
{/* Active toggle */}
<label className="flex items-center gap-3 cursor-pointer select-none">
<div onClick={() => set('is_active', !f.is_active)}
className={`relative w-9 h-5 rounded-full transition-colors ${f.is_active ? 'bg-sky-500' : 'bg-slate-200'}`}>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${f.is_active ? 'translate-x-4' : ''}`} />
</div>
<span className="text-[13px] font-medium text-slate-700">Ενεργή τώρα</span>
</label>
{/* Auto-schedule */}
<div className="rounded-lg border border-slate-200 p-3 space-y-3">
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-slate-400" />
<span className="text-[12px] font-semibold text-slate-600 uppercase tracking-wide">Αυτόματο Χρονοδιάγραμμα</span>
<span className="text-[11px] text-slate-400">(προαιρετικό)</span>
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-1">Ενεργοποίηση</label>
<input type="time" className="input w-full text-[13px]"
value={f.auto_enable_time ?? ''} onChange={e => set('auto_enable_time', e.target.value)} />
</div>
<div className="flex-1">
<label className="block text-[11px] text-slate-500 mb-1">Απενεργοποίηση</label>
<input type="time" className="input w-full text-[13px]"
value={f.auto_disable_time ?? ''} onChange={e => set('auto_disable_time', e.target.value)} />
</div>
</div>
<div>
<label className="block text-[11px] text-slate-500 mb-1.5">Ημέρες</label>
<div className="flex gap-1.5 flex-wrap">
{DAYS.map((d, i) => (
<button type="button" key={i} onClick={() => toggleDay(i)}
className={`px-2.5 py-1 rounded text-[12px] font-medium transition-colors ${
f.auto_days.includes(i) ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}>{d}</button>
))}
</div>
</div>
{hasSchedule && (!f.auto_enable_time || !f.auto_disable_time || !f.auto_days.length) && (
<p className="text-[11px] text-amber-600 bg-amber-50 rounded px-2 py-1">
Για πλήρες χρονοδιάγραμμα ορίστε ώρα ενεργοποίησης, ώρα απενεργοποίησης και τουλάχιστον μία ημέρα.
</p>
)}
</div>
<Modal.Footer>
<Button type="button" variant="secondary" onClick={onCancel}>Ακύρωση</Button>
<Button type="submit" variant="primary" disabled={saving}>
{saving ? 'Αποθήκευση...' : 'Αποθήκευση'}
</Button>
</Modal.Footer>
</form>
)
}
function GroupCard({ group, onEdit, onDelete }) {
const qc = useQueryClient()
const toggle = useMutation({
mutationFn: () => api.toggle(group.id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['pricing-groups'] }),
onError: () => toast.error('Σφάλμα'),
})
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-4 flex items-start gap-3">
{/* Color dot */}
<div className="w-3 h-3 rounded-full mt-1 shrink-0" style={{ background: group.color ?? '#6366f1' }} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-semibold text-slate-800 truncate">{group.name}</span>
<span className={`text-[11px] font-medium px-1.5 py-0.5 rounded-full ${
group.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'
}`}>{group.is_active ? 'Ενεργή' : 'Ανενεργή'}</span>
</div>
{group.description && <p className="text-[12px] text-slate-500 mt-0.5 truncate">{group.description}</p>}
{group.auto_enable_time && group.auto_disable_time && (
<p className="text-[11px] text-slate-400 mt-1 flex items-center gap-1">
<Clock className="w-3 h-3" />
{group.auto_enable_time}{group.auto_disable_time}
{group.auto_days?.length > 0 && (
<span className="ml-1">{group.auto_days.map(d => DAYS[d]).join(', ')}</span>
)}
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{/* Quick toggle */}
<button onClick={() => toggle.mutate()}
className={`p-1.5 rounded-lg transition-colors ${
group.is_active ? 'text-emerald-600 hover:bg-emerald-50' : 'text-slate-400 hover:bg-slate-100'
}`} title={group.is_active ? 'Απενεργοποίηση' : 'Ενεργοποίηση'}>
<Power className="w-4 h-4" />
</button>
<button onClick={() => onEdit(group)}
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
<Pencil className="w-4 h-4" />
</button>
<button onClick={() => onDelete(group)}
className="p-1.5 rounded-lg text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)
}
export default function PriceGroupsTab() {
const qc = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState(null)
const [deleting, setDeleting] = useState(null)
const { data: groups = [], isLoading } = useQuery({ queryKey: ['pricing-groups'], queryFn: api.list })
const create = useMutation({
mutationFn: api.create,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-groups'] }); setShowForm(false); toast.success('Ομάδα δημιουργήθηκε') },
onError: () => toast.error('Σφάλμα δημιουργίας'),
})
const update = useMutation({
mutationFn: ({ id, body }) => api.update(id, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-groups'] }); setEditing(null); toast.success('Αποθηκεύτηκε') },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const remove = useMutation({
mutationFn: api.remove,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pricing-groups'] }); setDeleting(null); toast.success('Διαγράφηκε') },
onError: () => toast.error('Σφάλμα διαγραφής'),
})
return (
<div className="p-6 space-y-4">
{/* Header row */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-[14px] font-semibold text-slate-800">Ομάδες Τιμών</h2>
<p className="text-[12px] text-slate-500 mt-0.5">
Ονομαστές ομάδες που ενεργοποιούνται χειροκίνητα ή αυτόματα. Χρησιμοποιούνται ως συνθήκη σε τροποποιητές τιμής.
</p>
</div>
<Button variant="primary" onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-1.5" />Νέα Ομάδα
</Button>
</div>
{/* List */}
{isLoading ? (
<div className="flex justify-center py-12">
<div className="w-5 h-5 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
</div>
) : groups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
<Tag className="w-10 h-10 mb-3 opacity-30" />
<p className="text-[13px]">Δεν υπάρχουν ομάδες τιμών</p>
<p className="text-[12px] mt-1">Δημιουργήστε μια ομάδα για Happy Hour, Εποχή κ.λπ.</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{groups.map(g => (
<GroupCard key={g.id} group={g}
onEdit={setEditing}
onDelete={setDeleting} />
))}
</div>
)}
{/* Create modal */}
{showForm && (
<Modal title="Νέα Ομάδα Τιμών" onClose={() => setShowForm(false)} maxWidth="max-w-md">
<GroupForm onSave={body => create.mutate(body)} onCancel={() => setShowForm(false)} saving={create.isPending} />
</Modal>
)}
{/* Edit modal */}
{editing && (
<Modal title="Επεξεργασία Ομάδας" onClose={() => setEditing(null)} maxWidth="max-w-md">
<GroupForm initial={editing} onSave={body => update.mutate({ id: editing.id, body })}
onCancel={() => setEditing(null)} saving={update.isPending} />
</Modal>
)}
{/* Delete confirm */}
{deleting && (
<ConfirmModal
title="Διαγραφή Ομάδας"
message={`Θέλετε σίγουρα να διαγράψετε την ομάδα "${deleting.name}"; Τυχόν τροποποιητές που τη χρησιμοποιούν δεν θα επηρεαστούν αλλά η συνθήκη δεν θα εκπληρώνεται πλέον.`}
confirmLabel="Διαγραφή"
onConfirm={() => remove.mutate(deleting.id)}
onCancel={() => setDeleting(null)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,854 @@
import { useState, useEffect, useRef } from 'react'
// ── Helpers ───────────────────────────────────────────────────────────────────
const DECIMAL_UNIT_CONFIG = {
kg: { step: 0.05, min: 0.05, presets: [0.10, 0.20, 0.25, 0.50, 0.70, 1.00, 1.50, 2.00, 2.50, 3.00, 4.00, 5.00], offsets: [0.1, 0.2, 0.5, 1.0], label: 'kg' },
liter: { step: 0.05, min: 0.05, presets: [0.10, 0.20, 0.25, 0.50, 0.70, 1.00, 1.50, 2.00, 2.50, 3.00, 4.00, 5.00], offsets: [0.1, 0.2, 0.5, 1.0], label: 'L' },
gram: { step: 10, min: 10, presets: [50, 100, 150, 200, 250, 300, 400, 500], offsets: [10, 50, 100, 200], label: 'g' },
ml: { step: 5, min: 5, presets: [25, 50, 100, 150, 200, 250, 300, 500], offsets: [25, 50, 100, 200], label: 'mL' },
}
function fmtDecimalQty(qty, cfg) {
if (!cfg) return String(qty)
if (cfg.label === 'kg' || cfg.label === 'L') {
const n = Number(qty)
return n % 1 === 0 ? n.toFixed(1) : (Math.round(n * 100) / 100).toFixed(2).replace(/0$/, '')
}
return String(qty)
}
function buildInitialState(product) {
const preferenceSets = product.preference_sets || []
const prefs = {}
const subChoices = {}
const sharedSubs = {}
preferenceSets.forEach(ps => {
const def = ps.default_choice_id != null
? ps.choices.find(c => c.id === ps.default_choice_id) ?? null
: null
prefs[ps.id] = def
if (def) {
if (def.sub_choices?.length > 0) {
subChoices[def.id] = def.sub_choices.find(s => s.is_default) ?? def.sub_choices[0]
}
if (ps.shared_subset?.choices?.length > 0 && !def.disables_subset) {
sharedSubs[ps.id] = ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]
}
}
})
return { prefs, subChoices, sharedSubs }
}
const QUICK_NOTES = ['Χωρίς αλάτι', 'Βγάλτε γρήγορα', 'Αλλεργία!', 'Κόψτε σε μικρά κομμάτια', 'Έξτρα χαρτοπετσέτες']
const PRICE_DELTAS = [-5, -3, -2, -1, -0.5, -0.2, +0.2, +0.5, +1, +2, +3, +5]
// ── Primitive UI ──────────────────────────────────────────────────────────────
function CheckCircle({ selected }) {
return (
<div style={{
width: 22, height: 22, borderRadius: '50%', flexShrink: 0,
border: `2px solid ${selected ? '#f59e0b' : '#d1d5db'}`,
background: selected ? '#f59e0b' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 120ms ease',
}}>
{selected && <svg width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M5 12.5L10 17.5L19 7.5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
)
}
function RadioDot({ selected }) {
return (
<div style={{
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
border: `2px solid ${selected ? '#f59e0b' : '#d1d5db'}`,
background: 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{selected && <div style={{ width: 9, height: 9, borderRadius: '50%', background: '#f59e0b' }} />}
</div>
)
}
function Row({ selected, onClick, children, right, left }) {
return (
<div onClick={onClick} style={{
padding: '10px 12px',
background: selected ? 'rgba(245,158,11,0.08)' : '#f8fafc',
border: `1px solid ${selected ? 'rgba(245,158,11,0.4)' : '#e2e8f0'}`,
borderRadius: 10,
display: 'flex', alignItems: 'center', gap: 10,
cursor: onClick ? 'pointer' : 'default',
transition: 'background 120ms ease, border-color 120ms ease',
minHeight: 50,
}}>
{left && <div style={{ flexShrink: 0 }}>{left}</div>}
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
{right && <div style={{ flexShrink: 0 }}>{right}</div>}
</div>
)
}
function Stepper({ value, onChange, min = 0, max = 99 }) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', height: 36, borderRadius: 18, background: '#f1f5f9', border: '1px solid #e2e8f0', overflow: 'hidden' }}
onClick={e => e.stopPropagation()}>
<button onClick={() => onChange(Math.max(min, value - 1))} disabled={value <= min}
style={{ width: 36, height: 36, border: 'none', background: 'transparent', fontSize: 16, fontWeight: 500, cursor: value <= min ? 'default' : 'pointer', color: value <= min ? '#94a3b8' : '#1e293b' }}></button>
<div style={{ minWidth: 26, textAlign: 'center', fontSize: 14, fontWeight: 700, color: '#1e293b' }}>{value}</div>
<button onClick={() => onChange(Math.min(max, value + 1))} disabled={value >= max}
style={{ width: 36, height: 36, border: 'none', background: 'transparent', fontSize: 16, fontWeight: 500, cursor: value >= max ? 'default' : 'pointer', color: value >= max ? '#94a3b8' : '#1e293b' }}>+</button>
</div>
)
}
// ── Option rows ───────────────────────────────────────────────────────────────
function QuickOptionRow({ opt, quickState, setQuickState }) {
const qty = quickState[opt.id] || 0
const selected = qty > 0
return (
<Row selected={selected} onClick={() => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 }))}
left={<CheckCircle selected={selected} />}
right={opt.allow_multiple ? (
<div onClick={e => e.stopPropagation()}>
{selected
? <Stepper value={qty} onChange={v => setQuickState(s => ({ ...s, [opt.id]: v }))} />
: <button onClick={e => { e.stopPropagation(); setQuickState(s => ({ ...s, [opt.id]: 1 })) }}
style={{ width: 30, height: 30, borderRadius: '50%', background: '#f1f5f9', border: '1px solid #e2e8f0', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/></svg>
</button>
}
</div>
) : null}
>
<div style={{ fontSize: 14, fontWeight: 500, color: '#1e293b' }}>{opt.name}</div>
{opt.price > 0 && <div style={{ fontSize: 12, color: '#64748b', marginTop: 1 }}>+{opt.price.toFixed(2)} </div>}
</Row>
)
}
function ExtraOptionRow({ opt, extrasState, setExtrasState, expandedExtra, setExpandedExtra }) {
const sel = extrasState[opt.id]
const selected = !!sel
const open = expandedExtra === opt.id
const hasSubs = opt.sub_choices?.length > 0
const subLabel = sel ? opt.sub_choices?.find(s => s.name === sel.subName)?.name : null
const toggle = () => {
if (selected) {
setExtrasState(s => { const n = { ...s }; delete n[opt.id]; return n })
if (open) setExpandedExtra(null)
} else {
const firstSub = hasSubs ? opt.sub_choices[0] : null
setExtrasState(s => ({ ...s, [opt.id]: { qty: 1, subName: firstSub?.name ?? null } }))
if (hasSubs) setExpandedExtra(opt.id)
}
}
return (
<div>
<Row selected={selected} onClick={toggle}
left={<CheckCircle selected={selected} />}
right={
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }} onClick={e => e.stopPropagation()}>
{selected && opt.allow_multiple && (
<Stepper value={sel.qty} onChange={v => {
if (v === 0) { setExtrasState(s => { const n = { ...s }; delete n[opt.id]; return n }); return }
setExtrasState(s => ({ ...s, [opt.id]: { ...sel, qty: v } }))
}} />
)}
{selected && hasSubs && (
<button onClick={e => { e.stopPropagation(); setExpandedExtra(open ? null : opt.id) }}
style={{ width: 30, height: 30, borderRadius: '50%', background: '#f1f5f9', border: '1px solid #e2e8f0', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${open ? 180 : 0}deg)`, transition: 'transform 180ms' }}>
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
</svg>
</button>
)}
</div>
}
>
<div style={{ fontSize: 14, fontWeight: 500, color: '#1e293b' }}>{opt.name}</div>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 1 }}>
{(opt.extra_cost ?? 0) !== 0 ? `+${opt.extra_cost.toFixed(2)}` : 'Included'}
{subLabel && <span style={{ color: '#f59e0b', fontWeight: 600 }}> · {subLabel}</span>}
</div>
</Row>
{selected && open && hasSubs && (
<div style={{ margin: '5px 0 2px 14px', paddingLeft: 12, borderLeft: '2px solid rgba(245,158,11,0.35)', display: 'flex', flexDirection: 'column', gap: 5 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.6, padding: '4px 2px 2px' }}>Επιλογή</div>
{opt.sub_choices.map((sub, si) => {
const isSel = sel.subName === sub.name
return (
<Row key={si} selected={isSel} onClick={() => setExtrasState(s => ({ ...s, [opt.id]: { ...sel, subName: sub.name } }))} left={<RadioDot selected={isSel} />}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: 13, color: '#1e293b' }}>{sub.name}</div>
{(sub.extra_cost ?? 0) !== 0 && <div style={{ fontSize: 12, color: '#64748b' }}>+{sub.extra_cost.toFixed(2)} </div>}
</div>
</Row>
)
})}
</div>
)}
</div>
)
}
function IngredientRow({ ing, removedState, setRemovedState }) {
const removed = !!removedState[ing.id]
return (
<Row selected={false} onClick={() => setRemovedState(s => ({ ...s, [ing.id]: !s[ing.id] }))}
right={
<div style={{
height: 30, padding: '0 12px', borderRadius: 15,
background: removed ? '#ef4444' : '#f1f5f9',
border: `1px solid ${removed ? '#ef4444' : '#e2e8f0'}`,
color: removed ? '#fff' : '#475569',
fontSize: 12, fontWeight: 600,
display: 'inline-flex', alignItems: 'center',
}}>
{removed ? 'Αφαιρέθηκε' : 'Αφαίρεση'}
</div>
}
>
<div style={{ fontSize: 14, fontWeight: 500, color: removed ? '#94a3b8' : '#1e293b', textDecoration: removed ? 'line-through' : 'none' }}>{ing.name}</div>
</Row>
)
}
function PrefSetBlock({ ps, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
const selChoice = prefs[ps.id] ?? null
const complete = selChoice != null
&& !(selChoice.sub_choices?.length > 0 && subChoices[selChoice.id] == null)
&& !(ps.shared_subset?.choices?.length > 0 && !selChoice.disables_subset && sharedSubs[ps.id] == null)
const showShared = ps.shared_subset?.choices?.length > 0 && selChoice != null && !selChoice.disables_subset
function selectPref(choice) {
setPrefs(p => ({ ...p, [ps.id]: choice }))
if (choice?.sub_choices?.length > 0) {
setSubChoices(s => ({ ...s, [choice.id]: s[choice.id] ?? (choice.sub_choices.find(x => x.is_default) ?? choice.sub_choices[0]) }))
}
if (ps.shared_subset?.choices?.length > 0 && !choice?.disables_subset) {
setSharedSubs(s => s[ps.id] != null ? s : { ...s, [ps.id]: ps.shared_subset.choices.find(x => x.is_default) ?? ps.shared_subset.choices[0] })
}
}
return (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, padding: '0 2px 8px' }}>
<div style={{ fontSize: 13, fontWeight: 700, color: complete ? '#1e293b' : '#ef4444' }}>{ps.name}</div>
{!complete && <div style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', textTransform: 'uppercase', letterSpacing: 0.6 }}>Απαιτείται</div>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{ps.choices.map(ch => {
const isSel = selChoice?.id === ch.id
const hasSubs = ch.sub_choices?.length > 0
return (
<div key={ch.id}>
<Row selected={isSel} onClick={() => selectPref(ch)} left={<RadioDot selected={isSel} />}
right={(ch.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 13, color: '#64748b' }}>{ch.extra_cost > 0 ? '+' : ''}{ch.extra_cost.toFixed(2)} </div> : null}>
<div style={{ fontSize: 14, fontWeight: 500, color: '#1e293b' }}>{ch.name}</div>
</Row>
{isSel && hasSubs && (
<div style={{ margin: '5px 0 2px 14px', paddingLeft: 12, borderLeft: `2px solid ${subChoices[ch.id] == null ? '#ef4444' : 'rgba(245,158,11,0.35)'}`, display: 'flex', flexDirection: 'column', gap: 5 }}>
{ch.sub_choices.map((sub, si) => {
const subSel = subChoices[ch.id]?.name === sub.name
return (
<Row key={si} selected={subSel} onClick={() => setSubChoices(s => ({ ...s, [ch.id]: sub }))} left={<RadioDot selected={subSel} />}
right={(sub.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 12, color: '#64748b' }}>+{sub.extra_cost.toFixed(2)} </div> : null}>
<div style={{ fontSize: 13, color: '#1e293b' }}>{sub.name}</div>
</Row>
)
})}
</div>
)}
</div>
)
})}
{showShared && (
<div style={{ marginTop: 4, marginLeft: 8, paddingLeft: 12, borderLeft: '2px solid rgba(245,158,11,0.35)', display: 'flex', flexDirection: 'column', gap: 5 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.6, padding: '4px 2px 2px' }}>{ps.shared_subset.name}</div>
{ps.shared_subset.choices.map((sub, si) => {
const subSel = sharedSubs[ps.id]?.name === sub.name
return (
<Row key={si} selected={subSel} onClick={() => setSharedSubs(s => ({ ...s, [ps.id]: sub }))} left={<RadioDot selected={subSel} />}
right={(sub.extra_cost ?? 0) !== 0 ? <div style={{ fontSize: 12, color: '#64748b' }}>+{sub.extra_cost.toFixed(2)} </div> : null}>
<div style={{ fontSize: 13, color: '#1e293b' }}>{sub.name}</div>
</Row>
)
})}
</div>
)}
</div>
</div>
)
}
// ── Tab content ───────────────────────────────────────────────────────────────
function QuickTab({ product, quickState, setQuickState }) {
const quickOptions = product.quick_options || []
if (quickOptions.length === 0) return <p style={{ color: '#94a3b8', textAlign: 'center', padding: '24px 0', fontSize: 14 }}>Δεν υπάρχουν γρήγορες επιλογές.</p>
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{quickOptions.map(opt => (
<div key={opt.id} style={{ width: opt.is_compact ? 'calc(50% - 4px)' : '100%', minWidth: 0 }}>
<QuickOptionRow opt={opt} quickState={quickState} setQuickState={setQuickState} />
</div>
))}
</div>
)
}
function ExtrasTab({ product, extrasState, setExtrasState, expandedExtra, setExpandedExtra }) {
const options = product.options || []
if (options.length === 0) return <p style={{ color: '#94a3b8', textAlign: 'center', padding: '24px 0', fontSize: 14 }}>Δεν υπάρχουν extras.</p>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{options.map(opt => (
<ExtraOptionRow key={opt.id} opt={opt} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />
))}
</div>
)
}
function IngredientsTab({ product, removedState, setRemovedState }) {
const ingredients = product.ingredients || []
if (ingredients.length === 0) return <p style={{ color: '#94a3b8', textAlign: 'center', padding: '24px 0', fontSize: 14 }}>Δεν υπάρχουν υλικά.</p>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ padding: '8px 12px', background: '#f1f5f9', borderRadius: 8, fontSize: 12, color: '#64748b', marginBottom: 2 }}>
Κάντε κλικ για να αφαιρέσετε υλικό.
</div>
{ingredients.map(ing => (
<IngredientRow key={ing.id} ing={ing} removedState={removedState} setRemovedState={setRemovedState} />
))}
</div>
)
}
function PrefsTab({ product, prefs, setPrefs, subChoices, setSubChoices, sharedSubs, setSharedSubs }) {
const preferenceSets = product.preference_sets || []
if (preferenceSets.length === 0) return <p style={{ color: '#94a3b8', textAlign: 'center', padding: '24px 0', fontSize: 14 }}>Δεν υπάρχουν προτιμήσεις.</p>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{preferenceSets.map(ps => (
<PrefSetBlock key={ps.id} ps={ps}
prefs={prefs} setPrefs={setPrefs}
subChoices={subChoices} setSubChoices={setSubChoices}
sharedSubs={sharedSubs} setSharedSubs={setSharedSubs}
/>
))}
</div>
)
}
function QuantityTab({ unitCfg, qty, setQty }) {
const [editing, setEditing] = useState(false)
const [qtyInput, setQtyInput] = useState('')
const inputRef = useRef(null)
function clamp(v) { return Math.max(unitCfg.min, Math.round(v * 1000) / 1000) }
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', height: 48, borderRadius: 24, background: '#f1f5f9', border: '1px solid #e2e8f0', overflow: 'hidden' }}>
<button onClick={() => setQty(q => clamp(q - unitCfg.step))}
style={{ width: 48, height: 48, border: 'none', background: 'transparent', fontSize: 20, fontWeight: 500, cursor: 'pointer', color: '#1e293b' }}></button>
{editing ? (
<input ref={inputRef} type="number" inputMode="decimal" value={qtyInput}
onChange={e => setQtyInput(e.target.value)}
onBlur={() => { const v = parseFloat(qtyInput); if (!isNaN(v)) setQty(clamp(v)); setEditing(false) }}
onKeyDown={e => { if (e.key === 'Enter') e.target.blur() }}
autoFocus
style={{ width: 70, height: 48, border: 'none', background: 'transparent', textAlign: 'center', fontSize: 16, fontWeight: 700, color: '#1e293b', outline: 'none', fontFamily: 'inherit' }}
/>
) : (
<div onClick={() => { setQtyInput(String(qty)); setEditing(true) }}
style={{ minWidth: 70, textAlign: 'center', fontSize: 16, fontWeight: 700, color: '#1e293b', cursor: 'text', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
{fmtDecimalQty(qty, unitCfg)}
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.65 }}>{unitCfg.label}</span>
</div>
)}
<button onClick={() => setQty(q => clamp(q + unitCfg.step))}
style={{ width: 48, height: 48, border: 'none', background: 'transparent', fontSize: 20, fontWeight: 500, cursor: 'pointer', color: '#1e293b' }}>+</button>
</div>
</div>
<div>
<p style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', letterSpacing: 0.8, marginBottom: 6, textAlign: 'center' }}>ΓΡΗΓΟΡΗ ΕΠΙΛΟΓΗ</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
{unitCfg.presets.map(v => (
<button key={v} onClick={() => setQty(v)} style={{
padding: '8px 4px', borderRadius: 10, border: 'none',
background: qty === v ? '#f59e0b' : '#f1f5f9',
color: qty === v ? '#fff' : '#1e293b',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}>
{fmtDecimalQty(v, unitCfg)}{unitCfg.label}
</button>
))}
</div>
</div>
<div>
<p style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', letterSpacing: 0.8, marginBottom: 6, textAlign: 'center' }}>ΓΡΗΓΟΡΗ ΠΡΟΣΘΗΚΗ</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 6 }}>
{unitCfg.offsets.map(off => (
<button key={`+${off}`} onClick={() => setQty(q => clamp(q + off))} style={{
padding: '8px 4px', borderRadius: 10, border: 'none',
background: 'rgba(34,197,94,0.1)', color: '#16a34a',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}>+{fmtDecimalQty(off, unitCfg)}{unitCfg.label}</button>
))}
{unitCfg.offsets.map(off => (
<button key={`-${off}`} onClick={() => setQty(q => clamp(q - off))} style={{
padding: '8px 4px', borderRadius: 10, border: 'none',
background: 'rgba(239,68,68,0.08)', color: '#dc2626',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}>{fmtDecimalQty(off, unitCfg)}{unitCfg.label}</button>
))}
</div>
</div>
</div>
)
}
function PriceAdjustTab({ basePrice, priceAdj, setPriceAdj }) {
const [editing, setEditing] = useState(false)
const [rawInput, setRawInput] = useState('')
const inputRef = useRef(null)
const effectivePrice = basePrice + priceAdj
function applyDelta(d) {
setPriceAdj(a => {
const next = Math.round((a + d) * 100) / 100
return Math.max(-(basePrice - 0.01), next)
})
}
function commitInput() {
const v = parseFloat(rawInput.replace(',', '.'))
if (!isNaN(v) && v >= 0.01) setPriceAdj(Math.round((v - basePrice) * 100) / 100)
setEditing(false)
}
useEffect(() => { if (editing && inputRef.current) inputRef.current.focus() }, [editing])
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ padding: '8px 12px', background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.2)', borderRadius: 8, fontSize: 12, color: '#d97706' }}>
Αλλαγή τιμής μόνο για αυτό το αντικείμενο.
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10 }}>
<button onClick={() => applyDelta(-0.1)} style={{ width: 40, height: 40, borderRadius: '50%', border: '1px solid #e2e8f0', background: '#f1f5f9', fontSize: 18, fontWeight: 700, color: '#1e293b', cursor: 'pointer' }}></button>
{editing ? (
<input ref={inputRef} type="number" inputMode="decimal" value={rawInput}
onChange={e => setRawInput(e.target.value)}
onBlur={commitInput}
onKeyDown={e => { if (e.key === 'Enter') e.target.blur() }}
style={{ width: 100, height: 48, border: '2px solid #f59e0b', borderRadius: 12, background: 'white', textAlign: 'center', fontSize: 20, fontWeight: 800, color: '#f59e0b', outline: 'none', fontFamily: 'inherit' }}
/>
) : (
<button onClick={() => { setRawInput(effectivePrice.toFixed(2)); setEditing(true) }} style={{
minWidth: 100, height: 48, borderRadius: 12,
border: `2px solid ${priceAdj !== 0 ? '#f59e0b' : '#e2e8f0'}`,
background: priceAdj !== 0 ? 'rgba(245,158,11,0.08)' : '#f8fafc',
color: priceAdj !== 0 ? '#f59e0b' : '#1e293b',
fontSize: 20, fontWeight: 800, cursor: 'pointer', fontFamily: 'inherit',
}}>
{effectivePrice.toFixed(2)}
</button>
)}
<button onClick={() => applyDelta(+0.1)} style={{ width: 40, height: 40, borderRadius: '50%', border: '1px solid #e2e8f0', background: '#f1f5f9', fontSize: 18, fontWeight: 700, color: '#1e293b', cursor: 'pointer' }}>+</button>
</div>
{priceAdj !== 0 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: '#64748b' }}>Βάση: {basePrice.toFixed(2)} </span>
<span style={{ fontSize: 12, fontWeight: 700, color: priceAdj > 0 ? '#16a34a' : '#dc2626' }}>{priceAdj > 0 ? '+' : ''}{priceAdj.toFixed(2)} </span>
<button onClick={() => setPriceAdj(0)} style={{ padding: '2px 8px', borderRadius: 6, border: '1px solid #e2e8f0', background: '#f1f5f9', color: '#64748b', fontSize: 11, cursor: 'pointer' }}>Επαναφορά</button>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
{PRICE_DELTAS.map(d => (
<button key={d} onClick={() => applyDelta(d)} style={{
padding: '9px 4px', borderRadius: 10, border: 'none',
background: d < 0 ? 'rgba(239,68,68,0.08)' : 'rgba(34,197,94,0.08)',
color: d < 0 ? '#dc2626' : '#16a34a',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}>
{d > 0 ? '+' : ''}{d.toFixed(2 - (Number.isInteger(d) ? 2 : 0))}
</button>
))}
</div>
</div>
)
}
function NotesTab({ note, setNote }) {
return (
<div>
<textarea
value={note}
onChange={e => setNote(e.target.value)}
placeholder="π.χ. Χωρίς αλάτι, κόψτε στη μέση..."
rows={4}
style={{ width: '100%', padding: 12, fontSize: 14, fontFamily: 'inherit', color: '#1e293b', background: 'white', border: '1px solid #e2e8f0', borderRadius: 10, resize: 'none', outline: 'none', boxSizing: 'border-box' }}
/>
<div style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: 0.6, marginTop: 14, marginBottom: 6 }}>Γρήγορες σημειώσεις</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{QUICK_NOTES.map(q => (
<button key={q} onClick={() => setNote(n => n ? `${n}\n${q}` : q)}
style={{ height: 32, padding: '0 12px', borderRadius: 16, background: '#f1f5f9', border: '1px solid #e2e8f0', color: '#475569', fontSize: 12, fontWeight: 500, cursor: 'pointer' }}>
+ {q}
</button>
))}
</div>
</div>
)
}
// ── Courses tab ───────────────────────────────────────────────────────────────
function CoursesTab({ courses, courseId, setCourseId }) {
return (
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
onClick={() => setCourseId(null)}
style={{
padding: '10px 14px', borderRadius: 10, textAlign: 'left',
background: courseId === null ? '#2563eb' : '#f8fafc',
color: courseId === null ? '#fff' : '#1e293b',
border: `2px solid ${courseId === null ? '#2563eb' : '#e2e8f0'}`,
fontWeight: 600, fontSize: 14, cursor: 'pointer', fontFamily: 'inherit',
}}
>
No course (fire immediately)
</button>
{courses.map(c => (
<button
key={c.id}
onClick={() => setCourseId(courseId === c.id ? null : c.id)}
style={{
padding: '10px 14px', borderRadius: 10, textAlign: 'left',
background: courseId === c.id ? c.color : '#f8fafc',
color: courseId === c.id ? '#fff' : '#1e293b',
border: `2px solid ${courseId === c.id ? c.color : '#e2e8f0'}`,
fontWeight: 600, fontSize: 14, cursor: 'pointer', fontFamily: 'inherit',
display: 'flex', alignItems: 'center', gap: 10,
}}
>
<span style={{
width: 12, height: 12, borderRadius: '50%',
background: courseId === c.id ? 'rgba(255,255,255,0.5)' : c.color,
flexShrink: 0,
}} />
{c.name}
</button>
))}
</div>
)
}
// ── Main component ────────────────────────────────────────────────────────────
export default function ManagerOrderDrawer({ product, onClose, onAdd, initialState, courses }) {
const preferenceSets = product?.preference_sets || []
const quickOptions = product?.quick_options || []
const options = product?.options || []
const ingredients = product?.ingredients || []
const unitCfg = product ? (DECIMAL_UNIT_CONFIG[product.unit_type] ?? null) : null
const isDecimal = !!unitCfg
const hasTabs = {
quick: quickOptions.length > 0,
extras: options.length > 0,
ingredients: ingredients.length > 0,
prefs: preferenceSets.length > 0,
quantity: isDecimal,
}
const firstTab = hasTabs.quick ? 'quick'
: hasTabs.extras ? 'extras'
: hasTabs.ingredients ? 'ingredients'
: hasTabs.prefs ? 'prefs'
: hasTabs.quantity ? 'quantity'
: 'notes'
const [activeTab, setActiveTab] = useState(firstTab)
const [qty, setQty] = useState(isDecimal ? (unitCfg?.presets[0] ?? 0.1) : 1)
const [quickState, setQuickState] = useState({})
const [extrasState, setExtrasState] = useState({})
const [expandedExtra, setExpandedExtra] = useState(null)
const [removedState, setRemovedState] = useState({})
const [prefs, setPrefs] = useState({})
const [subChoices, setSubChoices] = useState({})
const [sharedSubs, setSharedSubs] = useState({})
const [note, setNote] = useState('')
const [priceAdj, setPriceAdj] = useState(0)
const [addAttempted, setAddAttempted] = useState(false)
const [courseId, setCourseId] = useState(initialState?.courseId ?? null)
// Reset when product changes
useEffect(() => {
if (!product) return
const base = buildInitialState(product)
if (initialState) {
setQty(initialState.qty ?? (isDecimal ? (unitCfg?.presets[0] ?? 0.1) : 1))
setQuickState(initialState.quickState ?? {})
setExtrasState(initialState.extrasState ?? {})
setRemovedState(initialState.removedState ?? {})
setPrefs(initialState.prefs ?? base.prefs)
setSubChoices(initialState.subChoices ?? base.subChoices)
setSharedSubs(initialState.sharedSubs ?? base.sharedSubs)
setNote(initialState.note ?? '')
setPriceAdj(initialState.priceAdj ?? 0)
setCourseId(initialState.courseId ?? null)
setActiveTab(initialState.activeTab ?? firstTab)
} else {
setQty(isDecimal ? (unitCfg?.presets[0] ?? 0.1) : 1)
setQuickState({})
setExtrasState({})
setRemovedState({})
setPrefs(base.prefs)
setSubChoices(base.subChoices)
setSharedSubs(base.sharedSubs)
setNote('')
setPriceAdj(0)
setCourseId(null)
setActiveTab(firstTab)
}
setExpandedExtra(null)
setAddAttempted(false)
}, [product?.id])
// Derived total price
const totalPrice = (() => {
if (!product) return 0
let price = product.base_price
preferenceSets.forEach(ps => {
const choice = prefs[ps.id]
if (!choice) return
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
const sharedSub = (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) ? (sharedSubs[ps.id] ?? null) : null
price += (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
})
quickOptions.forEach(opt => { price += (quickState[opt.id] || 0) * (opt.price ?? 0) })
options.forEach(opt => {
const sel = extrasState[opt.id]
if (!sel) return
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
price += ((opt.extra_cost ?? 0) + (sub?.extra_cost ?? 0)) * sel.qty
})
return (price + priceAdj) * qty
})()
function isPrefComplete(ps) {
const choice = prefs[ps.id]
if (!choice) return false
if (choice.sub_choices?.length > 0 && subChoices[choice.id] == null) return false
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset && sharedSubs[ps.id] == null) return false
return true
}
const allPrefsOk = preferenceSets.every(isPrefComplete)
const extrasSubsMissing = options.some(opt => {
const sel = extrasState[opt.id]
return sel && opt.sub_choices?.length > 0 && sel.subName == null
})
const canAdd = allPrefsOk && !extrasSubsMissing
function handleAdd() {
if (!canAdd) {
setAddAttempted(true)
if (!allPrefsOk && hasTabs.prefs) setActiveTab('prefs')
return
}
const prefChoices = preferenceSets.flatMap(ps => {
const choice = prefs[ps.id]
if (!choice) return []
const inlineSub = choice.sub_choices?.length > 0 ? (subChoices[choice.id] ?? null) : null
const sharedSub = ps.shared_subset?.choices?.length > 0 && !choice.disables_subset ? (sharedSubs[ps.id] ?? null) : null
const defaultChoice = ps.default_choice_id != null ? ps.choices.find(c => c.id === ps.default_choice_id) : null
const isDefaultChoice = defaultChoice && choice.id === defaultChoice.id
const defaultInlineSub = isDefaultChoice && defaultChoice.sub_choices?.length > 0
? (defaultChoice.sub_choices.find(s => s.is_default) ?? defaultChoice.sub_choices[0]) : null
const defaultSharedSub = isDefaultChoice && ps.shared_subset?.choices?.length > 0 && !choice.disables_subset
? (ps.shared_subset.choices.find(s => s.is_default) ?? ps.shared_subset.choices[0]) : null
const isFullyDefault = isDefaultChoice
&& (!inlineSub || inlineSub.name === defaultInlineSub?.name)
&& (!sharedSub || sharedSub.name === defaultSharedSub?.name)
if (isFullyDefault) return []
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0, type: 'pref' }]
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0, type: 'pref_sub' })
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0, type: 'pref_sub' })
return entries
})
const optionEntries = options.flatMap(opt => {
const sel = extrasState[opt.id]
if (!sel) return []
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
const entries = []
for (let i = 0; i < sel.qty; i++) {
entries.push({ id: opt.id, name: opt.name, price_delta: opt.extra_cost ?? 0, type: 'extra' })
if (sub) entries.push({ id: null, name: sub.name, price_delta: sub.extra_cost ?? 0, type: 'extra_sub' })
}
return entries
})
const quickEntries = quickOptions.flatMap(opt => {
const q = quickState[opt.id] || 0
if (q === 0) return []
return Array.from({ length: q }, () => ({ id: null, name: opt.name, price_delta: opt.price ?? 0, type: 'quick' }))
})
const removedNames = ingredients.filter(ing => removedState[ing.id]).map(ing => ing.name)
onAdd({
product_id: product.id,
quantity: qty,
unit_price: product.base_price,
selected_options: [...prefChoices, ...quickEntries, ...optionEntries],
removed_ingredients: removedNames,
notes: note,
price_adjustment: priceAdj !== 0 ? priceAdj : undefined,
course_id: courseId ?? undefined,
_drawerState: { qty, quickState, extrasState, removedState, prefs, subChoices, sharedSubs, note, priceAdj, courseId },
})
}
const tabs = [
hasTabs.quick && { id: 'quick', label: 'Quick' },
hasTabs.extras && { id: 'extras', label: 'Extras' },
hasTabs.ingredients && { id: 'ingredients', label: 'Υλικά' },
hasTabs.prefs && { id: 'prefs', label: 'Προτιμήσεις' },
hasTabs.quantity && { id: 'quantity', label: `Ποσότητα (${unitCfg?.label})` },
(courses?.length > 0) && { id: 'courses', label: 'Courses' },
{ id: 'notes', label: 'Σημείωση' },
{ id: 'price_adjust', label: '€ Τιμή' },
].filter(Boolean)
if (!product) return null
const initials = product.name.trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
return (
<div style={{
display: 'flex', flexDirection: 'column', height: '100%',
background: 'white', borderLeft: '1px solid #e2e8f0',
}}>
{/* Header */}
<div style={{
padding: '16px 16px 12px',
borderBottom: '1px solid #e2e8f0',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{
width: 52, height: 52, borderRadius: 12, flexShrink: 0,
background: '#f1f5f9', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{product.image_url
? <img src={product.image_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <span style={{ fontSize: 18, fontWeight: 700, color: '#94a3b8' }}>{initials}</span>
}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#1e293b', lineHeight: 1.2, marginBottom: 3 }}>{product.name}</div>
<div style={{ fontSize: 13, color: priceAdj !== 0 ? '#f59e0b' : '#64748b' }}>
{priceAdj !== 0
? <><span style={{ textDecoration: 'line-through', opacity: 0.5, marginRight: 4 }}>{product.base_price.toFixed(2)}</span> {(product.base_price + priceAdj).toFixed(2)} </>
: <>{product.base_price.toFixed(2)} </>
}
</div>
</div>
<button onClick={onClose} style={{
width: 32, height: 32, borderRadius: '50%', border: '1px solid #e2e8f0',
background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', flexShrink: 0, color: '#64748b',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
</button>
</div>
{/* Tabs bar */}
{tabs.length > 0 && (
<div style={{ display: 'flex', gap: 2, overflowX: 'auto', scrollbarWidth: 'none' }}>
{tabs.map(t => {
const active = activeTab === t.id
const isAlert = t.id === 'prefs' && !allPrefsOk && addAttempted
return (
<button key={t.id} onClick={() => setActiveTab(t.id)} style={{
padding: '6px 10px',
background: 'none', border: 'none',
borderBottom: `2px solid ${active ? '#f59e0b' : 'transparent'}`,
color: isAlert ? '#ef4444' : active ? '#f59e0b' : '#64748b',
fontSize: 12, fontWeight: active ? 700 : 500,
fontFamily: 'inherit', cursor: 'pointer',
whiteSpace: 'nowrap',
transition: 'color 100ms, border-color 100ms',
}}>
{t.label}
</button>
)
})}
</div>
)}
</div>
{/* Scrollable content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 14px 10px' }}>
{activeTab === 'quick' && <QuickTab product={product} quickState={quickState} setQuickState={setQuickState} />}
{activeTab === 'extras' && <ExtrasTab product={product} extrasState={extrasState} setExtrasState={setExtrasState} expandedExtra={expandedExtra} setExpandedExtra={setExpandedExtra} />}
{activeTab === 'ingredients' && <IngredientsTab product={product} removedState={removedState} setRemovedState={setRemovedState} />}
{activeTab === 'prefs' && <PrefsTab product={product} prefs={prefs} setPrefs={setPrefs} subChoices={subChoices} setSubChoices={setSubChoices} sharedSubs={sharedSubs} setSharedSubs={setSharedSubs} />}
{activeTab === 'quantity' && isDecimal && <QuantityTab unitCfg={unitCfg} qty={qty} setQty={setQty} />}
{activeTab === 'courses' && courses?.length > 0 && <CoursesTab courses={courses} courseId={courseId} setCourseId={setCourseId} />}
{activeTab === 'notes' && <NotesTab note={note} setNote={setNote} />}
{activeTab === 'price_adjust' && <PriceAdjustTab basePrice={product.base_price} priceAdj={priceAdj} setPriceAdj={setPriceAdj} />}
</div>
{/* Footer: qty stepper + ADD button */}
<div style={{
padding: '12px 14px',
borderTop: '1px solid #e2e8f0',
background: '#f8fafc',
display: 'flex', alignItems: 'center', gap: 10, flexShrink: 0,
}}>
{isDecimal ? (
<div onClick={() => setActiveTab('quantity')}
style={{ display: 'inline-flex', alignItems: 'center', height: 44, borderRadius: 22, background: '#f1f5f9', border: '1px solid #e2e8f0', overflow: 'hidden', flexShrink: 0, cursor: 'pointer', padding: '0 14px', gap: 4 }}>
<span style={{ fontSize: 16, fontWeight: 700, color: '#1e293b' }}>{fmtDecimalQty(qty, unitCfg)}</span>
<span style={{ fontSize: 12, color: '#64748b' }}>{unitCfg.label}</span>
</div>
) : (
<div style={{ display: 'inline-flex', alignItems: 'center', height: 44, borderRadius: 22, background: '#f1f5f9', border: '1px solid #e2e8f0', overflow: 'hidden', flexShrink: 0 }}>
<button onClick={() => setQty(q => Math.max(1, q - 1))} style={{ width: 44, height: 44, border: 'none', background: 'transparent', fontSize: 20, fontWeight: 500, cursor: qty <= 1 ? 'default' : 'pointer', color: qty <= 1 ? '#94a3b8' : '#1e293b' }}></button>
<div style={{ minWidth: 28, textAlign: 'center', fontSize: 16, fontWeight: 700, color: '#1e293b' }}>{qty}</div>
<button onClick={() => setQty(q => q + 1)} style={{ width: 44, height: 44, border: 'none', background: 'transparent', fontSize: 20, fontWeight: 500, cursor: 'pointer', color: '#1e293b' }}>+</button>
</div>
)}
<button onClick={handleAdd} style={{
flex: 1, height: 44, borderRadius: 22,
background: canAdd ? '#f59e0b' : '#e2e8f0',
border: 'none', color: canAdd ? '#fff' : '#94a3b8',
fontSize: 14, fontWeight: 700, fontFamily: 'inherit',
cursor: canAdd ? 'pointer' : 'not-allowed',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 18px',
transition: 'background 150ms ease',
}}>
<span>ΠΡΟΣΘΗΚΗ</span>
<span>{totalPrice.toFixed(2)} </span>
</button>
</div>
</div>
)
}

View File

@@ -446,7 +446,6 @@ export default function NotesPage() {
display: 'flex', alignItems: 'baseline', gap: 16, flexShrink: 0,
background: 'white',
}}>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Σημειώσεις & Εργασίες</h1>
<span style={{ fontSize: 13, color: '#9ca3af' }}>
{notes.length} σημειώσεις · {todos.filter(t => !t.is_done).length} εκκρεμείς εργασίες
</span>

View File

@@ -19,7 +19,7 @@ function timeAgo(iso) {
}
function calcTotals(items = []) {
return items.reduce((sum, it) => sum + (it.unit_price ?? 0) * (it.quantity ?? 1), 0)
return items.reduce((sum, it) => sum + ((it.unit_price ?? 0) + (it.price_adjustment ?? 0)) * (it.quantity ?? 1), 0)
}
const STATUS_LABELS = {
@@ -106,7 +106,7 @@ function OrderCard({ order, onAction }) {
{order.items.map((it, i) => (
<div key={i} className="flex justify-between">
<span>{it.quantity} × {it.product?.name ?? `#${it.product_id}`}</span>
<span className="text-gray-500">{(it.unit_price * it.quantity).toFixed(2)}</span>
<span className="text-gray-500">{(((it.unit_price ?? 0) + (it.price_adjustment ?? 0)) * it.quantity).toFixed(2)}</span>
</div>
))}
</div>
@@ -310,25 +310,8 @@ export default function OnlineOrdersPage() {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Page header */}
<div className="px-6 py-5 border-b border-gray-100 shrink-0 flex items-center gap-3">
<h1 className="text-xl font-bold text-gray-800">Online Παραγγελίες</h1>
{pendingCount > 0 && (
<span className="bg-red-500 text-white text-xs font-bold px-2 py-0.5 rounded-full">
{pendingCount}
</span>
)}
<button
onClick={fetchAll}
disabled={loading}
className="ml-auto btn btn-secondary text-sm px-3 min-h-0 h-8"
>
{loading ? '…' : '↻ Ανανέωση'}
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200 shrink-0 bg-white px-4">
{/* Tabs + refresh on same row */}
<div className="flex-shrink-0 flex items-stretch border-b border-slate-200 px-6 pt-2">
{TABS.map(tab => {
const count = tab.key === 'incoming' ? incoming.length
: tab.key === 'active' ? active.length
@@ -338,25 +321,34 @@ export default function OnlineOrdersPage() {
<button
key={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'
className={`relative flex items-center gap-2 px-4 py-3 text-[13.5px] font-medium transition-colors whitespace-nowrap ${
isActive ? 'text-slate-900' : 'text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
{count > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full font-mono ${
<span className={`text-[11px] px-1.5 py-0.5 rounded-full font-mono font-semibold ${
tab.key === 'incoming'
? 'bg-red-100 text-red-700'
: isActive ? 'bg-primary-100 text-primary-700' : 'bg-gray-100 text-gray-500'
: isActive ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-500'
}`}>
{count}
</span>
)}
{isActive && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-sky-500" />}
</button>
)
})}
<div className="flex-1" />
<div className="flex items-center pb-2">
<button
onClick={fetchAll}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium text-slate-500 hover:text-slate-700 border border-slate-200 rounded-lg bg-white transition-colors disabled:opacity-50"
>
{loading ? '…' : '↻ Ανανέωση'}
</button>
</div>
</div>
{/* Content */}

View File

@@ -3,9 +3,11 @@ import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
import { Percent } from 'lucide-react'
import Badge from '../ui/Badge'
import { ConfirmModal } from '../ui/Modal'
import PaymentMethodModal from '../ui/PaymentMethodModal'
import DesktopOrderingTab from './DesktopOrderingTab'
function PrintOrderModal({ onClose, onPrint, printers }) {
const [printerId, setPrinterId] = useState(printers[0]?.id ?? '')
@@ -37,8 +39,9 @@ function PrintOrderModal({ onClose, onPrint, printers }) {
)
}
function effPrice(item) { return (item.unit_price ?? 0) + (item.price_adjustment ?? 0) }
function itemTotal(item) {
return (item.unit_price * item.quantity).toFixed(2)
return (effPrice(item) * item.quantity).toFixed(2)
}
const PAYMENT_METHOD_STYLES = {
@@ -147,6 +150,98 @@ function AuditTab({ order, waiterMap }) {
)
}
// ─── Price Events Tab ────────────────────────────────────────────────────────
const PRICE_EVENT_LABELS = {
modifier_applied: 'Τροποποιητής',
deal_triggered: 'Προσφορά',
deal_offer_shown: 'Προσφορά (εμφανίστηκε)',
deal_offer_accepted: 'Προσφορά (αποδεκτή)',
deal_offer_dismissed:'Προσφορά (απορρίφθηκε)',
waiter_discount: 'Έκπτωση σερβιτόρου',
free_item_added: 'Δωρεάν αντικείμενο',
}
const PRICE_EVENT_COLORS = {
modifier_applied: { bg: '#eff6ff', color: '#1d4ed8', border: '#93c5fd' },
deal_triggered: { bg: '#f0fdf4', color: '#15803d', border: '#86efac' },
deal_offer_shown: { bg: '#fefce8', color: '#a16207', border: '#fde68a' },
deal_offer_accepted: { bg: '#f0fdf4', color: '#15803d', border: '#86efac' },
deal_offer_dismissed:{ bg: '#fef2f2', color: '#b91c1c', border: '#fca5a5' },
waiter_discount: { bg: '#fdf4ff', color: '#7e22ce', border: '#d8b4fe' },
free_item_added: { bg: '#f0fdf4', color: '#15803d', border: '#86efac' },
}
function PriceEventsTab({ orderId }) {
const { data: events = [], isLoading } = useQuery({
queryKey: ['price-events', orderId],
queryFn: () => client.get(`/api/pricing/events/order/${orderId}`).then(r => r.data),
})
if (isLoading) return (
<div className="flex justify-center py-16">
<div className="w-5 h-5 rounded-full border-2 border-sky-500 border-t-transparent animate-spin" />
</div>
)
if (events.length === 0) return (
<div className="py-16 text-center text-gray-400">
<Percent className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p className="text-sm">Δεν υπάρχουν συμβάντα τιμολόγησης για αυτή την παραγγελία.</p>
</div>
)
return (
<div className="divide-y divide-gray-100">
{events.map(ev => {
const label = PRICE_EVENT_LABELS[ev.event_type] ?? ev.event_type
const style = PRICE_EVENT_COLORS[ev.event_type] ?? { bg: '#f8fafc', color: '#334155', border: '#cbd5e1' }
const hasDelta = ev.price_before != null && ev.price_after != null
return (
<div key={ev.id} className="flex items-start gap-3 px-4 py-3">
<div className="shrink-0 mt-0.5">
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: style.bg, color: style.color, border: `1px solid ${style.border}` }}>
{label}
</span>
</div>
<div className="flex-1 min-w-0 text-sm">
<div className="flex flex-wrap gap-x-3 gap-y-0.5 items-baseline">
{ev.modifier_name && <span className="font-medium text-gray-800">{ev.modifier_name}</span>}
{ev.deal_name && <span className="font-medium text-gray-800">{ev.deal_name}</span>}
{ev.applied_by_username && (
<span className="text-gray-500 text-xs">από {ev.applied_by_username}</span>
)}
{hasDelta && (
<span className={`text-xs font-semibold ${ev.delta_amount < 0 ? 'text-green-700' : 'text-red-600'}`}>
{ev.delta_amount > 0 ? '+' : ''}{ev.delta_amount?.toFixed(2)}
<span className="font-normal text-gray-400 ml-1">
({ev.price_before?.toFixed(2)} {ev.price_after?.toFixed(2)})
</span>
</span>
)}
</div>
{ev.waiter_note && (
<p className="text-xs text-gray-400 mt-0.5 italic">"{ev.waiter_note}"</p>
)}
{ev.conditions_snapshot && Object.keys(ev.conditions_snapshot).length > 0 && (
<details className="mt-1">
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-600">Συνθήκες</summary>
<pre className="text-[10px] text-gray-500 bg-gray-50 rounded p-2 mt-1 overflow-x-auto">
{JSON.stringify(ev.conditions_snapshot, null, 2)}
</pre>
</details>
)}
</div>
<div className="shrink-0 text-right">
<span className="text-xs text-gray-400">{formatDate(ev.applied_at)}</span>
</div>
</div>
)
})}
</div>
)
}
export default function OrderDetailPage({ orderId: propOrderId, readOnly = false }) {
const { orderId: paramOrderId } = useParams()
const orderId = propOrderId ?? paramOrderId
@@ -178,6 +273,18 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
staleTime: 60_000,
})
const { data: rawPriceEvents = [] } = useQuery({
queryKey: ['price-events', orderId],
queryFn: () => client.get(`/api/pricing/events/order/${orderId}`).then(r => r.data),
enabled: !!orderId,
staleTime: 30_000,
})
const priceEventsMap = rawPriceEvents.reduce((acc, ev) => {
if (!acc[ev.order_item_id]) acc[ev.order_item_id] = []
acc[ev.order_item_id].push(ev)
return acc
}, {})
const printOrder = useMutation({
mutationFn: (printerId) => client.post(`/api/orders/${orderId}/print`, { printer_id: printerId }),
onSuccess: () => toast.success('Αποστολή στον εκτυπωτή…'),
@@ -272,10 +379,52 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
const activeItems = order.items.filter(i => i.status === 'active')
const total = order.items
.filter(i => i.status !== 'cancelled')
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
.reduce((s, i) => s + effPrice(i) * i.quantity, 0)
const isOpen = ['open', 'partially_paid'].includes(order.status)
// The ordering tab needs full height and no outer padding
if (tab === 'order') {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Compact header strip */}
<div style={{ padding: '10px 20px', borderBottom: '1px solid #e2e8f0', display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
{!propOrderId && (
<button onClick={() => navigate(-1)} className="btn btn-ghost text-sm"> Πίσω</button>
)}
<div style={{ fontSize: 14, color: '#64748b' }}>
Τραπέζι {order.table_name || order.table_id}
</div>
<Badge status={order.status} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#1e293b' }}>{total.toFixed(2)}</span>
<div style={{ flex: 1 }} />
{/* Tab strip inline */}
<div style={{ display: 'flex', gap: 1, borderBottom: 'none' }}>
{[['overview', 'Επισκόπηση'], ['order', 'Παραγγελία'], ['pricing', 'Τιμολόγηση'], ['audit', 'Ιστορικό']].map(([key, label]) => (
<button key={key} onClick={() => setTab(key)}
style={{
padding: '6px 14px', fontSize: 13, fontWeight: tab === key ? 700 : 500,
background: 'none', border: 'none', cursor: 'pointer',
borderBottom: `2px solid ${tab === key ? '#4f46e5' : 'transparent'}`,
color: tab === key ? '#4f46e5' : '#64748b',
}}>
{label}
</button>
))}
</div>
</div>
{/* Full-height ordering terminal */}
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<DesktopOrderingTab
orderId={orderId}
tableId={order.table_id}
orderStatus={order.status}
/>
</div>
</div>
)
}
return (
<div className="overflow-y-auto h-full p-6">
<div className="max-w-3xl mx-auto space-y-6">
@@ -286,8 +435,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
{/* Header */}
<div className="card p-5 flex flex-wrap gap-4 items-start justify-between">
<div>
<h1 className="text-xl font-bold text-gray-800">Παραγγελία #{order.id}</h1>
<p className="text-sm text-gray-500 mt-1">
<p className="text-sm text-gray-500">
Τραπέζι {order.table_name || order.table_id} · Ανοίχτηκε {formatDate(order.opened_at)}
{order.closed_at && ` · Έκλεισε ${formatDate(order.closed_at)}`}
</p>
@@ -367,7 +515,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
{/* Tabs */}
<div className="flex gap-1 border-b border-gray-200">
{[['overview', 'Επισκόπηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
{[['overview', 'Επισκόπηση'], ['order', 'Παραγγελία'], ['pricing', 'Τιμολόγηση'], ['audit', 'Ιστορικό Συναλλαγών']].map(([key, label]) => (
<button
key={key}
onClick={() => setTab(key)}
@@ -423,12 +571,45 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
const isCancelled = item.status === 'cancelled'
const isClosedItem = item.status === 'closed'
const badgeStatus = isClosedItem ? 'closed_item' : item.status
const hasPriceAdj = !!(item.price_adjustment && item.price_adjustment !== 0)
const isDealItem = !!item.deal_id
const itemPriceEvents = (priceEventsMap[item.id] ?? []).filter(ev => ['modifier_applied','waiter_discount','free_item_added'].includes(ev.event_type))
const EVENT_COLORS_ODP = { modifier_applied: '#2563eb', waiter_discount: '#7c3aed', free_item_added: '#16a34a' }
return (
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 ${isCancelled ? 'opacity-40 line-through' : ''} ${isClosedItem ? 'bg-amber-50/50' : ''}`}>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-800 text-sm">{item.product?.name ?? `#${item.product_id}`}</p>
<p className="font-medium text-gray-800 text-sm" style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{item.product?.name ?? `#${item.product_id}`}
{item.course_id != null && (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 18, height: 18, borderRadius: '50%',
background: '#6366f1', color: '#fff',
fontSize: 10, fontWeight: 700,
}} title="Course">
{item.course_id}
</span>
)}
{isDealItem && <span style={{ fontSize: 10, fontWeight: 700, padding: '1px 5px', borderRadius: 4, background: 'rgba(167,139,250,0.12)', color: '#7c3aed', border: '1px solid rgba(167,139,250,0.3)' }}>ΔΩΡΕΑΝ</span>}
</p>
{item.notes && <p className="text-xs text-gray-400">{item.notes}</p>}
<p className="text-xs text-gray-500">x{item.quantity} · {item.unit_price.toFixed(2)}/τμχ</p>
<p className="text-xs" style={{ color: hasPriceAdj ? '#3b82f6' : '#6b7280' }}>
x{item.quantity} · {effPrice(item).toFixed(2)}/τμχ
{hasPriceAdj && (
<span className="ml-1 line-through text-gray-400">{(item.unit_price ?? 0).toFixed(2)}</span>
)}
</p>
{itemPriceEvents.map((ev, ei) => {
const evColor = EVENT_COLORS_ODP[ev.event_type] || '#8a9099'
const evLabel = ev.modifier_name || ev.deal_name || (ev.event_type === 'waiter_discount' ? 'Έκπτωση' : ev.event_type === 'free_item_added' ? 'Δωρεάν' : ev.event_type)
return (
<p key={ei} className="text-xs" style={{ color: evColor, display: 'flex', alignItems: 'center', gap: 3 }}>
<span style={{ opacity: 0.6 }}></span>
<span>{evLabel}</span>
{ev.delta_amount != null && <span style={{ fontWeight: 600 }}>{ev.delta_amount > 0 ? '+' : ''}{ev.delta_amount.toFixed(2)} </span>}
</p>
)
})}
{item.paid_by && (
<p className="text-xs text-green-600 mt-0.5">
Πληρώθηκε: {item.paid_by_name ?? waiterMap[item.paid_by] ?? `#${item.paid_by}`}
@@ -445,7 +626,7 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm font-semibold text-gray-700 w-14 text-right">{itemTotal(item)}</span>
<span className="text-sm font-semibold w-14 text-right" style={{ color: hasPriceAdj ? '#3b82f6' : '#374151' }}>{itemTotal(item)}</span>
{isOpen && !readOnly && item.status === 'active' && (
<>
<button
@@ -516,6 +697,12 @@ export default function OrderDetailPage({ orderId: propOrderId, readOnly = false
</div>
</>}
{tab === 'pricing' && (
<div className="card divide-y divide-gray-100">
<PriceEventsTab orderId={orderId} />
</div>
)}
{tab === 'audit' && (
<div className="card divide-y divide-gray-100">
<AuditTab order={order} waiterMap={waiterMap} />

View File

@@ -0,0 +1,96 @@
import { Phone, PhoneIncoming, ChevronRight, Wifi, WifiOff } from 'lucide-react'
import usePhoneStore from '../store/phoneStore'
function formatTime(date) {
return date.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' })
}
export default function PhonePage() {
const { log, setActiveCall } = usePhoneStore()
return (
<div style={{ padding: 32, fontFamily: "'DM Sans','Segoe UI',sans-serif", maxWidth: 680, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 28 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'linear-gradient(135deg,#064e3b,#065f46)',
border: '1px solid rgba(52,211,153,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Phone size={18} color="#34d399" />
</div>
<div>
<h1 style={{ fontSize: 16, fontWeight: 700, color: '#0f172a', margin: 0, letterSpacing: '-0.01em' }}>
Εισερχόμενες Κλήσεις
</h1>
<p style={{ fontSize: 12, color: '#94a3b8', margin: '2px 0 0' }}>Grandstream UCM · Caller ID</p>
</div>
</div>
<div style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden' }}>
<div style={{
padding: '12px 16px', borderBottom: '1px solid #f1f5f9',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
Πρόσφατες Κλήσεις
</span>
{log.length > 0 && (
<span style={{ fontSize: 11, color: '#cbd5e1' }}>{log.length} κλήσεις</span>
)}
</div>
{log.length === 0 ? (
<div style={{ padding: '48px 16px', textAlign: 'center', color: '#cbd5e1', fontSize: 13 }}>
Δεν έχουν ληφθεί κλήσεις ακόμα.
</div>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{log.map((entry, i) => (
<li
key={i}
onClick={() => setActiveCall(entry)}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 16px',
borderBottom: i < log.length - 1 ? '1px solid #f8fafc' : 'none',
cursor: 'pointer', transition: 'background 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.background = '#f8fafc' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<div style={{
width: 34, height: 34, borderRadius: 10, flexShrink: 0,
background: entry.customer ? '#fef3c7' : '#f1f5f9',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{entry.customer
? <span style={{ fontSize: 14, fontWeight: 700, color: '#d97706' }}>
{entry.customer.name?.[0]?.toUpperCase()}
</span>
: <PhoneIncoming size={14} color="#94a3b8" />
}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#1e293b' }}>
{entry.customer ? entry.customer.name : entry.caller || '—'}
</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 1 }}>
{entry.customer ? entry.caller : 'Άγνωστος'}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 11, color: '#cbd5e1', fontVariantNumeric: 'tabular-nums' }}>
{formatTime(entry.at)}
</span>
<ChevronRight size={12} color="#cbd5e1" />
</div>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@@ -238,7 +238,7 @@ function PayersModal({ order, waiterMap, onClose }) {
</div>
<div className="overflow-y-auto flex-1 px-6 py-4 space-y-5">
{paymentGroups.map((g, i) => {
const groupTotal = g.items.reduce((s, it) => s + it.unit_price * it.quantity, 0)
const groupTotal = g.items.reduce((s, it) => s + ((it.unit_price ?? 0) + (it.price_adjustment ?? 0)) * it.quantity, 0)
const waiterName = waiterMap[g.waiter_id] || `#${g.waiter_id}`
return (
<div key={i} className="space-y-1">
@@ -257,7 +257,7 @@ function PayersModal({ order, waiterMap, onClose }) {
{g.items.map(it => (
<li key={it.id} className="flex justify-between text-sm text-gray-600">
<span>{it.product?.name ?? `#${it.product_id}`}{it.quantity > 1 ? ` ×${it.quantity}` : ''}</span>
<span>{(it.unit_price * it.quantity).toFixed(2)}</span>
<span>{(((it.unit_price ?? 0) + (it.price_adjustment ?? 0)) * it.quantity).toFixed(2)}</span>
</li>
))}
</ul>
@@ -281,14 +281,15 @@ function OrderDetailsModal({ order, tableMap, waiterMap, onClose, printers, onPr
if (!order) return null
const activeItems = order.items.filter(i => i.status !== 'cancelled')
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const effPrice = i => (i.unit_price ?? 0) + (i.price_adjustment ?? 0)
const total = activeItems.reduce((s, i) => s + effPrice(i) * i.quantity, 0)
// Per-waiter subtotals for paid items
const waiterTotals = {}
for (const item of activeItems.filter(i => i.status === 'paid' && i.paid_by)) {
const wid = item.paid_by
if (!waiterTotals[wid]) waiterTotals[wid] = 0
waiterTotals[wid] += item.unit_price * item.quantity
waiterTotals[wid] += effPrice(item) * item.quantity
}
const waiterTotalEntries = Object.entries(waiterTotals)
@@ -327,8 +328,8 @@ function OrderDetailsModal({ order, tableMap, waiterMap, onClose, printers, onPr
<tr key={item.id} className={item.status === 'cancelled' ? 'opacity-40 line-through' : ''}>
<td className="px-4 py-2.5 text-gray-800">{item.product?.name ?? `#${item.product_id}`}</td>
<td className="px-4 py-2.5 text-center text-gray-700">{item.quantity}</td>
<td className="px-4 py-2.5 text-right text-gray-700">{item.unit_price.toFixed(2)}</td>
<td className="px-4 py-2.5 text-right font-semibold text-gray-800">{(item.unit_price * item.quantity).toFixed(2)}</td>
<td className="px-4 py-2.5 text-right text-gray-700">{effPrice(item).toFixed(2)}</td>
<td className="px-4 py-2.5 text-right font-semibold text-gray-800">{(effPrice(item) * item.quantity).toFixed(2)}</td>
<td className="px-4 py-2.5"><Badge status={item.status} /></td>
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{item.paid_at ? fmtDt(item.paid_at) : '—'}</td>
<td className="px-4 py-2.5 text-gray-500">{item.payment_method ? pmLabel(item.payment_method) : '—'}</td>
@@ -836,7 +837,7 @@ function HistoryTab({ filters, setFilters }) {
{visibleOrders.map(o => {
const total = o.items
.filter(i => i.status !== 'cancelled')
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0)) * i.quantity, 0)
// Opener / closer
const openerName = waiterMap[o.opened_by] || `#${o.opened_by}`

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import PrintFontsTab from './tabs/PrintFontsTab'
import SecurityTab from './tabs/SecurityTab'
import DevelopmentTab from './tabs/DevelopmentTab'
import Phase2FeaturesTab from './tabs/Phase2FeaturesTab'
import FiscalTab from './tabs/FiscalTab'
import { TabGroup } from '../../ui/Tabs'
const TABS = [
@@ -14,6 +15,7 @@ const TABS = [
{ id: 'operation', label: 'Λειτουργία' },
{ id: 'colours', label: 'Εμφάνιση' },
{ id: 'print-fonts', label: 'Εκτύπωση' },
{ id: 'fiscal', label: 'Ταμειακή' },
{ id: 'features', label: 'Λειτουργίες' },
{ id: 'development', label: 'Developer' },
]
@@ -30,6 +32,7 @@ export default function SettingsPage() {
{activeTab === 'operation' && <OperationTab />}
{activeTab === 'colours' && <ColoursTab />}
{activeTab === 'print-fonts' && <PrintFontsTab />}
{activeTab === 'fiscal' && <FiscalTab />}
{activeTab === 'features' && <Phase2FeaturesTab />}
{activeTab === 'development' && <DevelopmentTab />}
</div>

View File

@@ -437,7 +437,7 @@ export default function AppInfoTab() {
<DataTransferSection />
{user?.role === 'sysadmin' && (
{user?.role === 'superadmin' && (
<div className="card p-5 space-y-3 border-amber-200 bg-amber-50">
<h2 className="font-semibold text-amber-800">Sysadmin</h2>
<p className="text-sm text-amber-700">Έλεγχος κλειδώματος συστήματος.</p>

View File

@@ -6,18 +6,29 @@ import toast from 'react-hot-toast'
// ─── Colour slot metadata ────────────────────────────────────────────────────
const SLOTS = [
{ key: 'cardBg', label: 'Κύριο Φόντο', hint: 'Φόντο κάρτας' },
{ key: 'badgeBg', label: 'Δευτερεύον Φόντο', hint: 'Φόντο badge κατάστασης' },
{ key: 'nameText', label: 'Κύριο Κείμενο', hint: 'Όνομα τραπεζιού' },
{ key: 'badgeText', label: 'Δευτερεύον Κείμενο', hint: 'Ετικέτα badge' },
{ key: 'cardBg', key2: 'cardBg2', label: 'Κύριο Φόντο', hint: 'Φόντο κάρτας' },
{ key: 'badgeBg', key2: 'badgeBg2', label: 'Δευτερεύον Φόντο', hint: 'Φόντο badge κατάστασης' },
{ key: 'nameText', key2: 'nameText2', label: 'Κύριο Κείμενο', hint: 'Όνομα τραπεζιού' },
{ key: 'badgeText', key2: 'badgeText2', label: 'Δευτερεύον Κείμενο', hint: 'Ετικέτα badge' },
]
function useFlashToggle(active, ms = 700) {
const [frame, setFrame] = useState(false)
useEffect(() => {
if (!active) { setFrame(false); return }
const id = setInterval(() => setFrame(f => !f), ms)
return () => clearInterval(id)
}, [active, ms])
return frame
}
const STATUSES = [
{ key: 'free', label: 'Ελεύθερο' },
{ key: 'open', label: 'Ανοιχτό (όχι δικό μου)' },
{ key: 'mine', label: 'Ανοιχτό (δικό μου)' },
{ key: 'partially_paid', label: 'Μερικώς Πληρωμένο' },
{ key: 'paid', label: 'Πληρωμένο' },
{ key: 'kds_ready', label: 'Έτοιμο για Παράδοση (KDS)', hasFlash: true },
]
const STATUS_LABELS_MOCK = {
@@ -26,14 +37,19 @@ const STATUS_LABELS_MOCK = {
mine: 'ΔΙΚΟ ΜΟΥ',
partially_paid: 'ΜΕΡ. ΠΛHΡ.',
paid: 'ΠΛΗΡΩΜΕΝΟ',
kds_ready: 'READY',
}
// Quick-suggest palettes per slot type
const QUICK_SWATCHES = {
cardBg: ['#dde5ef', '#243044', '#FF8F60', '#e8610a', '#FFDC67', '#81D264', '#a78bfa', '#38bdf8', '#f43f5e', '#1e293b'],
badgeBg: ['rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', 'rgba(255,255,255,0.6)', 'rgba(30,41,59,0.85)', '#ffffff', '#000000'],
nameText: ['#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#f8fafc', '#111827', '#fef3c7', '#dcfce7'],
badgeText: ['#3d5270', '#94b8d4', '#e8610a', '#FF8F60', '#FFDC67', '#d4a800', '#81D264', '#ffffff', '#1e293b'],
cardBg: ['#dde5ef', '#243044', '#FF8F60', '#e8610a', '#FFDC67', '#81D264', '#a78bfa', '#38bdf8', '#f43f5e', '#1e293b'],
cardBg2: ['rgba(0,0,0,0)', '#ffffff', '#dde5ef', '#243044', '#FF8F60', '#e8610a', '#22c55e', '#3b82f6'],
badgeBg: ['rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', 'rgba(255,255,255,0.6)', 'rgba(30,41,59,0.85)', '#ffffff', '#000000'],
badgeBg2: ['rgba(0,0,0,0)', 'rgba(255,255,255,0.92)', 'rgba(0,0,0,0.55)', '#ffffff', '#000000'],
nameText: ['#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#f8fafc', '#111827', '#fef3c7', '#dcfce7'],
nameText2: ['rgba(0,0,0,0)', '#ffffff', '#1e293b', '#3d5270', '#94b8d4', '#15803d', '#bbf7d0'],
badgeText: ['#3d5270', '#94b8d4', '#e8610a', '#FF8F60', '#FFDC67', '#d4a800', '#81D264', '#ffffff', '#1e293b'],
badgeText2: ['rgba(0,0,0,0)', '#3d5270', '#94b8d4', '#15803d', '#bbf7d0', '#22c55e', '#ffffff'],
}
// ─── Color picker modal ──────────────────────────────────────────────────────
@@ -106,7 +122,9 @@ function ColourPickerModal({ value, onClose, onChange, slot }) {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111827' }}>Επιλογή Χρώματος</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>{SLOTS.find(s => s.key === slot)?.label}</div>
<div style={{ fontSize: 12, color: '#6b7280', marginTop: 2 }}>
{SLOTS.find(s => s.key === slot)?.label || (SLOTS.find(s => s.key2 === slot)?.label + ' (χρώμα 2)')}
</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: 22, cursor: 'pointer', color: '#6b7280', lineHeight: 1 }}>×</button>
</div>
@@ -259,34 +277,43 @@ function ColourSlotRow({ mode, status, slotKey, label, value, onOpen }) {
// ─── Mini mock table card (for preview) ──────────────────────────────────────
function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ' }) {
function MockCard({ cfg, label, mockName, groupName = 'ΜΕΣΑ', flashFrame = false }) {
return (
<div style={{
width: '100%', height: 90, borderRadius: 12, background: cfg.cardBg,
width: '100%', height: 90, borderRadius: 12,
background: flashFrame ? (cfg.cardBg2 ?? cfg.cardBg) : cfg.cardBg,
position: 'relative', flexShrink: 0,
boxShadow: '0 2px 8px rgba(0,0,0,0.18)',
overflow: 'hidden',
transition: 'background 0.15s',
}}>
{/* Table name + group */}
<div style={{ position: 'absolute', top: 8, left: 10, display: 'flex', flexDirection: 'column', gap: 1 }}>
<span style={{
fontSize: 17, fontWeight: 800, color: cfg.nameText,
fontSize: 17, fontWeight: 800,
color: flashFrame ? (cfg.nameText2 ?? cfg.nameText) : cfg.nameText,
lineHeight: 1, letterSpacing: -0.5,
transition: 'color 0.15s',
}}>{mockName}</span>
<span style={{
fontSize: 7, fontWeight: 600, letterSpacing: 0.8,
color: cfg.nameText + '80',
color: (flashFrame ? (cfg.nameText2 ?? cfg.nameText) : cfg.nameText) + '80',
textTransform: 'uppercase',
}}>{groupName}</span>
</div>
{/* Status badge — tight equal padding on all sides */}
{/* Status badge */}
<div style={{
position: 'absolute', bottom: 7, left: 7,
background: cfg.badgeBg,
background: flashFrame ? (cfg.badgeBg2 ?? cfg.badgeBg) : cfg.badgeBg,
borderRadius: 4, padding: '2px 5px',
lineHeight: 1,
lineHeight: 1, transition: 'background 0.15s',
}}>
<span style={{ fontSize: 7, fontWeight: 700, color: cfg.badgeText, whiteSpace: 'nowrap', lineHeight: 1 }}>
<span style={{
fontSize: 7, fontWeight: 700,
color: flashFrame ? (cfg.badgeText2 ?? cfg.badgeText) : cfg.badgeText,
whiteSpace: 'nowrap', lineHeight: 1,
transition: 'color 0.15s',
}}>
{label}
</span>
</div>
@@ -336,30 +363,82 @@ function PreviewPanel({ colours, mode }) {
// ─── Status block (one status, showing all 4 slots) ──────────────────────────
function StatusBlock({ mode, status, label, colours, onOpen }) {
function StatusBlock({ mode, status, label, hasFlash, colours, onOpen, onSetFlash }) {
const cfg = colours[mode][status]
const flashFrame = useFlashToggle(hasFlash && !!cfg.flash)
return (
<div style={{ background: '#f9fafb', borderRadius: 12, padding: '14px 16px', border: '1px solid #f0f0f0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 88, flexShrink: 0 }}>
<MockCard cfg={cfg} label={STATUS_LABELS_MOCK[status]} mockName="T1" />
<MockCard cfg={cfg} label={STATUS_LABELS_MOCK[status]} mockName="T1" flashFrame={flashFrame} />
</div>
<div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#111827' }}>{label}</div>
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2 }}>Πατήστε ένα χρώμα για επεξεργασία</div>
{hasFlash && (
<div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<div
onClick={() => onSetFlash(mode, !cfg.flash)}
style={{
width: 36, height: 20, borderRadius: 10,
background: cfg.flash ? '#22c55e' : '#d1d5db',
position: 'relative', cursor: 'pointer', transition: 'background 0.2s',
flexShrink: 0,
}}
>
<div style={{
position: 'absolute', top: 2, left: cfg.flash ? 18 : 2,
width: 16, height: 16, borderRadius: '50%',
background: '#fff', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
transition: 'left 0.2s',
}} />
</div>
<span style={{ fontSize: 12, fontWeight: 600, color: '#374151' }}>
Αναλαμπή (Flashing)
</span>
</div>
)}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, borderTop: '1px solid #ebebeb', paddingTop: 8 }}>
{SLOTS.map(slot => (
<ColourSlotRow
key={slot.key}
mode={mode}
status={status}
slotKey={slot.key}
label={slot.label}
value={cfg[slot.key]}
onOpen={onOpen}
/>
<div key={slot.key}>
<ColourSlotRow
mode={mode}
status={status}
slotKey={slot.key}
label={slot.label}
value={cfg[slot.key]}
onOpen={onOpen}
/>
{hasFlash && cfg.flash && slot.key2 && (
<div style={{ paddingLeft: 20, marginTop: -4, marginBottom: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 12, height: 12, borderRadius: 3, background: cfg[slot.key2] || 'rgba(0,0,0,0)', border: '1px solid #e5e7eb', flexShrink: 0 }} />
<button
onClick={() => onOpen(mode, status, slot.key2, cfg[slot.key2] || cfg[slot.key])}
style={{
width: 36, height: 22, borderRadius: 6,
background: cfg[slot.key2] || 'rgba(0,0,0,0)',
border: '1.5px solid #e5e7eb', cursor: 'pointer', flexShrink: 0,
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
backgroundImage: !cfg[slot.key2] || cfg[slot.key2] === 'rgba(0,0,0,0)'
? 'linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%)'
: 'none',
backgroundSize: '6px 6px',
backgroundPosition: '0 0,0 3px,3px -3px,-3px 0',
}}
/>
<div>
<div style={{ fontSize: 11, color: '#6b7280' }}>{slot.label} (χρώμα 2)</div>
<div style={{ fontSize: 10, color: '#9ca3af', fontFamily: 'monospace' }}>
{cfg[slot.key2] || 'διάφανο'}
</div>
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
@@ -368,7 +447,7 @@ function StatusBlock({ mode, status, label, colours, onOpen }) {
// ─── Mode section (light or dark) ────────────────────────────────────────────
function ModeSection({ mode, colours, onOpen }) {
function ModeSection({ mode, colours, onOpen, onSetFlash }) {
const label = mode === 'light' ? '☀️ Φωτεινό θέμα' : '🌙 Σκοτεινό θέμα'
return (
<div>
@@ -380,8 +459,10 @@ function ModeSection({ mode, colours, onOpen }) {
mode={mode}
status={s.key}
label={s.label}
hasFlash={s.hasFlash}
colours={colours}
onOpen={onOpen}
onSetFlash={onSetFlash}
/>
))}
</div>
@@ -402,7 +483,17 @@ export default function ColoursTab() {
client.get('/api/settings/').then(r => {
const raw = r.data?.['ui.table_colours']?.value
if (raw) {
try { setColours(JSON.parse(raw)) } catch {}
try {
const loaded = JSON.parse(raw)
// Deep-merge with defaults so any missing statuses/slots are backfilled
const merged = { light: {}, dark: {} }
for (const mode of ['light', 'dark']) {
for (const [status, defaults] of Object.entries(DEFAULT_COLOURS[mode])) {
merged[mode][status] = { ...defaults, ...(loaded[mode]?.[status] ?? {}) }
}
}
setColours(merged)
} catch {}
}
})
}, [])
@@ -432,6 +523,20 @@ export default function ColoursTab() {
})
}
function setFlash(mode, value) {
setColours(prev => {
const next = {
...prev,
[mode]: {
...prev[mode],
kds_ready: { ...prev[mode].kds_ready, flash: value },
},
}
saveToBackend(next)
return next
})
}
function openModal(mode, status, slot, value) {
setModal({ mode, status, slot, value })
}
@@ -461,8 +566,8 @@ export default function ColoursTab() {
{/* Light + Dark mode settings */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 32 }}>
<ModeSection mode="light" colours={colours} onOpen={openModal} />
<ModeSection mode="dark" colours={colours} onOpen={openModal} />
<ModeSection mode="light" colours={colours} onOpen={openModal} onSetFlash={setFlash} />
<ModeSection mode="dark" colours={colours} onOpen={openModal} onSetFlash={setFlash} />
</div>
</div>

View File

@@ -0,0 +1,383 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../../../api/client'
// ── Helpers ───────────────────────────────────────────────────────────────────
function Toggle({ checked, onChange, disabled = false }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
style={{
width: 44, height: 24, borderRadius: 999, border: 'none',
cursor: disabled ? 'default' : 'pointer',
background: checked ? '#16a34a' : '#d1d5db',
position: 'relative', transition: 'background 150ms', flexShrink: 0,
opacity: disabled ? 0.5 : 1,
}}
>
<span style={{
position: 'absolute', top: 3, left: checked ? 23 : 3,
width: 18, height: 18, borderRadius: '50%', background: 'white',
transition: 'left 150ms', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
)
}
function SectionTitle({ children }) {
return (
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3 mt-6 first:mt-0">
{children}
</h3>
)
}
function SettingRow({ label, hint, children }) {
return (
<div className="flex items-start justify-between gap-6 py-3 border-b border-slate-100 last:border-0">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-700">{label}</p>
{hint && <p className="text-xs text-slate-400 mt-0.5">{hint}</p>}
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function FiscalTab() {
const queryClient = useQueryClient()
const { data: settings, isLoading } = useQuery({
queryKey: ['pos-settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 30_000,
})
const val = (key) => settings?.[key]?.value ?? ''
// Local state for folder paths (to show test result without saving first)
const [outFolder, setOutFolder] = useState('')
const [inFolder, setInFolder] = useState('')
const [testResult, setTestResult] = useState(null)
const [testing, setTesting] = useState(false)
// VAT groups state (parsed from JSON setting)
const [vatGroups, setVatGroups] = useState([])
// End message lines
const [endLines, setEndLines] = useState(['', '', '', '', ''])
useEffect(() => {
if (!settings) return
setOutFolder(val('fiscal.out_folder'))
setInFolder(val('fiscal.in_folder'))
try {
const groups = JSON.parse(val('fiscal.vat_groups') || '[]')
setVatGroups(Array.isArray(groups) ? groups : [])
} catch { setVatGroups([]) }
try {
const lines = JSON.parse(val('fiscal.end_message') || '[]')
const padded = [...lines, '', '', '', '', ''].slice(0, 5)
setEndLines(padded)
} catch { setEndLines(['', '', '', '', '']) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings])
const saveSetting = useMutation({
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['pos-settings'] }),
})
function save(key, value) {
saveSetting.mutate({ key, value }, {
onError: () => toast.error(`Αποτυχία αποθήκευσης ${key}`),
})
}
function saveAndToast(key, value, label) {
saveSetting.mutate({ key, value }, {
onSuccess: () => toast.success(`${label} αποθηκεύτηκε`),
onError: () => toast.error('Αποτυχία αποθήκευσης'),
})
}
async function testFolders() {
setTesting(true)
setTestResult(null)
try {
const res = await client.post('/api/fiscal/test-folders', {
out_folder: outFolder,
in_folder: inFolder,
})
setTestResult(res.data)
} catch (e) {
setTestResult({ ok: false, out_folder_error: String(e), in_folder_error: null })
} finally {
setTesting(false)
}
}
function saveFolders() {
save('fiscal.out_folder', outFolder)
save('fiscal.in_folder', inFolder)
toast.success('Φάκελοι αποθηκεύτηκαν')
setTestResult(null)
}
// VAT group helpers
function addVatGroup() {
setVatGroups(prev => [...prev, { machine_id: '', friendly_name: '' }])
}
function removeVatGroup(i) {
setVatGroups(prev => prev.filter((_, idx) => idx !== i))
}
function updateVatGroup(i, field, value) {
setVatGroups(prev => prev.map((g, idx) => idx === i ? { ...g, [field]: value } : g))
}
function saveVatGroups() {
const cleaned = vatGroups
.filter(g => String(g.machine_id).trim() && g.friendly_name.trim())
.map(g => ({ machine_id: parseInt(g.machine_id, 10), friendly_name: g.friendly_name.trim() }))
saveAndToast('fiscal.vat_groups', JSON.stringify(cleaned), 'Ομάδες ΦΠΑ')
}
// End message helpers
function updateEndLine(i, value) {
setEndLines(prev => prev.map((l, idx) => idx === i ? value : l))
}
function saveEndMessage() {
const cleaned = endLines.map(l => l.trim())
saveAndToast('fiscal.end_message', JSON.stringify(cleaned), 'Μήνυμα τέλους')
}
if (isLoading) return <div className="text-sm text-slate-400 py-8 text-center">Φόρτωση</div>
const fiscalEnabled = val('fiscal.enabled') === 'true'
return (
<div className="max-w-xl space-y-1">
{/* Master switch */}
<div className="card px-5 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-slate-800">Φορολογική Εκτύπωση (ΦΗΜ)</p>
<p className="text-xs text-slate-400 mt-0.5">
Ενεργοποιεί αυτόματη αποστολή αποδείξεων στη Φορολογική Μηχανή κατά την πληρωμή
</p>
</div>
<Toggle
checked={fiscalEnabled}
onChange={v => save('fiscal.enabled', v ? 'true' : 'false')}
/>
</div>
{fiscalEnabled && (
<div className="mt-3 rounded-lg bg-amber-50 border border-amber-200 px-3 py-2 text-xs text-amber-800">
Η ΦΗΜ είναι <strong>ενεργή</strong>. Κάθε πληρωμή στο PWA θα αποστέλλει αυτόματα απόδειξη.
Βεβαιωθείτε ότι οι φάκελοι και οι ομάδες ΦΠΑ είναι σωστά ρυθμισμένα.
</div>
)}
</div>
{/* Driver type */}
<div className="card px-5 divide-y divide-slate-100">
<SectionTitle>Τύπος Προγράμματος Οδήγησης</SectionTitle>
<SettingRow
label="Τύπος ΦΗΜ"
hint="Επί του παρόντος υποστηρίζεται μόνο TXT File (dTEC100extra)"
>
<select
value={val('fiscal.type')}
onChange={e => save('fiscal.type', e.target.value)}
className="input text-sm h-9 pr-8"
>
<option value="txt_file">TXT File (dTEC100extra)</option>
</select>
</SettingRow>
</div>
{/* Folder paths */}
<div className="card px-5 py-4 space-y-3">
<SectionTitle>Φάκελοι Επικοινωνίας</SectionTitle>
<p className="text-xs text-slate-500 -mt-2">
Οι φάκελοι πρέπει να είναι προσβάσιμοι μέσα από το Docker container (mounted volume ή UNC path).
<br />
<span className="font-medium">OUT folder</span> = εκεί γράφει το Xenia (ο driver τα διαβάζει ως IN).
<br />
<span className="font-medium">IN folder</span> = εκεί γράφει ο driver τις απαντήσεις (το Xenia τις διαβάζει).
</p>
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-slate-600 block mb-1">OUT Folder (Xenia ΦΗΜ driver)</label>
<input
className="input w-full text-sm font-mono"
placeholder="/mnt/fiscal/in"
value={outFolder}
onChange={e => setOutFolder(e.target.value)}
/>
</div>
<div>
<label className="text-xs font-medium text-slate-600 block mb-1">IN Folder (ΦΗΜ driver Xenia)</label>
<input
className="input w-full text-sm font-mono"
placeholder="/mnt/fiscal/out"
value={inFolder}
onChange={e => setInFolder(e.target.value)}
/>
</div>
</div>
<div className="flex gap-2 pt-1">
<button
onClick={testFolders}
disabled={testing || !outFolder || !inFolder}
className="btn btn-secondary text-sm px-4"
>
{testing ? 'Έλεγχος…' : 'Έλεγχος σύνδεσης'}
</button>
<button
onClick={saveFolders}
disabled={!outFolder || !inFolder}
className="btn btn-primary text-sm px-4"
>
Αποθήκευση
</button>
</div>
{testResult && (
<div className={`rounded-lg border px-3 py-2 text-xs font-mono ${
testResult.ok
? 'bg-green-50 border-green-200 text-green-800'
: 'bg-red-50 border-red-200 text-red-800'
}`}>
{testResult.ok ? (
<span> Και οι δύο φάκελοι είναι προσβάσιμοι</span>
) : (
<div className="space-y-1">
{testResult.out_folder_error && <div>OUT: {testResult.out_folder_error}</div>}
{testResult.in_folder_error && <div>IN: {testResult.in_folder_error}</div>}
</div>
)}
</div>
)}
</div>
{/* Clerk / EFTPOS IDs */}
<div className="card px-5 divide-y divide-slate-100">
<SectionTitle>Ρυθμίσεις Εντολών</SectionTitle>
<SettingRow
label="Clerk ID"
hint="Κωδικός ταμία για εντολές CR / CD"
>
<input
type="number" min="1"
className="input w-20 text-sm text-center"
value={val('fiscal.clerk_id')}
onChange={e => save('fiscal.clerk_id', e.target.value)}
/>
</SettingRow>
<SettingRow
label="EFTPOS ID"
hint="Αριθμός τερματικού EFTPOS για εντολή CD (πληρωμή με κάρτα)"
>
<input
type="number" min="1"
className="input w-20 text-sm text-center"
value={val('fiscal.eftpos_id')}
onChange={e => save('fiscal.eftpos_id', e.target.value)}
/>
</SettingRow>
</div>
{/* End message */}
<div className="card px-5 py-4 space-y-3">
<SectionTitle>Μήνυμα Τέλους Απόδειξης (FM)</SectionTitle>
<p className="text-xs text-slate-500 -mt-2">
Μέχρι 5 γραμμές κειμένου που εκτυπώνονται στο τέλος κάθε απόδειξης.
Αφήστε κενές τις γραμμές που δεν θέλετε να χρησιμοποιήσετε.
</p>
{endLines.map((line, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs text-slate-400 w-4 text-right shrink-0">{i + 1}</span>
<input
className="input flex-1 text-sm font-mono uppercase"
placeholder={`Γραμμή ${i + 1}`}
value={line}
maxLength={40}
onChange={e => updateEndLine(i, e.target.value)}
/>
</div>
))}
<button onClick={saveEndMessage} className="btn btn-primary text-sm px-4">
Αποθήκευση μηνύματος
</button>
</div>
{/* VAT Groups */}
<div className="card px-5 py-4 space-y-3">
<SectionTitle>Ομάδες ΦΠΑ ΦΗΜ</SectionTitle>
<p className="text-xs text-slate-500 -mt-2">
Ορίστε τις ομάδες ΦΠΑ όπως είναι προγραμματισμένες στη ΦΗΜ.
Το <strong>ID ΦΗΜ</strong> είναι ο αριθμός που χρησιμοποιεί η μηχανή (π.χ. 1, 2, 3).
Θα χρησιμοποιηθούν κατά την ανάθεση σε προϊόντα.
</p>
{vatGroups.length === 0 && (
<p className="text-xs text-slate-400 italic">Δεν έχουν οριστεί ομάδες ΦΠΑ ακόμα.</p>
)}
<div className="space-y-2">
{vatGroups.map((g, i) => (
<div key={i} className="flex items-center gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-slate-400 font-medium">ID ΦΗΜ</span>
<input
type="number" min="1"
className="input w-16 text-sm text-center"
placeholder="1"
value={g.machine_id}
onChange={e => updateVatGroup(i, 'machine_id', e.target.value)}
/>
</div>
<div className="flex flex-col flex-1 gap-0.5">
<span className="text-[10px] text-slate-400 font-medium">Φιλικό όνομα</span>
<input
className="input text-sm"
placeholder="π.χ. Νερό, Καφές, Αλκοόλ…"
value={g.friendly_name}
onChange={e => updateVatGroup(i, 'friendly_name', e.target.value)}
/>
</div>
<button
onClick={() => removeVatGroup(i)}
className="btn btn-danger text-xs px-2 py-1.5 min-h-0 h-8 mt-4 shrink-0"
>
</button>
</div>
))}
</div>
<div className="flex gap-2 pt-1">
<button onClick={addVatGroup} className="btn btn-secondary text-sm px-3">
+ Προσθήκη ομάδας
</button>
{vatGroups.length > 0 && (
<button onClick={saveVatGroups} className="btn btn-primary text-sm px-4">
Αποθήκευση ομάδων
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../../../api/client'
@@ -63,11 +63,14 @@ function ShiftSettingsSection() {
function toggle(key, current) {
updateMut.mutate({ key, value: current === 'true' ? 'false' : 'true' })
}
const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
const cancelAllowed = settings?.['orders.waiter_cancellations_allowed']?.value ?? 'false'
const selfStart = settings?.['shifts.waiter_self_start']?.value ?? 'true'
const selfEnd = settings?.['shifts.waiter_self_end']?.value ?? 'true'
const cancelAllowed = settings?.['orders.waiter_cancellations_allowed']?.value ?? 'false'
const hideRevenue = settings?.['shifts.hide_revenue_from_waiters']?.value ?? 'false'
const revertAllowed = settings?.['payments.waiter_revert_allowed']?.value ?? 'false'
const priceAdjAllowed = settings?.['orders.waiter_price_adjust_allowed']?.value ?? 'false'
return (
<SectionCard title="Ρυθμίσεις Βάρδιας" description="Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους">
<SectionCard title="Ρυθμίσεις Προσωπικού" description="Έλεγχος του τι επιτρέπεται να κάνουν οι σερβιτόροι μόνοι τους">
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση</p>}
{!isLoading && (
<>
@@ -83,6 +86,320 @@ function ShiftSettingsSection() {
>
<Toggle checked={cancelAllowed === 'true'} onChange={() => toggle('orders.waiter_cancellations_allowed', cancelAllowed)} disabled={updateMut.isPending} />
</OptionRow>
<OptionRow
label="Απόκρυψη Εισπράξεων από Σερβιτόρους"
description="Όταν είναι ΕΝΕΡΓΌ, οι σερβιτόροι δεν βλέπουν το ποσό είσπραξης και το ποσό παράδοσης στην Επισκόπηση Βάρδιας — αποτρέπει παραποίηση των ταμειακών."
>
<Toggle checked={hideRevenue === 'true'} onChange={() => toggle('shifts.hide_revenue_from_waiters', hideRevenue)} disabled={updateMut.isPending} />
</OptionRow>
<OptionRow
label="Αναίρεση Πληρωμής από Σερβιτόρους"
description="Επιτρέπει στους σερβιτόρους να αναιρούν μεμονωμένες πληρωμένες χρεώσεις από την καρτέλα ΠΛΗΡΩΜΕΝΑ, επαναφέροντάς τες σε μη πληρωμένες. Χρήσιμο για διόρθωση λαθών χωρίς παρέμβαση διαχειριστή."
>
<Toggle checked={revertAllowed === 'true'} onChange={() => toggle('payments.waiter_revert_allowed', revertAllowed)} disabled={updateMut.isPending} />
</OptionRow>
<OptionRow
label="Προσαρμογή Τιμής από Σερβιτόρους"
description="Επιτρέπει στους σερβιτόρους να αλλάζουν την τιμή ενός αντικειμένου κατά την παραγγελία ή μετά. Αν είναι ΚΛΕΙΣΤΌ, κανένας σερβιτόρος δεν μπορεί να τροποποιήσει τιμή — ανεξάρτητα από τις ατομικές ρυθμίσεις."
>
<Toggle checked={priceAdjAllowed === 'true'} onChange={() => toggle('orders.waiter_price_adjust_allowed', priceAdjAllowed)} disabled={updateMut.isPending} />
</OptionRow>
</>
)}
</SectionCard>
)
}
function PaymentSettingsSection() {
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery({
queryKey: ['pos-settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 30_000,
})
const updateMut = useMutation({
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
function toggle(key, current) {
updateMut.mutate({ key, value: current === 'true' ? 'false' : 'true' })
}
const cardEnabled = settings?.['payments.card_enabled']?.value ?? 'false'
return (
<SectionCard title="Ρυθμίσεις Πληρωμών" description="Μέθοδοι πληρωμής που είναι διαθέσιμες στους σερβιτόρους">
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση</p>}
{!isLoading && (
<OptionRow
label="Πληρωμή με Κάρτα"
description="Επιτρέπει στους σερβιτόρους να καταχωρούν πληρωμές με κάρτα. Το σύστημα δεν επικοινωνεί με POS μηχάνημα — η επιλογή καταγράφεται μόνο για αναφορά."
>
<Toggle checked={cardEnabled === 'true'} onChange={() => toggle('payments.card_enabled', cardEnabled)} disabled={updateMut.isPending} />
</OptionRow>
)}
</SectionCard>
)
}
const COURSE_COLORS = ['#ef4444','#f97316','#eab308','#22c55e','#3b82f6','#8b5cf6','#ec4899','#06b6d4','#64748b','#10b981']
function CoursesSection({ settings, updateMut }) {
const coursesEnabled = settings?.['orders.courses_enabled']?.value === 'true'
const [courses, setCourses] = useState(() => {
try { return JSON.parse(settings?.['orders.courses']?.value || '[]') }
catch { return [] }
})
// Sync courses from settings when settings change (e.g. after save)
const settingsCoursesStr = settings?.['orders.courses']?.value
useEffect(() => {
try { setCourses(JSON.parse(settingsCoursesStr || '[]')) }
catch { setCourses([]) }
}, [settingsCoursesStr])
const dragItem = useRef(null)
const dragOverItem = useRef(null)
function saveCourses(next) {
setCourses(next)
updateMut.mutate({ key: 'orders.courses', value: JSON.stringify(next) })
}
function addCourse() {
const newId = Math.max(...courses.map(c => c.id), 0) + 1
const colorIdx = courses.length % COURSE_COLORS.length
saveCourses([...courses, { id: newId, name: `Course ${newId}`, color: COURSE_COLORS[colorIdx] }])
}
function deleteCourse(id) {
saveCourses(courses.filter(c => c.id !== id))
}
function updateName(id, name) {
saveCourses(courses.map(c => c.id === id ? { ...c, name } : c))
}
function cycleColor(id) {
saveCourses(courses.map(c => {
if (c.id !== id) return c
const idx = COURSE_COLORS.indexOf(c.color)
const next = COURSE_COLORS[(idx + 1) % COURSE_COLORS.length]
return { ...c, color: next }
}))
}
function handleDragStart(e, idx) {
dragItem.current = idx
e.dataTransfer.effectAllowed = 'move'
}
function handleDragOver(e, idx) {
e.preventDefault()
dragOverItem.current = idx
}
function handleDrop() {
if (dragItem.current === null || dragOverItem.current === null) return
if (dragItem.current === dragOverItem.current) return
const next = [...courses]
const dragged = next.splice(dragItem.current, 1)[0]
next.splice(dragOverItem.current, 0, dragged)
dragItem.current = null
dragOverItem.current = null
saveCourses(next)
}
const rowStyle = { display: 'flex', alignItems: 'center', gap: 8, padding: '8px 20px', borderBottom: '1px solid #f4f4f2' }
return (
<div>
{coursesEnabled && (
<div>
{courses.map((course, idx) => (
<div
key={course.id}
draggable
onDragStart={e => handleDragStart(e, idx)}
onDragOver={e => handleDragOver(e, idx)}
onDrop={handleDrop}
style={{ ...rowStyle, cursor: 'grab' }}
>
{/* Drag handle */}
<span style={{ color: '#9ca3af', fontSize: 14, flexShrink: 0, cursor: 'grab' }}></span>
{/* Color swatch */}
<button
onClick={() => cycleColor(course.id)}
title="Click to change color"
style={{
width: 20, height: 20, borderRadius: '50%',
background: course.color,
border: '2px solid rgba(0,0,0,0.12)',
cursor: 'pointer', flexShrink: 0,
padding: 0,
}}
/>
{/* Name input */}
<input
value={course.name}
onChange={e => setCourses(cs => cs.map(c => c.id === course.id ? { ...c, name: e.target.value } : c))}
onBlur={e => updateName(course.id, e.target.value)}
style={{
flex: 1, height: 32, borderRadius: 6, border: '1px solid #dfe2e6',
padding: '0 10px', fontSize: 13, fontFamily: 'inherit',
}}
/>
{/* Delete */}
<button
onClick={() => deleteCourse(course.id)}
style={{
height: 28, padding: '0 10px', borderRadius: 6,
border: '1px solid #fee2e2', background: '#fff5f5',
fontSize: 12, cursor: 'pointer', color: '#dc2626', flexShrink: 0,
}}
>Διαγραφή</button>
</div>
))}
<div style={{ padding: '10px 20px' }}>
<button
onClick={addCourse}
style={{
height: 32, padding: '0 14px', borderRadius: 8,
border: '1px solid #dfe2e6', background: 'white',
fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151',
}}
>+ Add course</button>
</div>
</div>
)}
</div>
)
}
const DEFAULT_QUICK_NOTES = ['Χωρίς αλάτι', 'Βγάλτε γρήγορα', 'Αλλεργία!', 'Κόψτε σε μικρά κομμάτια', 'Έξτρα χαρτοπετσέτες']
function QuickNotesSection({ settings, updateMut }) {
const raw = settings?.['orders.quick_notes']?.value
const [notes, setNotes] = useState(() => {
try { return JSON.parse(raw || 'null') ?? DEFAULT_QUICK_NOTES }
catch { return DEFAULT_QUICK_NOTES }
})
const [newNote, setNewNote] = useState('')
const settingsRaw = settings?.['orders.quick_notes']?.value
useEffect(() => {
try { setNotes(JSON.parse(settingsRaw || 'null') ?? DEFAULT_QUICK_NOTES) }
catch { setNotes(DEFAULT_QUICK_NOTES) }
}, [settingsRaw])
function saveNotes(next) {
setNotes(next)
updateMut.mutate({ key: 'orders.quick_notes', value: JSON.stringify(next) })
}
function updateNote(idx, val) {
const next = notes.map((n, i) => i === idx ? val : n)
setNotes(next)
}
function commitNote(idx) {
saveNotes(notes.map((n, i) => i === idx ? n.trim() : n).filter(n => n.length > 0))
}
function deleteNote(idx) {
saveNotes(notes.filter((_, i) => i !== idx))
}
function addNote() {
const trimmed = newNote.trim()
if (!trimmed) return
setNewNote('')
saveNotes([...notes, trimmed])
}
const rowStyle = { display: 'flex', alignItems: 'center', gap: 8, padding: '8px 20px', borderBottom: '1px solid #f4f4f2' }
return (
<div>
<div style={{ padding: '12px 20px 4px', fontSize: 12, fontWeight: 600, color: '#6b7280', textTransform: 'uppercase', letterSpacing: 0.5 }}>
Quick Note Options
</div>
{notes.map((note, idx) => (
<div key={idx} style={rowStyle}>
<input
value={note}
onChange={e => updateNote(idx, e.target.value)}
onBlur={() => commitNote(idx)}
style={{ flex: 1, height: 32, borderRadius: 6, border: '1px solid #dfe2e6', padding: '0 10px', fontSize: 13, fontFamily: 'inherit' }}
/>
<button
onClick={() => deleteNote(idx)}
style={{ height: 28, padding: '0 10px', borderRadius: 6, border: '1px solid #fee2e2', background: '#fff5f5', fontSize: 12, cursor: 'pointer', color: '#dc2626', flexShrink: 0 }}
>Διαγραφή</button>
</div>
))}
<div style={{ display: 'flex', gap: 8, padding: '10px 20px' }}>
<input
value={newNote}
onChange={e => setNewNote(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addNote()}
placeholder="Νέα γρήγορη σημείωση…"
style={{ flex: 1, height: 32, borderRadius: 6, border: '1px solid #dfe2e6', padding: '0 10px', fontSize: 13, fontFamily: 'inherit' }}
/>
<button
onClick={addNote}
style={{ height: 32, padding: '0 14px', borderRadius: 8, border: '1px solid #dfe2e6', background: 'white', fontSize: 12, fontWeight: 600, cursor: 'pointer', color: '#374151' }}
>+ Προσθήκη</button>
</div>
</div>
)
}
function TableOrderSettingsSection() {
const qc = useQueryClient()
const { data: settings, isLoading } = useQuery({
queryKey: ['pos-settings'],
queryFn: () => client.get('/api/settings/').then(r => r.data),
staleTime: 30_000,
})
const updateMut = useMutation({
mutationFn: ({ key, value }) => client.put(`/api/settings/${key}`, { value }),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['pos-settings'] }) },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
function toggle(key, current) {
updateMut.mutate({ key, value: current === 'true' ? 'false' : 'true' })
}
const autoClose = settings?.['orders.auto_close_on_full_payment']?.value ?? 'false'
const bypassKds = settings?.['orders.bypass_kds_serve']?.value ?? 'false'
return (
<SectionCard title="Ρυθμίσεις Τραπεζιών και Παραγγελιών" description="Συμπεριφορά τραπεζιών και παραγγελιών">
{isLoading && <p className="px-5 py-4 text-sm text-gray-400">Φόρτωση</p>}
{!isLoading && (
<>
<OptionRow
label="Αυτόματο Κλείσιμο Τραπεζιού μετά την πλήρη πληρωμή"
description="Το τραπέζι κλείνει αυτόματα όταν πληρωθούν όλα τα αντικείμενα"
>
<Toggle checked={autoClose === 'true'} onChange={() => toggle('orders.auto_close_on_full_payment', autoClose)} disabled={updateMut.isPending} />
</OptionRow>
<OptionRow
label="Παράκαμψη KDS και Κατάστασης Σερβιρίσματος"
description="Οι νέες παραγγελίες σημειώνονται άμεσα ως σερβιρισμένες — δεν απαιτείται επιβεβαίωση από σερβιτόρο"
>
<Toggle checked={bypassKds === 'true'} onChange={() => toggle('orders.bypass_kds_serve', bypassKds)} disabled={updateMut.isPending} />
</OptionRow>
<OptionRow
label="Courses"
description="Enable course-based ordering — assign items to courses (Starter, Main, Dessert, etc.) for sequenced firing"
>
<Toggle
checked={settings?.['orders.courses_enabled']?.value === 'true'}
onChange={() => toggle('orders.courses_enabled', settings?.['orders.courses_enabled']?.value ?? 'false')}
disabled={updateMut.isPending}
/>
</OptionRow>
<CoursesSection settings={settings} updateMut={updateMut} />
<QuickNotesSection settings={settings} updateMut={updateMut} />
</>
)}
</SectionCard>
@@ -268,21 +585,21 @@ function QuickTemplatesSection() {
const [showNew, setShowNew] = useState(false)
const { data: templates = [], isLoading } = useQuery({
queryKey: ['quick-templates'],
queryFn: () => client.get('/api/messages/templates').then(r => r.data),
queryFn: () => client.get('/api/notifications/templates').then(r => r.data),
staleTime: 30_000,
})
const createMut = useMutation({
mutationFn: (body) => client.post('/api/messages/templates', body),
mutationFn: (body) => client.post('/api/notifications/templates', body),
onSuccess: () => { toast.success('Δημιουργήθηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }); setShowNew(false); setNewBody('') },
onError: () => toast.error('Σφάλμα'),
})
const updateMut = useMutation({
mutationFn: ({ id, body }) => client.put(`/api/messages/templates/${id}`, { body }),
mutationFn: ({ id, body }) => client.put(`/api/notifications/templates/${id}`, { body }),
onSuccess: () => { toast.success('Αποθηκεύτηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }); setEditingId(null) },
onError: () => toast.error('Σφάλμα αποθήκευσης'),
})
const deleteMut = useMutation({
mutationFn: (id) => client.delete(`/api/messages/templates/${id}`),
mutationFn: (id) => client.delete(`/api/notifications/templates/${id}`),
onSuccess: () => { toast.success('Διαγράφηκε'); qc.invalidateQueries({ queryKey: ['quick-templates'] }) },
onError: () => toast.error('Σφάλμα'),
})
@@ -334,10 +651,66 @@ function QuickTemplatesSection() {
)
}
// ─── Auto schedule settings ───────────────────────────────────────────────────
const SCHED_STORAGE_KEY = 'xenia_schedule_settings_v1'
const SCHED_DEFAULTS = {
autoStartWorkday: false,
autoCloseWorkday: false,
autoCloseShiftsOnWorkday: false,
autoStartStaffShift: false,
autoCloseStaffShift: false,
}
function loadSchedSettings() {
try { return { ...SCHED_DEFAULTS, ...JSON.parse(localStorage.getItem(SCHED_STORAGE_KEY) || '{}') } }
catch { return { ...SCHED_DEFAULTS } }
}
function AutoScheduleSection() {
const [s, setS] = useState(loadSchedSettings)
function toggle(key) {
setS(prev => {
const next = { ...prev, [key]: !prev[key] }
const stored = JSON.parse(localStorage.getItem(SCHED_STORAGE_KEY) || '{}')
localStorage.setItem(SCHED_STORAGE_KEY, JSON.stringify({ ...stored, ...next }))
return next
})
}
return (
<SectionCard title="Αυτόματο Πρόγραμμα Καταστήματος" description="Ρυθμίσεις αυτόματης έναρξης/κλεισίματος εργάσιμης και βαρδιών">
<OptionRow label="Αυτόματη Έναρξη Εργάσιμης" description="Η εργάσιμη ξεκινά αυτόματα σύμφωνα με το πρόγραμμα">
<Toggle checked={s.autoStartWorkday} onChange={() => toggle('autoStartWorkday')} />
</OptionRow>
<OptionRow label="Αυτόματο Κλείσιμο Εργάσιμης" description="Η εργάσιμη κλείνει αυτόματα σύμφωνα με το πρόγραμμα">
<Toggle checked={s.autoCloseWorkday} onChange={() => toggle('autoCloseWorkday')} />
</OptionRow>
{s.autoCloseWorkday && (
<div className="pl-8">
<OptionRow label="Κλείσιμο βαρδιών προσωπικού" description="Κλείνουν αυτόματα αν δεν υπάρχουν ανοιχτές παραγγελίες">
<Toggle checked={s.autoCloseShiftsOnWorkday} onChange={() => toggle('autoCloseShiftsOnWorkday')} />
</OptionRow>
</div>
)}
<OptionRow label="Αυτόματη Έναρξη Βάρδιας Προσωπικού" description="Οι βάρδιες ξεκινούν αυτόματα σύμφωνα με το πρόγραμμα">
<Toggle checked={s.autoStartStaffShift} onChange={() => toggle('autoStartStaffShift')} />
</OptionRow>
<OptionRow label="Αυτόματο Κλείσιμο Βάρδιας Προσωπικού" description="Οι βάρδιες κλείνουν αυτόματα σύμφωνα με το πρόγραμμα">
<Toggle checked={s.autoCloseStaffShift} onChange={() => toggle('autoCloseStaffShift')} />
</OptionRow>
</SectionCard>
)
}
export default function OperationTab() {
return (
<div className="space-y-6">
<ShiftSettingsSection />
<PaymentSettingsSection />
<TableOrderSettingsSection />
<AutoScheduleSection />
<FlagDefsSection />
<QuickTemplatesSection />
</div>

View File

@@ -637,7 +637,15 @@ function BeepSection({ beepEnabled, beepPattern, onChange, isPending, printers }
// ── Printers section ───────────────────────────────────────────────────────
const PROTOCOLS = [{ value: 'escpos_tcp', label: 'ESC/POS TCP (standard)' }]
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', line_width: 48, is_active: true, duplicates: 0 }
const PRINTER_PROFILES = [
{ label: 'Jolimark TP850UE', codepage_n: 29 },
{ label: 'S4MAS Giant 100', codepage_n: 29 },
{ label: 'NETUM NT8330L', codepage_n: 24 },
]
const codepageToProfile = (n) => PRINTER_PROFILES.find(p => p.codepage_n === n) ?? PRINTER_PROFILES[0]
const EMPTY_FORM = { name: '', ip_address: '', port: 9100, protocol: 'escpos_tcp', line_width: 48, codepage_n: 29, is_active: true }
function PrinterForm({ initial, onSave, onCancel, isPending }) {
const [form, setForm] = useState(initial ?? EMPTY_FORM)
@@ -663,10 +671,16 @@ function PrinterForm({ initial, onSave, onCancel, isPending }) {
<input value={form.port} onChange={e => set('port', parseInt(e.target.value) || 9100)}
type="number" style={inputStyle} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 160px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΠΡΩΤΟΚΟΛΛΟ</label>
<select value={form.protocol} onChange={e => set('protocol', e.target.value)} style={inputStyle}>
{PROTOCOLS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '1 1 180px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΜΟΝΤΕΛΟ ΕΚΤΥΠΩΤΗ</label>
<select
value={form.codepage_n}
onChange={e => set('codepage_n', parseInt(e.target.value))}
style={inputStyle}
>
{PRINTER_PROFILES.map(p => (
<option key={p.label} value={p.codepage_n}>{p.label}</option>
))}
</select>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 90px' }}>
@@ -674,11 +688,6 @@ function PrinterForm({ initial, onSave, onCancel, isPending }) {
<input value={form.line_width} onChange={e => set('line_width', parseInt(e.target.value) || 48)}
type="number" min={20} max={80} style={inputStyle} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flex: '0 0 90px' }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>ΑΝΤΙΓΡΑΦΑ</label>
<input value={form.duplicates ?? 0} onChange={e => set('duplicates', Math.max(0, Math.min(9, parseInt(e.target.value) || 0)))}
type="number" min={0} max={9} style={inputStyle} title="0 = μία εκτύπωση, 1 = δύο κ.ο.κ." />
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', paddingBottom: 2 }}>
<button onClick={() => onSave(form)} disabled={isPending || !form.name.trim() || !form.ip_address.trim()}
style={btnPrimary}>Αποθήκευση</button>
@@ -742,13 +751,8 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending }
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 8 }}>
{printer.ip_address}:{printer.port}
</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {printer.protocol}</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {codepageToProfile(printer.codepage_n ?? 29).label}</span>
<span style={{ fontSize: 11, color: '#9ca3af', marginLeft: 6 }}> {printer.line_width ?? 48} χαρ.</span>
{(printer.duplicates ?? 0) > 0 && (
<span style={{ fontSize: 11, color: '#f59e0b', fontWeight: 700, marginLeft: 6 }}>
x{(printer.duplicates ?? 0) + 1} αντ.
</span>
)}
</div>
<span style={{

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Shield, Lock, LogOut, User, KeyRound } from 'lucide-react'
import { Shield, Lock, LogOut, User, KeyRound, ShieldCheck, Copy, RefreshCw, X } from 'lucide-react'
import client from '../../../api/client'
import { PANEL_CLASS } from '../../../ui/tokens'
import Button from '../../../ui/Button'
@@ -114,6 +115,143 @@ function TimeSelect({ value, onChange, disabled }) {
)
}
// ─── Recovery Codes section ───────────────────────────────────────────────────
function RecoveryCodesSection() {
const [revealedCodes, setRevealedCodes] = useState(null)
const [confirming, setConfirming] = useState(false)
const { data: statusData, refetch: refetchStatus } = useQuery({
queryKey: ['recovery-code-status'],
queryFn: () => client.get('/api/recovery/status').then(r => r.data),
})
const generateMut = useMutation({
mutationFn: () => client.post('/api/recovery/generate').then(r => r.data),
onSuccess: (data) => {
setRevealedCodes(data.codes)
setConfirming(false)
refetchStatus()
},
onError: () => toast.error('Σφάλμα δημιουργίας κωδικών'),
})
const unused = statusData?.unused_count ?? null
function copyAll() {
if (!revealedCodes) return
navigator.clipboard.writeText(revealedCodes.join('\n'))
toast.success('Οι κωδικοί αντιγράφηκαν!')
}
return (
<SectionCard
icon={ShieldCheck}
title="Κωδικοί Ανάκτησης"
description="Μονόχρηστοι κωδικοί έκτακτης ανάγκης. Κάθε κωδικός χρησιμοποιείται μία φορά και καίγεται."
>
<div className="px-5 py-4 space-y-4">
{/* Status badge */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-700">Διαθέσιμοι κωδικοί:</span>
{unused === null ? (
<span className="text-xs text-gray-400">Φόρτωση</span>
) : (
<span className={`text-sm font-bold ${
unused === 0 ? 'text-rose-600' : unused <= 2 ? 'text-amber-600' : 'text-emerald-600'
}`}>
{unused} / 5
</span>
)}
</div>
{unused === 0 && (
<span className="text-[11px] bg-rose-50 text-rose-600 border border-rose-200 rounded-full px-2 py-0.5 font-medium">
Δεν υπάρχουν κωδικοί!
</span>
)}
{unused > 0 && unused <= 2 && (
<span className="text-[11px] bg-amber-50 text-amber-600 border border-amber-200 rounded-full px-2 py-0.5 font-medium">
Λίγοι κωδικοί
</span>
)}
</div>
{/* Revealed codes (shown once after generation) */}
{revealedCodes && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-[12px] font-semibold text-emerald-800">
Αντιγράψτε τώρα δεν θα εμφανιστούν ξανά!
</p>
<button onClick={() => setRevealedCodes(null)} className="text-emerald-500 hover:text-emerald-700">
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-1.5">
{revealedCodes.map((code, i) => (
<div key={i} className="flex items-center justify-between rounded-lg bg-white border border-emerald-200 px-3 py-2">
<span className="font-mono text-sm font-semibold text-emerald-900 tracking-wider">{code}</span>
<button
onClick={() => { navigator.clipboard.writeText(code); toast.success('Αντιγράφηκε') }}
className="text-emerald-400 hover:text-emerald-600"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
<Button variant="secondary" className="w-full justify-center gap-2 text-[13px]" onClick={copyAll}>
<Copy className="h-3.5 w-3.5" /> Αντιγραφή όλων
</Button>
</div>
)}
{/* Generate / confirm */}
{!revealedCodes && (
confirming ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 space-y-3">
<p className="text-[13px] text-amber-800">
Αυτό θα <strong>ακυρώσει</strong> τους {unused} υπάρχοντες κωδικούς και θα δημιουργήσει 5 νέους.
</p>
<div className="flex gap-2">
<Button
variant="danger"
className="flex-1 justify-center text-[13px]"
onClick={() => generateMut.mutate()}
disabled={generateMut.isPending}
>
{generateMut.isPending ? 'Δημιουργία…' : 'Ναι, δημιούργησε νέους'}
</Button>
<Button variant="secondary" className="flex-1 justify-center text-[13px]" onClick={() => setConfirming(false)}>
Άκυρο
</Button>
</div>
</div>
) : (
<Button
variant={unused === 0 ? 'primary' : 'secondary'}
className="w-full justify-center gap-2 text-[13px]"
onClick={() => unused > 0 ? setConfirming(true) : generateMut.mutate()}
disabled={generateMut.isPending}
>
<RefreshCw className="h-3.5 w-3.5" />
{unused === 0 ? 'Δημιουργία κωδικών ανάκτησης' : 'Αναδημιουργία κωδικών'}
</Button>
)
)}
<p className="text-[11px] text-gray-400">
Αποθηκεύστε τους σε ασφαλές μέρος. Κάθε κωδικός δουλεύει μόνο μία φορά.
Χρησιμοποιείται στη σελίδα σύνδεσης με το όνομα χρήστη + κωδικό ανάκτησης.
</p>
</div>
</SectionCard>
)
}
// ─── Main tab ─────────────────────────────────────────────────────────────────
export default function SecurityTab() {
@@ -265,6 +403,9 @@ export default function SecurityTab() {
)}
</SectionCard>
{/* ── Κωδικοί Ανάκτησης ────────────────────────────────────────────── */}
<RecoveryCodesSection />
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,10 @@ export default function TablesPage() {
const [batchModal, setBatchModal] = useState(null) // group object or null
const [groupModal, setGroupModal] = useState(null) // null | {} | group object
const [confirmDelete, setConfirmDelete] = useState(null)
const [bulkMoveModal, setBulkMoveModal] = useState(false)
const [bulkRenameModal, setBulkRenameModal] = useState(false)
const [showInactive, setShowInactive] = useState(false)
const [selectMode, setSelectMode] = useState(false)
const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
const [selected, setSelected] = useState(new Set())
const [anyHovered, setAnyHovered] = useState(false)
@@ -122,11 +125,59 @@ export default function TablesPage() {
})
const clearSelect = () => setSelected(new Set())
const anySelected = selected.size > 0
const allVisibleSelected = visibleTables.length > 0 && visibleTables.every(t => selected.has(t.id))
function toggleSelectAll() {
if (allVisibleSelected) {
// Deselect all visible
setSelected(prev => {
const n = new Set(prev)
visibleTables.forEach(t => n.delete(t.id))
return n
})
} else {
// Select all visible
setSelected(prev => {
const n = new Set(prev)
visibleTables.forEach(t => n.add(t.id))
return n
})
}
}
function exitSelectMode() {
setSelectMode(false)
clearSelect()
}
function bulkDelete() {
setConfirmDelete({ type: 'bulk', ids: [...selected] })
}
async function handleBulkMove(groupId) {
const ids = [...selected]
await Promise.allSettled(ids.map(id => client.put(`/api/tables/${id}`, { group_id: groupId })))
toast.success(`${ids.length} τραπέζια μετακινήθηκαν`)
setBulkMoveModal(false)
exitSelectMode()
invalidate()
}
async function handleBulkRename(prefix, startNumber) {
const ids = [...selected]
// Apply names in the order tables appear in the visible list
const orderedIds = visibleTables.filter(t => selected.has(t.id)).map(t => t.id)
const patches = orderedIds.map((id, i) => ({
id,
label: `${prefix}${startNumber + i}`,
}))
await Promise.allSettled(patches.map(({ id, label }) => client.put(`/api/tables/${id}`, { label })))
toast.success(`${orderedIds.length} τραπέζια μετονομάστηκαν`)
setBulkRenameModal(false)
exitSelectMode()
invalidate()
}
if (isLoading) return <div className="flex items-center justify-center h-64 text-gray-400">Φόρτωση</div>
const zoneTabs = [
@@ -135,22 +186,66 @@ export default function TablesPage() {
...(tables.some(t => !t.group_id) ? [{ id: 'ungrouped', label: 'Χωρίς ζώνη', color: null }] : []),
]
const totalTables = tables.length
const inactiveTables = tables.filter(t => !t.is_active).length
const zoneCount = groups.length
return (
<div className="flex flex-col h-full min-h-0">
{/* Toolbar */}
<div className="flex gap-2 flex-wrap items-center border-b border-slate-200 px-6 flex-shrink-0" style={{ height: 60 }}>
{anySelected ? (
<div className="flex gap-2 flex-wrap items-center border-b border-slate-200 px-6 flex-shrink-0" style={{ minHeight: 60 }}>
{/* Stats — left side */}
{!isLoading && !anySelected && !selectMode && (
<div className="flex items-center gap-3 text-[12px] text-slate-400 mr-2">
<span><strong className="text-slate-700 font-semibold">{totalTables}</strong> τραπέζια</span>
<span className="text-slate-200">·</span>
<span><strong className="text-slate-700 font-semibold">{zoneCount}</strong> {zoneCount === 1 ? 'ζώνη' : 'ζώνες'}</span>
{inactiveTables > 0 && (
<>
<span className="text-slate-200">·</span>
<span><strong className="text-amber-600 font-semibold">{inactiveTables}</strong> ανενεργά</span>
</>
)}
</div>
)}
{/* Multi-select action bar */}
{(selectMode || anySelected) && (
<>
<button onClick={clearSelect} className="text-xs text-slate-500 hover:text-slate-700 flex items-center gap-1.5 mr-1">
{/* Select-all / deselect-all toggle + close */}
<button
onClick={exitSelectMode}
className="text-xs text-slate-500 hover:text-slate-700 flex items-center gap-1.5 mr-1"
>
<span className="w-4 h-4 rounded border border-slate-300 flex items-center justify-center text-[10px]"></span>
{selected.size} επιλεγμένα
{anySelected ? `${selected.size} επιλεγμένα` : 'Επιλογή'}
</button>
<Button variant="danger" size="sm" onClick={bulkDelete}>Διαγραφή επιλεγμένων</Button>
<button
onClick={toggleSelectAll}
className="text-xs text-sky-600 hover:text-sky-800 underline"
>
{allVisibleSelected ? 'Αποεπιλογή όλων' : 'Επιλογή όλων'}
</button>
{anySelected && (
<>
<span className="w-px h-4 bg-slate-200 mx-1" />
<Button variant="secondary" size="sm" onClick={() => setBulkMoveModal(true)}>
Μετακίνηση ζώνης
</Button>
<Button variant="secondary" size="sm" onClick={() => setBulkRenameModal(true)}>
Μαζική μετονομασία
</Button>
<Button variant="danger" size="sm" onClick={bulkDelete}>
Διαγραφή
</Button>
</>
)}
<span className="flex-1" />
</>
) : (
<span className="flex-1" />
)}
{!selectMode && !anySelected && <span className="flex-1" />}
<Button
variant={showInactive ? 'primary' : 'secondary'}
size="sm"
@@ -158,6 +253,11 @@ export default function TablesPage() {
>
{showInactive ? '✓ ' : ''}Ανενεργά
</Button>
{!selectMode && (
<Button variant="secondary" size="sm" onClick={() => setSelectMode(true)}>
Επιλογή
</Button>
)}
<Button variant="secondary" size="sm" onClick={() => setGroupModal({})}>+ Νέα ζώνη</Button>
<Button variant="primary" size="sm" onClick={() => setAddModal(true)}>+ Νέο τραπέζι</Button>
</div>
@@ -254,20 +354,29 @@ export default function TablesPage() {
<p className="py-10 text-sm text-slate-400 text-center">
{showInactive ? 'Δεν υπάρχουν τραπέζια.' : 'Δεν υπάρχουν ενεργά τραπέζια.'}
</p>
) : (
) : (() => {
// Build a set of labels that appear more than once across ALL tables (not just visible)
const labelCounts = {}
tables.forEach(t => { if (t.label) labelCounts[t.label] = (labelCounts[t.label] || 0) + 1 })
const duplicateLabels = new Set(Object.keys(labelCounts).filter(l => labelCounts[l] > 1))
return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden divide-y divide-slate-100">
{visibleTables.map((t, idx) => {
const isSelected = selected.has(t.id)
const isDuplicate = t.label && duplicateLabels.has(t.label)
const showCheckbox = selectMode || anySelected || isSelected
return (
<div
key={t.id}
className={`flex items-center gap-4 px-4 py-3 group transition-colors ${!t.is_active ? 'opacity-50' : ''} ${isSelected ? 'bg-sky-50' : 'hover:bg-slate-50'}`}
className={`flex items-center gap-4 px-4 py-3 group transition-colors ${!t.is_active ? 'opacity-50' : ''} ${isSelected ? 'bg-sky-50' : 'hover:bg-slate-50'} ${selectMode ? 'cursor-pointer' : ''}`}
onClick={selectMode ? () => toggleSelect(t.id) : undefined}
onMouseEnter={() => setAnyHovered(true)}
onMouseLeave={() => setAnyHovered(false)}
>
{/* Number / Checkbox */}
<div className="w-6 shrink-0 flex items-center justify-center">
{anySelected || isSelected ? (
{showCheckbox ? (
<input
type="checkbox"
checked={isSelected}
@@ -276,15 +385,13 @@ export default function TablesPage() {
onClick={e => e.stopPropagation()}
/>
) : (
<span
className="text-xs text-slate-400 font-mono group-hover:hidden"
>{idx + 1}</span>
<span className="text-xs text-slate-400 font-mono group-hover:hidden">{idx + 1}</span>
)}
{!anySelected && !isSelected && (
{!showCheckbox && (
<input
type="checkbox"
checked={false}
onChange={() => toggleSelect(t.id)}
onChange={() => { setSelectMode(true); toggleSelect(t.id) }}
className="hidden group-hover:block w-4 h-4 rounded accent-sky-500 cursor-pointer"
onClick={e => e.stopPropagation()}
/>
@@ -292,51 +399,66 @@ export default function TablesPage() {
</div>
<p className="flex-1 font-medium text-slate-800">{t.label || `Τραπέζι ${t.number}`}</p>
{isDuplicate && (
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 border border-amber-200">
Διπλό όνομα
</span>
)}
{t.seat_count != null && (
<span className="text-xs text-slate-400 hidden sm:inline" title="Αριθμός θέσεων">
{t.seat_count} θέσ.
</span>
)}
{t.group && (
<span className="text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded hidden sm:inline">
{t.group.name}
</span>
)}
{!t.is_active && <span className="text-xs text-amber-600 font-medium">Ανενεργό</span>}
<Button variant="secondary" size="sm" onClick={() => setEditModal(t)}>Επεξεργασία</Button>
{t.is_active
? <Button
variant="ghost"
{!selectMode && (
<>
<Button variant="secondary" size="sm" onClick={() => setEditModal(t)}>Επεξεργασία</Button>
{t.is_active
? <Button
variant="ghost"
size="sm"
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: false })}
disabled={t.has_active_order}
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
className="text-amber-600 hover:bg-amber-50 disabled:opacity-40 disabled:cursor-not-allowed"
>Απενεργ.</Button>
: <Button
variant="ghost"
size="sm"
onClick={() => updateTable.mutate({ id: t.id, is_active: true })}
className="text-green-600 hover:bg-green-50"
>Ενεργοπ.</Button>
}
<Button
variant="danger"
size="sm"
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: false })}
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: true })}
disabled={t.has_active_order}
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
className="text-amber-600 hover:bg-amber-50 disabled:opacity-40 disabled:cursor-not-allowed"
>Απενεργ.</Button>
: <Button
variant="ghost"
size="sm"
onClick={() => updateTable.mutate({ id: t.id, is_active: true })}
className="text-green-600 hover:bg-green-50"
>Ενεργοπ.</Button>
}
<Button
variant="danger"
size="sm"
onClick={() => !t.has_active_order && setConfirmDelete({ type: 'single', id: t.id, hard: true })}
disabled={t.has_active_order}
title={t.has_active_order ? 'Υπάρχει ενεργή παραγγελία' : undefined}
className="disabled:opacity-40 disabled:cursor-not-allowed"
>Διαγραφή</Button>
className="disabled:opacity-40 disabled:cursor-not-allowed"
>Διαγραφή</Button>
</>
)}
</div>
)
})}
</div>
)}
)
})()}
</div>
{/* Add single table */}
{addModal && (
<TableModal
title="Νέο τραπέζι"
initial={{ label: '', group_id: activeTab !== 'all' && activeTab !== 'ungrouped' ? activeTab : '' }}
initial={{ label: '', group_id: activeTab !== 'all' && activeTab !== 'ungrouped' ? activeTab : '', seat_count: null }}
groups={groups}
onSave={(f) => createTable.mutate({ label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
onSave={(f) => createTable.mutate({ label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null, seat_count: f.seat_count || null })}
onClose={() => setAddModal(false)}
/>
)}
@@ -345,9 +467,9 @@ export default function TablesPage() {
{editModal && (
<TableModal
title="Επεξεργασία τραπεζιού"
initial={{ label: editModal.label || '', group_id: editModal.group_id || '' }}
initial={{ label: editModal.label || '', group_id: editModal.group_id || '', seat_count: editModal.seat_count ?? null }}
groups={groups}
onSave={(f) => updateTable.mutate({ id: editModal.id, label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null })}
onSave={(f) => updateTable.mutate({ id: editModal.id, label: f.label || null, group_id: f.group_id ? Number(f.group_id) : null, seat_count: f.seat_count || null })}
onClose={() => setEditModal(null)}
/>
)}
@@ -362,6 +484,25 @@ export default function TablesPage() {
/>
)}
{/* Bulk move zone */}
{bulkMoveModal && (
<BulkMoveModal
count={selected.size}
groups={groups}
onSave={handleBulkMove}
onClose={() => setBulkMoveModal(false)}
/>
)}
{/* Bulk rename */}
{bulkRenameModal && (
<BulkRenameModal
selectedTables={visibleTables.filter(t => selected.has(t.id))}
onSave={handleBulkRename}
onClose={() => setBulkRenameModal(false)}
/>
)}
{/* Group/Zone form */}
{groupModal !== null && (
<GroupModal
@@ -393,7 +534,7 @@ export default function TablesPage() {
if (confirmDelete.type === 'bulk') {
await Promise.allSettled(confirmDelete.ids.map(id => client.delete(`/api/tables/${id}?hard=true`)))
toast.success(`${confirmDelete.ids.length} τραπέζια διαγράφηκαν`)
setConfirmDelete(null); clearSelect(); invalidate()
setConfirmDelete(null); exitSelectMode(); invalidate()
} else {
deleteTable.mutate({ id: confirmDelete.id, hard: confirmDelete.hard })
}
@@ -443,6 +584,19 @@ function TableModal({ title, initial, groups, onSave, onClose }) {
{groups.map(g => <option key={g.id} value={g.id}>{g.name}{g.prefix ? ` (${g.prefix})` : ''}</option>)}
</select>
</div>
<div>
<label className="label">Αριθμός θέσεων</label>
<input
className="input"
type="number"
min="1"
max="99"
placeholder="π.χ. 4"
value={form.seat_count ?? ''}
onChange={e => setForm(f => ({ ...f, seat_count: e.target.value ? Number(e.target.value) : null }))}
/>
<p className="text-xs text-gray-400 mt-1">Αφήστε κενό αν δεν θέλετε να ορίσετε θέσεις.</p>
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
@@ -458,6 +612,123 @@ function TableModal({ title, initial, groups, onSave, onClose }) {
)
}
function BulkMoveModal({ count, groups, onSave, onClose }) {
const [groupId, setGroupId] = useState('')
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">Μετακίνηση σε ζώνη</h2>
<p className="text-sm text-slate-500">{count} {count === 1 ? 'τραπέζι' : 'τραπέζια'} επιλεγμένα.</p>
<div>
<label className="label">Νέα ζώνη</label>
<select className="input" value={groupId} onChange={e => setGroupId(e.target.value)} autoFocus>
<option value=""> Χωρίς ζώνη </option>
{groups.map(g => (
<option key={g.id} value={g.id}>{g.name}{g.prefix ? ` (${g.prefix})` : ''}</option>
))}
</select>
</div>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => onSave(groupId ? Number(groupId) : null)}
className="flex-1 btn btn-primary"
>
Μετακίνηση
</button>
</div>
</div>
</div>
)
}
function BulkRenameModal({ selectedTables, onSave, onClose }) {
const count = selectedTables.length
const [prefix, setPrefix] = useState('')
const [startNumber, setStartNumber] = useState(1)
const trimmedPrefix = prefix.trim()
const lastN = startNumber + count - 1
const worstCase = `${trimmedPrefix}${lastN}`
const lengthError = trimmedPrefix && worstCase.length > MAX_TABLE_NAME_LENGTH
? `Το τελευταίο όνομα θα είναι '${worstCase}' (${worstCase.length} χαρ.). Μικρύνετε το πρόθεμα ή αλλάξτε αριθμό εκκίνησης.`
: null
// Live preview — at most 5 names shown
const previewNames = Array.from({ length: Math.min(count, 5) }, (_, i) =>
trimmedPrefix ? `${trimmedPrefix}${startNumber + i}` : null
)
const hasMore = count > 5
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 className="font-bold text-gray-800">Μαζική μετονομασία</h2>
<p className="text-sm text-slate-500">
{count} {count === 1 ? 'τραπέζι' : 'τραπέζια'} επιλεγμένα θα μετονομαστούν με τη σειρά που εμφανίζονται στη λίστα.
</p>
<div>
<label className="label">Πρόθεμα</label>
<input
className="input font-mono"
placeholder="π.χ. BS-"
value={prefix}
maxLength={MAX_TABLE_NAME_LENGTH - 1}
onChange={e => setPrefix(e.target.value)}
autoFocus
/>
</div>
<div>
<label className="label">Αριθμός εκκίνησης</label>
<input
className="input"
type="number"
min="1"
max="999"
value={startNumber}
onChange={e => setStartNumber(Math.max(1, Number(e.target.value) || 1))}
/>
</div>
{/* Live preview */}
{trimmedPrefix && !lengthError && (
<div className="bg-slate-50 rounded-lg px-3 py-2 text-xs text-slate-500 space-y-1">
<p className="font-medium text-slate-600 mb-1">Προεπισκόπηση:</p>
<div className="flex flex-wrap gap-1.5">
{previewNames.map((name, i) => (
<span key={i} className="bg-white border border-slate-200 rounded px-2 py-0.5 font-mono text-slate-700">
{name}
</span>
))}
{hasMore && (
<span className="text-slate-400 self-center"> +{count - 5} ακόμα</span>
)}
</div>
</div>
)}
{lengthError && (
<p className="text-xs text-red-500">{lengthError}</p>
)}
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 btn btn-secondary">Ακύρωση</button>
<button
onClick={() => onSave(trimmedPrefix, startNumber)}
disabled={!trimmedPrefix || !!lengthError}
className="flex-1 btn btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
Μετονομασία {count} τραπεζιών
</button>
</div>
</div>
</div>
)
}
function computeStartNumber(tables, groupId, prefix) {
if (!prefix) return 1
const inGroup = groupId ? tables.filter(t => t.group_id === groupId) : []

View File

@@ -102,7 +102,7 @@ function occupiedMinsFromDate(openedAt) {
function orderTotal(items = []) {
return items
.filter(i => i.status !== 'cancelled')
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0)) * i.quantity, 0)
}
function avatarColor(name) {
@@ -158,7 +158,7 @@ function QuickActionModal({ table, order, flagDefs, currentFlags, waiters, templ
await setFlagsMut.mutateAsync()
if (notifyWaiters && notifyMsg.trim() && order) {
const waiterIds = notifyAll ? [] : order.waiters.map(w => w.waiter_id)
await client.post('/api/messages/send', {
await client.post('/api/notifications/send', {
body: notifyMsg.trim(),
target_waiter_ids: waiterIds,
table_ids: [table.id],
@@ -600,7 +600,7 @@ export default function TablesPage() {
const { data: quickTemplates = [] } = useQuery({
queryKey: ['quick-templates'],
queryFn: () => client.get('/api/messages/templates').then(r => r.data),
queryFn: () => client.get('/api/notifications/templates').then(r => r.data),
staleTime: 60_000,
})

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { ChevronDown, Search } from 'lucide-react'
import client from '../api/client'
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -121,130 +122,174 @@ function ForgiveModal({ tab, onClose, onForgive, isPending }) {
)
}
// ── Tab detail card ───────────────────────────────────────────────────────────
// ── Tab detail card (collapsible) ─────────────────────────────────────────────
function TabDetail({ tab, onPay, onClose, onForgive }) {
function TabDetail({ tab, onPay, onClose, onForgive, defaultExpanded = false }) {
const [expanded, setExpanded] = useState(defaultExpanded)
const sc = STATUS_CONFIG[tab.status] || STATUS_CONFIG.open
const canClose = tab.status === 'open' && tab.balance <= 0.005 && tab.entries.length > 0
return (
<div style={{ border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden', background: 'white' }}>
{/* Header */}
<div style={{ padding: '14px 18px', background: '#f9fafb', borderBottom: '1px solid #e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#111315' }}>{tab.customer_name}</div>
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
Άνοιγμα: {fmtDateTime(tab.opened_at)} · {daysSince(tab.opened_at)} ανοιχτή
{/* Clickable header — always visible */}
<button
onClick={() => setExpanded(e => !e)}
style={{
width: '100%', textAlign: 'left',
padding: '14px 18px', background: '#f9fafb',
borderBottom: expanded ? '1px solid #e5e7eb' : 'none',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
flexWrap: 'wrap', gap: 8, cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<ChevronDown
size={16}
style={{
flexShrink: 0, color: '#9ca3af',
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.18s',
}}
/>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: '#111315' }}>{tab.customer_name}</div>
<div style={{ fontSize: 12, color: '#9ca3af', marginTop: 1 }}>
Άνοιγμα: {fmtDateTime(tab.opened_at)} · {daysSince(tab.opened_at)} ανοιχτή
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<span style={{ fontSize: 22, fontWeight: 800, color: tab.balance > 0 ? '#dc2626' : '#16a34a' }}>
{fmt(tab.balance)}
</span>
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: sc.bg, color: sc.color }}>{sc.label}</span>
<span style={{ fontSize: 11.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99, background: sc.bg, color: sc.color }}>
{sc.label}
</span>
</div>
</div>
</button>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: '#f0f0ef' }}>
{[
{ label: 'Συνολικές χρεώσεις', value: fmt(tab.total_charged) },
{ label: 'Πληρωμένο', value: fmt(tab.total_paid), color: '#16a34a' },
{ label: 'Υπόλοιπο', value: fmt(tab.balance), color: tab.balance > 0 ? '#dc2626' : '#16a34a' },
].map(s => (
<div key={s.label} style={{ background: 'white', padding: '10px 14px', textAlign: 'center' }}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{s.label}</div>
<div style={{ fontSize: 15, fontWeight: 800, color: s.color || '#111315', marginTop: 2 }}>{s.value}</div>
</div>
))}
</div>
{/* Entries */}
{tab.entries.length > 0 && (
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Χρεώσεις</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{tab.entries.map(e => (
<div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
<span style={{ color: '#374151' }}>{e.description || `Χρέωση #${e.id}`}</span>
<span style={{ fontWeight: 600, color: '#dc2626', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(e.amount)}</span>
{/* Expanded body */}
{expanded && (
<>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: '#f0f0ef' }}>
{[
{ label: 'Συνολικές χρεώσεις', value: fmt(tab.total_charged) },
{ label: 'Πληρωμένο', value: fmt(tab.total_paid), color: '#16a34a' },
{ label: 'Υπόλοιπο', value: fmt(tab.balance), color: tab.balance > 0 ? '#dc2626' : '#16a34a' },
].map(s => (
<div key={s.label} style={{ background: 'white', padding: '10px 14px', textAlign: 'center' }}>
<div style={{ fontSize: 10, color: '#9ca3af', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{s.label}</div>
<div style={{ fontSize: 15, fontWeight: 800, color: s.color || '#111315', marginTop: 2 }}>{s.value}</div>
</div>
))}
</div>
</div>
)}
{/* Payments */}
{tab.payments.length > 0 && (
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Πληρωμές</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{tab.payments.map(p => (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
<span style={{ color: '#374151' }}>
{p.received_by_name} · {fmtDateTime(p.created_at)}
{p.payment_method && <span style={{ color: '#9ca3af', marginLeft: 6 }}>({p.payment_method})</span>}
{p.notes && <span style={{ color: '#9ca3af', marginLeft: 6 }}> {p.notes}</span>}
</span>
<span style={{ fontWeight: 600, color: '#16a34a', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(p.amount)}</span>
{/* Entries */}
{tab.entries.length > 0 && (
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Χρεώσεις</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{tab.entries.map(e => (
<div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
<span style={{ color: '#374151' }}>{e.description || `Χρέωση #${e.id}`}</span>
<span style={{ fontWeight: 600, color: '#dc2626', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(e.amount)}</span>
</div>
))}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Actions */}
{tab.status === 'open' && (
<div style={{ padding: '12px 18px', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{tab.balance > 0.005 && (
<button onClick={() => onPay(tab)}
style={{ padding: '7px 16px', borderRadius: 8, border: 'none', background: '#16a34a', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
Πληρωμή
</button>
{/* Payments */}
{tab.payments.length > 0 && (
<div style={{ padding: '12px 18px', borderBottom: '1px solid #f0f0ef' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>Πληρωμές</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{tab.payments.map(p => (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
<span style={{ color: '#374151' }}>
{p.received_by_name} · {fmtDateTime(p.created_at)}
{p.payment_method && <span style={{ color: '#9ca3af', marginLeft: 6 }}>({p.payment_method})</span>}
{p.notes && <span style={{ color: '#9ca3af', marginLeft: 6 }}> {p.notes}</span>}
</span>
<span style={{ fontWeight: 600, color: '#16a34a', whiteSpace: 'nowrap', marginLeft: 12 }}>{fmt(p.amount)}</span>
</div>
))}
</div>
</div>
)}
{canClose && (
<button onClick={() => onClose(tab)}
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #d1d5db', background: 'white', color: '#374151', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
Κλείσιμο Καρτέλας
</button>
{/* Actions */}
{tab.status === 'open' && (
<div style={{ padding: '12px 18px', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{tab.balance > 0.005 && (
<button onClick={() => onPay(tab)}
style={{ padding: '7px 16px', borderRadius: 8, border: 'none', background: '#16a34a', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
Πληρωμή
</button>
)}
{canClose && (
<button onClick={() => onClose(tab)}
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #d1d5db', background: 'white', color: '#374151', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
Κλείσιμο Καρτέλας
</button>
)}
<button onClick={() => onForgive(tab)}
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #fecaca', background: 'white', color: '#dc2626', fontSize: 13, fontWeight: 500, cursor: 'pointer', marginLeft: 'auto' }}>
Χάρισμα Υπολοίπου
</button>
</div>
)}
<button onClick={() => onForgive(tab)}
style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid #fecaca', background: 'white', color: '#dc2626', fontSize: 13, fontWeight: 500, cursor: 'pointer', marginLeft: 'auto' }}>
Χάρισμα Υπολοίπου
</button>
</div>
</>
)}
</div>
)
}
// ── Filter chip ───────────────────────────────────────────────────────────────
function FilterChip({ label, active, onClick }) {
return (
<button
onClick={onClick}
className={`px-3 py-1 rounded-full text-[12px] font-semibold border transition-colors ${
active
? 'bg-slate-800 text-white border-slate-800'
: 'bg-white text-slate-500 border-slate-200 hover:border-slate-400 hover:text-slate-700'
}`}
>
{label}
</button>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function TabsPage() {
const qc = useQueryClient()
const [showAll, setShowAll] = useState(false)
const [modal, setModal] = useState(null) // { type: 'pay'|'forgive', tab }
const [statusFilter, setStatusFilter] = useState('open') // 'open' | 'closed' | 'all'
const [search, setSearch] = useState('')
const [modal, setModal] = useState(null)
const { data: tabs = [], isLoading } = useQuery({
queryKey: ['tabs', showAll],
queryFn: () => client.get('/api/tabs/', { params: showAll ? { tab_status: 'all' } : {} }).then(r => r.data),
// Fetch open and closed separately — merge for client-side filtering
const { data: openTabs = [], isLoading: loadingOpen } = useQuery({
queryKey: ['tabs-open'],
queryFn: () => client.get('/api/tabs/').then(r => r.data),
staleTime: 15_000,
})
// Refetch tabs with all statuses when toggled
const { data: allTabs = [] } = useQuery({
queryKey: ['tabs-all'],
const { data: closedTabs = [], isLoading: loadingClosed } = useQuery({
queryKey: ['tabs-closed'],
queryFn: () => client.get('/api/tabs/', { params: { tab_status: 'closed' } }).then(r => r.data),
staleTime: 30_000,
enabled: showAll,
staleTime: 15_000,
})
const tabs = [...openTabs, ...closedTabs]
const isLoading = loadingOpen || loadingClosed
const payTab = useMutation({
mutationFn: ({ id, amount, payment_method, notes }) =>
client.post(`/api/tabs/${id}/pay`, { amount, payment_method, notes }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tabs'] })
qc.invalidateQueries({ queryKey: ['tabs-all'] })
qc.invalidateQueries({ queryKey: ['tabs-open'] }); qc.invalidateQueries({ queryKey: ['tabs-closed'] })
setModal(null)
toast.success('Πληρωμή καταγράφηκε')
},
@@ -254,7 +299,7 @@ export default function TabsPage() {
const closeTab = useMutation({
mutationFn: (id) => client.post(`/api/tabs/${id}/close`, {}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tabs'] })
qc.invalidateQueries({ queryKey: ['tabs-open'] }); qc.invalidateQueries({ queryKey: ['tabs-closed'] })
toast.success('Καρτέλα έκλεισε')
},
onError: (e) => toast.error(e?.response?.data?.detail || 'Σφάλμα'),
@@ -263,61 +308,85 @@ export default function TabsPage() {
const forgiveTab = useMutation({
mutationFn: ({ id, reason }) => client.post(`/api/tabs/${id}/forgive`, { reason }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tabs'] })
qc.invalidateQueries({ queryKey: ['tabs-open'] }); qc.invalidateQueries({ queryKey: ['tabs-closed'] })
setModal(null)
toast.success('Το υπόλοιπο χαρίστηκε')
},
onError: () => toast.error('Σφάλμα'),
})
const openTabs = tabs.filter(t => t.status === 'open')
const totalOutstanding = openTabs.reduce((s, t) => s + t.balance, 0)
// Live client-side filtering
const filtered = useMemo(() => {
let result = tabs
if (statusFilter !== 'all') {
result = result.filter(t => {
if (statusFilter === 'open') return t.status === 'open'
if (statusFilter === 'closed') return t.status === 'closed' || t.status === 'forgiven'
return true
})
}
if (search.trim()) {
const q = search.trim().toLowerCase()
result = result.filter(t => t.customer_name?.toLowerCase().includes(q))
}
return result
}, [tabs, statusFilter, search])
const openCount = tabs.filter(t => t.status === 'open').length
const closedCount = tabs.filter(t => t.status === 'closed' || t.status === 'forgiven').length
const totalOutstanding = tabs.filter(t => t.status === 'open').reduce((s, t) => s + t.balance, 0)
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Καρτέλες</h1>
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
{openTabs.length} ανοιχτές καρτέλες · Σύνολο οφειλόμενων:{' '}
<strong style={{ color: totalOutstanding > 0 ? '#dc2626' : '#16a34a' }}>{fmt(totalOutstanding)}</strong>
</p>
<div className="flex flex-col h-full overflow-hidden">
{/* Toolbar */}
<div className="px-6 py-3 border-b border-slate-200 bg-white shrink-0 flex items-center gap-3 flex-wrap">
{/* Search */}
<div className="relative flex items-center">
<Search className="absolute left-2.5 h-3.5 w-3.5 text-slate-400 pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Αναζήτηση πελάτη…"
className="pl-8 pr-3 py-1.5 text-[13px] bg-slate-50 border border-slate-200 rounded-lg w-48 focus:outline-none focus:ring-2 focus:ring-sky-500/30 focus:border-sky-400 transition placeholder:text-slate-400"
/>
</div>
{/* Status filter chips */}
<div className="flex items-center gap-2">
<FilterChip label={`Ανοιχτές (${openCount})`} active={statusFilter === 'open'} onClick={() => setStatusFilter('open')} />
<FilterChip label={`Κλειστές (${closedCount})`} active={statusFilter === 'closed'} onClick={() => setStatusFilter('closed')} />
<FilterChip label="Όλες" active={statusFilter === 'all'} onClick={() => setStatusFilter('all')} />
</div>
{/* Summary */}
<div className="ml-auto text-[12px] text-slate-400">
{statusFilter === 'open' && totalOutstanding > 0 && (
<span>Σύνολο: <strong className="text-red-600">{fmt(totalOutstanding)}</strong></span>
)}
</div>
<button
onClick={() => setShowAll(s => !s)}
style={{ padding: '7px 14px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', fontSize: 12.5, cursor: 'pointer', color: '#6b7280' }}
>
{showAll ? 'Μόνο ανοιχτές' : 'Εμφάνιση κλειστών'}
</button>
</div>
{/* Tab list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 28px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{isLoading && <div style={{ color: '#9ca3af', fontSize: 13, textAlign: 'center', padding: '48px 0' }}>Φόρτωση</div>}
{!isLoading && openTabs.length === 0 && (
<div style={{ textAlign: 'center', color: '#9ca3af', fontSize: 14, padding: '48px 0' }}>
Δεν υπάρχουν ανοιχτές καρτέλες.
<div className="flex-1 overflow-y-auto px-6 py-4 flex flex-col gap-3">
{isLoading && (
<div className="text-center text-slate-400 text-[13px] py-12">Φόρτωση</div>
)}
{!isLoading && filtered.length === 0 && (
<div className="text-center text-slate-400 text-[14px] py-12">
{search ? 'Δεν βρέθηκαν αποτελέσματα.' : 'Δεν υπάρχουν καρτέλες.'}
</div>
)}
{openTabs.map(tab => (
{filtered.map(tab => (
<TabDetail
key={tab.id}
tab={tab}
defaultExpanded={false}
onPay={t => setModal({ type: 'pay', tab: t })}
onClose={t => closeTab.mutate(t.id)}
onForgive={t => setModal({ type: 'forgive', tab: t })}
/>
))}
{showAll && allTabs.length > 0 && (
<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 8 }}>Κλειστές / Χαρισμένες</div>
{allTabs.map(tab => (
<TabDetail key={tab.id} tab={tab} onPay={() => {}} onClose={() => {}} onForgive={() => {}} />
))}
</>
)}
</div>
{modal?.type === 'pay' && (

View File

@@ -218,8 +218,7 @@ export default function WastePage() {
{/* Header */}
<div style={{ padding: '18px 28px 14px', borderBottom: '1px solid #f0f0ef', flexShrink: 0, background: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 800, color: '#111315' }}>Αποβλήτα / Φθορές</h1>
<p style={{ margin: '2px 0 0', fontSize: 13, color: '#9ca3af' }}>
<p style={{ margin: 0, fontSize: 13, color: '#9ca3af' }}>
Σήμερα: {todayCount} καταχωρήσεις
{todayCost > 0 && <span style={{ color: '#dc2626', fontWeight: 600 }}> · εκτιμώμενο κόστος {todayCost.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} </span>}
</p>

View File

@@ -0,0 +1 @@
export { default } from '../ContactsPage'

View File

@@ -0,0 +1 @@
export { default } from '../CustomersPage'

View File

@@ -0,0 +1 @@
export { default } from '../ExpensesPage'

View File

@@ -0,0 +1 @@
export { default } from '../TabsPage'

View File

@@ -0,0 +1,11 @@
import { Boxes } from 'lucide-react'
export default function StockPage() {
return (
<div className="flex flex-col items-center justify-center h-full text-center gap-3 text-slate-400">
<Boxes size={36} className="opacity-40" />
<p className="text-[15px] font-semibold text-slate-500">Stock Management</p>
<p className="text-[13px]">Coming soon manage item stock volumes here.</p>
</div>
)
}

View File

@@ -0,0 +1 @@
export { default } from '../WastePage'

View File

@@ -0,0 +1,439 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { FileText, AlertCircle, CheckCircle, Clock } from 'lucide-react'
import client from '../../api/client'
import { FilterBar, FilterSelect } from './shared/FilterBar'
import { Panel, DataTable, THead, TH, TR, TD } from './shared/TablePrimitives'
import StatCard from './shared/StatCard'
import EmptyState from './shared/EmptyState'
import SkeletonTable from './shared/SkeletonTable'
import { fmtEUR, fmtDate } from './shared/reportDesignTokens'
const CATEGORY_LABELS = {
food: 'Τρόφιμα',
beverage: 'Ποτά',
supplies: 'Αναλώσιμα',
utilities: 'Κοινόχρηστα',
rent: 'Ενοίκιο',
salary: 'Μισθοί',
maintenance: 'Συντήρηση',
other: 'Άλλο',
}
const STATUS_META = {
due: { label: 'Εκκρεμεί', bg: '#fef2f2', color: '#dc2626' },
partial: { label: 'Μερική', bg: '#fff7ed', color: '#c2410c' },
paid: { label: 'Πληρώθηκε', bg: '#eef7f0', color: '#1f7042' },
}
// 12 distinct colours for combined view supplier segments
const SUPPLIER_COLORS = [
'#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6',
'#06b6d4','#f97316','#84cc16','#ec4899','#14b8a6',
'#a78bfa','#fb923c',
]
function fmtCat(cat) {
return CATEGORY_LABELS[cat] || cat || '—'
}
// ── Breakdown section ─────────────────────────────────────────────────────────
const BREAKDOWN_VIEWS = [
{ key: 'category', label: 'ΑΝΑ ΚΑΤΗΓΟΡΙΑ' },
{ key: 'contact', label: 'ΑΝΑ ΕΠΑΦΗ' },
{ key: 'combined', label: 'ΣΥΝΔΙΑΣΤΙΚΑ' },
]
function BreakdownToggle({ value, onChange }) {
return (
<div style={{
display: 'flex', background: '#f1f5f9', borderRadius: 8, padding: 3, gap: 2,
}}>
{BREAKDOWN_VIEWS.map(v => (
<button
key={v.key}
onClick={() => onChange(v.key)}
style={{
padding: '4px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
fontSize: 10, fontWeight: 700, letterSpacing: 0.4,
transition: 'all 120ms',
background: value === v.key ? 'white' : 'transparent',
color: value === v.key ? '#0f172a' : '#64748b',
boxShadow: value === v.key ? '0 1px 3px rgba(0,0,0,0.12)' : 'none',
}}
>
{v.label}
</button>
))}
</div>
)
}
function BreakdownByCategoryRow({ cat, total }) {
return (
<div className="flex items-center gap-4 px-5 py-3">
<div className="w-28 text-[13px] text-slate-700 font-medium flex-shrink-0">{fmtCat(cat.category)}</div>
<div className="flex-1 h-2 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-sky-500 rounded-full transition-all" style={{ width: `${total > 0 ? (cat.total / total) * 100 : 0}%` }} />
</div>
<div className="text-right flex-shrink-0 w-20">
<div className="text-[13px] font-mono font-medium text-slate-900">{fmtEUR(cat.total)}</div>
{cat.due > 0 && <div className="text-[11px] text-rose-500">{fmtEUR(cat.due)} εκκρεμεί</div>}
</div>
</div>
)
}
function BreakdownByContactRow({ name, total, due, grandTotal }) {
return (
<div className="flex items-center gap-4 px-5 py-3">
<div style={{ width: 140 }} className="text-[13px] text-slate-700 font-medium flex-shrink-0 truncate">{name}</div>
<div className="flex-1 h-2 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all" style={{
width: `${grandTotal > 0 ? (total / grandTotal) * 100 : 0}%`,
background: '#10b981',
}} />
</div>
<div className="text-right flex-shrink-0 w-20">
<div className="text-[13px] font-mono font-medium text-slate-900">{fmtEUR(total)}</div>
{due > 0 && <div className="text-[11px] text-rose-500">{fmtEUR(due)} εκκρεμεί</div>}
</div>
</div>
)
}
function BreakdownCombinedRow({ cat, segments, supplierColorMap, grandTotal }) {
const catTotal = segments.reduce((s, sg) => s + sg.amount, 0)
if (catTotal === 0) return null
return (
<div className="flex items-center gap-4 px-5 py-3">
<div className="w-28 text-[13px] text-slate-700 font-medium flex-shrink-0">{fmtCat(cat)}</div>
<div className="flex-1 h-4 bg-slate-100 rounded-full overflow-hidden flex">
{segments.map(sg => (
sg.amount > 0 ? (
<div
key={sg.name}
title={`${sg.name}: ${fmtEUR(sg.amount)}`}
style={{
width: `${grandTotal > 0 ? (sg.amount / grandTotal) * 100 : 0}%`,
background: supplierColorMap[sg.name] || '#94a3b8',
transition: 'width 300ms',
}}
/>
) : null
))}
</div>
<div className="text-right flex-shrink-0 w-20">
<div className="text-[13px] font-mono font-medium text-slate-900">{fmtEUR(catTotal)}</div>
</div>
</div>
)
}
function BreakdownSection({ expenses, summary }) {
const [view, setView] = useState('category')
// By contact
const byContact = useMemo(() => {
const map = {}
for (const e of expenses) {
const key = e.contact_name || '(χωρίς προμηθευτή)'
if (!map[key]) map[key] = { name: key, total: 0, due: 0 }
map[key].total += e.total_amount
map[key].due += e.due_amount
}
return Object.values(map).sort((a, b) => b.total - a.total)
}, [expenses])
// By combined (category × contact stacked)
const { combinedRows, supplierColorMap, allSuppliers } = useMemo(() => {
const catMap = {}
const suppSet = new Set()
for (const e of expenses) {
const cat = e.category || 'other'
const supp = e.contact_name || '(χωρίς)'
suppSet.add(supp)
if (!catMap[cat]) catMap[cat] = {}
catMap[cat][supp] = (catMap[cat][supp] || 0) + e.total_amount
}
const allSupps = [...suppSet]
const colorMap = {}
allSupps.forEach((s, i) => { colorMap[s] = SUPPLIER_COLORS[i % SUPPLIER_COLORS.length] })
const rows = Object.entries(catMap)
.map(([cat, suppAmounts]) => ({
cat,
segments: allSupps.map(s => ({ name: s, amount: suppAmounts[s] || 0 })),
}))
.sort((a, b) => {
const ta = a.segments.reduce((s, sg) => s + sg.amount, 0)
const tb = b.segments.reduce((s, sg) => s + sg.amount, 0)
return tb - ta
})
return { combinedRows: rows, supplierColorMap: colorMap, allSuppliers: allSupps }
}, [expenses])
const grandTotal = expenses.reduce((s, e) => s + e.total_amount, 0)
const catGrandTotal = (summary?.by_category || []).reduce((s, c) => s + c.total, 0)
const hasData = expenses.length > 0 || (summary?.by_category?.length > 0)
if (!hasData) return null
return (
<div className="mb-6">
<div className="rounded-lg border border-slate-200 bg-white shadow-[0_1px_0_rgba(15,23,42,0.04)] overflow-hidden">
<div className="px-5 py-3 border-b border-slate-100 flex items-center justify-between">
<div className="text-[11px] font-semibold uppercase tracking-[0.1em] text-slate-500">
{view === 'category' ? 'Ανά Κατηγορία' : view === 'contact' ? 'Ανά Επαφή / Προμηθευτή' : 'Συνδιαστική Ανάλυση'}
</div>
<BreakdownToggle value={view} onChange={setView} />
</div>
{view === 'category' && (
<div className="divide-y divide-slate-100">
{(summary?.by_category || [])
.sort((a, b) => b.total - a.total)
.map(cat => (
<BreakdownByCategoryRow key={cat.category} cat={cat} total={catGrandTotal} />
))}
</div>
)}
{view === 'contact' && (
<div className="divide-y divide-slate-100">
{byContact.map(row => (
<BreakdownByContactRow
key={row.name} name={row.name}
total={row.total} due={row.due}
grandTotal={grandTotal}
/>
))}
{byContact.length === 0 && (
<div className="px-5 py-4 text-[13px] text-slate-400">Δεν υπάρχουν δεδομένα.</div>
)}
</div>
)}
{view === 'combined' && (
<>
<div className="divide-y divide-slate-100">
{combinedRows.map(row => (
<BreakdownCombinedRow
key={row.cat} cat={row.cat}
segments={row.segments}
supplierColorMap={supplierColorMap}
grandTotal={grandTotal}
/>
))}
{combinedRows.length === 0 && (
<div className="px-5 py-4 text-[13px] text-slate-400">Δεν υπάρχουν δεδομένα.</div>
)}
</div>
{allSuppliers.length > 0 && (
<div className="px-5 py-3 border-t border-slate-100 flex flex-wrap gap-3">
{allSuppliers.map(s => (
<div key={s} className="flex items-center gap-1.5">
<div style={{ width: 10, height: 10, borderRadius: 3, background: supplierColorMap[s], flexShrink: 0 }} />
<span style={{ fontSize: 11, color: '#64748b' }}>{s}</span>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
)
}
// ── Detail modal ──────────────────────────────────────────────────────────────
function ExpenseDetailModal({ expense, onClose }) {
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 520, maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
<div style={{ padding: '20px 24px', borderBottom: '1px solid #edeff1', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315' }}>{expense.description}</div>
<div style={{ fontSize: 12, color: '#5a6169', marginTop: 2 }}>{fmtCat(expense.category)} · {fmtDate(expense.created_at)}</div>
</div>
<button onClick={onClose} style={{ width: 30, height: 30, borderRadius: 8, border: '1px solid #edeff1', background: 'white', fontSize: 16, cursor: 'pointer' }}>×</button>
</div>
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
{[
{ label: 'Σύνολο', value: fmtEUR(expense.total_amount), color: '#111315' },
{ label: 'Πληρώθηκε', value: fmtEUR(expense.paid_amount), color: '#1f7042' },
{ label: 'Εκκρεμεί', value: fmtEUR(expense.due_amount), color: expense.due_amount > 0 ? '#dc2626' : '#1f7042' },
].map(s => (
<div key={s.label} style={{ padding: '12px 14px', borderRadius: 10, background: '#fafafa', border: '1px solid #edeff1' }}>
<div style={{ fontSize: 11, color: '#8a9099', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>{s.label}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: s.color, fontFamily: 'monospace', marginTop: 4 }}>{s.value}</div>
</div>
))}
</div>
{expense.contact_name && (
<div style={{ fontSize: 13, color: '#374151' }}>
<span style={{ fontWeight: 600 }}>Προμηθευτής:</span> {expense.contact_name}
</div>
)}
{expense.due_date && (
<div style={{ fontSize: 13, color: '#374151' }}>
<span style={{ fontWeight: 600 }}>Ημ. λήξης:</span> {fmtDate(expense.due_date)}
</div>
)}
{expense.notes && (
<div style={{ fontSize: 13, color: '#374151', background: '#f9fafb', borderRadius: 8, padding: '10px 14px' }}>
{expense.notes}
</div>
)}
{expense.payments.length > 0 && (
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>Πληρωμές</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{expense.payments.map(p => (
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, padding: '8px 12px', borderRadius: 8, background: '#eef7f0', border: '1px solid #d1fae5' }}>
<span style={{ color: '#374151' }}>{fmtDate(p.paid_at)}{p.paid_by_name ? ` · ${p.paid_by_name}` : ''}</span>
<span style={{ fontWeight: 700, color: '#1f7042', fontFamily: 'monospace' }}>{fmtEUR(p.amount)}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function ExpensesPage() {
const [statusF, setStatusF] = useState('all')
const [categoryF, setCategoryF] = useState('all')
const [selectedExpense, setSelectedExpense] = useState(null)
const queryParams = {
...(statusF !== 'all' ? { status: statusF } : {}),
...(categoryF !== 'all' ? { category: categoryF } : {}),
}
const { data: expenses = [], isLoading, isError, refetch } = useQuery({
queryKey: ['expenses-report', statusF, categoryF],
queryFn: () => client.get('/api/expenses/', { params: queryParams }).then(r => r.data),
staleTime: 30_000,
})
const { data: summary } = useQuery({
queryKey: ['expenses-summary'],
queryFn: () => client.get('/api/expenses/summary').then(r => r.data),
staleTime: 30_000,
})
const allCategories = [...new Set(expenses.map(e => e.category).filter(Boolean))]
const categoryOptions = [
{ value: 'all', label: 'Όλες οι Κατηγορίες' },
...allCategories.map(c => ({ value: c, label: fmtCat(c) })),
]
const statusOptions = [
{ value: 'all', label: 'Όλες Κατάσταση' },
{ value: 'due', label: 'Εκκρεμείς' },
{ value: 'partial', label: 'Μερική Πληρωμή' },
{ value: 'paid', label: 'Πληρωμένες' },
]
const totalAmount = expenses.reduce((s, e) => s + e.total_amount, 0)
const paidAmount = expenses.reduce((s, e) => s + e.paid_amount, 0)
const dueAmount = expenses.reduce((s, e) => s + e.due_amount, 0)
const dueCount = expenses.filter(e => e.status !== 'paid').length
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={10} columns={6} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center">
<button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button>
</div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<FilterSelect value={statusF} onChange={setStatusF} options={statusOptions} label="Κατάσταση" />
<FilterSelect value={categoryF} onChange={setCategoryF} options={categoryOptions} label="Κατηγορία" />
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
{/* Summary stat cards */}
<div className="grid grid-cols-4 gap-4 mb-6">
<StatCard label="Συνολικές Δαπάνες" value={fmtEUR(totalAmount)} icon={FileText} />
<StatCard label="Πληρωμένο" value={fmtEUR(paidAmount)} icon={CheckCircle} accent />
<StatCard label="Εκκρεμεί" value={fmtEUR(dueAmount)} icon={AlertCircle} sub={dueCount > 0 ? `${dueCount} εκκρεμείς` : 'όλα πληρωμένα'} />
<StatCard label="Αριθμός Δαπανών" value={String(expenses.length)} icon={Clock} />
</div>
<BreakdownSection expenses={expenses} summary={summary} />
{/* Expense list */}
<Panel title="Λίστα Δαπανών" subtitle={`${expenses.length} αποτελέσματα`} padded={false}>
{expenses.length === 0 ? (
<EmptyState title="Δεν βρέθηκαν δαπάνες" description="Δοκιμάστε διαφορετικά φίλτρα." />
) : (
<DataTable>
<THead>
<TH>Περιγραφή</TH>
<TH>Κατηγορία</TH>
<TH>Προμηθευτής</TH>
<TH>Σύνολο</TH>
<TH>Εκκρεμεί</TH>
<TH>Κατάσταση</TH>
<TH>Ημερομηνία</TH>
</THead>
<tbody>
{expenses.map(e => {
const meta = STATUS_META[e.status] || STATUS_META.due
return (
<TR
key={e.id}
striped
className="cursor-pointer hover:bg-sky-50/40"
onClick={() => setSelectedExpense(e)}
>
<TD className="font-medium">{e.description}</TD>
<TD>{fmtCat(e.category)}</TD>
<TD className="text-slate-500">{e.contact_name || '—'}</TD>
<TD mono>{fmtEUR(e.total_amount)}</TD>
<TD mono className={e.due_amount > 0 ? 'text-rose-600 font-semibold' : 'text-slate-400'}>
{e.due_amount > 0 ? fmtEUR(e.due_amount) : '—'}
</TD>
<TD>
<span style={{ padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 700, background: meta.bg, color: meta.color }}>
{meta.label}
</span>
</TD>
<TD className="text-slate-500 text-[12px]">{fmtDate(e.created_at)}</TD>
</TR>
)
})}
</tbody>
</DataTable>
)}
</Panel>
</div>
{selectedExpense && (
<ExpenseDetailModal expense={selectedExpense} onClose={() => setSelectedExpense(null)} />
)}
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { useState } from 'react'
import { TabBar } from '../../ui/Tabs'
import CancellationsLog from './operations/CancellationsLog'
import DiscountsLog from './operations/DiscountsLog'
const TABS = [
{ id: 'cancellations', label: 'Ακυρώσεις', Component: CancellationsLog },
{ id: 'discounts', label: 'Εκπτώσεις', Component: DiscountsLog },
]
export default function MiscPage() {
const [active, setActive] = useState('cancellations')
const tab = TABS.find(t => t.id === active) || TABS[0]
const { Component } = tab
return (
<div className="flex flex-col h-full min-h-0">
<TabBar tabs={TABS} active={active} onChange={setActive} />
<div className="flex-1 min-h-0 flex flex-col">
<Component key={active} />
</div>
</div>
)
}

View File

@@ -0,0 +1,524 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ConciergeBell, Printer, Download, PrinterIcon, X, ChevronDown, ChevronUp } from 'lucide-react'
import toast from 'react-hot-toast'
import client from '../../api/client'
import { FilterBar, FilterSelect, FilterDateInput, WorkDayDateToggle } from './shared/FilterBar'
import StatCard from './shared/StatCard'
import SkeletonTable from './shared/SkeletonTable'
import { DataTable, THead, TH, TR, TD } from './shared/TablePrimitives'
import { fmtNum, fmtEUR, fmtDate, fmtDateTime } from './shared/reportDesignTokens'
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
// ── CSV export (client-side) ──────────────────────────────────────────────────
function downloadCSV(filename, rows, headers) {
const escape = v => `"${String(v ?? '').replace(/"/g, '""')}"`
const lines = [
headers.map(escape).join(','),
...rows.map(r => r.map(escape).join(',')),
]
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = filename; a.click()
URL.revokeObjectURL(url)
}
// ── Print modal ───────────────────────────────────────────────────────────────
function PrintModal({ zone, queryParams, periodLabel, onClose }) {
const [targetPrinter, setTargetPrinter] = useState('browser')
const [printing, setPrinting] = useState(false)
const { data: printersData } = useQuery({
queryKey: ['printers-list'],
queryFn: () => client.get('/api/reports/meta/printers').then(r => r.data),
staleTime: 60_000,
})
const printers = printersData?.printers || []
const printerOptions = [
{ value: 'browser', label: 'Εκτύπωση μέσω browser (PDF)' },
...printers.map(p => ({ value: String(p.id), label: p.name })),
]
function handleBrowserPrint() {
const items = [...zone.all_products].sort((a, b) => b.count - a.count)
const totalItems = items.reduce((s, i) => s + i.count, 0)
const rows = items.map(item => {
const dots = '.'.repeat(Math.max(2, 42 - item.name.length - String(item.count).length))
return `<tr><td>${item.name}</td><td style="text-align:right;font-family:monospace;white-space:nowrap">${dots} ${item.count}</td></tr>`
}).join('')
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Ζώνη: ${zone.name}</title>
<style>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial,sans-serif; font-size: 12px; color: #000; padding: 24px; max-width: 680px; }
h1 { font-size: 15px; font-weight: bold; font-family: 'Courier New',monospace; border-bottom: 2px solid #000; padding-bottom: 6px; margin-bottom: 10px; }
.meta { font-size: 11px; color: #555; margin-bottom: 14px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
td { padding: 4px 4px; border-bottom: 1px dotted #ccc; }
tfoot td { border-top: 2px solid #000; border-bottom: none; font-weight: bold; padding-top: 8px; }
@media print { body { padding: 0; } }</style></head>
<body>
<h1>ΖΩΝΗ ΠΡΟΕΤΟΙΜΑΣΙΑΣ: ${zone.name.toUpperCase()}</h1>
<div class="meta">Περίοδος: ${periodLabel}</div>
<table>
<thead><tr><th style="text-align:left">Προϊόν</th><th style="text-align:right">Ποσότητα</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><td>ΣΥΝΟΛΟ ΕΙΔΩΝ</td><td style="text-align:right">${totalItems}</td></tr></tfoot>
</table>
</body></html>`
const win = window.open('', '_blank', 'width=800,height=600')
win.document.write(html)
win.document.close()
win.focus()
win.print()
onClose()
}
async function handleThermalPrint() {
setPrinting(true)
try {
const body = {
printer_id: parseInt(targetPrinter, 10),
zone_id: zone.id,
...queryParams,
}
await client.post('/api/reports/print/prep-zone', body)
toast.success(`Αποστολή στον εκτυπωτή…`)
onClose()
} catch {
toast.error('Αποτυχία εκτύπωσης')
} finally {
setPrinting(false)
}
}
const isBrowser = targetPrinter === 'browser'
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div style={{ background: 'white', borderRadius: 16, width: '100%', maxWidth: 460, boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
<div style={{ padding: '18px 22px', borderBottom: '1px solid #edeff1', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700 }}>Εκτύπωση Σύνοψης</div>
<div style={{ fontSize: 12, color: '#5a6169', marginTop: 2 }}>Ζώνη: {zone.name} · {periodLabel}</div>
</div>
<button onClick={onClose} style={{ width: 28, height: 28, borderRadius: 8, border: '1px solid #edeff1', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<X size={14} />
</button>
</div>
<div style={{ padding: '18px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Preview of shopping list */}
<div style={{ background: '#f8fafc', borderRadius: 10, border: '1px solid #e2e8f0', padding: '10px 14px', maxHeight: 180, overflowY: 'auto', fontFamily: 'monospace', fontSize: 12 }}>
{zone.all_products.slice(0, 20).map(p => {
const dots = '.'.repeat(Math.max(2, 34 - p.name.length - String(p.count).length))
return <div key={p.name}>{p.name}{dots}{p.count}</div>
})}
{zone.all_products.length > 20 && (
<div style={{ color: '#94a3b8', marginTop: 4 }}>+{zone.all_products.length - 20} ακόμα</div>
)}
</div>
{/* Printer target */}
<div>
<label style={{ display: 'block', fontSize: 11, fontWeight: 700, color: '#5a6169', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Προορισμός</label>
<select
value={targetPrinter}
onChange={e => setTargetPrinter(e.target.value)}
style={{ width: '100%', border: '1px solid #d1d5db', borderRadius: 8, padding: '8px 10px', fontSize: 13, background: 'white', color: '#374151' }}
>
{printerOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
{/* Action button */}
<button
onClick={isBrowser ? handleBrowserPrint : handleThermalPrint}
disabled={printing}
style={{
width: '100%', padding: '10px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
background: '#3758c9', color: 'white', fontWeight: 700, fontSize: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
opacity: printing ? 0.6 : 1,
}}
>
<PrinterIcon size={16} />
{printing ? 'Εκτύπωση…' : isBrowser ? 'Άνοιγμα / Εκτύπωση PDF' : 'Αποστολή στον Εκτυπωτή'}
</button>
</div>
</div>
</div>
)
}
// ── Zone multi-select filter ──────────────────────────────────────────────────
function ZoneFilter({ zones, selected, onChange }) {
const allSelected = selected.length === 0 || selected.length === zones.length
function toggleAll() { onChange([]) }
function toggle(id) {
if (allSelected) {
onChange([id])
} else {
const next = selected.includes(id) ? selected.filter(z => z !== id) : [...selected, id]
onChange(next.length === zones.length ? [] : next)
}
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.5 }}>Ζώνες:</span>
<button
onClick={toggleAll}
style={{
padding: '3px 10px', borderRadius: 999, fontSize: 11, fontWeight: 700,
border: `1.5px solid ${allSelected ? '#3758c9' : '#d1d5db'}`,
background: allSelected ? '#3758c9' : 'white',
color: allSelected ? 'white' : '#374151',
cursor: 'pointer',
}}
>
Όλες
</button>
{zones.map(z => {
const on = !allSelected && selected.includes(z.id)
return (
<button
key={z.id}
onClick={() => toggle(z.id)}
style={{
padding: '3px 10px', borderRadius: 999, fontSize: 11, fontWeight: 700,
border: `1.5px solid ${on ? '#3758c9' : '#d1d5db'}`,
background: on ? '#3758c9' : 'white',
color: on ? 'white' : '#374151',
cursor: 'pointer',
}}
>
{z.name}
</button>
)
})}
</div>
)
}
// ── Zone detail card ──────────────────────────────────────────────────────────
function ZoneDetailCard({ zone, queryParams, periodLabel }) {
const [showPrintModal, setShowPrintModal] = useState(false)
const [ordersExpanded, setOrdersExpanded] = useState(false)
function handleCSVItems() {
downloadCSV(
`prep-zone-${zone.name}-items.csv`,
zone.all_products.map(p => [p.name, p.count, p.value]),
['Προϊόν', 'Ποσότητα', 'Αξία (€)'],
)
}
function handleCSVOrders() {
downloadCSV(
`prep-zone-${zone.name}-orders.csv`,
zone.all_orders.map(o => [o.order_id, o.table, o.opened_at ? new Date(o.opened_at).toLocaleString('el-GR') : '', o.items_count, o.value]),
['Αρ. Παραγγελίας', 'Τραπέζι', 'Ώρα', 'Τεμάχια', 'Αξία (€)'],
)
}
return (
<>
<div style={{
border: '1px solid #e2e8f0', borderRadius: 14, overflow: 'hidden',
background: 'white', boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
marginBottom: 16,
}}>
{/* Zone header bar */}
<div style={{
padding: '16px 20px', background: '#fafbff', borderBottom: '1px solid #e2e8f0',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12,
}}>
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111315', display: 'flex', alignItems: 'center', gap: 8 }}>
<ConciergeBell size={16} style={{ color: '#3758c9' }} />
{zone.name}
{zone.notification_name && zone.notification_name !== zone.name && (
<span style={{ fontSize: 12, color: '#8a9099', fontWeight: 500 }}>({zone.notification_name})</span>
)}
</div>
{zone.printers.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4 }}>
<Printer size={11} style={{ color: '#8a9099' }} />
<span style={{ fontSize: 11, color: '#8a9099' }}>{zone.printers.map(p => p.name).join(', ')}</span>
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
{/* Stats */}
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 10, color: '#8a9099', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>Συνολικά Είδη</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#111315', fontFamily: 'monospace' }}>{fmtNum(zone.item_count)}</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 10, color: '#8a9099', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>Αξία</div>
<div style={{ fontSize: 24, fontWeight: 700, color: '#3758c9', fontFamily: 'monospace' }}>{fmtEUR(zone.total_value)}</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => setShowPrintModal(true)}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '6px 12px', borderRadius: 8, border: '1.5px solid #3758c9',
background: '#3758c9', color: 'white',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}
>
<PrinterIcon size={13} />
Εκτύπωση
</button>
</div>
</div>
</div>
{zone.item_count === 0 ? (
<div style={{ padding: '32px 20px', textAlign: 'center', color: '#94a3b8', fontSize: 13 }}>
Δεν βρέθηκαν παραγγελίες για αυτή την ζώνη στο επιλεγμένο διάστημα.
</div>
) : (
<div style={{ padding: '0 0 4px' }}>
{/* Items table */}
<div style={{ padding: '14px 20px 8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#374151', textTransform: 'uppercase', letterSpacing: 0.5 }}>
Είδη ({zone.all_products.length})
</div>
<button
onClick={handleCSVItems}
style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db',
background: 'white', color: '#374151', fontSize: 11, fontWeight: 600, cursor: 'pointer',
}}
>
<Download size={11} />
CSV Ειδών
</button>
</div>
<DataTable>
<THead>
<TH>Προϊόν</TH>
<TH>Ποσότητα</TH>
<TH>Αξία</TH>
</THead>
<tbody>
{zone.all_products.map((p, i) => (
<TR key={p.name} striped>
<TD className="font-medium">{p.name}</TD>
<TD mono>{fmtNum(p.count)}</TD>
<TD mono className="text-slate-500">{fmtEUR(p.value)}</TD>
</TR>
))}
</tbody>
</DataTable>
{/* Orders section */}
<div style={{ padding: '14px 20px 8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<button
onClick={() => setOrdersExpanded(v => !v)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
fontSize: 12, fontWeight: 700, color: '#374151', textTransform: 'uppercase', letterSpacing: 0.5,
}}
>
{ordersExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
Παραγγελίες ({zone.all_orders.length})
</button>
{ordersExpanded && (
<button
onClick={handleCSVOrders}
style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db',
background: 'white', color: '#374151', fontSize: 11, fontWeight: 600, cursor: 'pointer',
}}
>
<Download size={11} />
CSV Παραγγελιών
</button>
)}
</div>
{ordersExpanded && (
<DataTable>
<THead>
<TH>#</TH>
<TH>Τραπέζι</TH>
<TH>Ώρα</TH>
<TH>Τεμάχια</TH>
<TH>Αξία</TH>
</THead>
<tbody>
{zone.all_orders.map(o => (
<TR key={o.order_id} striped>
<TD mono className="text-slate-400 text-[11px]">#{o.order_id}</TD>
<TD className="font-medium">{o.table}</TD>
<TD className="text-slate-500 text-[12px]">
{o.opened_at ? new Date(o.opened_at).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit' }) : '—'}
</TD>
<TD mono>{fmtNum(o.items_count)}</TD>
<TD mono className="text-slate-500">{fmtEUR(o.value)}</TD>
</TR>
))}
</tbody>
</DataTable>
)}
<div style={{ height: 8 }} />
</div>
)}
</div>
{showPrintModal && (
<PrintModal
zone={zone}
queryParams={queryParams}
periodLabel={periodLabel}
onClose={() => setShowPrintModal(false)}
/>
)}
</>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function PrepZonesPage() {
const [mode, setMode] = useState('range')
const [from, setFrom] = useState(monthAgo())
const [to, setTo] = useState(today())
const [businessDayId, setBusinessDayId] = useState('all')
const [selectedZoneIds, setSelectedZoneIds] = useState([]) // [] = all
const { data: bdData } = useQuery({
queryKey: ['business-days-list'],
queryFn: () => client.get('/api/reports/business-days').then(r => r.data),
staleTime: 60_000,
})
const queryParams = {
...(mode === 'workday' && businessDayId !== 'all' ? { business_day_id: businessDayId } : {}),
...(mode === 'range' ? { from: from + 'T00:00:00', to: to + 'T23:59:59' } : {}),
}
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['prep-zones-report', mode, from, to, businessDayId],
queryFn: () => client.get('/api/reports/prep-zones', { params: queryParams }).then(r => r.data),
staleTime: 60_000,
})
const bdOptions = [
{ value: 'all', label: 'Όλες οι Εργάσιμες Μέρες' },
...((bdData?.business_days || []).map(bd => ({
value: String(bd.id),
label: `${fmtDate(bd.opened_at)}`,
}))),
]
const allZones = data?.zones || []
const visibleZones = useMemo(() => {
if (selectedZoneIds.length === 0) return allZones
return allZones.filter(z => selectedZoneIds.includes(z.id))
}, [allZones, selectedZoneIds])
const totalItems = visibleZones.reduce((s, z) => s + z.item_count, 0)
const totalValue = visibleZones.reduce((s, z) => s + z.total_value, 0)
const busyZone = visibleZones.reduce((best, z) => (!best || z.item_count > best.item_count ? z : best), null)
const periodLabel = useMemo(() => {
if (mode === 'workday' && businessDayId !== 'all') {
const bd = (bdData?.business_days || []).find(b => String(b.id) === businessDayId)
return bd ? fmtDate(bd.opened_at) : `Εργάσιμη #${businessDayId}`
}
return `${from} ${to}`
}, [mode, businessDayId, from, to, bdData])
if (isLoading) return <div className="flex-1 overflow-y-auto p-6"><SkeletonTable rows={6} columns={4} /></div>
if (isError) return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar><span className="text-[12px] text-slate-500">Αδυναμία φόρτωσης δεδομένων</span></FilterBar>
<div className="flex flex-1 items-center justify-center">
<button onClick={() => refetch()} className="rounded-md border border-slate-200 px-4 py-2 text-sm hover:bg-slate-50">Επανάληψη</button>
</div>
</div>
)
return (
<div className="flex flex-col flex-1 min-h-0">
<FilterBar>
<WorkDayDateToggle mode={mode} onChange={setMode} />
{mode === 'workday' ? (
<FilterSelect value={businessDayId} onChange={setBusinessDayId} options={bdOptions} label="Μέρα" />
) : (
<>
<FilterDateInput value={from} onChange={setFrom} label="Από" />
<FilterDateInput value={to} onChange={setTo} label="Έως" />
</>
)}
</FilterBar>
{/* Zone multi-select */}
{allZones.length > 1 && (
<div style={{ padding: '10px 20px', borderBottom: '1px solid #e2e8f0', background: 'white' }}>
<ZoneFilter
zones={allZones}
selected={selectedZoneIds}
onChange={setSelectedZoneIds}
/>
</div>
)}
<div className="flex-1 overflow-y-auto p-6">
{/* Summary stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
<StatCard label="Συνολικά Είδη" value={fmtNum(totalItems)} icon={ConciergeBell} />
<StatCard label="Συνολική Αξία" value={fmtEUR(totalValue)} icon={ConciergeBell} accent />
<StatCard
label="Πιο Απασχολημένη Ζώνη"
value={busyZone?.name || '—'}
sub={busyZone ? `${fmtNum(busyZone.item_count)} είδη` : undefined}
/>
</div>
{allZones.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-slate-400">
<ConciergeBell size={36} className="opacity-40" />
<p className="text-[15px] font-semibold text-slate-500">Δεν βρέθηκαν ζώνες προετοιμασίας</p>
<p className="text-[13px]">Δημιουργήστε ζώνες από το Management Prep Zones.</p>
</div>
) : (
<div>
{visibleZones.map(zone => (
<ZoneDetailCard
key={zone.id}
zone={zone}
queryParams={queryParams}
periodLabel={periodLabel}
/>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { useState } from 'react'
import { TabBar } from '../../ui/Tabs'
import ProductPerformance from './restaurant/ProductPerformance'
import CategoryPerformance from './restaurant/CategoryPerformance'
const TABS = [
{ id: 'products', label: 'Απόδοση Προϊόντων', Component: ProductPerformance },
{ id: 'categories', label: 'Κατηγορίες', Component: CategoryPerformance },
]
export default function ProductsReportPage() {
const [active, setActive] = useState('products')
const tab = TABS.find(t => t.id === active) || TABS[0]
const { Component } = tab
return (
<div className="flex flex-col h-full min-h-0">
<TabBar tabs={TABS} active={active} onChange={setActive} />
<div className="flex-1 min-h-0 flex flex-col">
<Component key={active} />
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { TabBar } from '../../ui/Tabs'
import ShiftsOverview from './staff/ShiftsOverview'
import Payments from './staff/Payments'
import Activity from './staff/Activity'
import StaffLeaderboard from './staff/StaffLeaderboard'
const TABS = [
{ id: 'shifts', label: 'Βάρδιες', Component: ShiftsOverview },
{ id: 'payments', label: 'Πληρωμές', Component: Payments },
{ id: 'activity', label: 'Δραστηριότητα', Component: Activity },
{ id: 'leaderboard', label: 'Κατάταξη', Component: StaffLeaderboard },
]
export default function StaffReportPage() {
const [active, setActive] = useState('shifts')
const navigate = useNavigate()
const tab = TABS.find(t => t.id === active) || TABS[0]
const { Component } = tab
// ShiftsOverview uses onNavigate to drill into order history — redirect to Store page
function handleNavigate({ parent, sub, ...rest }) {
if (parent === 'restaurant' && sub === 'orders') {
navigate('/reports/store')
}
}
return (
<div className="flex flex-col h-full min-h-0">
<TabBar tabs={TABS} active={active} onChange={setActive} />
<div className="flex-1 min-h-0 flex flex-col">
<Component key={active} onNavigate={handleNavigate} />
</div>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { useState } from 'react'
import { TabBar } from '../../ui/Tabs'
import WorkDaySummary from './restaurant/WorkDaySummary'
import OrderHistory from './restaurant/OrderHistory'
import TrafficAnalytics from './restaurant/TrafficAnalytics'
import RevenueTrends from './restaurant/RevenueTrends'
import TableAnalytics from './restaurant/TableAnalytics'
const TABS = [
{ id: 'workday', label: 'Ημέρες Λειτουργίας', Component: WorkDaySummary },
{ id: 'orders', label: 'Ιστορικό Παραγγελιών', Component: OrderHistory },
{ id: 'traffic', label: 'Κίνηση', Component: TrafficAnalytics },
{ id: 'trends', label: 'Τάσεις Εσόδων', Component: RevenueTrends },
{ id: 'tables', label: 'Τραπέζια', Component: TableAnalytics },
]
export default function StorePage() {
const [active, setActive] = useState('workday')
const tab = TABS.find(t => t.id === active) || TABS[0]
const { Component } = tab
return (
<div className="flex flex-col h-full min-h-0">
<TabBar tabs={TABS} active={active} onChange={setActive} />
<div className="flex-1 min-h-0 flex flex-col">
<Component key={active} />
</div>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { useState } from 'react'
import { TabBar } from '../../ui/Tabs'
import PrinterHistory from './operations/PrinterHistory'
import PrinterHealthLog from './operations/PrinterHealthLog'
const TABS = [
{ id: 'printer-history', label: 'Ιστορικό Εκτυπωτή', Component: PrinterHistory },
{ id: 'printer-health', label: 'Υγεία Εκτυπωτή', Component: PrinterHealthLog },
]
export default function TechnicalPage() {
const [active, setActive] = useState('printer-history')
const tab = TABS.find(t => t.id === active) || TABS[0]
const { Component } = tab
return (
<div className="flex flex-col h-full min-h-0">
<TabBar tabs={TABS} active={active} onChange={setActive} />
<div className="flex-1 min-h-0 flex flex-col">
<Component key={active} />
</div>
</div>
)
}

View File

@@ -0,0 +1,9 @@
import Today from './restaurant/Today'
export default function TodayPage() {
return (
<div className="flex flex-col h-full min-h-0">
<Today />
</div>
)
}

View File

@@ -83,7 +83,19 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
</FilterBar>
<div className="flex-1 overflow-y-auto p-6">
<Panel title="Παραγγελίες" subtitle={`${orders.length} αποτελέσματα`} padded={false}>
<Panel
padded={false}
title={
<span className="flex items-center gap-1.5">
<span>Παραγγελίες</span>
<span className="text-slate-300">·</span>
<span className="font-normal text-slate-500">{orders.length} αποτελέσματα</span>
</span>
}
right={
<span className="text-[11px] text-slate-400 italic">Κάντε Click για Λεπτομέρειες Παραγγελίας</span>
}
>
{orders.length === 0 ? (
<EmptyState title="Δεν βρέθηκαν παραγγελίες" description="Δοκιμάστε διαφορετικό εύρος ημερομηνιών ή φίλτρο κατάστασης." />
) : (
@@ -91,7 +103,7 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
<DataTable>
<THead>
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
<TH>Κατάσταση</TH><TH align="right">Είδη</TH><TH align="right">Ακυρώσεις</TH><TH align="right">Σύνολο</TH><TH align="right" className="w-24"></TH>
<TH>Κατάσταση</TH><TH align="right">Είδη</TH><TH align="right">Ακυρώσεις</TH><TH align="right">Σύνολο</TH>
</THead>
<tbody>
{orders.slice(0, 200).map(o => {
@@ -100,10 +112,10 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
const cancelledItems = allItems.filter(i => i.status === 'cancelled')
const totalQty = allItems.reduce((s, i) => s + (i.quantity || 1), 0)
const cancelledQty = cancelledItems.reduce((s, i) => s + (i.quantity || 1), 0)
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const total = activeItems.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0)) * i.quantity, 0)
const isCancelled = o.status === 'cancelled'
return (
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className={isCancelled ? 'opacity-50' : ''}>
<TD mono>#{o.id}</TD>
<TD>{o.table_name ?? o.table_id}</TD>
<TD mono>{fmtDateTime(o.opened_at)}</TD>
@@ -116,14 +128,6 @@ export default function OrderHistory({ initialBusinessDayId } = {}) {
: <span className="text-slate-300"></span>}
</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(total)}</TD>
<TD align="right">
<button
onClick={() => setDrillOrder(o)}
className="rounded border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-medium text-slate-600 hover:bg-slate-50"
>
Λεπτομέρειες
</button>
</TD>
</TR>
)
})}

View File

@@ -1,3 +1,4 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import { TrendingUp, ReceiptText, Clock, Users, Trophy, XCircle, Package, Sunset, PiggyBank, ShoppingBag } from 'lucide-react'
@@ -8,9 +9,12 @@ import StatCard from '../shared/StatCard'
import EmptyState from '../shared/EmptyState'
import SkeletonTable from '../shared/SkeletonTable'
import ExportButton from '../shared/ExportButton'
import OrderDetailModal from '../shared/OrderDetailModal'
import { fmtEUR, fmtNum, fmtTime, fmtDateTime } from '../shared/reportDesignTokens'
export default function Today() {
const [drillOrder, setDrillOrder] = useState(null)
const { data: bdData, isLoading: bdLoading } = useQuery({
queryKey: ['business-day-current'],
queryFn: () => client.get('/api/reports/business-days/current').then(r => r.data),
@@ -164,13 +168,26 @@ export default function Today() {
</div>
<div className="mt-4">
<Panel title="Παραγγελίες Σήμερα" subtitle={`${orders.length} συνολικά`} padded={false} right={
<ExportButton
endpoint="/api/reports/orders/export"
params={{ business_day_id: bd.id }}
filename={`today-orders-${bd.id}.csv`}
/>
}>
<Panel
padded={false}
title={
<span className="flex items-center gap-1.5">
<span>Παραγγελίες Σήμερα</span>
<span className="text-slate-300">·</span>
<span className="font-normal text-slate-500">{orders.length} συνολικά</span>
</span>
}
right={
<div className="flex items-center gap-3">
<span className="text-[11px] text-slate-400 italic">Κάντε Click για Λεπτομέρειες Παραγγελίας</span>
<ExportButton
endpoint="/api/reports/orders/export"
params={{ business_day_id: bd.id }}
filename={`today-orders-${bd.id}.csv`}
/>
</div>
}
>
<DataTable>
<THead>
<TH>#</TH><TH>Τραπέζι</TH><TH>Άνοιξε</TH><TH>Έκλεισε</TH>
@@ -179,14 +196,14 @@ export default function Today() {
<tbody>
{orders.slice(0, 30).map(o => {
const activeItems = (o.items || []).filter(i => ['active', 'paid'].includes(i.status))
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const total = activeItems.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0)) * i.quantity, 0)
const costedItems = activeItems.filter(i => i.unit_cost != null)
const orderProfit = costedItems.length > 0
? costedItems.reduce((s, i) => s + (i.unit_price - i.unit_cost) * i.quantity, 0)
? costedItems.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0) - (i.unit_cost ?? 0)) * i.quantity, 0)
: null
const isCancelled = o.status === 'cancelled'
return (
<TR key={o.id} striped className={isCancelled ? 'opacity-50' : ''}>
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className={isCancelled ? 'opacity-50' : ''}>
<TD mono>#{o.id}</TD>
<TD>{o.table_id}</TD>
<TD mono>{fmtDateTime(o.opened_at)}</TD>
@@ -208,6 +225,10 @@ export default function Today() {
</Panel>
</div>
</div>
{drillOrder && (
<OrderDetailModal order={drillOrder} onClose={() => setDrillOrder(null)} />
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react'
import { Trash2, Pencil, Check, X as XIcon } from 'lucide-react'
import toast from 'react-hot-toast'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import client from '../../../api/client'
@@ -18,8 +18,73 @@ import { fmtEUR, fmtNum, fmtDate, fmtTime, fmtDuration, fmtDateTime, fmtDateStr
function today() { return new Date().toISOString().slice(0, 10) }
function monthAgo() { const d = new Date(); d.setDate(d.getDate() - 30); return d.toISOString().slice(0, 10) }
// ── Edit close-time inline control ─────────────────────────────────────────
function EditCloseTime({ day, onSaved }) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const qc = useQueryClient()
function startEdit() {
// pre-fill with current closed_at in local datetime-local format
const d = day.closed_at ? new Date(day.closed_at) : new Date()
const pad = n => String(n).padStart(2, '0')
const local = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
setValue(local)
setEditing(true)
}
const mut = useMutation({
mutationFn: (iso) => client.patch(`/api/business-day/${day.id}`, { closed_at: iso }).then(r => r.data),
onSuccess: (data) => {
toast.success('Ώρα κλεισίματος ενημερώθηκε')
qc.invalidateQueries({ queryKey: ['business-days'] })
setEditing(false)
onSaved && onSaved(data)
},
onError: (err) => toast.error(err?.response?.data?.detail || 'Σφάλμα ενημέρωσης'),
})
if (!editing) {
return (
<button
onClick={startEdit}
title="Επεξεργασία ώρας κλεισίματος"
className="ml-1 rounded p-0.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100"
>
<Pencil size={13} />
</button>
)
}
function handleSave() {
if (!value) return
const iso = new Date(value).toISOString()
mut.mutate(iso)
}
return (
<span className="inline-flex items-center gap-1 ml-1">
<input
type="datetime-local"
value={value}
onChange={e => setValue(e.target.value)}
className="rounded border border-slate-300 px-1 py-0 text-[11px] font-mono text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400"
style={{ height: 22 }}
max={new Date().toISOString().slice(0, 16)}
/>
<button onClick={handleSave} disabled={mut.isPending} className="rounded p-0.5 text-emerald-600 hover:bg-emerald-50" title="Αποθήκευση">
<Check size={14} />
</button>
<button onClick={() => setEditing(false)} className="rounded p-0.5 text-slate-400 hover:bg-slate-100" title="Ακύρωση">
<XIcon size={14} />
</button>
</span>
)
}
// ── Workday drill-down modal ────────────────────────────────────────────────
function WorkDayModal({ day, onClose, onDeleteShift }) {
function WorkDayModal({ day: initialDay, onClose, onDeleteShift }) {
const [day, setDay] = useState(initialDay)
const [tab, setTab] = useState('orders')
const [drillOrder, setDrillOrder] = useState(null)
const [detailShift, setDetailShift] = useState(null)
@@ -44,11 +109,44 @@ function WorkDayModal({ day, onClose, onDeleteShift }) {
{ key: 'shifts', label: `Βάρδιες (${shifts.length})` },
]
const hasCost = (day.total_cost || 0) > 0
const hasProfit = (day.trackable_profit || 0) > 0
// Build the rich subtitle
const openDate = fmtDate(day.opened_at)
const closeDate = day.closed_at ? fmtDate(day.closed_at) : null
const openTime = fmtTime(day.opened_at)
const closeTime = day.closed_at ? fmtTime(day.closed_at) : null
const spansDays = closeDate && closeDate !== openDate
const subtitleParts = [
`${fmtEUR(day.revenue)} έσοδα`,
hasCost ? `${fmtEUR(day.total_cost)} Έξοδα` : null,
hasProfit ? `${fmtEUR(day.trackable_profit)} Κέρδος` : null,
].filter(Boolean)
const openLabel = spansDays ? `${openDate} ${openTime}` : openTime
const closeLabel = day.closed_at
? (spansDays ? `${closeDate} ${closeTime}` : closeTime)
: 'ανοιχτή'
const subtitle = subtitleParts.join(' · ')
const timeRange = `${openLabel} ${closeLabel}`
return (
<>
<DrillDownModal
title={`Εργάσιμη Μέρα · ${fmtDate(day.opened_at)}`}
subtitle={`${fmtEUR(day.revenue)} έσοδα · ${fmtTime(day.opened_at)} ${day.closed_at ? fmtTime(day.closed_at) : 'ανοιχτή'}`}
subtitle={
<span>
{subtitle}
<span className="text-slate-400 mx-1">·</span>
{timeRange}
{day.status === 'closed' && (
<EditCloseTime day={day} onSaved={updated => setDay(d => ({ ...d, closed_at: updated.closed_at }))} />
)}
</span>
}
onClose={onClose}
>
{/* Tabs */}
@@ -80,7 +178,7 @@ function WorkDayModal({ day, onClose, onDeleteShift }) {
const cancelledItems = allItems.filter(i => i.status === 'cancelled')
const totalQty = allItems.reduce((s, i) => s + (i.quantity || 1), 0)
const cancelledQty = cancelledItems.reduce((s, i) => s + (i.quantity || 1), 0)
const total = activeItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const total = activeItems.reduce((s, i) => s + ((i.unit_price ?? 0) + (i.price_adjustment ?? 0)) * i.quantity, 0)
return (
<TR key={o.id} striped onClick={() => setDrillOrder(o)} className="cursor-pointer">
<TD mono>#{o.id}</TD>

View File

@@ -1,6 +1,8 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { X } from 'lucide-react'
import { fmtEUR, fmtDateTime } from './reportDesignTokens'
import client from '../../../api/client'
// ── Status badge ────────────────────────────────────────────────────────────
const STATUS_META = {
@@ -47,6 +49,7 @@ const EVENT_LABELS = {
ORDER_CLOSED: { label: 'Κλείσιμο', bg: '#f9fafb', text: '#374151' },
ORDER_CANCELLED: { label: 'Ακύρωση', bg: '#fef2f2', text: '#b91c1c' },
ITEM_CANCELLED: { label: 'Ακύρωση αντ.', bg: '#fef2f2', text: '#b91c1c' },
PAYMENT_REVERTED:{ label: 'Αναίρεση Πληρωμής', bg: '#fffbeb', text: '#b45309' },
}
function AuditTimeline({ logs, itemsById }) {
@@ -115,11 +118,12 @@ function AuditTimeline({ logs, itemsById }) {
}
// ── Items tab ───────────────────────────────────────────────────────────────
function ItemsTab({ order }) {
function ItemsTab({ order, priceEventsMap = {} }) {
const itemsFiltered = order.items || []
const billableItems = itemsFiltered.filter(i => i.status !== 'cancelled')
const closedTotal = billableItems.filter(i => i.status === 'closed').reduce((s, i) => s + i.unit_price * i.quantity, 0)
const total = billableItems.reduce((s, i) => s + i.unit_price * i.quantity, 0)
const effPrice = i => (i.unit_price ?? 0) + (i.price_adjustment ?? 0)
const closedTotal = billableItems.filter(i => i.status === 'closed').reduce((s, i) => s + effPrice(i) * i.quantity, 0)
const total = billableItems.reduce((s, i) => s + effPrice(i) * i.quantity, 0)
if (itemsFiltered.length === 0) {
return <p style={{ color: '#b8bdc4', fontSize: 13, textAlign: 'center', padding: '12px 0' }}>Κανένα αντικείμενο</p>
@@ -128,6 +132,8 @@ function ItemsTab({ order }) {
// Column widths: [item info] [ordered by] [paid by] [pay type] [total]
const GRID = '1fr 150px 150px 110px 80px'
const EVENT_COLORS = { modifier_applied: '#2563eb', waiter_discount: '#7c3aed', free_item_added: '#16a34a' }
return (
<div style={{ borderRadius: 10, border: '1px solid #edeff1', overflow: 'hidden', marginBottom: 16 }}>
{/* Header */}
@@ -141,23 +147,37 @@ function ItemsTab({ order }) {
{itemsFiltered.map(item => {
const isCancelled = item.status === 'cancelled'
const hasPriceAdj = !!(item.price_adjustment && item.price_adjustment !== 0)
const isDealItem = !!item.deal_id
const itemEvents = (priceEventsMap[item.id] ?? []).filter(ev => ['modifier_applied','waiter_discount','free_item_added'].includes(ev.event_type))
return (
<div key={item.id} style={{
display: 'grid', gridTemplateColumns: GRID, gap: 12,
padding: '9px 14px', borderTop: '1px solid #f4f4f2',
opacity: isCancelled ? 0.45 : 1,
background: isCancelled ? '#fef2f2' : item.status === 'closed' ? '#fffbeb' : 'white',
}}>
<div key={item.id} style={{ borderTop: '1px solid #f4f4f2', opacity: isCancelled ? 0.45 : 1, background: isCancelled ? '#fef2f2' : item.status === 'closed' ? '#fffbeb' : 'white' }}>
<div style={{ display: 'grid', gridTemplateColumns: GRID, gap: 12, padding: '9px 14px' }}>
{/* Item info */}
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#111315', textDecoration: isCancelled ? 'line-through' : 'none' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#111315', textDecoration: isCancelled ? 'line-through' : 'none', display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
{item.product?.name ?? `#${item.product_id}`}
{isDealItem && <span style={{ fontSize: 10, fontWeight: 700, padding: '1px 5px', borderRadius: 4, background: 'rgba(167,139,250,0.12)', color: '#7c3aed', border: '1px solid rgba(167,139,250,0.3)' }}>ΔΩΡΕΑΝ</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: '#8a9099', fontFamily: 'ui-monospace,monospace' }}>×{item.quantity}</span>
<span style={{ fontSize: 12, color: '#374151', fontFamily: 'ui-monospace,monospace' }}>{fmtEUR(item.unit_price)}</span>
<span style={{ fontSize: 12, color: hasPriceAdj ? '#3b82f6' : '#374151', fontFamily: 'ui-monospace,monospace' }}>{fmtEUR(effPrice(item))}</span>
{hasPriceAdj && (
<span style={{ fontSize: 10, color: '#9ca3af', fontFamily: 'ui-monospace,monospace', textDecoration: 'line-through' }}>{fmtEUR(item.unit_price)}</span>
)}
<StatusPill status={item.status} />
</div>
{itemEvents.map((ev, ei) => {
const evColor = EVENT_COLORS[ev.event_type] || '#8a9099'
const evLabel = ev.modifier_name || ev.deal_name || (ev.event_type === 'waiter_discount' ? 'Έκπτωση' : ev.event_type === 'free_item_added' ? 'Δωρεάν' : ev.event_type)
return (
<div key={ei} style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, fontSize: 11, color: evColor }}>
<span style={{ opacity: 0.6 }}></span>
<span style={{ flex: 1 }}>{evLabel}</span>
{ev.delta_amount != null && <span style={{ fontWeight: 600 }}>{ev.delta_amount > 0 ? '+' : ''}{ev.delta_amount.toFixed(2)} </span>}
</div>
)
})}
</div>
{/* Ordered by */}
@@ -183,8 +203,9 @@ function ItemsTab({ order }) {
</div>
{/* Total */}
<div style={{ fontSize: 13, fontWeight: 700, color: '#111315', textAlign: 'right', fontFamily: 'ui-monospace,monospace', paddingTop: 2 }}>
{fmtEUR(item.unit_price * item.quantity)}
<div style={{ fontSize: 13, fontWeight: 700, color: hasPriceAdj ? '#3b82f6' : '#111315', textAlign: 'right', fontFamily: 'ui-monospace,monospace', paddingTop: 2 }}>
{fmtEUR(effPrice(item) * item.quantity)}
</div>
</div>
</div>
)
@@ -207,6 +228,19 @@ function ItemsTab({ order }) {
// ── Main modal ──────────────────────────────────────────────────────────────
export default function OrderDetailModal({ order, onClose }) {
const [tab, setTab] = useState('items')
const { data: rawPriceEvents = [] } = useQuery({
queryKey: ['price-events', order?.id],
queryFn: () => client.get(`/api/pricing/events/order/${order.id}`).then(r => r.data),
enabled: !!order?.id,
staleTime: 60_000,
})
const priceEventsMap = rawPriceEvents.reduce((acc, ev) => {
if (!acc[ev.order_item_id]) acc[ev.order_item_id] = []
acc[ev.order_item_id].push(ev)
return acc
}, {})
if (!order) return null
const itemsById = Object.fromEntries((order.items || []).map(i => [i.id, i]))
@@ -277,7 +311,7 @@ export default function OrderDetailModal({ order, onClose }) {
{/* Tab content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
{tab === 'items' && <ItemsTab order={order} />}
{tab === 'items' && <ItemsTab order={order} priceEventsMap={priceEventsMap} />}
{tab === 'audit' && <AuditTimeline logs={order.audit_logs} itemsById={itemsById} />}
</div>
</div>

View File

@@ -4,6 +4,101 @@ import { X } from 'lucide-react'
import client from '../../../api/client'
import { fmtEUR, fmtDateTime, fmtTime, fmtDate } from './reportDesignTokens'
function fmtDur(ms) {
const totalMin = Math.round(ms / 60000)
const h = Math.floor(totalMin / 60)
const m = totalMin % 60
return h > 0 ? `${h}ω ${m}λ` : `${m}λ`
}
// ── Breaks timeline bar ──────────────────────────────────────────────────────
function BreaksTimeline({ started_at, ended_at, breaks }) {
if (!breaks || breaks.length === 0) return null
const start = new Date(started_at).getTime()
const end = ended_at ? new Date(ended_at).getTime() : Date.now()
const total = end - start
if (total <= 0) return null
return (
<div style={{ padding: '10px 24px 12px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#8a9099', textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: 6 }}>
Διαλείμματα
</div>
<div style={{ position: 'relative', height: 8, borderRadius: 4, background: '#e2e8f0', overflow: 'hidden' }}>
{/* work = whole bar base */}
{breaks.map((b, i) => {
const bs = new Date(b.started_at).getTime()
const be = b.ended_at ? new Date(b.ended_at).getTime() : Date.now()
const left = Math.max(0, ((bs - start) / total) * 100)
const width = Math.min(100 - left, ((be - bs) / total) * 100)
return (
<div
key={b.id}
title={`Διάλειμμα ${fmtTime(b.started_at)} ${b.ended_at ? fmtTime(b.ended_at) : 'ενεργό'} · Διάρκεια: ${fmtDur(be - bs)}`}
style={{
position: 'absolute', top: 0, height: '100%',
left: `${left}%`, width: `${width}%`,
background: '#f59e0b', borderRadius: 2,
}}
/>
)
})}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 16px', marginTop: 6 }}>
{breaks.map(b => {
const bs = new Date(b.started_at).getTime()
const be = b.ended_at ? new Date(b.ended_at).getTime() : Date.now()
return (
<span key={b.id} style={{ fontSize: 11, color: '#5a6169' }}>
<span style={{ color: '#b45309', fontWeight: 700 }}></span>{' '}
{fmtTime(b.started_at)} {b.ended_at ? fmtTime(b.ended_at) : 'ενεργό'} · {fmtDur(be - bs)}
</span>
)
})}
</div>
</div>
)
}
// ── Order-status distribution bar ───────────────────────────────────────────
function StatusDistributionBar({ counts }) {
const total = counts.all || 1
const segments = [
{ key: 'both', color: '#16a34a', label: 'Πλήρης' },
{ key: 'paid', color: '#2563eb', label: 'Πληρώθηκε' },
{ key: 'ordered', color: '#ca8a04', label: 'Παρήγγειλε' },
{ key: 'unpaid', color: '#ea580c', label: 'Ανοιχτό' },
{ key: 'cancelled', color: '#dc2626', label: 'Ακύρωση' },
].filter(s => counts[s.key] > 0)
if (segments.length === 0) return null
return (
<div style={{ marginTop: 10 }}>
{/* bar */}
<div style={{ display: 'flex', height: 8, borderRadius: 4, overflow: 'hidden', gap: 1 }}>
{segments.map(s => (
<div
key={s.key}
title={`${s.label}: ${counts[s.key]} (${Math.round(counts[s.key] / total * 100)}%)`}
style={{ flex: counts[s.key], background: s.color, minWidth: 2 }}
/>
))}
</div>
{/* legend with % */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 14px', marginTop: 6 }}>
{segments.map(s => (
<span key={s.key} style={{ fontSize: 10, color: '#5a6169', display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: s.color, flexShrink: 0, display: 'inline-block' }} />
{s.label}: {counts[s.key]} ({Math.round(counts[s.key] / total * 100)}%)
</span>
))}
</div>
</div>
)
}
// ── colour rules ────────────────────────────────────────────────────────────
function classify(item, shiftWaiterId) {
if (item.status === 'cancelled')
@@ -210,19 +305,30 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) {
? '✓ Εντάξει'
: discrepancy < 0
? `Έλλειμμα ${fmtEUR(Math.abs(discrepancy))}`
: `Πλεόνασμα ${fmtEUR(discrepancy)}`
: `Πλεόνασμα +${fmtEUR(discrepancy)}`
const discAccent = discrepancy == null
? undefined
: Math.abs(discrepancy) < 0.005 ? '#16a34a' : '#dc2626'
: Math.abs(discrepancy) < 0.005
? '#16a34a'
: discrepancy > 0
? '#16a34a' // surplus → green
: '#dc2626' // shortage → red
const cancelEvents = summary.cancellation_events ?? 0
const cancelItemsQty = summary.orders
? summary.orders.reduce((s, o) => s + o.items.filter(i => i.status === 'cancelled').reduce((ss, i) => ss + i.quantity, 0), 0)
: 0
const cashSales = summary.cash_sales ?? summary.total_collected ?? 0
const cardSales = summary.card_sales ?? 0
const totalRev = cashSales + cardSales
const collectLabel = cardSales > 0
? `${fmtEUR(cashSales)} + ${fmtEUR(cardSales)}`
: fmtEUR(cashSales)
const row2 = [
{ label: 'Σύνολο Παραγγελιών', value: String(totalOrders) },
{ label: 'Εισπράξεις', value: fmtEUR(summary.total_collected), accent: '#2f9e5e' },
{ label: 'Εισπράξεις (μετρητά + κάρτα)', value: collectLabel, accent: '#2f9e5e' },
{
label: 'Ακυρώσεις',
value: cancelEvents > 0 ? `${cancelEvents} / ${cancelItemsQty} είδη` : '—',
@@ -246,14 +352,16 @@ export default function ShiftDetailModal({ shiftId, shiftWaiterId, onClose }) {
)
})()}
{/* Colour legend */}
<div style={{ display: 'flex', gap: 12, padding: '8px 24px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0, flexWrap: 'wrap' }}>
{LEGEND.map(l => (
<div key={l.label} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#5a6169' }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: l.dot, flexShrink: 0 }} />
{l.label}
</div>
))}
{/* Breaks timeline (hidden when no breaks) */}
<BreaksTimeline
started_at={summary.started_at}
ended_at={summary.ended_at}
breaks={summary.breaks || []}
/>
{/* Status distribution bar */}
<div style={{ padding: '8px 24px 10px', background: '#fafafa', borderBottom: '1px solid #edeff1', flexShrink: 0 }}>
<StatusDistributionBar counts={counts} />
</div>
{/* Filter bar */}

View File

@@ -159,23 +159,32 @@ export default function ShiftsOverview({ onNavigate } = {}) {
: <span className="text-slate-400 text-[11px]">Δεν παρακολουθείται</span>}
</TD>
<TD mono align="right">{fmtEUR(s.starting_cash)}</TD>
<TD mono align="right">{fmtEUR(s.total_collected)}</TD>
<TD mono align="right">
<span className="font-semibold text-emerald-700">{fmtEUR(s.cash_sales ?? s.total_collected)}</span>
{(s.card_sales ?? 0) > 0 && (
<span className="block text-[10px] text-slate-400">+{fmtEUR(s.card_sales)} κάρτα</span>
)}
</TD>
<TD mono align="right" className="font-semibold text-slate-900">{fmtEUR(s.net_to_deliver)}</TD>
<TD mono align="right">
{s.counted_cash_end != null ? (
<span className={
s.cash_discrepancy == null || Math.abs(s.cash_discrepancy) < 0.005
? 'text-emerald-600 font-semibold'
: 'text-red-600 font-semibold'
}>
{fmtEUR(s.counted_cash_end)}
{s.cash_discrepancy != null && Math.abs(s.cash_discrepancy) >= 0.005 && (
<span className="block text-[10px] font-medium">
{s.cash_discrepancy > 0 ? '+' : ''}{fmtEUR(s.cash_discrepancy)}
</span>
)}
</span>
) : (
{s.counted_cash_end != null ? (() => {
const d = s.cash_discrepancy
const isClean = d == null || Math.abs(d) < 0.005
const isSurplus = d != null && d > 0.005
const colorClass = isClean ? 'text-emerald-600 font-semibold'
: isSurplus ? 'text-emerald-600 font-semibold'
: 'text-red-600 font-semibold'
return (
<span className={colorClass}>
{fmtEUR(s.counted_cash_end)}
{d != null && Math.abs(d) >= 0.005 && (
<span className="block text-[10px] font-medium">
{isSurplus ? `+${fmtEUR(d)}` : `${fmtEUR(Math.abs(d))}`}
</span>
)}
</span>
)
})() : (
<span className="text-slate-400 text-[11px]"></span>
)}
</TD>