/**
* 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 }) => (
{ e.currentTarget.style.filter = 'brightness(1.18)' }}
onMouseLeave={(e) => { e.currentTarget.style.filter = '' }}
onMouseDown={(e) => { e.currentTarget.style.transform = 'scale(.95)' }}
onMouseUp={(e) => { e.currentTarget.style.transform = '' }}
>
{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 && (
{ e.stopPropagation(); onSetOrderStatus(order.id, '__call') }}
style={{
position: 'absolute', left: 0, right: 0, bottom: 0,
height: footH, zIndex: 4,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: Math.round(7 * fs),
background: COLORS.amberD, color: '#fff',
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
fontSize: Math.round(13 * fs), fontWeight: 700,
transition: 'filter .1s',
}}
onMouseEnter={(e) => { e.currentTarget.style.filter = 'brightness(1.15)' }}
onMouseLeave={(e) => { e.currentTarget.style.filter = '' }}
>
Κάλεσε σερβιτόρο
)}
>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 }) => (
{ e.stopPropagation(); onClick() }}
style={{
flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: Math.round(3 * fs), background: bg, color: '#fff',
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
borderRadius: btnR, fontSize: Math.round(10 * fs), fontWeight: 700, letterSpacing: '.06em', textTransform: 'uppercase',
transition: 'filter .1s, transform .08s',
}}
onMouseEnter={(e) => { e.currentTarget.style.filter = 'brightness(1.18)' }}
onMouseLeave={(e) => { e.currentTarget.style.filter = '' }}
onMouseDown={(e) => { e.currentTarget.style.transform = 'scale(.95)' }}
onMouseUp={(e) => { e.currentTarget.style.transform = '' }}
>
{label}
)
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 => (
setReason(r.value)}
style={{
width: '100%', padding: '12px 14px', borderRadius: 10, textAlign: 'left',
background: reason === r.value ? 'rgba(232,81,63,.15)' : '#1e2832',
border: `2px solid ${reason === r.value ? COLORS.red : '#2c3744'}`,
color: reason === r.value ? '#f5a7a0' : '#9ca8b6',
fontSize: 14, fontWeight: reason === r.value ? 600 : 400,
cursor: 'pointer', fontFamily: 'inherit',
transition: 'background .1s, border-color .1s, color .1s',
}}
>
{r.label}
))}
{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',
}}
/>
)}
{ e.currentTarget.style.background = '#2e3a47' }}
onMouseLeave={e => { e.currentTarget.style.background = '#252e38' }}>
Ακύρωση
{ if (reason) e.currentTarget.style.background = '#c02e1e' }}
onMouseLeave={e => { if (reason) e.currentTarget.style.background = COLORS.redD }}
>
{isItem ? 'Απόρριψη αντικειμένου' : 'Απόρριψη παραγγελίας'}
)
}
function IconBtn({ bg, onClick, title, children }) {
return (
{ e.currentTarget.style.filter = 'brightness(1.12)' }}
onMouseLeave={(e) => { e.currentTarget.style.filter = '' }}
onMouseDown={(e) => { e.currentTarget.style.transform = 'scale(.95)' }}
onMouseUp={(e) => { e.currentTarget.style.transform = '' }}
>
{children}
)
}
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 && (
)}
{/* 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 && (
{ e.currentTarget.style.background = '#252e38' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none' }}
>
)}
)
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.prefs.length > 0 && (
)}
{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 (
{ if (!disabled) { e.currentTarget.style.filter = 'brightness(1.12)' } }}
onMouseLeave={(e) => { e.currentTarget.style.filter = '' }}
>
{children}
)
}
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 (
onTab(key)}
style={{
flexShrink: 0,
display: 'flex', alignItems: 'center', gap: Math.round(7 * fs),
height: tabH, padding: `0 ${Math.round(14 * fs)}px`, borderRadius: tabR,
background: active ? COLORS.tabActiveBg : COLORS.tab,
color: active ? COLORS.tabActiveInk : COLORS.tabInk,
fontSize: Math.round(13 * fs), fontWeight: 600, whiteSpace: 'nowrap',
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
transition: 'background .12s, color .12s',
}}
>
{label}
{counts[key] ?? 0}
)
})}
{/* left spacer — pushes summary button to true center */}
{/* summary button — only when topbar_button mode, sits dead-center */}
{showSummaryBtn && (
{ if (!summaryOpen) e.currentTarget.style.filter = 'brightness(1.1)' }}
onMouseLeave={(e) => { e.currentTarget.style.filter = '' }}
>
Σύνοψη
)}
{/* 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 */}
onFontScale(Math.max(FONT_SCALE_MIN, +(fontScale - FONT_SCALE_STEP).toFixed(2)))}
title="Μικρότερο"
style={{ width: tabH, height: tabH, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: 'none', cursor: 'pointer', color: COLORS.tabInk, borderRadius: Math.round(7 * fs) }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#333c47' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none' }}
>
{Math.round(fontScale * 100)}%
onFontScale(Math.min(FONT_SCALE_MAX, +(fontScale + FONT_SCALE_STEP).toFixed(2)))}
title="Μεγαλύτερο"
style={{ width: tabH, height: tabH, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: 'none', cursor: 'pointer', color: COLORS.tabInk, borderRadius: Math.round(7 * fs) }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#333c47' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none' }}
>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 (
{ if (canDec) e.currentTarget.style.background = '#3a4f62' }}
onMouseLeave={(e) => { e.currentTarget.style.background = canDec ? '#2e3a47' : '#232d38' }}>
−
{display}
{ if (canInc) e.currentTarget.style.background = '#3a4f62' }}
onMouseLeave={(e) => { e.currentTarget.style.background = canInc ? '#2e3a47' : '#232d38' }}>
+
)
}
function Toggle({ on, onToggle, disabled }) {
return (
)
}
function TextRow({ label, sub, value, onChange, placeholder }) {
return (
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 (
onChange(e.target.value)}
style={{
flexShrink: 0, height: 44, padding: '0 10px', borderRadius: 10,
background: '#232d38', border: '1px solid #3d4f5e', color: '#e7ecf2',
fontSize: 13, fontWeight: 600, fontFamily: 'inherit', outline: 'none', cursor: 'pointer',
maxWidth: 200,
}}
>
{options.map(o => {o.label} )}
)
}
function SRow({ label, sub, children, comingSoon }) {
return (
{label}
{comingSoon && (
Σύντομα
)}
{sub &&
{sub}
}
{children}
)
}
function ColorSwatchBtn({ colorVal, onColorChange }) {
const [open, setOpen] = useState(false)
return (
setOpen((v) => !v)}
style={{ width: 44, height: 44, borderRadius: 10, border: '2px solid #3d4f5e', background: colorVal, cursor: 'pointer' }}
/>
{open && (
setOpen(false)}
>
{COLOR_PRESETS.map((p) => (
{ onColorChange(p.value); setOpen(false) }}
style={{ width: 36, height: 36, borderRadius: 8, border: colorVal === p.value ? '2px solid #fff' : '2px solid transparent', background: p.value, cursor: 'pointer' }}
/>
))}
)}
)
}
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 */}
set('kdsZoneId', null)}
style={{
padding: '12px 8px', borderRadius: 10, textAlign: 'center',
background: !local.kdsZoneId ? 'rgba(47,127,240,.15)' : '#1e2832',
border: `2px solid ${!local.kdsZoneId ? COLORS.accent : '#2c3744'}`,
color: !local.kdsZoneId ? COLORS.accent : '#7b8694',
fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
minHeight: 64,
transition: 'background .1s, border-color .1s, color .1s',
}}
>
{!local.kdsZoneId && }
Όλες οι ζώνες
{prepZones.map(z => {
const active = local.kdsZoneId === z.id
return (
handleZoneSelect(z.id)}
style={{
padding: '12px 8px', borderRadius: 10, textAlign: 'center',
background: active ? 'rgba(47,127,240,.15)' : '#1e2832',
border: `2px solid ${active ? COLORS.accent : '#2c3744'}`,
color: active ? '#e7ecf2' : '#9ca8b6', fontSize: 12, fontWeight: active ? 700 : 500,
cursor: 'pointer', fontFamily: 'inherit',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
minHeight: 64,
transition: 'background .1s, border-color .1s',
}}
>
{active && }
{z.name}
{z.description && {z.description} }
)
})}
)}
,
// ── 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
{ e.currentTarget.style.background = '#252e38' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none' }}>
{/* tab bar */}
{SETTINGS_TABS.map((t, i) => (
setTab(i)}
style={{
height: 40, padding: '0 18px', borderRadius: '9px 9px 0 0',
background: tab === i ? '#252e38' : 'none',
color: tab === i ? '#f1f4f8' : '#6b7a8d',
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 14, fontWeight: tab === i ? 700 : 500,
borderBottom: tab === i ? '2px solid #2f7ff0' : '2px solid transparent',
transition: 'color .12s, background .12s',
}}
onMouseEnter={(e) => { if (tab !== i) e.currentTarget.style.color = '#aab8c8' }}
onMouseLeave={(e) => { if (tab !== i) e.currentTarget.style.color = '#6b7a8d' }}
>
{t}
))}
{/* content area — each tab absolutely fills the same fixed box so modal never resizes */}
{TAB_CONTENT.map((content, i) => (
{content}
))}
{/* footer */}
{ e.currentTarget.style.background = '#2e3a47' }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#252e38' }}>
Ακύρωση
{ onSave(local); onClose() }}
style={{ height: 48, padding: '0 24px', borderRadius: 11, background: COLORS.accent, color: '#fff', fontSize: 15, fontWeight: 700, border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8, fontFamily: 'inherit' }}
onMouseEnter={(e) => { e.currentTarget.style.filter = 'brightness(1.12)' }}
onMouseLeave={(e) => { e.currentTarget.style.filter = '' }}>
Αποθήκευση
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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} θέλει βοήθεια
{ e.currentTarget.style.background = '#252e38' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none' }}>
{/* waiter list */}
{loading && (
Φόρτωση…
)}
{!loading && waiters.length === 0 && (
Κανένας σερβιτόρος σε βάρδια
)}
{!loading && waiters.length > 0 && (
<>
{/* Select all row */}
setSelected(new Set()) : selectAll}
style={{
width: '100%', height: 52, borderRadius: 12, marginBottom: 8,
background: selected.size === waiters.length ? 'rgba(47,127,240,.18)' : '#1e2832',
border: `2px solid ${selected.size === waiters.length ? COLORS.accent : '#2c3744'}`,
color: selected.size === waiters.length ? COLORS.accent : '#7b8694',
fontSize: 14, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
transition: 'background .1s, border-color .1s, color .1s',
}}
>
{selected.size === waiters.length ? : null}
Όλοι οι σερβιτόροι
{waiters.map((w) => {
const sel = selected.has(w.id)
return (
toggle(w.id)}
style={{
width: '100%', height: 60, borderRadius: 12, marginBottom: 8,
background: sel ? 'rgba(47,127,240,.18)' : '#1e2832',
border: `2px solid ${sel ? COLORS.accent : '#2c3744'}`,
color: '#e7ecf2', fontSize: 15, fontWeight: 600,
cursor: 'pointer', fontFamily: 'inherit',
display: 'flex', alignItems: 'center', gap: 14, padding: '0 16px',
transition: 'background .1s, border-color .1s',
}}
>
{sel ? : (w.nickname || w.username)?.[0]?.toUpperCase()}
{w.nickname || w.username}
)
})}
>
)}
{/* footer */}
{ e.currentTarget.style.background = '#2e3a47' }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#252e38' }}>
Ακύρωση
{sending ? 'Αποστολή…' : `Κάλεσε${selected.size > 0 ? ` (${selected.size})` : ''}`}
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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)}
/>
)}
>
)
}