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>
3025 lines
135 KiB
JavaScript
3025 lines
135 KiB
JavaScript
/**
|
||
* 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>
|
||
</>
|
||
)
|
||
}
|