general fixes and ordering display overhaul

This commit is contained in:
2026-04-30 16:58:13 +03:00
parent 1fd7d16ec9
commit 8e27b7666e
19 changed files with 1470 additions and 335 deletions

View File

@@ -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"), {

View File

@@ -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
})

View File

@@ -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>
)
}

View File

@@ -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>
))}

View File

@@ -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