feat: Phase 2I — KDS Kitchen Display Screen
Backend:
- New router /api/kds/:
- GET /items — returns all active order items for open orders, grouped by printer zone
(zone_id, zone_name, items array with table_name, product_name, qty, notes, added_at)
Zones sorted by zone_id asc, no-zone column last
- PUT /orders/{order_id}/items/{item_id}/status — mark item 'ready' (active→ready only)
Broadcasts item_status_changed SSE event to all connected clients
- No migration needed — 'ready' is a new valid string value for order_items.status
- pay_items endpoint: now accepts 'ready' items as payable (status.in_(['active','ready']))
- active_remaining count for order status also includes 'ready' items
Frontend:
- KdsPage (/kds): full-screen dark layout, columns per printer zone
- Zone columns: dark header with zone name + pending count, scrollable item cards
- Item cards: table name badge, elapsed time (colour-coded: green<8m, amber<15m, red>=15m),
product name + quantity, notes in amber, tap anywhere to mark ready
- Optimistic marking: card dims while request in flight, disappears on success
- SSE live updates: listens for order_updated / item_status_changed / order_paid / order_closed
- Fallback poll every 30s; manual ↻ Ανανέωση button; live clock in top bar
- Empty state: ✓ message when no pending items
- Sidebar: ChefHat icon for KDS (after Αποβλήτα)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ from routers.expenses import contacts_router, expenses_router
|
|||||||
from routers import customers as customers_router
|
from routers import customers as customers_router
|
||||||
from routers import tabs as tabs_router
|
from routers import tabs as tabs_router
|
||||||
from routers import waste as waste_router
|
from routers import waste as waste_router
|
||||||
|
from routers import kds as kds_router
|
||||||
|
|
||||||
|
|
||||||
def _run_migrations():
|
def _run_migrations():
|
||||||
@@ -460,3 +461,4 @@ app.include_router(expenses_router, prefix="/api/expenses", tag
|
|||||||
app.include_router(customers_router.router, prefix="/api/customers", tags=["customers"])
|
app.include_router(customers_router.router, prefix="/api/customers", tags=["customers"])
|
||||||
app.include_router(tabs_router.router, prefix="/api/tabs", tags=["tabs"])
|
app.include_router(tabs_router.router, prefix="/api/tabs", tags=["tabs"])
|
||||||
app.include_router(waste_router.router, prefix="/api/waste", tags=["waste"])
|
app.include_router(waste_router.router, prefix="/api/waste", tags=["waste"])
|
||||||
|
app.include_router(kds_router.router, prefix="/api/kds", tags=["kds"])
|
||||||
|
|||||||
128
local_backend/routers/kds.py
Normal file
128
local_backend/routers/kds.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
KDS — Kitchen Display System router.
|
||||||
|
|
||||||
|
GET /api/kds/items
|
||||||
|
Returns all active (status='active') order items for open orders,
|
||||||
|
enriched with zone name and table name. Used by the KDS frontend.
|
||||||
|
|
||||||
|
PUT /api/orders/{order_id}/items/{item_id}/status
|
||||||
|
Mark an item ready (active → ready). Only that transition is allowed here.
|
||||||
|
Broadcasts item_status_changed SSE event.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
from models.order import Order, OrderItem
|
||||||
|
from models.product import Product
|
||||||
|
from models.printer import Printer
|
||||||
|
from models.table import Table
|
||||||
|
from models.user import User
|
||||||
|
from routers.deps import get_current_user
|
||||||
|
from services.sse_bus import broadcast_sync
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ItemStatusUpdate(BaseModel):
|
||||||
|
status: str # only "ready" is accepted
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items")
|
||||||
|
def kds_items(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return all active order items grouped by zone for the KDS display."""
|
||||||
|
# Only look at open / partially_paid orders
|
||||||
|
open_orders = db.query(Order).filter(Order.status.in_(["open", "partially_paid"])).all()
|
||||||
|
order_ids = [o.id for o in open_orders]
|
||||||
|
if not order_ids:
|
||||||
|
return {"zones": []}
|
||||||
|
|
||||||
|
order_map = {o.id: o for o in open_orders}
|
||||||
|
|
||||||
|
items = db.query(OrderItem).filter(
|
||||||
|
OrderItem.order_id.in_(order_ids),
|
||||||
|
OrderItem.status == "active",
|
||||||
|
).order_by(OrderItem.added_at.asc()).all()
|
||||||
|
|
||||||
|
tables_map = {t.id: (t.label or f"T{t.number}") for t in db.query(Table).all()}
|
||||||
|
printers_map = {p.id: p.name for p in db.query(Printer).all()}
|
||||||
|
|
||||||
|
# Zone = printer_zone_id (None = no zone)
|
||||||
|
zones: dict = {}
|
||||||
|
|
||||||
|
def _zone_key(zone_id):
|
||||||
|
if zone_id is None:
|
||||||
|
return 0
|
||||||
|
return zone_id
|
||||||
|
|
||||||
|
def _zone_name(zone_id):
|
||||||
|
if zone_id is None:
|
||||||
|
return "Χωρίς Ζώνη"
|
||||||
|
return printers_map.get(zone_id, f"Ζώνη #{zone_id}")
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
product = item.product
|
||||||
|
zone_id = product.printer_zone_id if product else None
|
||||||
|
zkey = _zone_key(zone_id)
|
||||||
|
|
||||||
|
if zkey not in zones:
|
||||||
|
zones[zkey] = {
|
||||||
|
"zone_id": zone_id,
|
||||||
|
"zone_name": _zone_name(zone_id),
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
order = order_map.get(item.order_id)
|
||||||
|
table_name = tables_map.get(order.table_id) if order and order.table_id else None
|
||||||
|
|
||||||
|
zones[zkey]["items"].append({
|
||||||
|
"id": item.id,
|
||||||
|
"order_id": item.order_id,
|
||||||
|
"table_name": table_name,
|
||||||
|
"product_name": product.name if product else f"#{item.product_id}",
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"notes": item.notes,
|
||||||
|
"added_at": item.added_at,
|
||||||
|
"status": item.status,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort zones: named zones first (by zone_id), then no-zone last
|
||||||
|
zone_list = sorted(zones.values(), key=lambda z: (z["zone_id"] is None, z["zone_id"] or 0))
|
||||||
|
return {"zones": zone_list}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/orders/{order_id}/items/{item_id}/status")
|
||||||
|
def update_item_status(
|
||||||
|
order_id: int,
|
||||||
|
item_id: int,
|
||||||
|
body: ItemStatusUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if body.status != "ready":
|
||||||
|
raise HTTPException(status_code=400, detail="Only 'ready' is a valid status transition via this endpoint")
|
||||||
|
|
||||||
|
item = db.query(OrderItem).filter(
|
||||||
|
OrderItem.id == item_id,
|
||||||
|
OrderItem.order_id == order_id,
|
||||||
|
).first()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
if item.status != "active":
|
||||||
|
raise HTTPException(status_code=400, detail=f"Item is '{item.status}' — only active items can be marked ready")
|
||||||
|
|
||||||
|
item.status = "ready"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
broadcast_sync("item_status_changed", {
|
||||||
|
"order_id": order_id,
|
||||||
|
"item_id": item_id,
|
||||||
|
"status": "ready",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"status": "ready", "item_id": item_id}
|
||||||
@@ -374,7 +374,7 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
|
|||||||
items = db.query(OrderItem).filter(
|
items = db.query(OrderItem).filter(
|
||||||
OrderItem.id.in_(body.item_ids),
|
OrderItem.id.in_(body.item_ids),
|
||||||
OrderItem.order_id == order_id,
|
OrderItem.order_id == order_id,
|
||||||
OrderItem.status == "active",
|
OrderItem.status.in_(["active", "ready"]), # ready items are also payable
|
||||||
).all()
|
).all()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
active_shift = db.query(WaiterShift).filter(
|
active_shift = db.query(WaiterShift).filter(
|
||||||
@@ -393,7 +393,7 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db
|
|||||||
|
|
||||||
db.flush() # write item status changes before counting, since autoflush=False
|
db.flush() # write item status changes before counting, since autoflush=False
|
||||||
active_remaining = db.query(OrderItem).filter(
|
active_remaining = db.query(OrderItem).filter(
|
||||||
OrderItem.order_id == order_id, OrderItem.status == "active"
|
OrderItem.order_id == order_id, OrderItem.status.in_(["active", "ready"])
|
||||||
).count()
|
).count()
|
||||||
order.status = "paid" if active_remaining == 0 else "partially_paid"
|
order.status = "paid" if active_remaining == 0 else "partially_paid"
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import ExpensesPage from './pages/ExpensesPage'
|
|||||||
import CustomersPage from './pages/CustomersPage'
|
import CustomersPage from './pages/CustomersPage'
|
||||||
import TabsPage from './pages/TabsPage'
|
import TabsPage from './pages/TabsPage'
|
||||||
import WastePage from './pages/WastePage'
|
import WastePage from './pages/WastePage'
|
||||||
|
import KdsPage from './pages/KdsPage'
|
||||||
import client from './api/client'
|
import client from './api/client'
|
||||||
|
|
||||||
function Spinner() {
|
function Spinner() {
|
||||||
@@ -93,6 +94,7 @@ export default function App() {
|
|||||||
<Route path="customers" element={<CustomersPage />} />
|
<Route path="customers" element={<CustomersPage />} />
|
||||||
<Route path="tabs" element={<TabsPage />} />
|
<Route path="tabs" element={<TabsPage />} />
|
||||||
<Route path="waste" element={<WastePage />} />
|
<Route path="waste" element={<WastePage />} />
|
||||||
|
<Route path="kds" element={<KdsPage />} />
|
||||||
<Route path="online-orders" element={<OnlineOrdersPage />} />
|
<Route path="online-orders" element={<OnlineOrdersPage />} />
|
||||||
<Route path="reports" element={<ReportsPage />} />
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser, Users, CreditCard, Trash2 } from 'lucide-react'
|
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag, NotebookPen, Receipt, BookUser, Users, CreditCard, Trash2, ChefHat } from 'lucide-react'
|
||||||
import { getIncomingOrders } from '../api/client'
|
import { getIncomingOrders } from '../api/client'
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
@@ -32,6 +32,7 @@ export default function Sidebar() {
|
|||||||
{ to: '/customers', icon: Users, label: 'Πελάτες' },
|
{ to: '/customers', icon: Users, label: 'Πελάτες' },
|
||||||
{ to: '/tabs', icon: CreditCard, label: 'Καρτέλες' },
|
{ to: '/tabs', icon: CreditCard, label: 'Καρτέλες' },
|
||||||
{ to: '/waste', icon: Trash2, label: 'Αποβλήτα' },
|
{ to: '/waste', icon: Trash2, label: 'Αποβλήτα' },
|
||||||
|
{ to: '/kds', icon: ChefHat, label: 'KDS' },
|
||||||
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
|
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
278
manager_dashboard/src/pages/KdsPage.jsx
Normal file
278
manager_dashboard/src/pages/KdsPage.jsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../api/client'
|
||||||
|
import useAuthStore from '../store/authStore'
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function timeSince(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
|
||||||
|
if (mins < 1) return '< 1λ'
|
||||||
|
if (mins < 60) return `${mins}λ`
|
||||||
|
const h = Math.floor(mins / 60)
|
||||||
|
const m = mins % 60
|
||||||
|
return m === 0 ? `${h}ω` : `${h}ω ${m}λ`
|
||||||
|
}
|
||||||
|
|
||||||
|
function urgencyColor(iso) {
|
||||||
|
if (!iso) return '#111827'
|
||||||
|
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
|
||||||
|
if (mins >= 15) return '#dc2626' // red — very late
|
||||||
|
if (mins >= 8) return '#d97706' // amber — getting late
|
||||||
|
return '#111827' // normal
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Item card ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ItemCard({ item, onMarkReady, isMarking }) {
|
||||||
|
const [, forceUpdate] = useState(0)
|
||||||
|
|
||||||
|
// Rerender every 30s so elapsed time stays fresh
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => forceUpdate(n => n + 1), 30_000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const elapsed = timeSince(item.added_at)
|
||||||
|
const timeColor = urgencyColor(item.added_at)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => !isMarking && onMarkReady(item)}
|
||||||
|
style={{
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: '14px 16px',
|
||||||
|
cursor: isMarking ? 'default' : 'pointer',
|
||||||
|
opacity: isMarking ? 0.6 : 1,
|
||||||
|
transition: 'opacity 200ms, box-shadow 150ms',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
className="hover:shadow-md active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{/* Table + time */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12, fontWeight: 800, color: 'white',
|
||||||
|
background: '#111827', padding: '2px 9px', borderRadius: 20,
|
||||||
|
}}>
|
||||||
|
{item.table_name ?? `#${item.order_id}`}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: timeColor }}>
|
||||||
|
{elapsed}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product name + qty */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 17, fontWeight: 800, color: '#111827', lineHeight: 1.2 }}>
|
||||||
|
{item.product_name}
|
||||||
|
</span>
|
||||||
|
{item.quantity > 1 && (
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 700, color: '#6b7280' }}>
|
||||||
|
×{item.quantity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{item.notes && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 6, fontSize: 12.5, color: '#d97706',
|
||||||
|
background: '#fffbeb', borderRadius: 6, padding: '4px 8px',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}>
|
||||||
|
{item.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tap hint */}
|
||||||
|
<div style={{ marginTop: 8, fontSize: 10.5, color: '#9ca3af', textAlign: 'right' }}>
|
||||||
|
Πατήστε για ✓ Έτοιμο
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zone column ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ZoneColumn({ zone, onMarkReady, markingIds }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 0,
|
||||||
|
background: '#f9fafb', borderRadius: 14, overflow: 'hidden',
|
||||||
|
border: '1px solid #e5e7eb', minWidth: 260, flex: '1 1 260px',
|
||||||
|
}}>
|
||||||
|
{/* Zone header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px', background: '#111827',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 800, color: 'white', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
|
{zone.zone_name}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12, fontWeight: 700, background: '#374151',
|
||||||
|
color: 'white', padding: '2px 8px', borderRadius: 99,
|
||||||
|
}}>
|
||||||
|
{zone.items.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1, overflowY: 'auto', padding: 10,
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 8,
|
||||||
|
}}>
|
||||||
|
{zone.items.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', color: '#d1d5db', fontSize: 13, padding: '24px 0' }}>
|
||||||
|
Όλα έτοιμα ✓
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
zone.items.map(item => (
|
||||||
|
<ItemCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onMarkReady={onMarkReady}
|
||||||
|
isMarking={markingIds.has(item.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main KDS page ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function KdsPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const token = useAuthStore(s => s.token)
|
||||||
|
const [markingIds, setMarkingIds] = useState(new Set())
|
||||||
|
const [clock, setClock] = useState(new Date())
|
||||||
|
const esRef = useRef(null)
|
||||||
|
|
||||||
|
// Clock tick
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setClock(new Date()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['kds-items'],
|
||||||
|
queryFn: () => client.get('/api/kds/items').then(r => r.data),
|
||||||
|
staleTime: 0,
|
||||||
|
refetchInterval: 30_000, // fallback poll if SSE drops
|
||||||
|
})
|
||||||
|
|
||||||
|
// SSE — refetch on any order_updated or item_status_changed event
|
||||||
|
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 (['order_updated', 'item_status_changed', 'order_paid', 'order_closed'].includes(type)) {
|
||||||
|
qc.invalidateQueries({ queryKey: ['kds-items'] })
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return () => { es.close(); esRef.current = null }
|
||||||
|
}, [token, qc])
|
||||||
|
|
||||||
|
const markReady = useMutation({
|
||||||
|
mutationFn: ({ order_id, item_id }) =>
|
||||||
|
client.put(`/api/kds/orders/${order_id}/items/${item_id}/status`, { status: 'ready' }),
|
||||||
|
onMutate: ({ item_id }) => {
|
||||||
|
setMarkingIds(s => new Set([...s, item_id]))
|
||||||
|
},
|
||||||
|
onSuccess: (_, { item_id }) => {
|
||||||
|
setMarkingIds(s => { const n = new Set(s); n.delete(item_id); return n })
|
||||||
|
qc.invalidateQueries({ queryKey: ['kds-items'] })
|
||||||
|
},
|
||||||
|
onError: (e, { item_id }) => {
|
||||||
|
setMarkingIds(s => { const n = new Set(s); n.delete(item_id); return n })
|
||||||
|
toast.error(e?.response?.data?.detail || 'Σφάλμα')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const zones = data?.zones ?? []
|
||||||
|
const totalPending = zones.reduce((s, z) => s + z.items.length, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100%',
|
||||||
|
background: '#111827', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Top bar */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '10px 20px', background: '#0f172a', flexShrink: 0,
|
||||||
|
borderBottom: '1px solid #1e293b',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>
|
||||||
|
KDS — Κουζίνα
|
||||||
|
</span>
|
||||||
|
{totalPending > 0 && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 13, fontWeight: 700, padding: '3px 10px', borderRadius: 20,
|
||||||
|
background: totalPending >= 10 ? '#dc2626' : '#d97706',
|
||||||
|
color: 'white',
|
||||||
|
}}>
|
||||||
|
{totalPending} εκκρεμή
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{totalPending === 0 && !isLoading && (
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: '#22c55e' }}>✓ Όλα έτοιμα</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<span style={{ fontSize: 13, color: '#94a3b8', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{clock.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
style={{
|
||||||
|
fontSize: 12, fontWeight: 600, padding: '5px 12px', borderRadius: 7,
|
||||||
|
border: '1px solid #334155', background: '#1e293b',
|
||||||
|
color: '#94a3b8', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↻ Ανανέωση
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone columns */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1, overflowX: 'auto', overflowY: 'hidden',
|
||||||
|
padding: '14px 16px',
|
||||||
|
display: 'flex', gap: 12, alignItems: 'stretch',
|
||||||
|
}}>
|
||||||
|
{isLoading && (
|
||||||
|
<div style={{ color: '#64748b', fontSize: 14, margin: 'auto' }}>Φόρτωση…</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && zones.length === 0 && (
|
||||||
|
<div style={{ color: '#64748b', fontSize: 14, margin: 'auto', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 8 }}>✓</div>
|
||||||
|
Δεν υπάρχουν εκκρεμείς παραγγελίες.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{zones.map(zone => (
|
||||||
|
<ZoneColumn
|
||||||
|
key={zone.zone_id ?? 'no-zone'}
|
||||||
|
zone={zone}
|
||||||
|
onMarkReady={(item) => markReady.mutate({ order_id: item.order_id, item_id: item.id })}
|
||||||
|
markingIds={markingIds}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user