general fixes and ordering display overhaul
This commit is contained in:
@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.qb5i81hq8"
|
||||
"revision": "0.8icf0qrbd5"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
@@ -122,7 +122,7 @@ function Row({ selected, onClick, children, right, left, style = {} }) {
|
||||
|
||||
// ── Shared: single quick option row ──────────────────────────────────────────
|
||||
|
||||
function QuickOptionRow({ opt, quickState, setQuickState }) {
|
||||
function QuickOptionRow({ opt, quickState, setQuickState, compact }) {
|
||||
const qty = quickState[opt.id] || 0
|
||||
const selected = qty > 0
|
||||
const toggleSingle = () => setQuickState(s => ({ ...s, [opt.id]: selected ? 0 : 1 }))
|
||||
@@ -144,8 +144,8 @@ function QuickOptionRow({ opt, quickState, setQuickState }) {
|
||||
</div>
|
||||
) : null}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--text)' }}>{opt.name}</div>
|
||||
{opt.price > 0 && <div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2 }}>+{opt.price.toFixed(2)} €{opt.allow_multiple ? ' each' : ''}</div>}
|
||||
<div style={{ fontSize: compact ? 13 : 15, fontWeight: 500, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{opt.name}</div>
|
||||
{opt.price > 0 && <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>+{opt.price.toFixed(2)} €{opt.allow_multiple ? ' each' : ''}</div>}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
@@ -344,7 +344,7 @@ function FavoritesTab({ product, quickState, setQuickState, extrasState, setExtr
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{favorites.map((fav, fi) => {
|
||||
if (fav.type === 'quick') {
|
||||
return <QuickOptionRow key={`quick-${fav.item.id}`} opt={fav.item} quickState={quickState} setQuickState={setQuickState} />
|
||||
return <QuickOptionRow key={`quick-${fav.item.id}`} opt={fav.item} quickState={quickState} setQuickState={setQuickState} compact={false} />
|
||||
}
|
||||
if (fav.type === 'ingredient') {
|
||||
return <IngredientRow key={`ing-${fav.item.id}`} ing={fav.item} removedState={removedState} setRemovedState={setRemovedState} />
|
||||
@@ -375,9 +375,11 @@ function QuickTab({ product, quickState, setQuickState }) {
|
||||
<p style={{ color: 'var(--muted)', textAlign: 'center', padding: '32px 0', fontSize: 14 }}>Δεν υπάρχουν γρήγορες επιλογές.</p>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{quickOptions.map(opt => (
|
||||
<QuickOptionRow key={opt.id} opt={opt} quickState={quickState} setQuickState={setQuickState} />
|
||||
<div key={opt.id} style={{ width: opt.is_compact ? 'calc(50% - 4px)' : '100%', minWidth: 0 }}>
|
||||
<QuickOptionRow opt={opt} quickState={quickState} setQuickState={setQuickState} compact={opt.is_compact} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -484,11 +486,27 @@ function SummaryTab({ product, summaryLines, note, onJumpTab }) {
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} style={{ padding: '10px 14px', background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 10, display: 'flex', alignItems: 'center', gap: 10, minHeight: 44 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginRight: 6, fontVariantNumeric: 'tabular-nums' }}>{l.qty}×</span>}
|
||||
{l.label}
|
||||
</div>
|
||||
{l.detail && <div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 2 }}>{l.detail}</div>}
|
||||
{l.group === 'prefs' ? (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 2 }}>{l.label}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>{l.value}</div>
|
||||
</>
|
||||
) : l.group === 'removed' ? (
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||
Χωρίς {l.label}
|
||||
</div>
|
||||
) : l.group === 'extras' ? (
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||
{l.label}
|
||||
{l.subName && <span> · {l.subName}</span>}
|
||||
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginLeft: 6, fontVariantNumeric: 'tabular-nums' }}>×{l.qty}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||
{l.qty > 1 && <span style={{ color: 'var(--muted)', marginRight: 6, fontVariantNumeric: 'tabular-nums' }}>{l.qty}×</span>}
|
||||
{l.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{l.price !== 0 && <div style={{ fontSize: 13, fontWeight: 600, color: l.price < 0 ? 'var(--danger)' : 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{l.price > 0 ? '+' : ''}{l.price.toFixed(2)} €</div>}
|
||||
</div>
|
||||
@@ -600,9 +618,23 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
|
||||
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 delta = (choice.extra_cost ?? 0) + (inlineSub?.extra_cost ?? 0) + (sharedSub?.extra_cost ?? 0)
|
||||
const label = `${ps.name}: ${choice.name}${inlineSub ? ` · ${inlineSub.name}` : ''}${sharedSub ? ` · ${sharedSub.name}` : ''}`
|
||||
if (delta !== 0 || !choice.id) lines.push({ group: 'prefs', label, qty: 1, price: delta, detail: null })
|
||||
else lines.push({ group: 'prefs', label, qty: 1, price: 0, detail: null })
|
||||
|
||||
// Skip if this is entirely the default selection
|
||||
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) { price += delta; return }
|
||||
|
||||
const value = `${choice.name}${inlineSub ? ` · ${inlineSub.name}` : ''}${sharedSub ? ` · ${sharedSub.name}` : ''}`
|
||||
lines.push({ group: 'prefs', label: ps.name, value, qty: 1, price: delta, detail: null })
|
||||
price += delta
|
||||
})
|
||||
|
||||
@@ -619,12 +651,12 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
|
||||
if (!sel) return
|
||||
const sub = opt.sub_choices?.find(s => s.name === sel.subName)
|
||||
const linePrice = ((opt.extra_cost ?? 0) + (sub?.extra_cost ?? 0)) * sel.qty
|
||||
lines.push({ group: 'extras', label: opt.name, qty: sel.qty, price: linePrice, detail: sub?.name ?? null })
|
||||
lines.push({ group: 'extras', label: opt.name, qty: sel.qty, price: linePrice, subName: sub?.name ?? null, detail: null })
|
||||
price += linePrice
|
||||
})
|
||||
|
||||
ingredients.forEach(ing => {
|
||||
if (removedState[ing.id]) lines.push({ group: 'removed', label: `χωρίς ${ing.name}`, qty: 1, price: 0, detail: null })
|
||||
if (removedState[ing.id]) lines.push({ group: 'removed', label: ing.name, qty: 1, price: 0, detail: null })
|
||||
})
|
||||
|
||||
return { summaryLines: lines, totalPrice: price * qty }
|
||||
@@ -666,13 +698,26 @@ export default function OrderDrawer({ product, isOpen, onClose, onAdd, initialSt
|
||||
const prefChoices = preferenceSets.flatMap(ps => {
|
||||
const choice = prefs[ps.id]
|
||||
if (!choice) return []
|
||||
const entries = [{ id: choice.id, name: choice.name, price_delta: choice.extra_cost ?? 0 }]
|
||||
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
|
||||
|
||||
// Don't emit entries that are entirely at their defaults — nothing changed
|
||||
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 }]
|
||||
if (inlineSub) entries.push({ id: null, name: inlineSub.name, price_delta: inlineSub.extra_cost ?? 0 })
|
||||
if (ps.shared_subset?.choices?.length > 0 && !choice.disables_subset) {
|
||||
const sharedSub = sharedSubs[ps.id] ?? null
|
||||
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 })
|
||||
}
|
||||
if (sharedSub) entries.push({ id: null, name: sharedSub.name, price_delta: sharedSub.extra_cost ?? 0 })
|
||||
return entries
|
||||
})
|
||||
|
||||
|
||||
@@ -1,26 +1,112 @@
|
||||
import { useRef } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
function fmtPrice(v) {
|
||||
return Number(v).toFixed(2) + ' €'
|
||||
}
|
||||
|
||||
// ── Icons ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
// ── Parse selected_options into grouped sections (same logic as cart) ────────
|
||||
|
||||
function buildSections(item) {
|
||||
const sections = []
|
||||
const opts = (() => {
|
||||
try { return item.selected_options ? JSON.parse(item.selected_options) : [] } catch { return [] }
|
||||
})()
|
||||
const removed = (() => {
|
||||
try { return item.removed_ingredients ? JSON.parse(item.removed_ingredients) : [] } catch { return [] }
|
||||
})()
|
||||
|
||||
// We don't have product metadata here, so we classify by heuristics:
|
||||
// - id != null → could be a pref choice or extra; we use the _type hint if present, else we group them
|
||||
// - id == null → sub-choice (follows its parent)
|
||||
// Strategy: walk through opts in order, attaching sub-choices to their parent,
|
||||
// then classify parent items: items with a real id that appear multiple times → extra (stacked),
|
||||
// but without product metadata we can't fully distinguish prefs from extras.
|
||||
// We use a simple rule: if an option with id appears only once in the stream → treat as pref
|
||||
// (since extras can be added multiple times). This matches how handleAdd() emits them.
|
||||
|
||||
const prefGroups = [] // { setName: null (unknown), values: [...] }
|
||||
const extraGroups = [] // { id, name, subName, qty }
|
||||
const quickLines = [] // { name, _qty }
|
||||
|
||||
// Count how many times each id appears (extras can be stacked → appear multiple times)
|
||||
const idCount = {}
|
||||
opts.forEach(o => { if (o.id != null) idCount[o.id] = (idCount[o.id] || 0) + 1 })
|
||||
|
||||
// Single pass: consume each item and its optional following sub (id=null)
|
||||
const consumedAsSubAtIndex = new Set()
|
||||
let i = 0
|
||||
while (i < opts.length) {
|
||||
const o = opts[i]
|
||||
if (consumedAsSubAtIndex.has(i)) { i++; continue }
|
||||
|
||||
if (o.id == null) {
|
||||
// Standalone id=null → quick option
|
||||
const existing = quickLines.find(x => x.name === o.name)
|
||||
if (existing) existing._qty = (existing._qty || 1) + 1
|
||||
else quickLines.push({ name: o.name, _qty: 1 })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// id != null — look ahead for immediate sub
|
||||
let subName = null
|
||||
if (i + 1 < opts.length && opts[i + 1].id == null) {
|
||||
subName = opts[i + 1].name
|
||||
consumedAsSubAtIndex.add(i + 1)
|
||||
}
|
||||
|
||||
if (idCount[o.id] > 1) {
|
||||
// Extra — appears multiple times in the list
|
||||
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 {
|
||||
// Single occurrence → preference choice
|
||||
const value = subName ? `${o.name} · ${subName}` : o.name
|
||||
prefGroups.push({ setName: null, values: [value] })
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if (prefGroups.length > 0) sections.push({ type: 'prefs', lines: prefGroups })
|
||||
if (quickLines.length > 0) sections.push({ type: 'quick', lines: quickLines })
|
||||
if (extraGroups.length > 0) sections.push({ type: 'extras', lines: extraGroups })
|
||||
if (removed.length > 0) sections.push({ type: 'removed', lines: removed.map(n => ({ name: n })) })
|
||||
if (item.notes) sections.push({ type: 'note', lines: [{ name: item.notes }] })
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
// ── ItemRow ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast }) {
|
||||
const isPaid = item.status === 'paid'
|
||||
const isCancelled = item.status === 'cancelled'
|
||||
const isStacked = item.quantity > 1
|
||||
|
||||
let opts = []
|
||||
try { opts = item.selected_options ? JSON.parse(item.selected_options) : [] } catch {}
|
||||
let removed = []
|
||||
try { removed = item.removed_ingredients ? JSON.parse(item.removed_ingredients) : [] } catch {}
|
||||
const sections = buildSections(item)
|
||||
const hasDetails = sections.length > 0
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
// Long-press detection — only fires if the finger hasn't moved (avoids triggering during scroll)
|
||||
// Long-press detection
|
||||
const pressTimer = useRef(null)
|
||||
const didLongPress = useRef(false)
|
||||
const touchStartPos = useRef({ x: 0, y: 0 })
|
||||
|
||||
function handleTouchStart(e) {
|
||||
if (!selectable || isPaid || isCancelled || !isStacked || !onLongPress) return
|
||||
if (!selectable || isPaid || isCancelled || !onLongPress) return
|
||||
didLongPress.current = false
|
||||
touchStartPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||
pressTimer.current = setTimeout(() => {
|
||||
@@ -35,11 +121,9 @@ function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast })
|
||||
if (dx > 8 || dy > 8) clearTimeout(pressTimer.current)
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
clearTimeout(pressTimer.current)
|
||||
}
|
||||
function handleTouchEnd() { clearTimeout(pressTimer.current) }
|
||||
|
||||
function handleClick() {
|
||||
function handleBodyClick() {
|
||||
if (didLongPress.current) { didLongPress.current = false; return }
|
||||
if (selectable && !isPaid && !isCancelled) onToggle(item.id)
|
||||
}
|
||||
@@ -47,31 +131,115 @@ function ItemRow({ item, selectable, selected, onToggle, onLongPress, isLast })
|
||||
return (
|
||||
<div
|
||||
className={`order-item ${isPaid ? 'order-item--paid' : ''} ${isCancelled ? 'order-item--cancelled' : ''} ${selectable && selected ? 'order-item--selected' : ''} ${isLast ? 'order-item--last' : ''}`}
|
||||
onClick={handleClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
style={{ cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default', userSelect: 'none' }}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<div className="order-item__row">
|
||||
{/* Main row — click to select */}
|
||||
<div
|
||||
onClick={handleBodyClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 12px',
|
||||
cursor: selectable && !isPaid && !isCancelled ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
{/* Selection checkbox */}
|
||||
{selectable && !isPaid && !isCancelled && (
|
||||
<span style={{ marginRight: 8, color: selected ? '#f59e0b' : '#475569' }}>
|
||||
<span style={{ color: selected ? '#f59e0b' : '#475569', flexShrink: 0, fontSize: 16 }}>
|
||||
{selected ? '☑' : '☐'}
|
||||
</span>
|
||||
)}
|
||||
<span className="order-item__name">{item.product?.name || `#${item.product_id}`}</span>
|
||||
|
||||
{/* Name + badges */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className="order-item__name">{item.product?.name || `#${item.product_id}`}</span>
|
||||
{isPaid && <span className="badge badge--paid">Paid</span>}
|
||||
{isCancelled && <span className="badge badge--cancelled">Cancelled</span>}
|
||||
{!isPaid && !isCancelled && !item.printed && (
|
||||
<span className="badge badge--draft" title="Δεν εκτυπώθηκε ακόμα">⏳</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Qty + price */}
|
||||
<span className="order-item__qty">×{item.quantity}</span>
|
||||
<span className="order-item__price">{fmtPrice(item.unit_price * item.quantity)}</span>
|
||||
{isPaid && <span className="badge badge--paid">Paid</span>}
|
||||
{isCancelled && <span className="badge badge--cancelled">Cancelled</span>}
|
||||
{!isPaid && !isCancelled && !item.printed && (
|
||||
<span className="badge badge--draft" title="Δεν εκτυπώθηκε ακόμα">⏳</span>
|
||||
|
||||
{/* Expand arrow — only if there are details; stops propagation so it doesn't trigger select */}
|
||||
{hasDetails && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setExpanded(v => !v) }}
|
||||
style={{
|
||||
background: 'none', border: 'none', padding: 4, cursor: 'pointer',
|
||||
color: 'var(--muted)', display: 'flex', alignItems: 'center', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
style={{ transform: `rotate(${expanded ? 180 : 0}deg)`, transition: 'transform 180ms' }}>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{opts.map((o, i) => <div key={i} className="order-item__modifier">+ {o.name} {o.price_delta > 0 ? `(+${fmtPrice(o.price_delta)})` : ''}</div>)}
|
||||
{removed.map((r, i) => <div key={i} className="order-item__modifier">- {r}</div>)}
|
||||
{item.notes && <div className="order-item__modifier">📝 {item.notes}</div>}
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && hasDetails && (
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
{sections.map((sec, si) => (
|
||||
<div key={si}>
|
||||
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
|
||||
<div style={{ padding: '5px 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 }}>
|
||||
{line.setName && (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,20 @@ export default function AddItemsPage() {
|
||||
setCategories(catRes.data)
|
||||
setProducts(prodRes.data)
|
||||
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])
|
||||
@@ -139,28 +153,65 @@ export default function AddItemsPage() {
|
||||
const sections = []
|
||||
|
||||
if (item.selected_options?.length) {
|
||||
// Group consecutive options into logical sections by type
|
||||
// Prefs: options that match a preference choice (have a real id matching preference_sets choices)
|
||||
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))
|
||||
|
||||
const prefLines = []
|
||||
// Group prefs: { prefSetName, choiceName, subName }
|
||||
const prefGroups = []
|
||||
// Group extras: { name, subName, qty } — one entry per unique (id)
|
||||
const extraGroups = []
|
||||
const quickLines = []
|
||||
const extraLines = []
|
||||
|
||||
item.selected_options.forEach(o => {
|
||||
if (prefIds.has(o.id)) prefLines.push(o)
|
||||
else if (o.id != null && extraIds.has(o.id)) extraLines.push(o)
|
||||
else if (quickNames.has(o.name)) quickLines.push(o)
|
||||
else if (o.id == null) {
|
||||
// sub-choice — attach to last extra or pref line
|
||||
if (extraLines.length > 0) extraLines.push({ ...o, _sub: true })
|
||||
else if (prefLines.length > 0) prefLines.push({ ...o, _sub: true })
|
||||
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 = []
|
||||
@@ -170,9 +221,9 @@ export default function AddItemsPage() {
|
||||
else quickDeduped.push({ ...o, _qty: 1 })
|
||||
})
|
||||
|
||||
if (prefLines.length > 0) sections.push({ type: 'prefs', lines: prefLines })
|
||||
if (prefGroups.length > 0) sections.push({ type: 'prefs', lines: prefGroups })
|
||||
if (quickDeduped.length > 0) sections.push({ type: 'quick', lines: quickDeduped })
|
||||
if (extraLines.length > 0) sections.push({ type: 'extras', lines: extraLines })
|
||||
if (extraGroups.length > 0) sections.push({ type: 'extras', lines: extraGroups })
|
||||
}
|
||||
|
||||
if (item.removed_ingredients?.length) {
|
||||
@@ -196,7 +247,7 @@ export default function AddItemsPage() {
|
||||
else lines.push(o.name)
|
||||
})
|
||||
}
|
||||
if (item.removed_ingredients?.length) lines.push(`χωρίς: ${item.removed_ingredients.join(', ')}`)
|
||||
if (item.removed_ingredients?.length) lines.push(`Χωρίς: ${item.removed_ingredients.join(', ')}`)
|
||||
if (item.notes) lines.push(item.notes)
|
||||
return lines
|
||||
}
|
||||
@@ -502,29 +553,48 @@ function CartItem({ item, product, summaryLines, sections, onEdit, onRemove, onC
|
||||
<div style={{ paddingBottom: 10 }}>
|
||||
{sections.map((sec, si) => (
|
||||
<div key={si}>
|
||||
{/* Divider between sections */}
|
||||
<div style={{ margin: '0 12px', height: 1, background: 'var(--border)' }} />
|
||||
<div style={{ padding: '6px 12px 2px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{sec.lines.map((line, li) => (
|
||||
{sec.type === 'prefs' && sec.lines.map((line, li) => (
|
||||
<div key={li} style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
|
||||
<SectionIcon type={line._sub ? 'quick' : sec.type} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.4, flex: 1 }}>
|
||||
{sec.type === 'note' ? line.name : (
|
||||
<>
|
||||
{line.name}
|
||||
{line._qty > 1 && (
|
||||
<span style={{ color: '#f59e0b', marginLeft: 4, fontWeight: 700 }}>×{line._qty}</span>
|
||||
)}
|
||||
{line.price_delta !== 0 && line.price_delta != null && (
|
||||
<span style={{ color: 'var(--muted)', marginLeft: 4 }}>
|
||||
({line.price_delta > 0 ? '+' : ''}{line.price_delta.toFixed(2)} €)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -81,6 +81,80 @@ function SplitModal({ item, onConfirm, onClose }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Item action modal (long-press) ──────────────────────────────────────────
|
||||
|
||||
function ItemActionModal({ target, onOrderAgain, onSplit, onClose }) {
|
||||
const { items, singleStacked, multiSelect } = target
|
||||
const label = multiSelect
|
||||
? `${items.length} αντικείμενα επιλεγμένα`
|
||||
: items[0]?.product?.name || `#${items[0]?.product_id}`
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-sheet" onClick={e => e.stopPropagation()} style={{ gap: 0 }}>
|
||||
<div className="modal-handle" />
|
||||
<p style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 13, margin: '0 0 16px' }}>{label}</p>
|
||||
|
||||
<button
|
||||
onClick={onOrderAgain}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '16px 4px', background: 'none', border: 'none',
|
||||
borderBottom: singleStacked && !multiSelect ? '1px solid var(--border)' : 'none',
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
|
||||
background: 'rgba(245,158,11,0.15)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M1 4v6h6M23 20v-6h-6" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4-4.64 4.36A9 9 0 0 1 3.51 15" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: '#f59e0b' }}>Παραγγελία ξανά</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Προσθήκη στο νέο καλάθι</div>
|
||||
</div>
|
||||
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}>›</span>
|
||||
</button>
|
||||
|
||||
{singleStacked && !multiSelect && (
|
||||
<button
|
||||
onClick={onSplit}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '16px 4px', background: 'none', border: 'none',
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: 38, height: 38, borderRadius: 10, flexShrink: 0,
|
||||
background: 'rgba(96,165,250,0.15)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5" stroke="#60a5fa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: '#60a5fa' }}>Διαχωρισμός</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 1 }}>Χώρισμα σε δύο γραμμές</div>
|
||||
</div>
|
||||
<span style={{ marginLeft: 'auto', color: 'var(--muted)', fontSize: 18 }}>›</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="btn btn--secondary" style={{ width: '100%', marginTop: 12 }} onClick={onClose}>
|
||||
Άκυρο
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Actions top sheet ────────────────────────────────────────────────────────
|
||||
|
||||
function ActionsSheet({ order, tableId, onClose, onTransfer, onMerge, onSetFlags, onAssignWaiter, onPrintSynopsis }) {
|
||||
@@ -429,6 +503,7 @@ export default function TableDetailPage() {
|
||||
const [allWaiters, setAllWaiters] = useState([])
|
||||
const [actionDataLoading, setActionDataLoading] = useState(false)
|
||||
const [splitItem, setSplitItem] = useState(null)
|
||||
const [itemActionTarget, setItemActionTarget] = useState(null) // { items: [...], singleStacked: bool }
|
||||
|
||||
const scrollRef = useRef(null)
|
||||
|
||||
@@ -800,7 +875,15 @@ export default function TableDetailPage() {
|
||||
selectable={canInteract && !paying}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleItem}
|
||||
onLongPressItem={(item) => { setSplitItem(item) }}
|
||||
onLongPressItem={(item) => {
|
||||
// If multiple items are selected, order-again all selected items
|
||||
if (selectedIds.length > 1) {
|
||||
const items = activeItems.filter(i => selectedIds.includes(i.id))
|
||||
setItemActionTarget({ items, singleStacked: false, multiSelect: true })
|
||||
} else {
|
||||
setItemActionTarget({ items: [item], singleStacked: item.quantity > 1, multiSelect: false })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Floating controls row — only visible when items are selected */}
|
||||
@@ -937,6 +1020,32 @@ export default function TableDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Item action modal (long-press) */}
|
||||
{itemActionTarget && (
|
||||
<ItemActionModal
|
||||
target={itemActionTarget}
|
||||
onOrderAgain={() => {
|
||||
const items = itemActionTarget.items
|
||||
sessionStorage.setItem('orderAgainItems', JSON.stringify(
|
||||
items.map(it => ({
|
||||
product_id: it.product_id,
|
||||
quantity: it.quantity,
|
||||
selected_options: (() => { try { return JSON.parse(it.selected_options || '[]') } catch { return [] } })(),
|
||||
removed_ingredients: (() => { try { return JSON.parse(it.removed_ingredients || '[]') } catch { return [] } })(),
|
||||
notes: it.notes || '',
|
||||
}))
|
||||
))
|
||||
setItemActionTarget(null)
|
||||
navigate(`/tables/${tableId}/add`)
|
||||
}}
|
||||
onSplit={() => {
|
||||
setSplitItem(itemActionTarget.items[0])
|
||||
setItemActionTarget(null)
|
||||
}}
|
||||
onClose={() => setItemActionTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Split stepper modal */}
|
||||
{splitItem && (
|
||||
<SplitModal
|
||||
|
||||
Reference in New Issue
Block a user