Files
xenia-pos-local/waiter_pwa/src/pages/AddItemsPage.jsx
bonamin 5de89a722c feat: major dashboard & waiter PWA overhaul
- 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>
2026-05-21 15:24:54 +03:00

838 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() }}
/>
)}
</>
)
}