- Manager dashboard: replaced monolithic DashboardTab/OperationsPage with new DashboardPage; added OrderDetailModal, ShiftDetailModal, DeleteConfirmModal, PaymentMethodModal; updated Sidebar routing and App navigation - Reports: reworked WorkDaySummary, OrderHistory, ShiftsOverview with detail modals - Backend routers: extended orders, reports, shifts, products, business_day endpoints; updated cloud_sync service - Waiter PWA: refreshed app icons, improved ConnectionLostModal UX, updated TableCard, SSEContext, connectionStore; added useProductCache hook; vite config tweaks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
838 lines
38 KiB
JavaScript
838 lines
38 KiB
JavaScript
import { useEffect, useState } from 'react'
|
||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||
import ProductPicker from '../components/ProductPicker'
|
||
import OrderDrawer from '../components/OrderDrawer'
|
||
import client from '../api/client'
|
||
import { useProductCache } from '../hooks/useProductCache'
|
||
|
||
export default function AddItemsPage() {
|
||
const { tableId } = useParams()
|
||
const [searchParams] = useSearchParams()
|
||
const isNewTable = searchParams.get('new') === '1'
|
||
const navigate = useNavigate()
|
||
|
||
const { products, categories } = useProductCache()
|
||
|
||
const [cart, setCart] = useState([])
|
||
const [orderId, setOrderId] = useState(null)
|
||
const [sending, setSending] = useState(false)
|
||
const [retrying, setRetrying] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [printAck, setPrintAck] = useState(null)
|
||
const [cartOpen, setCartOpen] = useState(false)
|
||
const [editItem, setEditItem] = useState(null) // { cartKey, product, drawerState }
|
||
const [viewAllOpen, setViewAllOpen] = useState(false)
|
||
const [searchOpen, setSearchOpen] = useState(false)
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
|
||
useEffect(() => {
|
||
async function load() {
|
||
const statusRes = await client.get(`/api/tables/${tableId}/status`)
|
||
setOrderId(statusRes.data.active_order_id)
|
||
|
||
// Pre-populate cart from "order again" if present
|
||
const stored = sessionStorage.getItem('orderAgainItems')
|
||
if (stored) {
|
||
sessionStorage.removeItem('orderAgainItems')
|
||
try {
|
||
const items = JSON.parse(stored)
|
||
const initialCart = items.map(it => ({
|
||
...it,
|
||
_key: Date.now() + Math.random(),
|
||
}))
|
||
setCart(initialCart)
|
||
} catch {}
|
||
}
|
||
}
|
||
load()
|
||
}, [tableId])
|
||
|
||
// Back button: if this was a new table and nothing was added, leave the table FREE
|
||
function handleBack() {
|
||
if (isNewTable && cart.length === 0) {
|
||
navigate('/tables', { replace: true })
|
||
} else {
|
||
navigate(`/tables/${tableId}`)
|
||
}
|
||
}
|
||
|
||
function addToCart(item) {
|
||
setCart(prev => {
|
||
// Try to find an identical item already in the cart to stack onto.
|
||
// Two items are identical when every meaningful field matches exactly.
|
||
const { _key: _k, _drawerState: _ds, ...newCore } = item
|
||
const matchIdx = prev.findIndex(existing => {
|
||
const { _key, _drawerState, ...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 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
|
||
setCartOpen(false)
|
||
setEditItem({ cartKey: cartItem._key, product, drawerState: cartItem._drawerState })
|
||
}
|
||
|
||
function handleEditSave(updatedItem) {
|
||
setCart(prev => prev.map(i =>
|
||
i._key === editItem.cartKey ? { ...updatedItem, _key: i._key } : i
|
||
))
|
||
setEditItem(null)
|
||
}
|
||
|
||
async function sendOrder() {
|
||
if (cart.length === 0) return
|
||
setSending(true)
|
||
setError('')
|
||
setPrintAck(null)
|
||
setCartOpen(false)
|
||
try {
|
||
// For new (free) tables, open the order now — lazily
|
||
let activeOrderId = orderId
|
||
if (!activeOrderId) {
|
||
const { data: newOrder } = await client.post('/api/orders/', { table_id: Number(tableId) })
|
||
activeOrderId = newOrder.id
|
||
setOrderId(activeOrderId)
|
||
}
|
||
|
||
const res = await client.post(`/api/orders/${activeOrderId}/items`, {
|
||
items: cart.map(({ _key, _drawerState, ...item }) => item),
|
||
})
|
||
const printResults = res.data.print_results ?? []
|
||
const allOk = printResults.length === 0 || printResults.every(r => r.success)
|
||
setPrintAck({ allOk, results: printResults })
|
||
if (allOk) setTimeout(() => navigate('/tables'), 1200)
|
||
} catch (err) {
|
||
setError(err.response?.data?.detail || 'Σφάλμα αποστολής — η παραγγελία δεν στάλθηκε')
|
||
} finally {
|
||
setSending(false)
|
||
}
|
||
}
|
||
|
||
async function retryNow() {
|
||
if (!orderId) return
|
||
setRetrying(true)
|
||
try {
|
||
const res = await client.post(`/api/orders/${orderId}/retry-print`)
|
||
const printResults = res.data.print_results ?? []
|
||
const allOk = printResults.length === 0 || printResults.every(r => r.success)
|
||
setPrintAck({ allOk, results: printResults })
|
||
if (allOk) setTimeout(() => navigate('/tables'), 1200)
|
||
} catch { } finally { setRetrying(false) }
|
||
}
|
||
|
||
function saveAsDraft() { navigate(`/tables/${tableId}`, { replace: true }) }
|
||
function leaveAndContinue() { navigate(`/tables/${tableId}`, { replace: true }) }
|
||
|
||
function getProduct(id) { return products.find(p => p.id === id) }
|
||
|
||
// Returns structured sections for the expanded cart view
|
||
function buildItemSections(item, product) {
|
||
const sections = []
|
||
|
||
if (item.selected_options?.length) {
|
||
const prefIds = new Set(
|
||
(product?.preference_sets || []).flatMap(ps => ps.choices.map(c => c.id))
|
||
)
|
||
// Build a map: prefChoiceId → preference set name
|
||
const prefSetByChoiceId = {}
|
||
;(product?.preference_sets || []).forEach(ps => {
|
||
ps.choices.forEach(c => { prefSetByChoiceId[c.id] = ps.name })
|
||
})
|
||
const quickNames = new Set((product?.quick_options || []).map(o => o.name))
|
||
const extraIds = new Set((product?.options || []).map(o => o.id))
|
||
|
||
// Group prefs: { prefSetName, choiceName, subName }
|
||
const prefGroups = []
|
||
// Group extras: { name, subName, qty } — one entry per unique (id)
|
||
const extraGroups = []
|
||
const quickLines = []
|
||
|
||
let i = 0
|
||
const opts = item.selected_options
|
||
while (i < opts.length) {
|
||
const o = opts[i]
|
||
if (prefIds.has(o.id)) {
|
||
// Collect sub immediately following (id === null)
|
||
const setName = prefSetByChoiceId[o.id] ?? ''
|
||
let subName = null
|
||
if (i + 1 < opts.length && opts[i + 1].id == null) {
|
||
subName = opts[i + 1].name
|
||
i += 2
|
||
} else {
|
||
i++
|
||
}
|
||
// Merge into existing prefGroup for same setName, or create new
|
||
const existing = prefGroups.find(g => g.setName === setName)
|
||
if (existing) {
|
||
// multiple choices from same set (shouldn't normally happen, but handle gracefully)
|
||
existing.values.push(subName ? `${o.name} · ${subName}` : o.name)
|
||
} else {
|
||
prefGroups.push({ setName, values: [subName ? `${o.name} · ${subName}` : o.name] })
|
||
}
|
||
} else if (o.id != null && extraIds.has(o.id)) {
|
||
// Collect sub immediately following
|
||
let subName = null
|
||
if (i + 1 < opts.length && opts[i + 1].id == null) {
|
||
subName = opts[i + 1].name
|
||
i += 2
|
||
} else {
|
||
i++
|
||
}
|
||
// Merge duplicates
|
||
const existing = extraGroups.find(g => g.id === o.id && g.subName === subName)
|
||
if (existing) existing.qty++
|
||
else extraGroups.push({ id: o.id, name: o.name, subName, qty: 1 })
|
||
} else if (quickNames.has(o.name)) {
|
||
quickLines.push(o)
|
||
i++
|
||
} else {
|
||
i++
|
||
}
|
||
}
|
||
|
||
// Deduplicate quick lines: multiple entries of same name → single entry with qty
|
||
const quickDeduped = []
|
||
quickLines.forEach(o => {
|
||
const existing = quickDeduped.find(x => x.name === o.name)
|
||
if (existing) existing._qty = (existing._qty || 1) + 1
|
||
else quickDeduped.push({ ...o, _qty: 1 })
|
||
})
|
||
|
||
if (prefGroups.length > 0) sections.push({ type: 'prefs', lines: prefGroups })
|
||
if (quickDeduped.length > 0) sections.push({ type: 'quick', lines: quickDeduped })
|
||
if (extraGroups.length > 0) sections.push({ type: 'extras', lines: extraGroups })
|
||
}
|
||
|
||
if (item.removed_ingredients?.length) {
|
||
sections.push({ type: 'removed', lines: item.removed_ingredients.map(n => ({ name: n })) })
|
||
}
|
||
|
||
if (item.notes) {
|
||
sections.push({ type: 'note', lines: [{ name: item.notes }] })
|
||
}
|
||
|
||
return sections
|
||
}
|
||
|
||
// Simple flat summary for the collapsed one-liner
|
||
function buildItemSummary(item) {
|
||
const lines = []
|
||
if (item.selected_options?.length) {
|
||
item.selected_options.forEach(o => {
|
||
if (o.price_delta && o.price_delta !== 0)
|
||
lines.push(`${o.name} (${o.price_delta > 0 ? '+' : ''}${o.price_delta.toFixed(2)} €)`)
|
||
else lines.push(o.name)
|
||
})
|
||
}
|
||
if (item.removed_ingredients?.length) lines.push(`Χωρίς: ${item.removed_ingredients.join(', ')}`)
|
||
if (item.notes) lines.push(item.notes)
|
||
return lines
|
||
}
|
||
|
||
// Print-failure dialog
|
||
if (printAck && !printAck.allOk) {
|
||
return (
|
||
<div className="page">
|
||
<header className="top-bar">
|
||
<button className="icon-btn" onClick={() => navigate(`/tables/${tableId}`, { replace: true })}>←</button>
|
||
<span className="top-bar__title">Πρόβλημα εκτύπωσης</span>
|
||
</header>
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 14, padding: 20, overflowY: 'auto' }}>
|
||
<div style={{ background: '#7f1d1d', borderRadius: 14, padding: '14px 16px', border: '1px solid #ef4444' }}>
|
||
<p style={{ fontWeight: 700, fontSize: 15, color: '#fca5a5', marginBottom: 6 }}>⚠ Η παραγγελία αποθηκεύτηκε</p>
|
||
<p style={{ fontSize: 13, color: '#fca5a5', lineHeight: 1.5 }}>Ένας ή περισσότεροι εκτυπωτές δεν ανταποκρίθηκαν.</p>
|
||
</div>
|
||
{printAck.results.map((r, i) => (
|
||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, background: r.success ? '#14532d' : '#431407', border: `1px solid ${r.success ? '#22c55e' : '#c2410c'}`, borderRadius: 12, padding: '10px 14px' }}>
|
||
<span style={{ fontSize: 20 }}>{r.success ? '✓' : '✗'}</span>
|
||
<div style={{ flex: 1 }}>
|
||
<p style={{ fontWeight: 600, fontSize: 14, color: r.success ? '#86efac' : '#fdba74' }}>{r.printer_name}</p>
|
||
{!r.success && <p style={{ fontSize: 12, color: '#fdba74', marginTop: 2 }}>Εκτυπωτής μη προσβάσιμος</p>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
<p style={{ fontSize: 12, color: '#64748b', textAlign: 'center', margin: '4px 0' }}>Επιλέξτε πώς να συνεχίσετε:</p>
|
||
<button className="btn btn--primary" style={{ width: '100%', padding: '14px 16px', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12, opacity: retrying ? 0.7 : 1 }} onClick={retryNow} disabled={retrying}>
|
||
<span style={{ fontSize: 22 }}>🔄</span>
|
||
<div>
|
||
<p style={{ fontWeight: 700, fontSize: 14, margin: 0 }}>{retrying ? 'Επανάληψη…' : 'Επανάληψη τώρα'}</p>
|
||
<p style={{ fontSize: 12, opacity: 0.8, margin: 0, marginTop: 2 }}>Δοκιμή αποστολής στον εκτυπωτή ξανά</p>
|
||
</div>
|
||
</button>
|
||
<button className="btn btn--secondary" style={{ width: '100%', padding: '14px 16px', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }} onClick={saveAsDraft}>
|
||
<span style={{ fontSize: 22 }}>📋</span>
|
||
<div>
|
||
<p style={{ fontWeight: 700, fontSize: 14, margin: 0 }}>Αποθήκευση ως προσχέδιο</p>
|
||
<p style={{ fontSize: 12, opacity: 0.75, margin: 0, marginTop: 2 }}>Τα αντικείμενα μένουν στο τραπέζι με πορτοκαλί ένδειξη</p>
|
||
</div>
|
||
</button>
|
||
<button style={{ width: '100%', padding: '14px 16px', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12, background: '#1e293b', border: '1px solid #334155', borderRadius: 12, color: '#cbd5e1', cursor: 'pointer' }} onClick={leaveAndContinue}>
|
||
<span style={{ fontSize: 22 }}>🕐</span>
|
||
<div>
|
||
<p style={{ fontWeight: 700, fontSize: 14, margin: 0 }}>Συνέχεια (προσχέδιο)</p>
|
||
<p style={{ fontSize: 12, opacity: 0.75, margin: 0, marginTop: 2 }}>Τα αντικείμενα εμφανίζονται ως εκκρεμή στο dashboard</p>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Compact names for the strip preview (max 3 items shown)
|
||
const stripItems = cart.slice(-3).reverse()
|
||
const hiddenCount = cart.length > 3 ? cart.length - 3 : 0
|
||
|
||
return (
|
||
<div className="page" style={{ position: 'relative' }}>
|
||
<header className="top-bar">
|
||
<button className="icon-btn" onClick={handleBack}>←</button>
|
||
<span className="top-bar__title">{isNewTable ? 'Νέα Παραγγελία' : 'Προσθήκη'}</span>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
{/* Search button */}
|
||
<button className="icon-btn" onClick={() => { setSearchQuery(''); setSearchOpen(true) }} title="Αναζήτηση">
|
||
<svg width="20" height="20" 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>
|
||
{/* Categories button */}
|
||
<button className="icon-btn" onClick={() => setViewAllOpen(true)} title="Όλες οι κατηγορίες">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||
<rect x="14" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||
</svg>
|
||
</button>
|
||
{/* Cart button with badge */}
|
||
<button
|
||
className="icon-btn"
|
||
style={{ position: 'relative' }}
|
||
onClick={() => setCartOpen(true)}
|
||
>
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||
<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="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||
</svg>
|
||
{cart.length > 0 && (
|
||
<span style={{
|
||
position: 'absolute', top: -2, right: -2,
|
||
minWidth: 18, height: 18, borderRadius: 9,
|
||
background: 'var(--accent)', color: 'var(--accent-fg)',
|
||
fontSize: 11, fontWeight: 800,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
padding: '0 4px',
|
||
}}>{cart.length}</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Product picker takes all remaining space */}
|
||
{categories.length > 0 && (
|
||
<ProductPicker
|
||
categories={categories}
|
||
products={products}
|
||
onAdd={addToCart}
|
||
viewAllOpen={viewAllOpen}
|
||
setViewAllOpen={setViewAllOpen}
|
||
/>
|
||
)}
|
||
|
||
{/* ── Bottom bar: floating mini-cart + full-width ΑΠΟΣΤΟΛΗ ─────────────── */}
|
||
<div style={{
|
||
background: 'var(--bg2)',
|
||
borderTop: '1px solid var(--border)',
|
||
padding: '10px 12px 14px',
|
||
flexShrink: 0,
|
||
}}>
|
||
{/* Floating compact cart — shown only when there are items */}
|
||
{cart.length > 0 && (
|
||
<div
|
||
onClick={() => setCartOpen(true)}
|
||
style={{
|
||
background: 'var(--bg3)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 12,
|
||
padding: '8px 12px',
|
||
marginBottom: 10,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 4,
|
||
}}
|
||
>
|
||
{stripItems.map(item => {
|
||
const p = getProduct(item.product_id)
|
||
return (
|
||
<div key={item._key} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
<span style={{ fontSize: 12, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{p?.name ?? `#${item.product_id}`}</span>
|
||
<span style={{ fontSize: 12, fontWeight: 700, color: '#f59e0b', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>×{item.quantity}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
{hiddenCount > 0 && (
|
||
<div style={{ fontSize: 11, color: 'var(--muted)', textAlign: 'right' }}>
|
||
+{hiddenCount} ακόμα — δείτε όλα →
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Full-width send button */}
|
||
<button
|
||
className="btn btn--primary btn--lg"
|
||
style={{ width: '100%', opacity: cart.length === 0 ? 0.4 : 1 }}
|
||
onClick={sendOrder}
|
||
disabled={cart.length === 0 || sending || !!printAck?.allOk}
|
||
>
|
||
{sending ? 'Αποστολή…' : `ΑΠΟΣΤΟΛΗ${cart.length > 0 ? ` (${cart.length})` : ''}`}
|
||
</button>
|
||
|
||
{error && <p className="error-msg" style={{ marginTop: 8 }}>{error}</p>}
|
||
</div>
|
||
|
||
{/* ── Cart side drawer ────────────────────────────────────────────────── */}
|
||
<>
|
||
{/* Backdrop */}
|
||
<div
|
||
onClick={() => setCartOpen(false)}
|
||
style={{
|
||
position: 'fixed', inset: 0,
|
||
background: 'rgba(0,0,0,0.55)',
|
||
opacity: cartOpen ? 1 : 0,
|
||
pointerEvents: cartOpen ? 'auto' : 'none',
|
||
transition: 'opacity 240ms ease',
|
||
zIndex: 50,
|
||
}}
|
||
/>
|
||
{/* Panel */}
|
||
<div style={{
|
||
position: 'fixed', top: 0, right: 0, bottom: 0,
|
||
width: 'min(88vw, 380px)',
|
||
background: 'var(--bg)',
|
||
borderLeft: '1px solid var(--border)',
|
||
transform: cartOpen ? 'translateX(0)' : 'translateX(100%)',
|
||
transition: 'transform 280ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||
zIndex: 51,
|
||
display: 'flex', flexDirection: 'column',
|
||
boxShadow: '-8px 0 32px rgba(0,0,0,0.4)',
|
||
}}>
|
||
{/* Drawer header */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||
<div>
|
||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>Παραγγελία</div>
|
||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>{cart.length} {cart.length === 1 ? 'προϊόν' : 'προϊόντα'}</div>
|
||
</div>
|
||
<button onClick={() => setCartOpen(false)} style={{ background: 'var(--bg3)', border: 'none', borderRadius: '50%', width: 34, height: 34, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: 'var(--text)' }}>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Item list */}
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||
{cart.length === 0 ? (
|
||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '40px 0', fontSize: 14 }}>Η παραγγελία είναι κενή.</p>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{cart.map(item => {
|
||
const product = getProduct(item.product_id)
|
||
const summaryLines = buildItemSummary(item)
|
||
const sections = buildItemSections(item, product)
|
||
return (
|
||
<CartItem
|
||
key={item._key}
|
||
item={item}
|
||
product={product}
|
||
summaryLines={summaryLines}
|
||
sections={sections}
|
||
onEdit={() => openEditDrawer(item)}
|
||
onRemove={() => removeFromCart(item._key)}
|
||
onChangeQty={qty => changeCartQty(item._key, qty)}
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Drawer footer */}
|
||
<div style={{ padding: '12px 12px 20px', borderTop: '1px solid var(--border)', flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
<button
|
||
className="btn btn--primary btn--lg"
|
||
style={{ width: '100%' }}
|
||
onClick={sendOrder}
|
||
disabled={cart.length === 0 || sending || !!printAck?.allOk}
|
||
>
|
||
{sending ? 'Αποστολή…' : `Αποστολή Παραγγελίας (${cart.length})`}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
|
||
{/* Edit drawer */}
|
||
{editItem && (
|
||
<OrderDrawer
|
||
product={editItem.product}
|
||
isOpen={!!editItem}
|
||
onClose={() => setEditItem(null)}
|
||
onAdd={handleEditSave}
|
||
initialState={editItem.drawerState}
|
||
/>
|
||
)}
|
||
|
||
{/* ── Search modal ─────────────────────────────────────────────────────── */}
|
||
{searchOpen && (
|
||
<SearchModal
|
||
products={products}
|
||
query={searchQuery}
|
||
setQuery={setSearchQuery}
|
||
onClose={() => setSearchOpen(false)}
|
||
onAdd={item => { addToCart(item); setSearchOpen(false) }}
|
||
/>
|
||
)}
|
||
|
||
{/* Full-screen success overlay — blocks all interaction while navigating */}
|
||
{printAck?.allOk && (
|
||
<div style={{
|
||
position: 'fixed', inset: 0, zIndex: 9999,
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||
background: 'rgba(0,0,0,0.72)',
|
||
animation: 'fadeInOverlay 180ms ease',
|
||
}}>
|
||
<div style={{
|
||
background: '#14532d', border: '2px solid #22c55e',
|
||
borderRadius: 20, padding: '36px 48px',
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16,
|
||
animation: 'popIn 220ms cubic-bezier(0.34,1.56,0.64,1)',
|
||
}}>
|
||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none">
|
||
<circle cx="12" cy="12" r="11" stroke="#22c55e" strokeWidth="2"/>
|
||
<path d="M7 12.5l3.5 3.5 6.5-7" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||
</svg>
|
||
<span style={{ color: '#86efac', fontWeight: 700, fontSize: 18, letterSpacing: 0.3 }}>
|
||
Εκτυπώθηκε Επιτυχώς
|
||
</span>
|
||
</div>
|
||
<style>{`
|
||
@keyframes fadeInOverlay { from { opacity: 0 } to { opacity: 1 } }
|
||
@keyframes popIn { from { transform: scale(0.7); opacity: 0 } to { transform: scale(1); opacity: 1 } }
|
||
`}</style>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Cart Item (used in the side drawer) ───────────────────────────────────────
|
||
|
||
const SECTION_META = {
|
||
prefs: { icon: '◉', label: null },
|
||
quick: { icon: '>', label: null },
|
||
extras: { icon: '+', label: null },
|
||
removed: { icon: '−', label: null },
|
||
note: { icon: 'i', label: null },
|
||
}
|
||
|
||
function SectionIcon({ type }) {
|
||
const icons = {
|
||
prefs: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4" fill="#f59e0b"/><circle cx="12" cy="12" r="9" stroke="#f59e0b" strokeWidth="2"/></svg>,
|
||
quick: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="#a3e635" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||
extras: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="#60a5fa" strokeWidth="2.5" strokeLinecap="round"/></svg>,
|
||
removed: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 12h14" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round"/></svg>,
|
||
note: <svg width="13" height="13" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="1.5" fill="#94a3b8"/><path d="M12 7v1M12 16v1" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round"/><circle cx="12" cy="12" r="9" stroke="#94a3b8" strokeWidth="1.5"/></svg>,
|
||
}
|
||
return <span style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}>{icons[type] ?? null}</span>
|
||
}
|
||
|
||
function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onChangeQty }) {
|
||
const [expanded, setExpanded] = useState(false)
|
||
const hasDetails = sections.length > 0
|
||
|
||
return (
|
||
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
|
||
{/* Whole header row is always clickable to expand (qty stepper is always available) */}
|
||
<div
|
||
onClick={() => setExpanded(e => !e)}
|
||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', cursor: 'pointer' }}
|
||
>
|
||
{/* Chevron — always shown */}
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${expanded ? 180 : 0}deg)`, transition: 'transform 180ms', flexShrink: 0, color: 'var(--muted)' }}>
|
||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||
</svg>
|
||
|
||
{/* Name */}
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{product?.name ?? `#${item.product_id}`}
|
||
</div>
|
||
{!expanded && hasDetails && (
|
||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{summaryLines[0]}{summaryLines.length > 1 ? ` +${summaryLines.length - 1}` : ''}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Quantity on the right */}
|
||
<span style={{ color: '#f59e0b', fontSize: 13, fontWeight: 700, fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>×{item.quantity}</span>
|
||
|
||
{/* Edit — stop propagation so it doesn't toggle expand */}
|
||
<button onClick={e => { e.stopPropagation(); onEdit() }} style={{ background: 'none', border: '1px solid var(--border)', borderRadius: 7, color: 'var(--muted)', cursor: 'pointer', padding: '3px 9px', fontSize: 12, fontWeight: 500, flexShrink: 0 }}>
|
||
Επεξ.
|
||
</button>
|
||
{/* Remove */}
|
||
<button onClick={e => { e.stopPropagation(); onRemove() }} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', padding: 4, display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M6 6L18 18M6 18L18 6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/></svg>
|
||
</button>
|
||
</div>
|
||
|
||
{expanded && (
|
||
<div style={{ paddingBottom: 10 }}>
|
||
{sections.map((sec, si) => (
|
||
<div key={si}>
|
||
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
|
||
<div style={{ padding: '6px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||
{sec.type === 'prefs' && sec.lines.map((line, li) => (
|
||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||
<SectionIcon type="prefs" />
|
||
<span style={{ fontSize: 12, lineHeight: 1.4, flex: 1 }}>
|
||
<span style={{ color: 'var(--muted)', display: 'block', fontSize: 11 }}>{line.setName}</span>
|
||
<span style={{ color: 'var(--text)' }}>{line.values.join(' · ')}</span>
|
||
</span>
|
||
</div>
|
||
))}
|
||
{sec.type === 'quick' && sec.lines.map((line, li) => (
|
||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||
<SectionIcon type="quick" />
|
||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||
{line.name}
|
||
{line._qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>}
|
||
</span>
|
||
</div>
|
||
))}
|
||
{sec.type === 'extras' && sec.lines.map((line, li) => (
|
||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||
<SectionIcon type="extras" />
|
||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>
|
||
{line.name}
|
||
{line.subName && <span> · {line.subName}</span>}
|
||
{line.qty > 1 && <span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line.qty}</span>}
|
||
</span>
|
||
</div>
|
||
))}
|
||
{sec.type === 'removed' && sec.lines.map((line, li) => (
|
||
<div key={li} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||
<SectionIcon type="removed" />
|
||
<span style={{ fontSize: 12, color: 'var(--text)', flex: 1 }}>Χωρίς {line.name}</span>
|
||
</div>
|
||
))}
|
||
{sec.type === 'note' && sec.lines.map((line, li) => (
|
||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||
<SectionIcon type="note" />
|
||
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1, whiteSpace: 'pre-wrap' }}>{line.name}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* ── Quick qty row ── */}
|
||
<div style={{ margin: '8px 12px 0', height: 1, background: 'var(--border)' }} />
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16,
|
||
padding: '10px 12px 2px',
|
||
}}>
|
||
<button
|
||
onClick={e => { e.stopPropagation(); onChangeQty(item.quantity - 1) }}
|
||
style={{
|
||
width: 36, height: 36, borderRadius: '50%',
|
||
background: 'var(--bg3)', border: '1px solid var(--border)',
|
||
color: item.quantity <= 1 ? 'var(--muted)' : 'var(--danger)',
|
||
fontSize: 20, fontWeight: 700, cursor: 'pointer',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
}}
|
||
>
|
||
{item.quantity <= 1 ? (
|
||
<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>
|
||
<span style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)', minWidth: 28, textAlign: 'center' }}>
|
||
{item.quantity}
|
||
</span>
|
||
<button
|
||
onClick={e => { e.stopPropagation(); onChangeQty(item.quantity + 1) }}
|
||
style={{
|
||
width: 36, height: 36, borderRadius: '50%',
|
||
background: 'var(--bg3)', border: '1px solid var(--border)',
|
||
color: '#22c55e',
|
||
fontSize: 20, fontWeight: 700, cursor: 'pointer',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
}}
|
||
>+</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Search Modal ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
function SearchModal({ products, query, setQuery, onClose, onAdd }) {
|
||
const [drawerProduct, setDrawerProduct] = useState(null)
|
||
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())
|
||
)
|
||
|
||
function openProduct(p) {
|
||
// Blur the input first so the keyboard dismisses, then open the drawer
|
||
document.activeElement?.blur()
|
||
setDrawerProduct(p)
|
||
}
|
||
|
||
// The modal is position:fixed anchored to bottom:0.
|
||
// When the soft keyboard opens on mobile the browser shrinks the visual
|
||
// viewport and fixed elements reposition automatically — the panel sits
|
||
// right on top of the keyboard without any JS measurement needed.
|
||
return (
|
||
<>
|
||
{/* Dim backdrop — tap to close */}
|
||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 200 }} />
|
||
|
||
{/* Panel: fixed to bottom, grows upward, capped at 60vh so results don't
|
||
push the input off screen on short viewports */}
|
||
<div style={{
|
||
position: 'fixed', left: 0, right: 0, bottom: 0,
|
||
zIndex: 201,
|
||
background: 'var(--bg)',
|
||
borderTop: '1px solid var(--border)',
|
||
display: 'flex', flexDirection: 'column',
|
||
maxHeight: '60vh',
|
||
}}>
|
||
{/* Results scroll area — flex:1 so it takes space above the input */}
|
||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||
{query.trim().length === 0 ? (
|
||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: '16px 20px', fontSize: 14 }}>
|
||
Πληκτρολογήστε για αναζήτηση…
|
||
</p>
|
||
) : results.length === 0 ? (
|
||
<p style={{ textAlign: 'center', color: 'var(--muted)', padding: '16px 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={() => openProduct(p)}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: 12,
|
||
width: '100%', padding: '10px 16px',
|
||
background: 'none', border: 'none', cursor: 'pointer',
|
||
borderBottom: '1px solid var(--border)',
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<div style={{
|
||
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
|
||
background: 'var(--bg3)', 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: 13, fontWeight: 700, color: 'var(--muted)' }}>{initials}</span>
|
||
}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{p.name}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>
|
||
{Number(p.base_price).toFixed(2)} €
|
||
</div>
|
||
</div>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style={{ color: 'var(--muted)', flexShrink: 0 }}>
|
||
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Search input — pinned at the bottom of the panel, above the keyboard */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
padding: '10px 12px 12px',
|
||
borderTop: '1px solid var(--border)',
|
||
flexShrink: 0,
|
||
}}>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" style={{ color: 'var(--muted)', 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
|
||
autoFocus
|
||
value={query}
|
||
onChange={e => setQuery(e.target.value)}
|
||
placeholder="Αναζήτηση προϊόντος…"
|
||
style={{
|
||
flex: 1, height: 44, background: 'var(--bg2)',
|
||
border: '1px solid var(--border)', borderRadius: 12,
|
||
padding: '0 12px', fontSize: 16, color: 'var(--text)',
|
||
fontFamily: 'inherit', outline: 'none',
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
background: 'var(--bg3)', border: 'none', borderRadius: '50%',
|
||
width: 36, height: 36, flexShrink: 0,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
cursor: 'pointer', color: 'var(--text)',
|
||
}}
|
||
>
|
||
<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>
|
||
</div>
|
||
|
||
{/* Product drawer — closes search modal when item is added */}
|
||
{drawerProduct && (
|
||
<OrderDrawer
|
||
product={drawerProduct}
|
||
isOpen
|
||
onClose={() => setDrawerProduct(null)}
|
||
onAdd={item => { onAdd(item); setDrawerProduct(null); onClose() }}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|