Files
xenia-pos-local/manager_dashboard/src/pages/KdsPage.jsx
bonamin 34ae328b0d feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:00:14 +03:00

3025 lines
135 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 4,
color: col, fontSize, fontWeight: 600,
textDecoration: done ? 'line-through' : 'none',
}}>
{/* Icon container is exactly one line tall, centring the icon within that line */}
<span style={{
flexShrink: 0, width: iconSz, height: lineH,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon style={{ width: iconSz, height: iconSz, color: col }} />
</span>
<span style={{ lineHeight: `${lineH}px` }}>{text}</span>
</div>
)
}
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 (
<div
onClick={focused ? (e) => { 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 */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
<span style={{
flexShrink: 0,
minWidth: badgeH, height: badgeH,
paddingLeft: UNIT_LABELS[item.unit_type] ? Math.round(4 * fs) : 0,
paddingRight: UNIT_LABELS[item.unit_type] ? Math.round(4 * fs) : 0,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: done ? '#f1f2f4' : COLORS.qtyBg,
color: done ? COLORS.doneInk : COLORS.qtyInk,
borderRadius: Math.round(5 * fs),
fontFamily: 'ui-monospace, monospace',
fontWeight: 700, fontSize: Math.round(12 * fs),
lineHeight: 1,
}}>
{fmtItemQty(item.quantity, item.unit_type)}
</span>
<span style={{
color: done ? COLORS.doneInk : COLORS.ink,
fontSize: nameFontSize, fontWeight: fw,
lineHeight: `${nameLineH}px`,
textDecoration: done ? 'line-through' : 'none',
textDecorationThickness: 2,
flex: '1 1 auto', minWidth: 0,
}}>
{item.product_name}
</span>
{focused && (
<span style={{
flexShrink: 0, width: 8, height: 8, borderRadius: 4, display: 'inline-block',
marginTop: Math.round((badgeH - 8) / 2),
background: done ? COLORS.green : preparing ? COLORS.accent : COLORS.qtyBg,
border: `1px solid ${done ? COLORS.greenD : preparing ? COLORS.accent : '#ccc'}`,
}} />
)}
</div>
{hasMods && (
<div style={{ marginTop: 3, paddingLeft: badgeH + 7, display: 'flex', flexDirection: 'column', gap: 3 }}>
{combineModifiers ? (
<>
{removed.length > 0 && <ModRow Icon={Minus} color={COLORS.redD} text={removed.join(' · ')} done={done} fs={fs} />}
{extras.length > 0 && <ModRow Icon={Plus} color={COLORS.greenD} text={extras.join(' · ')} done={done} fs={fs} />}
{prefs.length > 0 && <ModRow Icon={ArrowRight} color={COLORS.amberD} text={prefs.join(' · ')} done={done} fs={fs} />}
</>
) : (
<>
{removed.map((r, i) => <ModRow key={'r'+i} Icon={Minus} color={COLORS.redD} text={r} done={done} fs={fs} />)}
{extras.map((e, i) => <ModRow key={'e'+i} Icon={Plus} color={COLORS.greenD} text={e} done={done} fs={fs} />)}
{prefs.map((p, i) => <ModRow key={'p'+i} Icon={ArrowRight} color={COLORS.amberD} text={p} done={done} fs={fs} />)}
</>
)}
{item.notes && <ModRow Icon={Info} color='#3b82f6' text={item.notes} done={done} fs={fs} />}
</div>
)}
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 (
<span style={{
display: 'flex', alignItems: 'center', gap: 6,
fontSize: Math.round(15 * fs), fontWeight: 700, whiteSpace: 'nowrap',
color: 'rgba(255,255,255,0.60)', flexShrink: 0,
}}>
<Icon style={{ width: iconSz, height: iconSz, opacity: 0.65 }} />
{text}
</span>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 }) => (
<button
title={title}
onClick={onClick}
style={{
flex: 1,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: bg, color: '#fff',
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
borderRadius: btnR,
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 = '' }}
>
{children}
</button>
)
return (
<>
<div
onClick={(e) => 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 */}
<Btn
title={isPreparing ? 'Επαναφορά' : 'Σε εκτέλεση'}
bg={isPreparing ? '#3d4757' : COLORS.accent}
onClick={() => onSetOrderStatus(order.id, isPreparing ? 'pending' : 'preparing')}
>
{isPreparing ? <Undo2 style={{ width: iconSz, height: iconSz }} /> : <Flame style={{ width: iconSz, height: iconSz }} />}
</Btn>
{/* Complete */}
<Btn
title={isDone ? 'Επαναφορά' : 'Ολοκλήρωση'}
bg={isDone ? '#3d4757' : COLORS.greenD}
onClick={() => onSetOrderStatus(order.id, isDone ? 'pending' : 'done')}
>
{isDone ? <Undo2 style={{ width: iconSz, height: iconSz }} /> : <Check style={{ width: iconSz, height: iconSz }} />}
</Btn>
{/* Print */}
<Btn
title="Εκτύπωση"
bg="rgba(255,255,255,.12)"
onClick={(e) => { e.stopPropagation(); onPrint(order.id) }}
>
<Printer style={{ width: iconSz, height: iconSz }} />
</Btn>
</div>
{/* Call Waiter footer overlay */}
{hasWaiter && (
<button
title="Κάλεσε σερβιτόρο"
onClick={(e) => { 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 = '' }}
>
<BellRing style={{ width: Math.round(14 * fs), height: Math.round(14 * fs) }} />
Κάλεσε σερβιτόρο
</button>
)}
</>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 }) => (
<button
onClick={(e) => { 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 = '' }}
>
<Icon style={{ width: iconSz, height: iconSz }} />
{label}
</button>
)
return (
<div
onClick={(e) => 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,
}}
>
<Btn
bg={isPreparing ? '#3d4757' : COLORS.accent}
label={isPreparing ? 'Undo' : 'Prep'}
Icon={isPreparing ? Undo2 : Flame}
onClick={() => { onSetOrderStatus(order.id, isPreparing ? 'pending' : 'preparing'); onDismiss() }}
/>
<Btn
bg={isDone ? '#3d4757' : COLORS.greenD}
label={isDone ? 'Undo' : 'Ready'}
Icon={isDone ? Undo2 : Check}
onClick={() => { onSetOrderStatus(order.id, isDone ? 'pending' : 'done'); onDismiss() }}
/>
<Btn
bg={COLORS.redD}
label="Decline"
Icon={Ban}
onClick={() => { onDecline(order.id, null); onDismiss() }}
/>
<Btn
bg="rgba(255,255,255,.10)"
label="Κλείσιμο"
Icon={X}
onClick={onDismiss}
/>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 (
<div
onClick={onClose}
style={{ position: 'absolute', inset: 0, zIndex: 300, background: 'rgba(10,13,17,.8)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<div
onClick={e => 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' }}
>
<div style={{ padding: '18px 20px 14px', borderBottom: '1px solid #252e38' }}>
<div style={{ fontSize: 17, fontWeight: 700, color: '#e7ecf2' }}>
{isItem ? 'Απόρριψη αντικειμένου' : 'Απόρριψη παραγγελίας'}
</div>
{isItem && itemName && (
<div style={{ fontSize: 13, color: '#6b7a8d', marginTop: 4 }}>{itemName}</div>
)}
</div>
<div style={{ padding: '14px 20px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{DECLINE_REASONS.map(r => (
<button
key={r.value}
onClick={() => 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}
</button>
))}
{reason === 'other' && (
<input
value={note}
onChange={e => 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',
}}
/>
)}
</div>
<div style={{ display: 'flex', gap: 10, padding: '12px 20px 18px' }}>
<button onClick={onClose} style={{ flex: 1, height: 48, borderRadius: 11, background: '#252e38', color: '#aab8c8', fontSize: 15, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
onMouseEnter={e => { e.currentTarget.style.background = '#2e3a47' }}
onMouseLeave={e => { e.currentTarget.style.background = '#252e38' }}>
Ακύρωση
</button>
<button
onClick={confirm}
disabled={!reason}
style={{ flex: 2, height: 48, borderRadius: 11, background: reason ? COLORS.redD : '#2c3744', color: reason ? '#fff' : '#5e6772', fontSize: 15, fontWeight: 700, border: 'none', cursor: reason ? 'pointer' : 'default', fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, transition: 'background .12s' }}
onMouseEnter={e => { if (reason) e.currentTarget.style.background = '#c02e1e' }}
onMouseLeave={e => { if (reason) e.currentTarget.style.background = COLORS.redD }}
>
<Ban style={{ width: 16, height: 16 }} />
{isItem ? 'Απόρριψη αντικειμένου' : 'Απόρριψη παραγγελίας'}
</button>
</div>
</div>
</div>
)
}
function IconBtn({ bg, onClick, title, children }) {
return (
<button
title={title}
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: 'none', cursor: 'pointer', borderRadius: 8,
width: 44, height: 44, flexShrink: 0,
background: bg, color: '#fff',
transition: 'filter .1s, transform .08s',
fontFamily: 'inherit',
}}
onMouseEnter={(e) => { 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}
</button>
)
}
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 (
<div
onClick={(e) => { 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 */}
<div
className={ageInfo.flash && settings.flashLate ? 'kds-flash' : undefined}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: `0 11px`, color: '#fff',
height: Math.round(CARD_HEAD_H_BASE * fs), flexShrink: 0,
background: headBg,
gap: 8,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{
background: 'rgba(255,255,255,0.92)', color: '#0a0c0f',
borderRadius: 7, padding: `0 ${Math.round(9 * fs)}px`,
height: Math.round(30 * fs),
fontSize: Math.round(17 * fs), fontWeight: 800,
fontFamily: '"Inter", "SF Pro Display", system-ui, -apple-system, sans-serif',
fontVariantNumeric: 'tabular-nums',
letterSpacing: '-0.02em',
flexShrink: 0,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
{ageInfo.label}
</span>
<span style={{ fontSize: 12 * fs, fontWeight: 600, opacity: 0.9, whiteSpace: 'nowrap', lineHeight: 1 }}>
{itemCount} item{itemCount !== 1 ? 's' : ''}
</span>
</div>
{/* Topbar click deselects focused card */}
<div
onClick={(e) => { if (focused) { e.stopPropagation(); onFocus(null) } }}
style={{ cursor: focused ? 'default' : 'inherit' }}
>
<ChannelBadge orderType={order.order_type} tableName={order.table_name} fs={fs} />
</div>
</div>
{/* subheader — height scales with fs */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: subBg, color: 'rgba(255,255,255,0.88)',
padding: '0 11px', height: Math.round(CARD_SUB_H_BASE * fs), flexShrink: 0,
fontSize: 12.5 * fs, fontWeight: 600,
}}>
<span style={{ fontFamily: 'ui-monospace, monospace', letterSpacing: '.02em', opacity: 0.85, lineHeight: 1 }}>
#{order.id}
</span>
<span
onClick={(e) => { 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'}
</span>
</div>
{/* body */}
<div style={{
background: isFuture ? '#f0f2f5'
: order.kds_status === 'done' ? '#f4f7f4'
: order.kds_status === 'preparing' ? '#f3f8ff'
: '#ffffff',
paddingTop: BODY_PAD_T, paddingBottom: BODY_PAD_B,
paddingLeft: BODY_PAD_H, paddingRight: BODY_PAD_H,
display: 'flex', gap: 12,
flex: '1 1 auto', overflow: 'hidden',
}}>
{/* grouped batch mode: single column with batch dividers */}
{batchMode === 'grouped' && order._batches?.length > 1 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: BLOCK_GAP, flex: '1 1 0', minWidth: 0 }}>
{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 (
<Fragment key={bi}>
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
fontSize: 10 * fs, fontWeight: 700, textTransform: 'uppercase',
letterSpacing: '.1em', color: '#7b8694',
padding: bi === 0 ? '0 0 2px' : '4px 0 2px',
}}>
<div style={{ flex: 1, height: 1, background: '#e0e5eb' }} />
<span>Batch #{bi + 1}</span>
<span style={{
background: '#f0f2f5', color: '#5b6370',
borderRadius: 5, padding: '1px 6px',
fontFamily: 'ui-monospace, monospace',
}}>{batchMins}m</span>
<div style={{ flex: 1, height: 1, background: '#e0e5eb' }} />
</div>
{batchItems.map(item => (
<ItemBlock
key={item.id}
item={item}
kdsStatus={item.kds_status}
focused={focused}
fontScale={fs}
fontWeight={settings.fontWeight}
combineModifiers={settings.combineModifiers}
onBump={(e) => { e && e.stopPropagation(); resetIdle(); onBumpItem(order.id, item.id) }}
onItemDecline={focused ? (itemId, itemName) => { resetIdle(); onItemDecline(order.id, itemId, itemName) } : null}
/>
))}
</Fragment>
)
})}
</div>
) : 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 (
<div key={ci} style={{ display: 'flex', flexDirection: 'column', gap: BLOCK_GAP, flex: '1 1 0', minWidth: 0 }}>
{orderedGroups.map((group, gi) => (
<div key={gi}>
<div style={{
fontSize: 10, fontWeight: 700, textTransform: 'uppercase',
letterSpacing: '0.08em',
color: group.course ? group.course.color : '#6b7a8d',
padding: '4px 10px 2px',
}}>
{group.course ? group.course.name : 'No course'}
</div>
{group.items.map(item => (
<ItemBlock
key={item.id}
item={item}
kdsStatus={item.kds_status}
focused={focused}
fontScale={fs}
fontWeight={settings.fontWeight}
combineModifiers={settings.combineModifiers}
onBump={(e) => { e && e.stopPropagation(); resetIdle(); onBumpItem(order.id, item.id) }}
onItemDecline={focused ? (itemId, itemName) => { resetIdle(); onItemDecline(order.id, itemId, itemName) } : null}
/>
))}
</div>
))}
</div>
)
}
return (
<div key={ci} style={{ display: 'flex', flexDirection: 'column', gap: BLOCK_GAP, flex: '1 1 0', minWidth: 0 }}>
{colItems.map((item) => (
<ItemBlock
key={item.id}
item={item}
kdsStatus={item.kds_status}
focused={focused}
fontScale={fs}
fontWeight={settings.fontWeight}
combineModifiers={settings.combineModifiers}
onBump={(e) => { e && e.stopPropagation(); resetIdle(); onBumpItem(order.id, item.id) }}
onItemDecline={focused ? (itemId, itemName) => { resetIdle(); onItemDecline(order.id, itemId, itemName) } : null}
/>
))}
</div>
)
})}
</div>
{/* Order-level note (amber, italic, blank separator line above) */}
{order.notes && (
<div style={{
background: '#fffbeb', borderTop: `1px solid ${COLORS.line}`,
flexShrink: 0,
}}>
<div style={{ height: Math.round(6 * fs) }} />
<div style={{
padding: `0 ${Math.round(BODY_PAD_H * fs)}px ${Math.round(8 * fs)}px`,
fontSize: Math.round(12.5 * fs), fontWeight: 600,
color: '#b45309', fontStyle: 'italic', lineHeight: 1.45,
display: 'flex', alignItems: 'flex-start', gap: Math.round(5 * fs),
}}>
<Info style={{ width: Math.round(13 * fs), height: Math.round(13 * fs), flexShrink: 0, marginTop: Math.round(2 * fs), color: '#d97706' }} />
<span>{order.notes}</span>
</div>
</div>
)}
{/* footer — waiter */}
{waiterLabel && (
<div style={{
background: COLORS.cardFoot, color: COLORS.cardFootInk,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7,
height: Math.round(CARD_FOOT_H_BASE * fs), flexShrink: 0,
fontSize: 13 * fs, fontWeight: 700,
borderTop: `1px solid ${COLORS.line}`,
}}>
<User style={{ width: 13, height: 13, opacity: 0.7 }} />
{waiterLabel}
</div>
)}
{/* Quick action overlay (long-press on unselected) */}
{quickAction && !focused && (
<QuickActionBar
order={order}
onSetOrderStatus={onSetOrderStatus}
onDecline={onDecline}
onDismiss={() => setQuickAction(false)}
fs={fs}
/>
)}
{/* Focus action overlay */}
{focused && (
<ActionBar
order={order}
onSetOrderStatus={onSetOrderStatus}
onFocus={onFocus}
onPrint={onPrint}
fs={fs}
hasWaiter={!!waiterLabel}
/>
)}
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 = (
<div style={{ padding: `${Math.round(10 * fs)}px ${Math.round(14 * fs)}px`, borderBottom: '1px solid #252e38', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: headerFontSz, fontWeight: 700, color: '#e7ecf2', letterSpacing: '.02em' }}>Σύνοψη αντικειμένων</div>
<div style={{ fontSize: subFontSz, color: '#6b7a8d', marginTop: 1 }}>Pending + Preparing · {groups.length} είδη</div>
</div>
{isModal && (
<button
onClick={onClose}
style={{ width: Math.round(28 * fs), height: Math.round(28 * fs), borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: 'none', cursor: 'pointer', color: '#6b7a8d', flexShrink: 0 }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#252e38' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none' }}
>
<X style={{ width: Math.round(13 * fs), height: Math.round(13 * fs) }} />
</button>
)}
</div>
)
const listContent = (
<div style={{ overflowY: 'auto', flex: '1 1 auto', padding: `${Math.round(6 * fs)}px 0` }}>
{groups.length === 0 ? (
<div style={{ textAlign: 'center', padding: `${Math.round(24 * fs)}px 0`, color: '#4a5568', fontSize: nameFontSize }}>Κανένα αντικείμενο</div>
) : groups.map((g) => {
const hasMods = g.removed.length || g.extras.length || g.prefs.length || g.notes
return (
<div key={g.key} style={{ padding: `${Math.round(4 * fs)}px ${Math.round(14 * fs)}px` }}>
{/* qty badge + name — mirrors ItemBlock layout, light colors for dark bg */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 7 }}>
<span style={{
flexShrink: 0,
minWidth: badgeH, height: badgeH,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: '#2b3540', color: '#f1f5f9',
borderRadius: Math.round(5 * fs),
fontFamily: 'ui-monospace, monospace',
fontWeight: 700, fontSize: Math.round(12 * fs),
lineHeight: 1,
}}>
{g.count}
</span>
<span style={{
color: '#e7ecf2',
fontSize: nameFontSize, fontWeight: fw,
lineHeight: `${nameLineH}px`,
flex: '1 1 auto', minWidth: 0,
}}>
{g.product_name}
</span>
</div>
{/* modifiers — light color variants for dark bg */}
{hasMods && (
<div style={{ marginTop: 3, paddingLeft: badgeH + 7, display: 'flex', flexDirection: 'column', gap: 3 }}>
{g.removed.length > 0 && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, color: '#fca5a5', fontSize: modFontSize, fontWeight: 600 }}>
<span style={{ flexShrink: 0, width: modIconSz, height: Math.round(modFontSize * 1.4), display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
<Minus style={{ width: modIconSz, height: modIconSz }} />
</span>
<span>{g.removed.join(' · ')}</span>
</div>
)}
{g.extras.length > 0 && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, color: '#86efac', fontSize: modFontSize, fontWeight: 600 }}>
<span style={{ flexShrink: 0, width: modIconSz, height: Math.round(modFontSize * 1.4), display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
<Plus style={{ width: modIconSz, height: modIconSz }} />
</span>
<span>{g.extras.join(' · ')}</span>
</div>
)}
{g.prefs.length > 0 && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, color: '#fcd34d', fontSize: modFontSize, fontWeight: 600 }}>
<span style={{ flexShrink: 0, width: modIconSz, height: Math.round(modFontSize * 1.4), display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
<ArrowRight style={{ width: modIconSz, height: modIconSz }} />
</span>
<span>{g.prefs.join(' · ')}</span>
</div>
)}
{g.notes && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, color: '#93c5fd', fontSize: modFontSize, fontWeight: 600 }}>
<span style={{ flexShrink: 0, width: modIconSz, height: Math.round(modFontSize * 1.4), display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
<Info style={{ width: modIconSz, height: modIconSz }} />
</span>
<span>{g.notes}</span>
</div>
)}
</div>
)}
</div>
)
})}
</div>
)
// ── Sidebar variant: fills its container column
if (mode === 'right_sidebar' || mode === 'left_sidebar') {
return (
<div style={{
...panelStyle, height: '100%',
borderLeft: mode === 'right_sidebar' ? '1px solid #2b3540' : 'none',
borderRight: mode === 'left_sidebar' ? '1px solid #2b3540' : 'none',
}}>
{header}
{listContent}
</div>
)
}
// ── Floating: absolutely positioned over board viewport, right side, vertically centred
if (mode === 'floating') {
return (
<div style={{
position: 'absolute', right: 18, zIndex: 50,
top: '50%', transform: 'translateY(-50%)',
width: `${Math.round(20 * fs)}vw`, minWidth: 200, maxHeight: '90%',
pointerEvents: 'auto',
...panelStyle,
boxShadow: '0 8px 40px rgba(0,0,0,.7), 0 0 0 1px rgba(255,255,255,.06)',
}}>
{header}
{listContent}
</div>
)
}
// ── Topbar button modal: centred overlay, closes on backdrop click
if (mode === 'topbar_button' && open) {
return (
<div
onClick={onClose}
style={{ position: 'absolute', inset: 0, zIndex: 150, background: 'rgba(10,13,17,.6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<div
onClick={(e) => 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}
</div>
</div>
)
}
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 (
<div style={{
position: 'absolute', left: -99999, top: 0,
visibility: 'hidden', pointerEvents: 'none',
width: colW - BODY_PAD_H * 2,
fontSize: 14 * fontScale,
}}>
{orders.map((o) => {
itemRefs.current[o.id] = itemRefs.current[o.id] || []
return (
<div key={o.id} style={{ padding: '9px 10px' }}>
{o.items.map((item, i) => (
<div
key={item.id}
ref={(el) => { itemRefs.current[o.id][i] = el }}
style={{ marginBottom: i < o.items.length - 1 ? BLOCK_GAP : 0 }}
>
<ItemBlock item={item} kdsStatus={item.kds_status} focused={false} onBump={null} fontScale={fontScale} fontWeight={fontWeight} combineModifiers={combineModifiers} />
</div>
))}
</div>
)
})}
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 (
<button
onClick={onClick}
disabled={disabled}
style={{
position: 'relative', width: '100%',
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: Math.round(4 * fs), padding: `${Math.round(9 * fs)}px ${Math.round(4 * fs)}px`,
borderRadius: Math.round(10 * fs),
color: active ? activeColor : '#8b95a1',
background: active ? 'rgba(255,255,255,0.06)' : 'none',
border: active ? `1px solid rgba(255,255,255,0.08)` : '1px solid transparent',
cursor: disabled ? 'default' : 'pointer',
fontFamily: 'inherit',
opacity: disabled ? 0.32 : 1,
transition: 'background .12s, color .12s, opacity .15s',
}}
onMouseEnter={(e) => { if (!disabled) { e.currentTarget.style.filter = 'brightness(1.12)' } }}
onMouseLeave={(e) => { e.currentTarget.style.filter = '' }}
>
{children}
</button>
)
}
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 (
<div style={{
width: railW, flexShrink: 0,
background: COLORS.rail,
borderRight: `1px solid ${COLORS.railLine}`,
display: 'flex', flexDirection: 'column',
alignItems: 'stretch', padding: `${Math.round(10 * fs)}px 0`, gap: 2,
overflowY: 'auto',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: `2px ${Math.round(8 * fs)}px` }}>
{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 (
<RailBtn key={key} active={active} activeColor={activeColor} onClick={() => onToggleFilter(key)} fs={fs}>
<Icon style={{ width: iconSzLg, height: iconSzLg }} />
<span style={{ fontSize: labelSz, fontWeight: 600, letterSpacing: '.06em', textTransform: 'uppercase' }}>{label}</span>
<span style={{
minWidth: badgeH, height: badgeH, padding: `0 ${Math.round(6 * fs)}px`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
borderRadius: Math.round(11 * fs), fontSize: badgeSz, fontWeight: 700,
background: active ? activeColor : '#2b333d',
color: active ? '#0e1217' : '#cfd6df',
}}>
{counts[key] || 0}
</span>
</RailBtn>
)
})}
<div style={{ height: 1, background: COLORS.railLine, margin: `${Math.round(6 * fs)}px ${Math.round(6 * fs)}px` }} />
<RailBtn active={false} activeColor="#fff" onClick={onUndo} disabled={!canUndo} fs={fs}>
<Undo2 style={{ width: iconSzSm, height: iconSzSm }} />
<span style={{ fontSize: labelSz, fontWeight: 600, letterSpacing: '.06em', textTransform: 'uppercase' }}>Undo</span>
</RailBtn>
<RailBtn active={false} activeColor="#fff" onClick={onRedo} disabled={!canRedo} fs={fs}>
<Redo2 style={{ width: iconSzSm, height: iconSzSm }} />
<span style={{ fontSize: labelSz, fontWeight: 600, letterSpacing: '.06em', textTransform: 'uppercase' }}>Redo</span>
</RailBtn>
</div>
<div style={{ flex: '1 1 auto' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, padding: `2px ${Math.round(8 * fs)}px` }}>
{[
{ 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 }) => (
<RailBtn key={label} active={false} activeColor="#fff" onClick={fn} fs={fs}>
<Icon style={{ width: iconSzSm, height: iconSzSm }} />
<span style={{ fontSize: labelSz, fontWeight: 600, letterSpacing: '.06em', textTransform: 'uppercase' }}>{label}</span>
</RailBtn>
))}
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 (
<div style={{
height: barH, flexShrink: 0,
background: COLORS.topbar,
borderBottom: `1px solid ${COLORS.railLine}`,
display: 'flex', alignItems: 'center',
gap: 8, padding: '0 12px',
}}>
{/* tabs — shrink to their natural width, don't grow */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0, overflowX: 'auto' }}>
{TABS.map(({ key, label }) => {
const active = activeTab === key
return (
<button
key={key}
onClick={() => 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}
<span style={{
minWidth: Math.round(20 * fs), height: Math.round(20 * fs), padding: `0 ${Math.round(5 * fs)}px`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
borderRadius: Math.round(6 * fs), fontSize: Math.round(11 * fs), fontWeight: 700,
background: active ? 'rgba(0,0,0,.10)' : 'rgba(255,255,255,.08)',
color: active ? '#41474f' : COLORS.tabInk,
}}>
{counts[key] ?? 0}
</span>
</button>
)
})}
</div>
{/* left spacer — pushes summary button to true center */}
<div style={{ flex: '1 1 0' }} />
{/* summary button — only when topbar_button mode, sits dead-center */}
{showSummaryBtn && (
<button
onClick={onToggleSummary}
style={{
flexShrink: 0,
display: 'flex', alignItems: 'center', gap: Math.round(6 * fs),
height: tabH, padding: `0 ${Math.round(14 * fs)}px`,
borderRadius: tabR,
background: summaryOpen ? COLORS.tabActiveBg : COLORS.tab,
color: summaryOpen ? COLORS.tabActiveInk : COLORS.tabInk,
fontSize: Math.round(13 * fs), fontWeight: 600,
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
transition: 'background .12s, color .12s',
}}
onMouseEnter={(e) => { if (!summaryOpen) e.currentTarget.style.filter = 'brightness(1.1)' }}
onMouseLeave={(e) => { e.currentTarget.style.filter = '' }}
>
<CookingPot style={{ width: Math.round(15 * fs), height: Math.round(15 * fs) }} />
Σύνοψη
</button>
)}
{/* right spacer — mirrors left spacer so clock+zoom stay right-aligned */}
<div style={{ flex: '1 1 0' }} />
{/* clock — single row: date · time · πμ/μμ */}
<div style={{ display: 'flex', alignItems: 'center', gap: Math.round(6 * fs), flexShrink: 0, whiteSpace: 'nowrap' }}>
<span style={{ fontSize: Math.round(14 * fs), fontWeight: 600, color: '#c8d0db', fontFamily: CLOCK_FONT }}>
{clock.date}
</span>
<span style={{ fontSize: Math.round(14 * fs), fontWeight: 700, color: '#f1f4f8', fontFamily: CLOCK_FONT }}>
{clock.time}
</span>
<span style={{ fontSize: Math.round(14 * fs), fontWeight: 600, color: 'rgba(200,208,219,0.50)', fontFamily: CLOCK_FONT }}>
{clock.ap}
</span>
</div>
{/* divider */}
<div style={{ width: 1, height: Math.round(24 * fs), background: COLORS.railLine, flexShrink: 0 }} />
{/* zoom % + — same height as tabs */}
<div style={{ display: 'flex', alignItems: 'center', gap: 2, background: COLORS.tab, borderRadius: tabR, padding: `0 ${Math.round(4 * fs)}px`, flexShrink: 0, height: tabH }}>
<button
onClick={() => 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' }}
>
<ZoomOut style={{ width: Math.round(15 * fs), height: Math.round(15 * fs) }} />
</button>
<span style={{ fontSize: Math.round(11 * fs), fontWeight: 700, color: COLORS.tabInk, minWidth: Math.round(34 * fs), textAlign: 'center', fontFamily: 'ui-monospace, monospace' }}>
{Math.round(fontScale * 100)}%
</span>
<button
onClick={() => 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' }}
>
<ZoomIn style={{ width: Math.round(15 * fs), height: Math.round(15 * fs) }} />
</button>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<button style={btnStyle(canDec)} onClick={canDec ? onDec : undefined}
onMouseEnter={(e) => { if (canDec) e.currentTarget.style.background = '#3a4f62' }}
onMouseLeave={(e) => { e.currentTarget.style.background = canDec ? '#2e3a47' : '#232d38' }}>
</button>
<span style={{ minWidth: 64, textAlign: 'center', fontSize: 16, fontWeight: 700, color: '#e7ecf2', fontFamily: 'ui-monospace, monospace' }}>
{display}
</span>
<button style={btnStyle(canInc)} onClick={canInc ? onInc : undefined}
onMouseEnter={(e) => { if (canInc) e.currentTarget.style.background = '#3a4f62' }}
onMouseLeave={(e) => { e.currentTarget.style.background = canInc ? '#2e3a47' : '#232d38' }}>
+
</button>
</div>
)
}
function Toggle({ on, onToggle, disabled }) {
return (
<button onClick={disabled ? undefined : onToggle} style={{
width: 56, height: 32, borderRadius: 16, flexShrink: 0,
background: on ? COLORS.accent : '#3a4450',
position: 'relative', border: 'none', cursor: disabled ? 'default' : 'pointer',
transition: 'background .15s',
opacity: disabled ? 0.4 : 1,
}}>
<span style={{
position: 'absolute', top: 4, left: 4, width: 24, height: 24,
borderRadius: '50%', background: '#fff',
transform: on ? 'translateX(24px)' : 'none',
transition: 'transform .15s',
}} />
</button>
)
}
function TextRow({ label, sub, value, onChange, placeholder }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, padding: '10px 0', borderBottom: '1px solid #252e38' }}>
<div style={{ flex: '1 1 auto', minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 500, color: '#e7ecf2' }}>{label}</span>
{sub && <div style={{ fontSize: 12.5, color: '#6b7a8d', marginTop: 3, lineHeight: 1.4 }}>{sub}</div>}
</div>
<input
type="text"
value={value}
onChange={(e) => 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',
}}
/>
</div>
)
}
function DropdownRow({ label, sub, value, onChange, options }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, padding: '10px 0', borderBottom: '1px solid #252e38' }}>
<div style={{ flex: '1 1 auto', minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 500, color: '#e7ecf2' }}>{label}</span>
{sub && <div style={{ fontSize: 12.5, color: '#6b7a8d', marginTop: 3, lineHeight: 1.4 }}>{sub}</div>}
</div>
<select
value={value}
onChange={(e) => 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 => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
)
}
function SRow({ label, sub, children, comingSoon }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, padding: '10px 0', borderBottom: '1px solid #252e38' }}>
<div style={{ flex: '1 1 auto', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 15, fontWeight: 500, color: '#e7ecf2' }}>{label}</span>
{comingSoon && (
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '.08em', textTransform: 'uppercase', background: '#2c3a4a', color: '#6b8aa8', borderRadius: 5, padding: '2px 6px' }}>
Σύντομα
</span>
)}
</div>
{sub && <div style={{ fontSize: 12.5, color: '#6b7a8d', marginTop: 3, lineHeight: 1.4 }}>{sub}</div>}
</div>
<div style={{ flexShrink: 0 }}>{children}</div>
</div>
)
}
function ColorSwatchBtn({ colorVal, onColorChange }) {
const [open, setOpen] = useState(false)
return (
<div style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={() => setOpen((v) => !v)}
style={{ width: 44, height: 44, borderRadius: 10, border: '2px solid #3d4f5e', background: colorVal, cursor: 'pointer' }}
/>
{open && (
<div
style={{ position: 'absolute', top: 52, left: 0, zIndex: 50, background: '#1c242d', border: '1px solid #3d4f5e', borderRadius: 12, padding: 8, display: 'grid', gridTemplateColumns: 'repeat(5, 36px)', gap: 6, boxShadow: '0 8px 24px rgba(0,0,0,.5)' }}
onMouseLeave={() => setOpen(false)}
>
{COLOR_PRESETS.map((p) => (
<button
key={p.value}
title={p.label}
onClick={() => { 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' }}
/>
))}
</div>
)}
</div>
)
}
function ColorRow({ label, colorVal, onColorChange, minutes, onMinutesChange, minMinutes, maxMinutes }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 0', borderBottom: '1px solid #252e38' }}>
<ColorSwatchBtn colorVal={colorVal} onColorChange={onColorChange} />
<span style={{ flex: '1 1 auto', fontSize: 15, fontWeight: 500, color: '#e7ecf2' }}>{label}</span>
<Stepper
value={minutes}
display={`${minutes}m`}
canDec={minutes > minMinutes}
canInc={minutes < maxMinutes}
onDec={() => onMinutesChange(Math.max(minMinutes, minutes - 1))}
onInc={() => onMinutesChange(Math.min(maxMinutes, minutes + 1))}
/>
</div>
)
}
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 ──
<div key="zone" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 12.5, color: '#6b7a8d', padding: '12px 0 14px', lineHeight: 1.5 }}>
Επιλέξτε τη ζώνη προετοιμασίας για αυτή την οθόνη KDS. Θα εμφανίζονται μόνο παραγγελίες με αντικείμενα ανήκοντα στην επιλεγμένη ζώνη.
</div>
{prepZones.length === 0 ? (
<div style={{ textAlign: 'center', padding: '24px 0', color: '#5e6772', fontSize: 14 }}>
Δεν έχουν οριστεί ζώνες. Δημιουργήστε ζώνες από Management Prep Zones.
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{/* All zones option */}
<button
onClick={() => 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 && <Check style={{ width: 14, height: 14, flexShrink: 0 }} />}
<span style={{ lineHeight: 1.3 }}>Όλες οι ζώνες</span>
</button>
{prepZones.map(z => {
const active = local.kdsZoneId === z.id
return (
<button
key={z.id}
onClick={() => 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 && <Check style={{ width: 14, height: 14, color: COLORS.accent, flexShrink: 0 }} />}
<span style={{ lineHeight: 1.3 }}>{z.name}</span>
{z.description && <span style={{ fontSize: 10, color: '#6b7a8d', lineHeight: 1.3 }}>{z.description}</span>}
</button>
)
})}
</div>
)}
</div>,
// ── FUNCTIONALITY ──
<div key="func" style={{ display: 'flex', flexDirection: 'column' }}>
<SRow label="Αυτόματη ανανέωση" sub="Ανανέωση από server ανά X δευτερόλεπτα (πέραν του SSE)">
<Stepper
value={refreshSnapped}
display={`${refreshSnapped}s`}
canDec={REFRESH_STEPS.indexOf(refreshSnapped) > 0}
canInc={REFRESH_STEPS.indexOf(refreshSnapped) < REFRESH_STEPS.length - 1}
onDec={() => set('autoRefresh', stepPrev(REFRESH_STEPS, refreshSnapped))}
onInc={() => set('autoRefresh', stepNext(REFRESH_STEPS, refreshSnapped))}
/>
</SRow>
<SRow label="Τριπλή κατάσταση παραγγελιών" sub="Pending → Preparing → Done. Αν είναι off, παραλείπεται το Preparing.">
<Toggle on={local.tripleState} onToggle={() => set('tripleState', !local.tripleState)} />
</SRow>
<SRow label="Κατηγορίες ως Φίλτρα" sub="ON: επιλέγεις πολλαπλές κατηγορίες ταυτόχρονα. OFF: μία κατηγορία κάθε φορά (tab mode).">
<Toggle on={local.categoriesAsFilters ?? true} onToggle={() => set('categoriesAsFilters', !(local.categoriesAsFilters ?? true))} />
</SRow>
<SRow label="Group and sort by Course" sub="When enabled, items on each card are grouped under their course name. Items with no course appear last under 'No course'.">
<Toggle on={local.groupByCourse ?? false} onToggle={() => set('groupByCourse', !(local.groupByCourse ?? false))} />
</SRow>
<DropdownRow
label="Ομαδοποίηση αντικειμένων"
sub="Συνολική εικόνα των αντικειμένων όλων των παραγγελιών (μόνο pending/preparing, ίδια προϊόντα με ίδιες προτιμήσεις)."
value={local.groupItems ?? 'off'}
onChange={(v) => 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: 'Κουμπί στη γραμμή τίτλου' },
]}
/>
<DropdownRow
label="Συγκέντρωση Κοινής Παραγγελίας"
sub="Πώς εμφανίζονται οι παρτίδες (batches) όταν ένα τραπέζι παραγγέλνει πολλές φορές στην ίδια παραγγελία."
value={local.batchMode ?? 'merged'}
onChange={(v) => set('batchMode', v)}
options={[
{ value: 'merged', label: 'Χωρίς Γκρουπ — μία κάρτα, όλα μαζί' },
{ value: 'grouped', label: 'Με Γκρουπ — μία κάρτα με διαχωριστικά ανά batch' },
{ value: 'split', label: 'Απενεργοποιημένο — ξεχωριστή κάρτα ανά batch' },
]}
/>
</div>,
// ── DISPLAY ──
<div key="disp" style={{ display: 'flex', flexDirection: 'column' }}>
<SRow label="Πλάτος κάρτας" sub="Βασικό πλάτος κάθε κάρτας παραγγελίας (px).">
<Stepper
value={colWSnapped}
display={`${colWSnapped}px`}
canDec={COL_W_STEPS.indexOf(colWSnapped) > 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))}
/>
</SRow>
<SRow label="Βάρος γραμματοσειράς" sub="Πάχος γραμμάτων ονομάτων προϊόντων.">
<Stepper
value={fontWeightSnapped}
display={`${fontWeightSnapped}`}
canDec={FW_STEPS.indexOf(fontWeightSnapped) > 0}
canInc={FW_STEPS.indexOf(fontWeightSnapped) < FW_STEPS.length - 1}
onDec={() => set('fontWeight', stepPrev(FW_STEPS, fontWeightSnapped))}
onInc={() => set('fontWeight', stepNext(FW_STEPS, fontWeightSnapped))}
/>
</SRow>
<SRow label="Flash αργοπορημένων παραγγελιών" sub="Το header αναβοσβήνει όταν η παραγγελία αργεί υπερβολικά.">
<Toggle on={local.flashLate} onToggle={() => set('flashLate', !local.flashLate)} />
</SRow>
<SRow label="Συγχώνευση modifiers ίδιου τύπου" sub="Π.χ. «− ντομάτα · σάλτσα» αντί για ξεχωριστές γραμμές.">
<Toggle on={local.combineModifiers} onToggle={() => set('combineModifiers', !local.combineModifiers)} />
</SRow>
</div>,
// ── COLORS ──
<div key="colors" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '.1em', textTransform: 'uppercase', color: '#6b7a8d', padding: '8px 0 4px' }}>
Χρόνος αναμονής
</div>
<ColorRow
label="Fresh" colorVal={fc.green ?? COLORS.green}
onColorChange={(v) => setFlashColor('green', v)}
minutes={thr.green} minMinutes={1} maxMinutes={thr.amber - 1}
onMinutesChange={(v) => setThr('green', v)}
/>
<ColorRow
label="Warning" colorVal={fc.amber ?? COLORS.amber}
onColorChange={(v) => setFlashColor('amber', v)}
minutes={thr.amber} minMinutes={thr.green + 1} maxMinutes={thr.flash - 1}
onMinutesChange={(v) => setThr('amber', v)}
/>
<ColorRow
label="Αργά / Flash" colorVal={fc.red ?? COLORS.red}
onColorChange={(v) => setFlashColor('red', v)}
minutes={thr.flash} minMinutes={thr.amber + 1} maxMinutes={120}
onMinutesChange={(v) => setThr('flash', v)}
/>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '.1em', textTransform: 'uppercase', color: '#6b7a8d', padding: '14px 0 4px' }}>
Κατάσταση
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 0', borderBottom: '1px solid #252e38' }}>
<ColorSwatchBtn colorVal={fc.inactive ?? COLORS.inactive} onColorChange={(v) => setFlashColor('inactive', v)} />
<div style={{ flex: '1 1 auto' }}>
<div style={{ fontSize: 15, fontWeight: 500, color: '#e7ecf2' }}>Inactive (Done)</div>
<div style={{ fontSize: 12.5, color: '#6b7a8d', marginTop: 3 }}>Χρώμα header για ολοκληρωμένες παραγγελίες.</div>
</div>
</div>
</div>,
// ── NOTIFICATIONS ──
<div key="notif" style={{ display: 'flex', flexDirection: 'column' }}>
<SRow label="Ήχος για νέες παραγγελίες" sub="Global ρύθμιση ήχου. Το κουμπί στο side nav είναι προσωρινή εναλλαγή — και τα δύο πρέπει να είναι ON.">
<Toggle on={local.sound} onToggle={() => set('sound', !local.sound)} />
</SRow>
<SRow label="Ειδοποίηση σερβιτόρου (ολοκλήρωση παραγγελίας)" sub="Ειδοποίηση στους σερβιτόρους της παραγγελίας όταν τελειώσει η προετιμασία.">
<Toggle on={local.notifyOrderComplete} onToggle={() => set('notifyOrderComplete', !local.notifyOrderComplete)} />
</SRow>
<SRow label="Ειδοποίηση σερβιτόρου (ολοκλήρωση αντικειμένου)" sub="Ειδοποίηση όταν ολοκληρωθεί μεμονωμένο αντικείμενο (πριν το σύνολο της παραγγελίας).">
<Toggle on={local.notifyItemComplete} onToggle={() => set('notifyItemComplete', !local.notifyItemComplete)} />
</SRow>
</div>,
]
return (
<div
onClick={onClose}
style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(10,13,17,.75)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div
onClick={(e) => 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 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '18px 22px 0' }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>Ρυθμίσεις KDS</h2>
<button onClick={onClose} style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#6b7a8d', background: 'none', border: 'none', cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#252e38' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none' }}>
<X style={{ width: 20, height: 20 }} />
</button>
</div>
{/* tab bar */}
<div style={{ display: 'flex', gap: 4, padding: '14px 22px 0', borderBottom: '1px solid #252e38' }}>
{SETTINGS_TABS.map((t, i) => (
<button
key={t}
onClick={() => 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}
</button>
))}
</div>
{/* content area — each tab absolutely fills the same fixed box so modal never resizes */}
<div style={{ position: 'relative', height: 470 }}>
{TAB_CONTENT.map((content, i) => (
<div
key={i}
style={{
position: 'absolute', inset: 0,
padding: '6px 22px 12px',
overflowY: tab === i ? 'auto' : 'hidden',
visibility: tab === i ? 'visible' : 'hidden',
pointerEvents: tab === i ? 'auto' : 'none',
}}
>
{content}
</div>
))}
</div>
{/* footer */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, padding: '14px 22px', borderTop: '1px solid #252e38' }}>
<button onClick={onClose}
style={{ height: 48, padding: '0 22px', borderRadius: 11, background: '#252e38', color: '#aab8c8', fontSize: 15, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#2e3a47' }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#252e38' }}>
Ακύρωση
</button>
<button
onClick={() => { 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 = '' }}>
<Check style={{ width: 18, height: 18 }} /> Αποθήκευση
</button>
</div>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// Toast
// ─────────────────────────────────────────────────────────────────────────────
function Toasts({ toasts }) {
return (
<div style={{ position: 'absolute', bottom: 18, left: '50%', transform: 'translateX(-50%)', zIndex: 300, display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center', pointerEvents: 'none' }}>
{toasts.map((t) => (
<div key={t.id} style={{
background: '#2b333d', color: '#eef2f6', border: '1px solid #3a4450',
padding: '11px 18px', borderRadius: 10, fontSize: 14, fontWeight: 600,
boxShadow: '0 8px 22px rgba(0,0,0,.4)',
}}>
{t.msg}
</div>
))}
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 (
<div
onClick={onClose}
style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(10,13,17,.78)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div
onClick={(e) => 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 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '18px 20px 14px' }}>
<div>
<div style={{ fontSize: 18, fontWeight: 700 }}>Κάλεσε Σερβιτόρο</div>
<div style={{ fontSize: 12.5, color: '#6b7a8d', marginTop: 2 }}>
Η <span style={{ color: '#e7ecf2', fontWeight: 600 }}>{zoneName}</span> θέλει βοήθεια
</div>
</div>
<button onClick={onClose} style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#6b7a8d', background: 'none', border: 'none', cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#252e38' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'none' }}>
<X style={{ width: 20, height: 20 }} />
</button>
</div>
{/* waiter list */}
<div style={{ padding: '0 14px', maxHeight: 320, overflowY: 'auto' }}>
{loading && (
<div style={{ textAlign: 'center', padding: '32px 0', color: '#5e6772', fontSize: 14 }}>Φόρτωση</div>
)}
{!loading && waiters.length === 0 && (
<div style={{ textAlign: 'center', padding: '32px 0', color: '#5e6772', fontSize: 14 }}>Κανένας σερβιτόρος σε βάρδια</div>
)}
{!loading && waiters.length > 0 && (
<>
{/* Select all row */}
<button
onClick={selected.size === waiters.length ? () => 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 ? <Check style={{ width: 16, height: 16 }} /> : null}
Όλοι οι σερβιτόροι
</button>
{waiters.map((w) => {
const sel = selected.has(w.id)
return (
<button
key={w.id}
onClick={() => 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',
}}
>
<span style={{
width: 36, height: 36, borderRadius: '50%', flexShrink: 0,
background: sel ? COLORS.accent : '#2c3744',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 16, fontWeight: 700,
transition: 'background .1s',
}}>
{sel ? <Check style={{ width: 18, height: 18 }} /> : (w.nickname || w.username)?.[0]?.toUpperCase()}
</span>
<span style={{ flex: 1, textAlign: 'left' }}>{w.nickname || w.username}</span>
</button>
)
})}
</>
)}
</div>
{/* footer */}
<div style={{ display: 'flex', gap: 10, padding: '14px 20px', borderTop: '1px solid #252e38' }}>
<button onClick={onClose}
style={{ flex: 1, height: 52, borderRadius: 11, background: '#252e38', color: '#aab8c8', fontSize: 15, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#2e3a47' }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#252e38' }}>
Ακύρωση
</button>
<button
onClick={send}
disabled={!selected.size || sending}
style={{
flex: 2, height: 52, borderRadius: 11, background: selected.size && !sending ? COLORS.amber : '#2c3744',
color: selected.size && !sending ? '#0e1217' : '#5e6772',
fontSize: 15, fontWeight: 700, border: 'none',
cursor: selected.size && !sending ? 'pointer' : 'default',
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
transition: 'background .12s, color .12s',
}}
>
<BellRing style={{ width: 18, height: 18 }} />
{sending ? 'Αποστολή…' : `Κάλεσε${selected.size > 0 ? ` (${selected.size})` : ''}`}
</button>
</div>
</div>
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 (
<>
<style>{FLASH_KEYFRAMES}</style>
<div style={{
display: 'flex', height: '100vh', width: '100vw',
overflow: 'hidden', position: 'fixed', inset: 0,
fontFamily: '"Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
background: COLORS.bg, color: '#e7ecf2',
WebkitFontSmoothing: 'antialiased',
userSelect: 'none', WebkitUserSelect: 'none',
}}>
<SideRail
activeFilters={activeFilters}
counts={counts}
fontScale={fontScale}
onToggleFilter={(key) => {
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)}
/>
<div style={{ flex: '1 1 auto', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<TopBar
activeTab={tabFilter}
counts={tabCounts}
onTab={(k) => { 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 */}
<div style={{ flex: '1 1 auto', display: 'flex', minHeight: 0 }}>
{/* Left sidebar */}
{settings.groupItems === 'left_sidebar' && (
<div style={{ width: `${Math.round(20 * fontScale)}vw`, flexShrink: 0, minHeight: 0 }}>
<ItemSummaryPanel orders={visible} mode="left_sidebar" fontScale={fontScale} fontWeight={settings.fontWeight} />
</div>
)}
{/* Board */}
<div style={{ flex: '1 1 auto', position: 'relative', minWidth: 0, minHeight: 0 }}>
<div
ref={boardRef}
onClick={() => 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 */}
<div style={{ position: 'relative', height: '100%', width: Math.max(layout.totalW, 1), minWidth: '100%', paddingRight: settings.groupItems === 'floating' ? `calc(${Math.round(20 * fontScale)}vw + 32px)` : 0 }}>
{isLoading && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#5e6772', fontSize: 14 }}>
Φόρτωση
</div>
)}
{!isLoading && visible.length === 0 && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 14, color: '#5e6772' }}
onClick={(e) => e.stopPropagation()}
>
<CookingPot style={{ width: 54, height: 54, opacity: 0.5 }} />
<div style={{ fontSize: 17, fontWeight: 600, color: '#7b8694' }}>
Δεν υπάρχουν παραγγελίες για τα επιλεγμένα φίλτρα
</div>
</div>
)}
{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 (
<OrderCard
key={order.id}
order={{ ...order, id: realId }}
ageInfo={ai}
layout={lo}
focused={focusId === order.id}
dimmed={focusId != null && focusId !== order.id}
isNew={false}
settings={settings}
activeFilters={activeFilters}
onFocus={(id) => setFocusId(id === realId ? order.id : id)}
onSetOrderStatus={handleSetOrderStatus}
onBumpItem={handleBumpItem}
onPrint={handlePrint}
onDecline={handleDecline}
onItemDecline={handleItemDecline}
courses={kdsCourses}
groupByCourse={settings.groupByCourse ?? false}
batchMode={batchMode}
/>
)
})}
</div>
</div>
{/* Floating summary panel — sits over the board viewport, not inside scroll content */}
{settings.groupItems === 'floating' && (
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 50 }}>
<ItemSummaryPanel orders={visible} mode="floating" onClose={() => {}} fontScale={fontScale} fontWeight={settings.fontWeight} />
</div>
)}
{/* Topbar-button modal — rendered over the board */}
{settings.groupItems === 'topbar_button' && showSummary && (
<ItemSummaryPanel orders={visible} mode="topbar_button" open={showSummary} onClose={() => setShowSummary(false)} fontScale={fontScale} fontWeight={settings.fontWeight} />
)}
</div>
{/* Right sidebar */}
{settings.groupItems === 'right_sidebar' && (
<div style={{ width: `${Math.round(20 * fontScale)}vw`, flexShrink: 0, minHeight: 0 }}>
<ItemSummaryPanel orders={visible} mode="right_sidebar" fontScale={fontScale} fontWeight={settings.fontWeight} />
</div>
)}
</div>
</div>
<MeasureLayer orders={visible} colW={colW} fontScale={fontScale} fontWeight={settings.fontWeight} combineModifiers={settings.combineModifiers} onResult={setMeasured} />
{showSettings && (
<SettingsModal
settings={settings}
onSave={saveSettings}
onClose={() => setShowSettings(false)}
/>
)}
{showCallModal && (
<CallWaiterModal
zoneName={kdsZone}
onSend={(waiterIds) =>
client.post('/api/kds/call-waiter-general', {
kds_zone: kdsZone,
waiter_ids: waiterIds,
}).then(() => pushToast('Ειδοποίηση στάλθηκε'))
.catch(() => pushToast('Αποτυχία αποστολής'))
}
onClose={() => setShowCallModal(false)}
/>
)}
{declineModal && (
<DeclineModal
orderId={declineModal.orderId}
itemId={declineModal.itemId}
itemName={declineModal.itemName}
onConfirm={confirmDecline}
onClose={() => setDeclineModal(null)}
/>
)}
<Toasts toasts={toasts} />
</div>
</>
)
}