/** * KDS — Kitchen Display System * Fullscreen standalone page. No AppLayout wrapper. * Designed for tablets/touchscreens running Fully Kiosk or similar. */ import { useState, useEffect, useRef, useMemo, useCallback, Fragment } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Timer, Flame, CheckCheck, Undo2, Redo2, BellRing, Volume2, VolumeX, Settings, X, Check, Minus, Plus, ArrowRight, Info, User, CookingPot, Utensils, ShoppingBag, Bike, ZoomIn, ZoomOut, Clock, Printer, Ban, } from 'lucide-react' import client from '../api/client' import useAuthStore from '../store/authStore' // ───────────────────────────────────────────────────────────────────────────── // Unit formatting helpers // ───────────────────────────────────────────────────────────────────────────── const UNIT_LABELS = { kg: 'kg', liter: 'L', gram: 'g', ml: 'mL' } function fmtItemQty(quantity, unitType) { const label = UNIT_LABELS[unitType] if (!label) return String(quantity) if (unitType === 'kg' || unitType === 'liter') { return `${Number(quantity).toFixed(1)}${label}` } return `${quantity}${label}` } // ───────────────────────────────────────────────────────────────────────────── // Constants & design tokens // ───────────────────────────────────────────────────────────────────────────── const COL_W_DEFAULT = 272 // base card width, overridden by settings.colW const COL_GAP = 14 const CARD_GAP_Y = 14 const BOARD_PAD = 14 const BLOCK_GAP = 4 // vertical gap between item blocks — keep compact const BODY_PAD_T = 8 // body top padding const BODY_PAD_B = 10 // body bottom padding (a bit extra so last item clears footer) const BODY_PAD_H = 10 const MAX_UNITS = 3 // These are BASE chrome heights at fontScale=1. Scale them by fontScale // when computing cardChrome() so layout is accurate at any zoom level. const CARD_HEAD_H_BASE = 46 const CARD_SUB_H_BASE = 28 const CARD_FOOT_H_BASE = 32 // Note block: spacer (6) + icon line (≈20) + bottom pad (8) + border (1) const CARD_NOTE_H_BASE = 35 const THR_DEFAULT = { green: 5, amber: 10, flash: 15 } const STORAGE_KEY = 'xenia_kds_settings_v2' const FONT_SCALE_MIN = 0.6 const FONT_SCALE_MAX = 2.0 const FONT_SCALE_STEP = 0.1 const COLORS = { bg: '#1d232b', rail: '#161b21', railLine: '#2b333d', topbar: '#20272f', tab: '#2b333d', tabInk: '#aab4c0', tabActiveBg: '#f3f6f9', tabActiveInk: '#1d232b', card: '#ffffff', cardSub: '#2c333c', cardSubInk: '#c5cdd7', cardFoot: '#eef1f5', cardFootInk: '#41474f', ink: '#1f242b', ink2: '#5b626c', qtyBg: '#f0f2f5', qtyInk: '#2b3038', line: '#e8ebef', doneInk: '#aeb4bc', green: '#34b56a', greenD: '#1e8048', // much darker subheader green amber: '#f0a52a', amberD: '#b87310', // much darker subheader amber red: '#e8513f', redD: '#a02d1e', // much darker subheader red future: '#6dd08a', // side-rail badge colour futureD: '#3ea85e', futureHead: '#4a5568', // grey card header for future orders futureSubBg: '#374151', // grey subheader inactive: '#3d4757', // done card header (user-configurable) inactiveD: '#2a3140', // done card subheader (auto-derived darker) accent: '#2f7ff0', railW: 104, topbarH: 54, // Category glow colors — used when multiple filters are active glowFuture: '#6dd08a', glowPending: '#f0a52a', glowPreparing:'#2f7ff0', glowDone: '#e7ecf2', } // ───────────────────────────────────────────────────────────────────────────── // Chime // ───────────────────────────────────────────────────────────────────────────── let _audio = null function chime() { try { _audio = _audio || new (window.AudioContext || window.webkitAudioContext)() if (_audio.state === 'suspended') _audio.resume() const t = _audio.currentTime ;[880, 1320].forEach((f, i) => { const o = _audio.createOscillator(), g = _audio.createGain() o.type = 'sine'; o.frequency.value = f o.connect(g); g.connect(_audio.destination) const s = t + i * 0.13 g.gain.setValueAtTime(0.0001, s) g.gain.exponentialRampToValueAtTime(0.28, s + 0.02) g.gain.exponentialRampToValueAtTime(0.0001, s + 0.26) o.start(s); o.stop(s + 0.28) }) } catch { /* ignore */ } } // ───────────────────────────────────────────────────────────────────────────── // Time helpers // Fix #1: backend returns UTC ISO strings without 'Z' suffix from SQLite, // so new Date() may treat them as local. We append 'Z' to force UTC parse. // ───────────────────────────────────────────────────────────────────────────── function parseUTC(isoStr) { if (!isoStr) return null // If already has tz info (Z or +xx:xx), use as-is; otherwise treat as UTC return new Date(/[Z+]/.test(isoStr) ? isoStr : isoStr + 'Z') } function minutesSince(isoStr, now) { const d = parseUTC(isoStr) if (!d) return 0 return Math.floor((now - d.getTime()) / 60_000) } function fmtDuration(absM) { if (absM >= 60) return `${Math.floor(absM / 60)}h ${absM % 60}m` return `${absM}m` } function fmtAgo(absM) { if (absM >= 60) return `${Math.floor(absM / 60)}h ${absM % 60}m ago` return `${absM}m ago` } function ageState(order, now, thr) { // Done orders: show elapsed time since completion (closed_at or fallback to opened_at) if (order.kds_status === 'done') { const ref = order.closed_at || order.opened_at const m = Math.max(0, minutesSince(ref, now)) return { label: fmtAgo(m), age: 'done', flash: false } } const m = minutesSince(order.opened_at, now) if (m < 0) { // future: show countdown (time remaining until scheduled) return { label: fmtDuration(Math.abs(m)), age: 'future', flash: false } } const label = m === 0 ? 'new' : fmtDuration(m) if (m >= thr.flash) return { label, age: 'red', flash: true } if (m >= thr.amber) return { label, age: 'red', flash: false } if (m >= thr.green) return { label, age: 'amber', flash: false } return { label, age: 'green', flash: false } } // ───────────────────────────────────────────────────────────────────────────── // Layout engine // // THE RULE: no vertical scrolling. Every card must fit within maxH (the // available board height). Cards widen into extra columns until they fit. // If even MAX_UNITS columns aren't enough, the last column overflows — that // is the only acceptable compromise and only happens with extremely tall items. // // Cards are absolutely positioned. Their CSS height is set explicitly from // the layout so the board inner div has a known height (no auto-height surprises). // ───────────────────────────────────────────────────────────────────────────── function colHeight(col, blocks, gap) { if (!col.length) return 0 let h = 0 for (const idx of col) h += blocks[idx] return h + (col.length - 1) * gap } // Distribute items across columns, targeting bodyH per column. function distribute(blocks, gap, bodyH) { if (!blocks.length) return [[]] const cols = [] let cur = [], curH = 0 for (let i = 0; i < blocks.length; i++) { const h = blocks[i] const tentative = cur.length ? curH + gap + h : h if (cur.length && tentative > bodyH) { cols.push(cur); cur = [i]; curH = h } else { cur.push(i); curH = tentative } } if (cur.length) cols.push(cur) return cols } // Size a card so it fits within maxH. Increases column count until it fits. // KEY FIX: each attempt divides availBodyH by the number of columns being tried, // so more columns = shorter per-column target = more splits = shorter card. function sizeCard({ blocks, blockGap, chrome, maxH, base, maxUnits = MAX_UNITS }) { const availBodyH = Math.max(40, maxH - chrome) for (let units = 1; units <= maxUnits; units++) { // Divide the body height equally among `units` columns. // This is the per-column height budget that drives splitting. const perColH = availBodyH / units const cols = distribute(blocks, blockGap, perColH) // Fold any overflow columns into the last slot let finalCols = cols if (cols.length > units) { const head = cols.slice(0, units - 1) const tailIdx = [] for (let i = units - 1; i < cols.length; i++) tailIdx.push(...cols[i]) finalCols = [...head, tailIdx] } const actualUnits = finalCols.length let maxColH = 0 for (const c of finalCols) maxColH = Math.max(maxColH, colHeight(c, blocks, blockGap)) const cardH = chrome + maxColH const cardW = actualUnits * base + (actualUnits - 1) * COL_GAP if (cardH <= maxH + 0.5) { return { units: actualUnits, cols: finalCols, cardW, cardH } } } // Absolute fallback: maxUnits wide, cap height to maxH (only for truly extreme content) const perColH = availBodyH / maxUnits const cols = distribute(blocks, blockGap, perColH) let finalCols = cols if (cols.length > maxUnits) { const head = cols.slice(0, maxUnits - 1) const tailIdx = [] for (let i = maxUnits - 1; i < cols.length; i++) tailIdx.push(...cols[i]) finalCols = [...head, tailIdx] } let maxColH = 0 for (const c of finalCols) maxColH = Math.max(maxColH, colHeight(c, blocks, blockGap)) return { units: maxUnits, cols: finalCols, cardW: maxUnits * base + (maxUnits - 1) * COL_GAP, cardH: maxH, // hard cap — content clipped only as absolute last resort } } // Skyline packing. Cards stack in a column until the next card would exceed maxH, // then start a new column to the right. Oldest orders are leftmost. function packBoard(cards, colW, maxH) { const tracks = [] // tracks[i] = current bottom y of track column i const positions = {} const trackW = colW + COL_GAP const topOf = (s, u) => { let y = 0 for (let k = 0; k < u; k++) y = Math.max(y, tracks[s + k] || 0) return y } for (const card of cards) { const u = card.units // Find the leftmost existing group of u tracks where the card fits vertically let placed = null for (let s = 0; s + u <= tracks.length; s++) { const y = topOf(s, u) if (y + card.cardH <= maxH + 0.5) { placed = { s, y } break } } // No room in existing tracks — open new track column(s) if (!placed) placed = { s: tracks.length, y: 0 } const { s, y } = placed const bottom = y + card.cardH + CARD_GAP_Y for (let k = 0; k < u; k++) tracks[s + k] = bottom positions[card.id] = { x: s * trackW, y } } let totalW = 0 for (const id in positions) { const card = cards.find(c => c.id === id) if (card) totalW = Math.max(totalW, positions[id].x + card.cardW) } return { positions, totalW } } // ───────────────────────────────────────────────────────────────────────────── // Item block // ───────────────────────────────────────────────────────────────────────────── const MOD_ICON_SZ = 13 // Chrome = fixed header rows + body padding. Scales with fontScale so the // layout engine stays accurate when the user zooms from the topbar buttons. function cardChrome(fs, hasWaiter, hasNote) { const head = Math.round(CARD_HEAD_H_BASE * fs) const sub = Math.round(CARD_SUB_H_BASE * fs) const foot = hasWaiter ? Math.round(CARD_FOOT_H_BASE * fs) : 0 const note = hasNote ? Math.round(CARD_NOTE_H_BASE * fs) : 0 return head + sub + foot + note + BODY_PAD_T + BODY_PAD_B } // Collapse repeated modifier names into "Nx Name" entries function dedupeModifiers(arr) { const counts = new Map() for (const name of arr) counts.set(name, (counts.get(name) || 0) + 1) return [...counts.entries()].map(([name, n]) => n > 1 ? `${n}x ${name}` : name) } // Modifier row. Icon always aligns with the FIRST line of text, even if text wraps. // alignItems:'flex-start' + matching height on icon achieves this. function ModRow({ Icon, color, text, done, fs }) { const col = done ? COLORS.doneInk : color const fontSize = Math.round(12 * fs) const lineH = Math.round(fontSize * 1.4) // the actual rendered line-height in px const iconSz = Math.round(MOD_ICON_SZ * fs) return (
{/* Icon container is exactly one line tall, centring the icon within that line */} {text}
) } function ItemBlock({ item, kdsStatus, focused, onBump, onItemDecline, fontScale, fontWeight, combineModifiers }) { const done = kdsStatus === 'done' || kdsStatus === 'served' const preparing = kdsStatus === 'preparing' const fs = fontScale ?? 1 const fw = fontWeight ?? 600 const removed = dedupeModifiers(item.removed ?? []) const extras = dedupeModifiers(item.extras ?? []) const prefs = dedupeModifiers(item.prefs ?? []) const hasMods = removed.length || extras.length || prefs.length || item.notes const nameFontSize = Math.round(15 * fs) const nameLineH = Math.round(nameFontSize * 1.4) // rendered line height of one name line // Badge is exactly one name-line tall so it always aligns with the first line of text, // even when the product name wraps. alignItems:'flex-start' keeps both at the top. const badgeH = nameLineH const itemHoldRef = useRef(null) const handleItemPointerDown = focused && onItemDecline ? (e) => { e.stopPropagation() itemHoldRef.current = setTimeout(() => { onItemDecline(item.id, item.product_name) }, 500) } : undefined const cancelItemHold = () => { if (itemHoldRef.current) { clearTimeout(itemHoldRef.current); itemHoldRef.current = null } } return (
{ e.stopPropagation(); cancelItemHold(); onBump(e) } : undefined} onPointerDown={handleItemPointerDown} onPointerUp={cancelItemHold} onPointerLeave={cancelItemHold} onPointerCancel={cancelItemHold} style={{ borderRadius: 7, margin: '0 -5px', padding: '2px 5px', cursor: focused ? 'pointer' : 'default', transition: 'background .1s', opacity: done ? 0.55 : 1, touchAction: 'none', }} onMouseEnter={focused ? (e) => { e.currentTarget.style.background = 'rgba(47,127,240,.07)' } : undefined} onMouseLeave={focused ? (e) => { e.currentTarget.style.background = '' } : undefined} > {/* badge + name: flex-start so badge stays at first line when name wraps */}
{fmtItemQty(item.quantity, item.unit_type)} {item.product_name} {focused && ( )}
{hasMods && (
{combineModifiers ? ( <> {removed.length > 0 && } {extras.length > 0 && } {prefs.length > 0 && } ) : ( <> {removed.map((r, i) => )} {extras.map((e, i) => )} {prefs.map((p, i) => )} )} {item.notes && }
)}
) } // ───────────────────────────────────────────────────────────────────────────── // Channel badge // ───────────────────────────────────────────────────────────────────────────── const CHANNEL_META = { here: { label: 'Εδώ', Icon: Utensils }, takeaway: { label: 'Takeaway', Icon: ShoppingBag }, delivery: { label: 'Delivery', Icon: Bike }, } function ChannelBadge({ orderType, tableName, fs = 1 }) { const meta = CHANNEL_META[orderType] || CHANNEL_META.here const Icon = meta.Icon const text = orderType === 'here' && tableName ? tableName : meta.label const iconSz = Math.round(17 * fs) return ( {text} ) } // ───────────────────────────────────────────────────────────────────────────── // Order Card // Fix #5: focus action bar is icon-only — all 4 buttons fit in one row, // staying inside the header strip so items below remain tappable. // ───────────────────────────────────────────────────────────────────────────── const FLASH_KEYFRAMES = ` @keyframes kds-redflash { 0%,100% { background: #e8513f; } 50% { background: #0a0c0f; } } .kds-flash { animation: kds-redflash 1.4s ease-in-out infinite; } @keyframes kds-cardin { from { transform: translateY(10px) scale(.97); opacity: 0; } to { transform: none; opacity: 1; } } ` // Action overlay shown when a card is focused. // Dark-grey container covers header+subheader; 3 padded rounded buttons fill it equally. // Footer overlay (foot height): Call Waiter button — only renders if there's a footer. function ActionBar({ order, onSetOrderStatus, onFocus, onPrint, fs = 1, hasWaiter }) { const isDone = order.kds_status === 'done' const isPreparing = order.kds_status === 'preparing' const iconSz = Math.round(22 * fs) const barH = Math.round((CARD_HEAD_H_BASE + CARD_SUB_H_BASE) * fs) const footH = Math.round(CARD_FOOT_H_BASE * fs) const pad = Math.round(6 * fs) const btnR = Math.round(9 * fs) const Btn = ({ bg, title, onClick, children }) => ( ) return ( <>
e.stopPropagation()} style={{ position: 'absolute', left: 0, right: 0, top: 0, height: barH, zIndex: 4, background: '#0a0e13', borderRadius: `10px 10px 0 0`, display: 'flex', alignItems: 'stretch', padding: pad, gap: pad, }} > {/* Prepare */} onSetOrderStatus(order.id, isPreparing ? 'pending' : 'preparing')} > {isPreparing ? : } {/* Complete */} onSetOrderStatus(order.id, isDone ? 'pending' : 'done')} > {isDone ? : } {/* Print */} { e.stopPropagation(); onPrint(order.id) }} >
{/* Call Waiter footer overlay */} {hasWaiter && ( )} ) } // ───────────────────────────────────────────────────────────────────────────── // Quick Action Bar — shown on long-press of unselected card // ───────────────────────────────────────────────────────────────────────────── function QuickActionBar({ order, onSetOrderStatus, onDecline, onDismiss, fs = 1 }) { const isDone = order.kds_status === 'done' const isPreparing = order.kds_status === 'preparing' const iconSz = Math.round(20 * fs) const barH = Math.round((CARD_HEAD_H_BASE + CARD_SUB_H_BASE) * fs) const pad = Math.round(6 * fs) const btnR = Math.round(9 * fs) const Btn = ({ bg, label, Icon, onClick }) => ( ) return (
e.stopPropagation()} style={{ position: 'absolute', left: 0, right: 0, top: 0, height: barH, zIndex: 5, background: '#0a0e13', borderRadius: `10px 10px 0 0`, display: 'flex', alignItems: 'stretch', padding: pad, gap: pad, }} > { onSetOrderStatus(order.id, isPreparing ? 'pending' : 'preparing'); onDismiss() }} /> { onSetOrderStatus(order.id, isDone ? 'pending' : 'done'); onDismiss() }} /> { onDecline(order.id, null); onDismiss() }} />
) } // ───────────────────────────────────────────────────────────────────────────── // Decline Modal // ───────────────────────────────────────────────────────────────────────────── const DECLINE_REASONS = [ { value: 'out_of_stock', label: 'Εξαντλήθηκε' }, { value: 'too_many_orders', label: 'Υπερβολικά πολλές παραγγελίες' }, { value: 'other', label: 'Άλλος λόγος' }, ] function DeclineModal({ orderId, itemId, itemName, onConfirm, onClose }) { const [reason, setReason] = useState(null) const [note, setNote] = useState('') const isItem = itemId != null const confirm = () => { if (!reason) return const finalNote = reason === 'other' ? (note.trim() || null) : DECLINE_REASONS.find(r => r.value === reason)?.label onConfirm(orderId, itemId, finalNote) onClose() } return (
e.stopPropagation()} style={{ width: 380, background: '#1a2029', border: '1px solid #2c3744', borderRadius: 18, boxShadow: '0 32px 80px rgba(0,0,0,.6)', color: '#e7ecf2', fontFamily: 'inherit', overflow: 'hidden' }} >
{isItem ? 'Απόρριψη αντικειμένου' : 'Απόρριψη παραγγελίας'}
{isItem && itemName && (
{itemName}
)}
{DECLINE_REASONS.map(r => ( ))} {reason === 'other' && ( setNote(e.target.value)} placeholder="Προσθέστε σημείωση (προαιρετικό)…" autoFocus style={{ width: '100%', height: 44, borderRadius: 10, padding: '0 12px', background: '#232d38', border: '1px solid #3d4f5e', color: '#e7ecf2', fontSize: 14, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box', }} /> )}
) } function IconBtn({ bg, onClick, title, children }) { return ( ) } function OrderCard({ order, ageInfo, layout, focused, dimmed, isNew, settings, activeFilters, onFocus, onSetOrderStatus, onBumpItem, onPrint, onDecline, onItemDecline, courses, groupByCourse, batchMode }) { const { cols } = layout const isFuture = ageInfo.age === 'future' const isDone = ageInfo.age === 'done' const fc = settings.flashColors ?? {} const headBg = isDone ? (fc.inactive ?? COLORS.inactive) : isFuture ? COLORS.futureHead : ageInfo.age === 'green' ? (fc.green ?? COLORS.green) : ageInfo.age === 'amber' ? (fc.amber ?? COLORS.amber) : (fc.red ?? COLORS.red) const subBg = isDone ? COLORS.inactiveD : isFuture ? COLORS.futureSubBg : ageInfo.age === 'green' ? COLORS.greenD : ageInfo.age === 'amber' ? COLORS.amberD : COLORS.redD const multiFilter = (activeFilters?.size ?? 1) > 1 const glowColor = multiFilter ? (isDone ? COLORS.glowDone : isFuture ? COLORS.glowFuture : order.kds_status === 'preparing' ? COLORS.glowPreparing : COLORS.glowPending) : null const itemCount = order.items.reduce((s, it) => s + it.quantity, 0) const waiterLabel = order.waiters?.length ? order.waiters[0] : null const fs = settings.fontScale ?? 1 // Press+hold on unselected card — show QuickActionBar const [quickAction, setQuickAction] = useState(false) const holdTimerRef = useRef(null) const handlePointerDown = (e) => { if (focused) return // already selected — let normal click/item-hold logic run holdTimerRef.current = setTimeout(() => { setQuickAction(true) }, 500) } const cancelHold = () => { if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null } } // Auto-deselect after 5s idle when focused const idleTimerRef = useRef(null) const resetIdle = useCallback(() => { if (!focused) return if (idleTimerRef.current) clearTimeout(idleTimerRef.current) idleTimerRef.current = setTimeout(() => onFocus(null), 5000) }, [focused, onFocus]) useEffect(() => { if (focused) { resetIdle() } else { if (idleTimerRef.current) { clearTimeout(idleTimerRef.current); idleTimerRef.current = null } } return () => { if (idleTimerRef.current) clearTimeout(idleTimerRef.current) } }, [focused, resetIdle]) return (
{ e.stopPropagation(); cancelHold(); if (!quickAction) { onFocus(order.id); resetIdle() } }} onPointerDown={handlePointerDown} onPointerUp={cancelHold} onPointerLeave={cancelHold} onPointerCancel={cancelHold} style={{ position: 'absolute', left: layout.x, top: layout.y, width: layout.w, height: layout.h, background: headBg, borderRadius: 10, overflow: 'hidden', boxShadow: focused ? `0 10px 24px rgba(0,0,0,.4), 0 0 0 4px rgba(47,127,240,.25)${glowColor ? `, 0 0 0 2px ${glowColor}40` : ''}` : `0 1px 2px rgba(0,0,0,.25), 0 6px 16px rgba(0,0,0,.22)${glowColor ? `, 0 0 12px 2px ${glowColor}55, 0 0 0 2px ${glowColor}44` : ''}`, outline: focused ? `2px solid ${COLORS.accent}` : '2px solid transparent', outlineOffset: 2, display: 'flex', flexDirection: 'column', transform: focused ? 'scale(1.012)' : 'none', transition: 'box-shadow .15s, transform .15s, outline-color .15s', opacity: dimmed ? 0.42 : 1, filter: dimmed ? 'saturate(.7)' : 'none', zIndex: focused ? 60 : 1, animation: isNew ? 'kds-cardin .42s cubic-bezier(.16,.8,.3,1) both' : 'none', cursor: 'pointer', userSelect: 'none', WebkitUserSelect: 'none', touchAction: 'none', }} > {/* header — height scales with fs */}
{ageInfo.label} {itemCount} item{itemCount !== 1 ? 's' : ''}
{/* Topbar click deselects focused card */}
{ if (focused) { e.stopPropagation(); onFocus(null) } }} style={{ cursor: focused ? 'default' : 'inherit' }} >
{/* subheader — height scales with fs */}
#{order.id} { if (focused) { e.stopPropagation(); onFocus(null) } }} style={{ fontSize: 11 * fs, fontWeight: 700, letterSpacing: '.08em', textTransform: 'uppercase', lineHeight: 1 }}> {order.kds_status === 'done' ? '✓ Done' : order.kds_status === 'preparing' ? '▶ Prep' : 'Pending'}
{/* body */}
{/* grouped batch mode: single column with batch dividers */} {batchMode === 'grouped' && order._batches?.length > 1 ? (
{order._batches.map((batch, bi) => { const batchItems = order.items.filter(it => (it._batchIndex ?? 0) === bi) if (!batchItems.length) return null const batchMins = Math.max(0, Math.floor((Date.now() - parseUTC(batch.opened_at).getTime()) / 60000)) return (
Batch #{bi + 1} {batchMins}m
{batchItems.map(item => ( { e && e.stopPropagation(); resetIdle(); onBumpItem(order.id, item.id) }} onItemDecline={focused ? (itemId, itemName) => { resetIdle(); onItemDecline(order.id, itemId, itemName) } : null} /> ))} ) })}
) : cols.map((col, ci) => { const colItems = col.map(idx => order.items[idx]).filter(Boolean) if (groupByCourse && courses?.length > 0) { // Group items by course_id const grouped = {} colItems.forEach(item => { const key = item.course_id != null ? String(item.course_id) : '__none__' if (!grouped[key]) grouped[key] = [] grouped[key].push(item) }) // Build ordered groups: courses in order, then null group last const orderedGroups = [] courses.forEach(course => { const key = String(course.id) if (grouped[key]?.length > 0) { orderedGroups.push({ course, items: grouped[key] }) } }) if (grouped['__none__']?.length > 0) { orderedGroups.push({ course: null, items: grouped['__none__'] }) } return (
{orderedGroups.map((group, gi) => (
{group.course ? group.course.name : 'No course'}
{group.items.map(item => ( { e && e.stopPropagation(); resetIdle(); onBumpItem(order.id, item.id) }} onItemDecline={focused ? (itemId, itemName) => { resetIdle(); onItemDecline(order.id, itemId, itemName) } : null} /> ))}
))}
) } return (
{colItems.map((item) => ( { e && e.stopPropagation(); resetIdle(); onBumpItem(order.id, item.id) }} onItemDecline={focused ? (itemId, itemName) => { resetIdle(); onItemDecline(order.id, itemId, itemName) } : null} /> ))}
) })}
{/* Order-level note (amber, italic, blank separator line above) */} {order.notes && (
{order.notes}
)} {/* footer — waiter */} {waiterLabel && (
{waiterLabel}
)} {/* Quick action overlay (long-press on unselected) */} {quickAction && !focused && ( setQuickAction(false)} fs={fs} /> )} {/* Focus action overlay */} {focused && ( )}
) } // ───────────────────────────────────────────────────────────────────────────── // Item Summary Panel — grouped item counts across all visible orders // Only pending/preparing items; groups only when product + all prefs are identical. // ───────────────────────────────────────────────────────────────────────────── function buildItemGroups(orders) { const map = new Map() for (const order of orders) { for (const item of order.items) { if (['done', 'served', 'cancelled', 'declined'].includes(item.kds_status)) continue if (item.status === 'cancelled') continue // Build a canonical key: name + sorted prefs/extras/removes + notes const removes = [...(item.removed ?? [])].sort().join('|') const extras = [...(item.extras ?? [])].sort().join('|') const prefs = [...(item.prefs ?? [])].sort().join('|') const notes = (item.notes ?? '').trim() const key = `${item.product_name}\x00${removes}\x00${extras}\x00${prefs}\x00${notes}` if (!map.has(key)) { map.set(key, { key, product_name: item.product_name, removed: item.removed ?? [], extras: item.extras ?? [], prefs: item.prefs ?? [], notes, count: 0, }) } map.get(key).count += item.quantity ?? 1 } } return [...map.values()].sort((a, b) => b.count - a.count) } function ItemSummaryPanel({ orders, mode, open, onClose, fontScale, fontWeight }) { const groups = useMemo(() => buildItemGroups(orders), [orders]) const fs = fontScale ?? 1 const fw = fontWeight ?? 600 // Text sizes matching card item style exactly const nameFontSize = Math.round(15 * fs) const nameLineH = Math.round(nameFontSize * 1.4) const badgeH = nameLineH const modFontSize = Math.round(12 * fs) const modIconSz = Math.round(MOD_ICON_SZ * fs) const headerFontSz = Math.round(13 * fs) const subFontSz = Math.round(11 * fs) const isModal = mode === 'floating' || mode === 'topbar_button' const panelStyle = { background: '#161b21', border: '1px solid #2b3540', borderRadius: isModal ? 14 : 0, color: '#e7ecf2', fontFamily: 'inherit', display: 'flex', flexDirection: 'column', overflow: 'hidden', } const header = (
Σύνοψη αντικειμένων
Pending + Preparing · {groups.length} είδη
{isModal && ( )}
) const listContent = (
{groups.length === 0 ? (
Κανένα αντικείμενο
) : groups.map((g) => { const hasMods = g.removed.length || g.extras.length || g.prefs.length || g.notes return (
{/* qty badge + name — mirrors ItemBlock layout, light colors for dark bg */}
{g.count} {g.product_name}
{/* modifiers — light color variants for dark bg */} {hasMods && (
{g.removed.length > 0 && (
{g.removed.join(' · ')}
)} {g.extras.length > 0 && (
{g.extras.join(' · ')}
)} {g.prefs.length > 0 && (
{g.prefs.join(' · ')}
)} {g.notes && (
{g.notes}
)}
)}
) })}
) // ── Sidebar variant: fills its container column if (mode === 'right_sidebar' || mode === 'left_sidebar') { return (
{header} {listContent}
) } // ── Floating: absolutely positioned over board viewport, right side, vertically centred if (mode === 'floating') { return (
{header} {listContent}
) } // ── Topbar button modal: centred overlay, closes on backdrop click if (mode === 'topbar_button' && open) { return (
e.stopPropagation()} style={{ ...panelStyle, width: `${Math.round(20 * fs)}vw`, minWidth: 220, maxHeight: '80vh', boxShadow: '0 24px 80px rgba(0,0,0,.7)' }} > {header} {listContent}
) } return null } // ───────────────────────────────────────────────────────────────────────────── // Measurement layer // ───────────────────────────────────────────────────────────────────────────── function MeasureLayer({ orders, colW, fontScale, fontWeight, combineModifiers, onResult }) { const itemRefs = useRef({}) const prevRef = useRef({}) useEffect(() => { const measured = {} for (const o of orders) { const refs = itemRefs.current[o.id] || [] measured[o.id] = { blocks: o.items.map((_, i) => (refs[i]?.offsetHeight ?? 36)) } } const prev = prevRef.current const keys = Object.keys(measured) if (keys.length !== Object.keys(prev).length) { prevRef.current = measured; onResult(measured); return } let changed = false for (const k of keys) { const a = prev[k], b = measured[k] if (!a || a.blocks.length !== b.blocks.length || a.blocks.some((h, i) => Math.abs(h - b.blocks[i]) > 0.5)) { changed = true; break } } if (changed) { prevRef.current = measured; onResult(measured) } }) return (
{orders.map((o) => { itemRefs.current[o.id] = itemRefs.current[o.id] || [] return (
{o.items.map((item, i) => (
{ itemRefs.current[o.id][i] = el }} style={{ marginBottom: i < o.items.length - 1 ? BLOCK_GAP : 0 }} >
))}
) })}
) } // ───────────────────────────────────────────────────────────────────────────── // Side rail // Fix #3: clock removed from rail, now lives in top bar. // ───────────────────────────────────────────────────────────────────────────── const STATUS_META = [ { key: 'future', label: 'Future', Icon: Clock, cls: 'green' }, { key: 'pending', label: 'Pending', Icon: Timer, cls: 'amber' }, { key: 'preparing', label: 'Prep', Icon: Flame, cls: 'accent' }, { key: 'done', label: 'Done', Icon: CheckCheck, cls: 'done' }, ] function RailBtn({ active, activeColor, onClick, disabled, fs, children }) { return ( ) } function SideRail({ activeFilters, counts, onToggleFilter, soundOn, onToggleSound, canUndo, canRedo, onUndo, onRedo, onSettings, onCallFloor, fontScale }) { const fs = fontScale ?? 1 const iconSzLg = Math.round(24 * fs) const iconSzSm = Math.round(20 * fs) const labelSz = Math.round(10 * fs) const badgeSz = Math.round(12 * fs) const badgeH = Math.round(22 * fs) const railW = Math.round(COLORS.railW * fs) return (
{STATUS_META.map(({ key, label, Icon, cls }) => { const active = activeFilters.has(key) const activeColor = cls === 'green' ? COLORS.green : cls === 'amber' ? COLORS.amber : cls === 'accent' ? COLORS.accent : '#cdd6e0' return ( onToggleFilter(key)} fs={fs}> {label} {counts[key] || 0} ) })}
Undo Redo
{[ { label: 'Call', Icon: BellRing, fn: onCallFloor }, { label: soundOn ? 'Sound' : 'Muted', Icon: soundOn ? Volume2 : VolumeX, fn: onToggleSound }, { label: 'Settings', Icon: Settings, fn: onSettings }, ].map(({ label, Icon, fn }) => ( {label} ))}
) } // ───────────────────────────────────────────────────────────────────────────── // Top bar — tabs + clock (Fix #3) + font zoom (Fix #4) // ───────────────────────────────────────────────────────────────────────────── const TABS = [ { key: 'all', label: 'Όλα' }, { key: 'here', label: 'Εδώ' }, { key: 'takeaway', label: 'Takeaway' }, { key: 'delivery', label: 'Delivery' }, ] function TopBar({ activeTab, counts, onTab, clock, fontScale, onFontScale, showSummaryBtn, summaryOpen, onToggleSummary }) { const CLOCK_FONT = 'Arial, "Helvetica Neue", sans-serif' const fs = fontScale ?? 1 const barH = Math.round(COLORS.topbarH * fs) const tabH = Math.round(36 * fs) const tabR = Math.round(9 * fs) return (
{/* tabs — shrink to their natural width, don't grow */}
{TABS.map(({ key, label }) => { const active = activeTab === key return ( ) })}
{/* left spacer — pushes summary button to true center */}
{/* summary button — only when topbar_button mode, sits dead-center */} {showSummaryBtn && ( )} {/* right spacer — mirrors left spacer so clock+zoom stay right-aligned */}
{/* clock — single row: date · time · πμ/μμ */}
{clock.date} {clock.time} {clock.ap}
{/* divider */}
{/* zoom − % + — same height as tabs */}
{Math.round(fontScale * 100)}%
) } // ───────────────────────────────────────────────────────────────────────────── // Settings modal — tabbed, fixed size, touch-friendly // ───────────────────────────────────────────────────────────────────────────── const COLOR_PRESETS = [ { label: 'Green', value: '#34b56a' }, { label: 'Lime', value: '#7ec828' }, { label: 'Yellow', value: '#f5c518' }, { label: 'Orange', value: '#f07d2a' }, { label: 'Red', value: '#e8513f' }, { label: 'Pink', value: '#e84f9a' }, { label: 'Purple', value: '#9b5de5' }, { label: 'Blue', value: '#2f7ff0' }, { label: 'Teal', value: '#1fabbe' }, { label: 'White', value: '#e7ecf2' }, ] const COL_W_STEPS = [200, 220, 240, 260, 280, 300, 320, 340, 360, 380, 400, 420, 440, 460, 480, 500, 540, 580, 620, 660, 700, 750, 800] const FW_STEPS = [300, 400, 500, 600, 700, 800] const REFRESH_STEPS = [10, 15, 20, 30, 45, 60, 90, 120] function stepPrev(steps, val) { const i = steps.indexOf(val); return i > 0 ? steps[i - 1] : steps[0] } function stepNext(steps, val) { const i = steps.indexOf(val); return i < steps.length - 1 ? steps[i + 1] : steps[steps.length - 1] } function nearestStep(steps, val) { return steps.reduce((a, b) => Math.abs(b - val) < Math.abs(a - val) ? b : a) } // Touch-friendly stepper: [−] value [+] function Stepper({ value, onDec, onInc, display, canDec, canInc }) { const btnStyle = (enabled) => ({ width: 44, height: 44, borderRadius: 10, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: enabled ? '#2e3a47' : '#232d38', color: enabled ? '#e7ecf2' : '#4a5568', border: `1px solid ${enabled ? '#3d4f5e' : '#2a3542'}`, cursor: enabled ? 'pointer' : 'default', fontFamily: 'inherit', fontSize: 22, fontWeight: 300, transition: 'background .1s, color .1s', userSelect: 'none', }) return (
{display}
) } function Toggle({ on, onToggle, disabled }) { return ( ) } function TextRow({ label, sub, value, onChange, placeholder }) { return (
{label} {sub &&
{sub}
}
onChange(e.target.value)} placeholder={placeholder} style={{ width: 160, height: 44, borderRadius: 10, flexShrink: 0, background: '#232d38', border: '1px solid #3d4f5e', color: '#e7ecf2', fontSize: 14, fontWeight: 600, padding: '0 12px', fontFamily: 'inherit', outline: 'none', }} />
) } function DropdownRow({ label, sub, value, onChange, options }) { return (
{label} {sub &&
{sub}
}
) } function SRow({ label, sub, children, comingSoon }) { return (
{label} {comingSoon && ( Σύντομα )}
{sub &&
{sub}
}
{children}
) } function ColorSwatchBtn({ colorVal, onColorChange }) { const [open, setOpen] = useState(false) return (
)}
) } function ColorRow({ label, colorVal, onColorChange, minutes, onMinutesChange, minMinutes, maxMinutes }) { return (
{label} minMinutes} canInc={minutes < maxMinutes} onDec={() => onMinutesChange(Math.max(minMinutes, minutes - 1))} onInc={() => onMinutesChange(Math.min(maxMinutes, minutes + 1))} />
) } const SETTINGS_TABS = ['Ζώνη', 'Λειτουργία', 'Εμφάνιση', 'Χρώματα', 'Ειδοποιήσεις'] function SettingsModal({ settings, onSave, onClose }) { const [local, setLocal] = useState(() => ({ ...SETTINGS_DEFAULTS, ...settings, // deep-merge flashColors so new keys (inactive) always have a value flashColors: { ...SETTINGS_DEFAULTS.flashColors, ...(settings.flashColors ?? {}) }, })) const [tab, setTab] = useState(0) const { data: prepZones = [] } = useQuery({ queryKey: ['prep-zones'], queryFn: () => client.get('/api/prep-zones').then(r => r.data), }) const set = (k, v) => setLocal((s) => ({ ...s, [k]: v })) const setThr = (k, v) => setLocal((s) => ({ ...s, thresholds: { ...s.thresholds, [k]: +v } })) const setFlashColor = (k, v) => setLocal((s) => ({ ...s, flashColors: { ...(s.flashColors ?? {}), [k]: v } })) const thr = local.thresholds ?? THR_DEFAULT const fc = local.flashColors ?? SETTINGS_DEFAULTS.flashColors const handleZoneSelect = (zoneId) => { set('kdsZoneId', zoneId) } const colWSnapped = nearestStep(COL_W_STEPS, local.colW ?? COL_W_DEFAULT) const fontWeightSnapped = nearestStep(FW_STEPS, local.fontWeight ?? 600) const refreshSnapped = nearestStep(REFRESH_STEPS, local.autoRefresh ?? 30) const TAB_CONTENT = [ // ── PREP ZONE ──
Επιλέξτε τη ζώνη προετοιμασίας για αυτή την οθόνη KDS. Θα εμφανίζονται μόνο παραγγελίες με αντικείμενα ανήκοντα στην επιλεγμένη ζώνη.
{prepZones.length === 0 ? (
Δεν έχουν οριστεί ζώνες. Δημιουργήστε ζώνες από Management → Prep Zones.
) : (
{/* All zones option */} {prepZones.map(z => { const active = local.kdsZoneId === z.id return ( ) })}
)}
, // ── FUNCTIONALITY ──
0} canInc={REFRESH_STEPS.indexOf(refreshSnapped) < REFRESH_STEPS.length - 1} onDec={() => set('autoRefresh', stepPrev(REFRESH_STEPS, refreshSnapped))} onInc={() => set('autoRefresh', stepNext(REFRESH_STEPS, refreshSnapped))} /> set('tripleState', !local.tripleState)} /> set('categoriesAsFilters', !(local.categoriesAsFilters ?? true))} /> set('groupByCourse', !(local.groupByCourse ?? false))} /> set('groupItems', v)} options={[ { value: 'off', label: 'Απενεργοποιημένο' }, { value: 'floating', label: 'Floating (πάντα ορατό)' }, { value: 'right_sidebar', label: 'Sidebar δεξιά (25%)' }, { value: 'left_sidebar', label: 'Sidebar αριστερά (25%)' }, { value: 'topbar_button', label: 'Κουμπί στη γραμμή τίτλου' }, ]} /> set('batchMode', v)} options={[ { value: 'merged', label: 'Χωρίς Γκρουπ — μία κάρτα, όλα μαζί' }, { value: 'grouped', label: 'Με Γκρουπ — μία κάρτα με διαχωριστικά ανά batch' }, { value: 'split', label: 'Απενεργοποιημένο — ξεχωριστή κάρτα ανά batch' }, ]} />
, // ── DISPLAY ──
0} canInc={COL_W_STEPS.indexOf(colWSnapped) < COL_W_STEPS.length - 1} onDec={() => set('colW', stepPrev(COL_W_STEPS, colWSnapped))} onInc={() => set('colW', stepNext(COL_W_STEPS, colWSnapped))} /> 0} canInc={FW_STEPS.indexOf(fontWeightSnapped) < FW_STEPS.length - 1} onDec={() => set('fontWeight', stepPrev(FW_STEPS, fontWeightSnapped))} onInc={() => set('fontWeight', stepNext(FW_STEPS, fontWeightSnapped))} /> set('flashLate', !local.flashLate)} /> set('combineModifiers', !local.combineModifiers)} />
, // ── COLORS ──
Χρόνος αναμονής
setFlashColor('green', v)} minutes={thr.green} minMinutes={1} maxMinutes={thr.amber - 1} onMinutesChange={(v) => setThr('green', v)} /> setFlashColor('amber', v)} minutes={thr.amber} minMinutes={thr.green + 1} maxMinutes={thr.flash - 1} onMinutesChange={(v) => setThr('amber', v)} /> setFlashColor('red', v)} minutes={thr.flash} minMinutes={thr.amber + 1} maxMinutes={120} onMinutesChange={(v) => setThr('flash', v)} />
Κατάσταση
setFlashColor('inactive', v)} />
Inactive (Done)
Χρώμα header για ολοκληρωμένες παραγγελίες.
, // ── NOTIFICATIONS ──
set('sound', !local.sound)} /> set('notifyOrderComplete', !local.notifyOrderComplete)} /> set('notifyItemComplete', !local.notifyItemComplete)} />
, ] return (
e.stopPropagation()} style={{ width: 680, background: '#1a2029', border: '1px solid #2c3744', borderRadius: 18, boxShadow: '0 32px 80px rgba(0,0,0,.6)', color: '#e7ecf2', fontFamily: 'inherit', display: 'flex', flexDirection: 'column', overflow: 'hidden', }} > {/* title bar */}

Ρυθμίσεις KDS

{/* tab bar */}
{SETTINGS_TABS.map((t, i) => ( ))}
{/* content area — each tab absolutely fills the same fixed box so modal never resizes */}
{TAB_CONTENT.map((content, i) => (
{content}
))}
{/* footer */}
) } // ───────────────────────────────────────────────────────────────────────────── // Toast // ───────────────────────────────────────────────────────────────────────────── function Toasts({ toasts }) { return (
{toasts.map((t) => (
{t.msg}
))}
) } // ───────────────────────────────────────────────────────────────────────────── // Call Waiter modal — shows on-shift waiters, touch-friendly multi-select // ───────────────────────────────────────────────────────────────────────────── function CallWaiterModal({ zoneName, onSend, onClose }) { const [waiters, setWaiters] = useState([]) const [selected, setSelected] = useState(new Set()) const [loading, setLoading] = useState(true) const [sending, setSending] = useState(false) useEffect(() => { client.get('/api/kds/on-shift-waiters') .then((r) => setWaiters(r.data)) .catch(() => setWaiters([])) .finally(() => setLoading(false)) }, []) const toggle = (id) => setSelected((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id); else next.add(id) return next }) const selectAll = () => setSelected(new Set(waiters.map((w) => w.id))) const send = async () => { if (!selected.size) return setSending(true) try { await onSend([...selected]) onClose() } finally { setSending(false) } } return (
e.stopPropagation()} style={{ width: 420, background: '#1a2029', border: '1px solid #2c3744', borderRadius: 18, boxShadow: '0 32px 80px rgba(0,0,0,.6)', color: '#e7ecf2', fontFamily: 'inherit', display: 'flex', flexDirection: 'column', overflow: 'hidden', }} > {/* header */}
Κάλεσε Σερβιτόρο
Η {zoneName} θέλει βοήθεια
{/* waiter list */}
{loading && (
Φόρτωση…
)} {!loading && waiters.length === 0 && (
Κανένας σερβιτόρος σε βάρδια
)} {!loading && waiters.length > 0 && ( <> {/* Select all row */} {waiters.map((w) => { const sel = selected.has(w.id) return ( ) })} )}
{/* footer */}
) } // ───────────────────────────────────────────────────────────────────────────── // Main KDS component // ───────────────────────────────────────────────────────────────────────────── const SETTINGS_DEFAULTS = { thresholds: THR_DEFAULT, flashColors: { green: COLORS.green, amber: COLORS.amber, red: COLORS.red, inactive: COLORS.inactive }, sound: true, flashLate: true, tripleState: false, categoriesAsFilters: true, colW: COL_W_DEFAULT, fontScale: 1, autoRefresh: 30, fontWeight: 600, combineModifiers: false, batchMode: 'merged', notifyOrderComplete: false, notifyItemComplete: false, kdsZoneName: 'Κουζίνα', kdsZoneId: null, groupItems: 'off', groupByCourse: false, } function loadSettings() { try { const raw = localStorage.getItem(STORAGE_KEY) if (raw) return { ...SETTINGS_DEFAULTS, ...JSON.parse(raw) } } catch { /* ignore */ } return { ...SETTINGS_DEFAULTS } } export default function KdsPage() { const qc = useQueryClient() const token = useAuthStore((s) => s.token) const [settings, setSettings] = useState(loadSettings) const saveSettings = useCallback((s) => { setSettings(s) try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)) } catch { /* ignore */ } }, []) // quick font-scale tweak without opening modal (Fix #4) const setFontScale = useCallback((v) => { setSettings((s) => { const next = { ...s, fontScale: v } try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) } catch { /* ignore */ } return next }) }, []) const colW = settings.colW ?? COL_W_DEFAULT const fontScale = settings.fontScale ?? 1 // Multi-select status toggles — default: future + pending + preparing visible const [activeFilters, setActiveFilters] = useState(() => new Set(['future', 'pending', 'preparing'])) const [tabFilter, setTabFilter] = useState('all') const [focusId, setFocusId] = useState(null) const [now, setNow] = useState(Date.now()) const [showSettings, setShowSettings] = useState(false) const [showCallModal, setShowCallModal] = useState(false) const [showSummary, setShowSummary] = useState(false) const [soundOn, setSoundOn] = useState(true) const [toasts, setToasts] = useState([]) const [boardH, setBoardH] = useState(0) const [measured, setMeasured] = useState({}) const undoStack = useRef([]) const redoStack = useRef([]) const [, setHistVer] = useState(0) const pushHistory = useCallback((entry) => { undoStack.current.push(entry) if (undoStack.current.length > 10) undoStack.current.shift() redoStack.current = [] setHistVer((v) => v + 1) }, []) const kdsZoneId = settings.kdsZoneId ?? null const { data: prepZonesData = [] } = useQuery({ queryKey: ['prep-zones'], queryFn: () => client.get('/api/prep-zones').then(r => r.data), staleTime: 60_000, }) const { data: posSettingsData } = useQuery({ queryKey: ['pos-settings'], queryFn: () => client.get('/api/settings/').then(r => r.data), staleTime: 60_000, }) const kdsCoursesEnabled = posSettingsData?.['orders.courses_enabled']?.value === 'true' const kdsCourses = kdsCoursesEnabled ? (() => { try { return JSON.parse(posSettingsData?.['orders.courses']?.value || '[]') } catch { return [] } })() : [] const activeKdsZone = prepZonesData.find(z => z.id === kdsZoneId) ?? null const { data, isLoading } = useQuery({ queryKey: ['kds-orders', kdsZoneId], queryFn: () => { const url = kdsZoneId ? `/api/kds/orders?zone_id=${kdsZoneId}` : '/api/kds/orders' return client.get(url).then((r) => r.data) }, staleTime: 0, refetchInterval: (settings.autoRefresh ?? 30) * 1000, }) const [orders, setOrders] = useState([]) const prevOrderIds = useRef(new Set()) useEffect(() => { if (!data?.orders) return setOrders(data.orders) const newIds = new Set(data.orders.map((o) => o.id)) const arrived = [...newIds].filter((id) => !prevOrderIds.current.has(id)) if (arrived.length && prevOrderIds.current.size > 0 && soundOn && settings.sound) { chime() pushToast(`${arrived.length} νέα παραγγελία`) } prevOrderIds.current = newIds }, [data]) const esRef = useRef(null) useEffect(() => { if (!token) return const es = new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`) esRef.current = es es.onmessage = (e) => { try { const { type } = JSON.parse(e.data) if (['kds_order_updated', 'kds_item_updated', 'order_updated', 'order_paid', 'order_closed', 'item_status_changed'].includes(type)) { qc.invalidateQueries({ queryKey: ['kds-orders'] }) } } catch { /* ignore */ } } return () => { es.close(); esRef.current = null } }, [token, qc]) useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000) return () => clearInterval(id) }, []) const boardRef = useRef(null) useEffect(() => { const el = boardRef.current if (!el) return const measure = () => setBoardH((p) => { const h = el.clientHeight; return Math.abs(p - h) > 1 ? h : p }) measure() const ro = new ResizeObserver(measure) ro.observe(el) window.addEventListener('resize', measure) const t1 = setTimeout(measure, 60), t2 = setTimeout(measure, 300) return () => { ro.disconnect(); window.removeEventListener('resize', measure); clearTimeout(t1); clearTimeout(t2) } }, []) // Drag-to-scroll on the board: capture pointer events before cards intercept them. // Cards use touchAction:'none' so native scroll is blocked; we re-implement it here. useEffect(() => { const el = boardRef.current if (!el) return let startX = 0, startScroll = 0, dragging = false, moved = false const onDown = (e) => { if (e.button !== 0 && e.pointerType === 'mouse') return startX = e.clientX startScroll = el.scrollLeft dragging = true moved = false } const onMove = (e) => { if (!dragging) return const dx = e.clientX - startX if (!moved && Math.abs(dx) < 6) return moved = true el.scrollLeft = startScroll - dx } const onUp = () => { dragging = false } // Use capture so we see events before cards do; passively listen on move for performance el.addEventListener('pointerdown', onDown, { capture: true }) window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) return () => { el.removeEventListener('pointerdown', onDown, { capture: true }) window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onUp) } }, []) const pushToast = useCallback((msg) => { const id = Math.random().toString(36).slice(2) setToasts((ts) => [...ts, { id, msg }]) setTimeout(() => setToasts((ts) => ts.filter((x) => x.id !== id)), 2200) }, []) const setOrderStatus = useMutation({ mutationFn: ({ orderId, status }) => client.put(`/api/kds/orders/${orderId}/kds_status`, { status }), onSuccess: () => qc.invalidateQueries({ queryKey: ['kds-orders'] }), }) const setItemStatus = useMutation({ mutationFn: ({ orderId, itemId, status }) => client.put(`/api/kds/orders/${orderId}/items/${itemId}/kds_status`, { status }), onSuccess: () => qc.invalidateQueries({ queryKey: ['kds-orders'] }), }) const kdsZone = (() => { if (kdsZoneId) { const z = prepZonesData.find(zz => zz.id === kdsZoneId) if (z) return z.notification_name || z.name } return settings.kdsZoneName || 'Κουζίνα' })() const callWaiterOnOrder = useCallback((orderId) => { client.post(`/api/kds/orders/${orderId}/call-waiter`, { kds_zone: kdsZone }) .then(() => pushToast('Ειδοποίηση σερβιτόρου στάλθηκε')) .catch(() => pushToast('Αποτυχία αποστολής')) }, [kdsZone]) const handleSetOrderStatus = useCallback((orderId, rawStatus) => { if (rawStatus === '__call') { callWaiterOnOrder(orderId); return } const order = orders.find((o) => o.id === orderId) if (!order) return setOrders((prev) => prev.map((o) => { if (o.id !== orderId) return o const shouldCascade = ['pending', 'preparing', 'done'].includes(rawStatus) const items = shouldCascade ? o.items.map((it) => it.status !== 'cancelled' && it.kds_status !== 'served' ? { ...it, kds_status: rawStatus } : it) : o.items return { ...o, kds_status: rawStatus, items } })) pushHistory({ type: 'order', orderId, prev: order.kds_status, next: rawStatus, prevItems: order.items.map((it) => ({ id: it.id, kds_status: it.kds_status })) }) setOrderStatus.mutate({ orderId, status: rawStatus }) // Auto-notify waiters on order completion if (rawStatus === 'done' && settings.notifyOrderComplete) { client.post(`/api/kds/orders/${orderId}/notify-complete`, { kds_zone: kdsZone }).catch(() => {}) } setFocusId(null) pushToast({ done: '✓ Ολοκληρώθηκε', preparing: '▶ Σε εκτέλεση', pending: '↩ Επαναφορά' }[rawStatus] ?? rawStatus) }, [orders, pushHistory, setOrderStatus, pushToast, settings.notifyOrderComplete, kdsZone]) const handleBumpItem = useCallback((orderId, itemId) => { const order = orders.find((o) => o.id === orderId) if (!order) return const item = order.items.find((it) => it.id === itemId) if (!item) return const cycle = settings.tripleState ? { pending: 'preparing', preparing: 'done', done: 'pending' } : { pending: 'done', preparing: 'done', done: 'pending' } const nextStatus = cycle[item.kds_status] ?? 'done' setOrders((prev) => prev.map((o) => { if (o.id !== orderId) return o const items = o.items.map((it) => it.id === itemId ? { ...it, kds_status: nextStatus } : it) const active = items.filter((it) => it.status !== 'cancelled') const statuses = new Set(active.map((it) => it.kds_status)) // Mirror _sync_order_kds_status from backend const isSubsetOf = (...vals) => [...statuses].every((s) => vals.includes(s)) const orderKds = statuses.size === 0 ? o.kds_status : isSubsetOf('served') ? 'served' : isSubsetOf('done', 'served') ? 'done' : statuses.has('preparing') || statuses.has('done') ? 'preparing' : 'pending' return { ...o, items, kds_status: orderKds } })) pushHistory({ type: 'item', orderId, itemId, prev: item.kds_status, next: nextStatus }) setItemStatus.mutate({ orderId, itemId, status: nextStatus }) // Auto-notify on item completion (only when bumping TO done) if (nextStatus === 'done' && (settings.notifyOrderComplete || settings.notifyItemComplete)) { const updatedItems = order.items.map((it) => it.id === itemId ? { ...it, kds_status: nextStatus } : it) const active = updatedItems.filter((it) => it.status !== 'cancelled' && it.kds_status !== 'served') const doneCount = active.filter((it) => it.kds_status === 'done').length const totalCount = active.length const allDone = doneCount === totalCount if (allDone && settings.notifyOrderComplete) { client.post(`/api/kds/orders/${orderId}/notify-complete`, { kds_zone: kdsZone }).catch(() => {}) } else if (!allDone && settings.notifyItemComplete) { client.post(`/api/kds/orders/${orderId}/notify-items-ready`, { kds_zone: kdsZone, ready_count: doneCount, total_count: totalCount, }).catch(() => {}) } } }, [orders, settings.tripleState, settings.notifyOrderComplete, settings.notifyItemComplete, kdsZone, pushHistory, setItemStatus]) // Decline state const [declineModal, setDeclineModal] = useState(null) // { orderId, itemId, itemName } | null const handleDecline = useCallback((orderId, itemId) => { const order = orders.find(o => o.id === orderId) if (!order) return setDeclineModal({ orderId, itemId: null, itemName: null }) }, [orders]) const handleItemDecline = useCallback((orderId, itemId, itemName) => { setDeclineModal({ orderId, itemId, itemName }) }, []) const confirmDecline = useCallback(async (orderId, itemId, note) => { try { if (itemId) { await client.put(`/api/kds/orders/${orderId}/items/${itemId}/decline`, { decline_note: note ?? null }) setOrders(prev => prev.map(o => { if (o.id !== orderId) return o return { ...o, items: o.items.map(it => it.id === itemId ? { ...it, kds_status: 'declined' } : it) } })) pushToast('Αντικείμενο απορρίφθηκε') } else { await client.put(`/api/kds/orders/${orderId}/decline`, { decline_note: note ?? null }) setOrders(prev => prev.map(o => { if (o.id !== orderId) return o return { ...o, kds_status: 'declined', items: o.items.map(it => it.status !== 'cancelled' ? { ...it, kds_status: 'declined', decline_note: note ?? null } : it) } })) pushToast('Παραγγελία απορρίφθηκε') } qc.invalidateQueries({ queryKey: ['kds-orders'] }) } catch { pushToast('Σφάλμα απόρριψης') } }, [pushToast, qc]) // KDS direct print — uses zone auto_print config (managed in Prep Zone settings) const handlePrint = useCallback(async (orderId) => { try { await client.post(`/api/orders/${orderId}/retry-print`) pushToast('Εκτύπωση εστάλη') } catch { pushToast('Σφάλμα εκτύπωσης') } }, [pushToast]) const handleUndo = useCallback(() => { if (!undoStack.current.length) return const entry = undoStack.current.pop() redoStack.current.push(entry) setHistVer((v) => v + 1) if (entry.type === 'order') { setOrders((prev) => prev.map((o) => { if (o.id !== entry.orderId) return o const items = entry.prevItems ? o.items.map((it) => { const f = entry.prevItems.find((p) => p.id === it.id); return f ? { ...it, kds_status: f.kds_status } : it }) : o.items return { ...o, kds_status: entry.prev, items } })) setOrderStatus.mutate({ orderId: entry.orderId, status: entry.prev }) } else { setOrders((prev) => prev.map((o) => { if (o.id !== entry.orderId) return o return { ...o, items: o.items.map((it) => it.id === entry.itemId ? { ...it, kds_status: entry.prev } : it) } })) setItemStatus.mutate({ orderId: entry.orderId, itemId: entry.itemId, status: entry.prev }) } pushToast('Αναίρεση') }, [setOrderStatus, setItemStatus, pushToast]) const handleRedo = useCallback(() => { if (!redoStack.current.length) return const entry = redoStack.current.pop() undoStack.current.push(entry) setHistVer((v) => v + 1) if (entry.type === 'order') { setOrders((prev) => prev.map((o) => { if (o.id !== entry.orderId) return o const shouldCascade = ['pending', 'preparing', 'done'].includes(entry.next) const items = shouldCascade ? o.items.map((it) => it.status !== 'cancelled' && it.kds_status !== 'served' ? { ...it, kds_status: entry.next } : it) : o.items return { ...o, kds_status: entry.next, items } })) setOrderStatus.mutate({ orderId: entry.orderId, status: entry.next }) } else { setOrders((prev) => prev.map((o) => { if (o.id !== entry.orderId) return o return { ...o, items: o.items.map((it) => it.id === entry.itemId ? { ...it, kds_status: entry.next } : it) } })) setItemStatus.mutate({ orderId: entry.orderId, itemId: entry.itemId, status: entry.next }) } pushToast('Επαναφορά') }, [setOrderStatus, setItemStatus, pushToast]) useEffect(() => { const h = (e) => { if (e.key === 'Escape') setFocusId(null) if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); if (e.shiftKey) handleRedo(); else handleUndo() } } window.addEventListener('keydown', h) return () => window.removeEventListener('keydown', h) }, [handleUndo, handleRedo]) // Compute each order's display bucket (future is a virtual bucket for pending orders scheduled in the future) const orderBucket = useCallback((order) => { if (order.kds_status !== 'pending') return order.kds_status return minutesSince(order.opened_at, now) < 0 ? 'future' : 'pending' }, [now]) const counts = useMemo(() => { const c = { future: 0, pending: 0, preparing: 0, done: 0 } for (const o of orders) { const b = orderBucket(o) c[b] = (c[b] || 0) + 1 } return c }, [orders, orderBucket]) const tabCounts = useMemo(() => { const c = { all: 0, here: 0, takeaway: 0, delivery: 0 } const inStatus = orders.filter((o) => activeFilters.has(orderBucket(o))) c.all = inStatus.length for (const o of inStatus) c[o.order_type] = (c[o.order_type] || 0) + 1 return c }, [orders, activeFilters, orderBucket]) const visible = useMemo(() => { let v = orders.filter((o) => activeFilters.has(orderBucket(o))) if (tabFilter !== 'all') v = v.filter((o) => o.order_type === tabFilter) v = v.slice().sort((a, b) => new Date(a.opened_at) - new Date(b.opened_at)) // Apply zone-level item sorting if a zone is selected const sortBy = activeKdsZone?.sort_items_by ?? 'order_time' if (sortBy !== 'order_time') { v = v.map((o) => { if (!o.items?.length) return o const sorted = [...o.items].sort((a, b) => { if (sortBy === 'item_count') return b.quantity - a.quantity if (sortBy === 'alpha') return (a.product_name || '').localeCompare(b.product_name || '', 'el') return 0 }) return { ...o, items: sorted } }) } const batchMode = settings.batchMode ?? 'merged' // Detect batches within each order by clustering items on added_at. // Items added within BATCH_GAP_MS of each other belong to the same batch. // This enriches every order with _batches[] and stamps items with _batchIndex. const BATCH_GAP_MS = 2 * 60 * 1000 // 2-minute gap = new batch v = v.map((o) => { if (!o.items?.length) return o const sorted = [...o.items].sort((a, b) => parseUTC(a.added_at) - parseUTC(b.added_at)) const batches = [] let curBatch = null for (const item of sorted) { const ts = item.added_at ? parseUTC(item.added_at).getTime() : 0 if (!curBatch || (ts - curBatch.lastTs) > BATCH_GAP_MS) { curBatch = { opened_at: item.added_at || o.opened_at, items: [], lastTs: ts } batches.push(curBatch) } curBatch.items.push(item) curBatch.lastTs = ts } // stamp _batchIndex on each item so the card renderer can group them const stampedItems = batches.flatMap((b, bi) => b.items.map(it => ({ ...it, _batchIndex: bi })) ) return { ...o, _batches: batches.map(({ opened_at, items }) => ({ opened_at, items })), items: stampedItems } }) if (batchMode === 'split') { // Explode each order: one virtual card per batch const exploded = [] for (const o of v) { if (!o._batches || o._batches.length <= 1) { exploded.push(o); continue } o._batches.forEach((batch, bi) => { exploded.push({ ...o, id: o.id + '_b' + bi, _realId: o.id, _batchIndex: bi, opened_at: batch.opened_at, items: batch.items, _batches: [batch], }) }) } return exploded } // merged / grouped: single card per order, _batches already set above return v }, [orders, activeFilters, tabFilter, orderBucket, settings.batchMode, activeKdsZone]) const layout = useMemo(() => { if (!visible.length || !boardH) return { map: {}, totalW: BOARD_PAD * 2 } const maxH = boardH - BOARD_PAD * 2 // hard height limit: board minus top+bottom padding const COURSE_HEADER_H = Math.round(18 * fontScale) // height of each course group label row const sized = visible.map((o) => { const hasWaiter = o.waiters?.length > 0 const hasNote = !!o.notes // chrome scales with fontScale — critical for correct height at any zoom level const chrome = cardChrome(fontScale, hasWaiter, hasNote) const m = measured[o.id] let blocks = m ? m.blocks : o.items.map(() => Math.round(28 * fontScale)) // When grouping by course, each group header adds height — inject phantom blocks if (settings.groupByCourse && kdsCourses?.length > 0 && o.items?.length > 0) { const groupIds = new Set() o.items.forEach(item => groupIds.add(item.course_id != null ? String(item.course_id) : '__none__')) const extraHeaders = groupIds.size const headerBlocks = Array(extraHeaders).fill(COURSE_HEADER_H) blocks = [...headerBlocks, ...blocks] } // In grouped batch mode, each batch divider adds a row of height const BATCH_DIV_H = Math.round(22 * fontScale) const batchMode = settings.batchMode ?? 'merged' if (batchMode === 'grouped' && o._batches?.length > 1) { const dividerBlocks = Array(o._batches.length).fill(BATCH_DIV_H) blocks = [...dividerBlocks, ...blocks] } const s = sizeCard({ blocks, blockGap: BLOCK_GAP, chrome, maxH, base: colW }) return { id: o.id, ...s } }) const { positions, totalW } = packBoard(sized, colW, maxH) const map = {} sized.forEach((s) => { const p = positions[s.id] if (!p) return map[s.id] = { cols: s.cols, x: p.x + BOARD_PAD, y: p.y + BOARD_PAD, w: s.cardW, h: s.cardH } }) return { map, totalW: totalW + BOARD_PAD * 2 } }, [visible, measured, boardH, colW, fontScale]) // clock — full Greek format const d = new Date(now) const GREEK_DAYS = ['Κυριακή', 'Δευτέρα', 'Τρίτη', 'Τετάρτη', 'Πέμπτη', 'Παρασκευή', 'Σάββατο'] const GREEK_MONTHS = ['Ιανουαρίου', 'Φεβρουαρίου', 'Μαρτίου', 'Απριλίου', 'Μαΐου', 'Ιουνίου', 'Ιουλίου', 'Αυγούστου', 'Σεπτεμβρίου', 'Οκτωβρίου', 'Νοεμβρίου', 'Δεκεμβρίου'] const rawH = d.getHours() const ap = rawH >= 12 ? 'μμ' : 'πμ' const hh = rawH % 12 || 12 const mm = String(d.getMinutes()).padStart(2, '0') const clock = { time: hh + ':' + mm, ap, date: `${GREEK_DAYS[d.getDay()]}, ${d.getDate()} ${GREEK_MONTHS[d.getMonth()]} ${d.getFullYear()}`, } return ( <>
{ const asFilters = settings.categoriesAsFilters ?? true if (asFilters) { // multi-select: toggle on/off, never allow zero active setActiveFilters((prev) => { const next = new Set(prev) if (next.has(key)) { next.delete(key) } else { next.add(key) } if (next.size === 0) return prev return next }) } else { // single-select tab mode: always exactly one active setActiveFilters(new Set([key])) } setTabFilter('all') setFocusId(null) }} soundOn={soundOn} onToggleSound={() => { setSoundOn((v) => { if (!v) chime(); return !v }) }} canUndo={undoStack.current.length > 0} canRedo={redoStack.current.length > 0} onUndo={handleUndo} onRedo={handleRedo} onSettings={() => setShowSettings(true)} onCallFloor={() => setShowCallModal(true)} />
{ setTabFilter(k); setFocusId(null) }} clock={clock} fontScale={fontScale} onFontScale={setFontScale} showSummaryBtn={settings.groupItems === 'topbar_button'} summaryOpen={showSummary} onToggleSummary={() => setShowSummary((v) => !v)} /> {/* Board area: flex row so sidebars can sit alongside the board */}
{/* Left sidebar */} {settings.groupItems === 'left_sidebar' && (
)} {/* Board */}
setFocusId(null)} style={{ position: 'absolute', inset: 0, overflowX: 'auto', overflowY: 'hidden' }} > {/* height: 100% so absolutely-positioned cards have a containing block */} {/* When floating panel is active, extra right padding keeps cards scrollable past the panel */}
{isLoading && (
Φόρτωση…
)} {!isLoading && visible.length === 0 && (
e.stopPropagation()} >
Δεν υπάρχουν παραγγελίες για τα επιλεγμένα φίλτρα
)} {visible.map((order) => { const lo = layout.map[order.id] if (!lo) return null const batchMode = settings.batchMode ?? 'merged' // Age clock: for multi-batch cards use the oldest batch with active items let ageOrder = order if (order._batches?.length > 1) { const oldestActive = order._batches.find(b => b.items.some(it => it.kds_status !== 'done' && it.kds_status !== 'served' && it.status !== 'cancelled') ) if (oldestActive) ageOrder = { ...order, opened_at: oldestActive.opened_at } } const ai = ageState(ageOrder, now, settings.thresholds) // In split mode, actions must target the real order id const realId = order._realId ?? order.id return ( setFocusId(id === realId ? order.id : id)} onSetOrderStatus={handleSetOrderStatus} onBumpItem={handleBumpItem} onPrint={handlePrint} onDecline={handleDecline} onItemDecline={handleItemDecline} courses={kdsCourses} groupByCourse={settings.groupByCourse ?? false} batchMode={batchMode} /> ) })}
{/* Floating summary panel — sits over the board viewport, not inside scroll content */} {settings.groupItems === 'floating' && (
{}} fontScale={fontScale} fontWeight={settings.fontWeight} />
)} {/* Topbar-button modal — rendered over the board */} {settings.groupItems === 'topbar_button' && showSummary && ( setShowSummary(false)} fontScale={fontScale} fontWeight={settings.fontWeight} /> )}
{/* Right sidebar */} {settings.groupItems === 'right_sidebar' && (
)}
{showSettings && ( setShowSettings(false)} /> )} {showCallModal && ( client.post('/api/kds/call-waiter-general', { kds_zone: kdsZone, waiter_ids: waiterIds, }).then(() => pushToast('Ειδοποίηση στάλθηκε')) .catch(() => pushToast('Αποτυχία αποστολής')) } onClose={() => setShowCallModal(false)} /> )} {declineModal && ( setDeclineModal(null)} /> )}
) }