Includes all work to date: - local_backend: FastAPI backend with products, orders, tables, shifts, cloud sync - manager_dashboard: React manager UI with product/category management, reports, settings - waiter_pwa: React PWA for waiter devices - Category reparent endpoint and UI - Waiter domain: local_ip sent on heartbeat, waiter_domain persisted from cloud response - QR code modal in AppInfoTab for waiter domain - Product form: number input spinners removed, category pre-selected on new product - Category row: count badge moved to far right Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
289 lines
13 KiB
JavaScript
289 lines
13 KiB
JavaScript
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 sections = buildSections(item)
|
||
const hasDetails = sections.length > 0
|
||
const [expanded, setExpanded] = useState(false)
|
||
|
||
// 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 || !onLongPress) return
|
||
didLongPress.current = false
|
||
touchStartPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||
pressTimer.current = setTimeout(() => {
|
||
didLongPress.current = true
|
||
onLongPress(item)
|
||
}, 500)
|
||
}
|
||
|
||
function handleTouchMove(e) {
|
||
const dx = Math.abs(e.touches[0].clientX - touchStartPos.current.x)
|
||
const dy = Math.abs(e.touches[0].clientY - touchStartPos.current.y)
|
||
if (dx > 8 || dy > 8) clearTimeout(pressTimer.current)
|
||
}
|
||
|
||
function handleTouchEnd() { clearTimeout(pressTimer.current) }
|
||
|
||
function handleBodyClick() {
|
||
if (didLongPress.current) { didLongPress.current = false; return }
|
||
if (selectable && !isPaid && !isCancelled) onToggle(item.id)
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={`order-item ${isPaid ? 'order-item--paid' : ''} ${isCancelled ? 'order-item--cancelled' : ''} ${selectable && selected ? 'order-item--selected' : ''} ${isLast ? 'order-item--last' : ''}`}
|
||
style={{ userSelect: 'none' }}
|
||
>
|
||
{/* 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={{ color: selected ? '#f59e0b' : '#475569', flexShrink: 0, fontSize: 16 }}>
|
||
{selected ? '☑' : '☐'}
|
||
</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>
|
||
|
||
{/* 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>
|
||
|
||
{/* 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>
|
||
)
|
||
}
|
||
|
||
export default function OrderSummary({ order, selectable = false, selectedIds = [], onToggle, onLongPressItem }) {
|
||
const activeItems = order.items?.filter(i => i.status !== 'cancelled') || []
|
||
const total = activeItems
|
||
.filter(i => i.status !== 'cancelled')
|
||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||
const paidTotal = activeItems
|
||
.filter(i => i.status === 'paid')
|
||
.reduce((s, i) => s + i.unit_price * i.quantity, 0)
|
||
|
||
return (
|
||
<div className="order-summary">
|
||
{activeItems.length === 0 && <p style={{ color: '#64748b', textAlign: 'center' }}>Δεν υπάρχουν αντικείμενα</p>}
|
||
{activeItems.map((item, idx) => (
|
||
<ItemRow
|
||
key={item.id}
|
||
item={item}
|
||
selectable={selectable}
|
||
selected={selectedIds.includes(item.id)}
|
||
onToggle={onToggle}
|
||
onLongPress={onLongPressItem}
|
||
isLast={idx === activeItems.length - 1}
|
||
/>
|
||
))}
|
||
<div className="order-summary__total">
|
||
<span>Σύνολο</span>
|
||
<span>{fmtPrice(total)}</span>
|
||
</div>
|
||
{paidTotal > 0 && paidTotal < total && (
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: 8, fontSize: 13, color: '#64748b' }}>
|
||
<span>Πληρωμένο</span>
|
||
<span style={{ color: '#22c55e' }}>{fmtPrice(paidTotal)}</span>
|
||
</div>
|
||
)}
|
||
{paidTotal > 0 && paidTotal < total && (
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: 8, fontSize: 13, color: '#94a3b8' }}>
|
||
<span>Εκκρεμεί</span>
|
||
<span style={{ color: '#f59e0b', fontWeight: 700 }}>{fmtPrice(total - paidTotal)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|