From 5e28273bb0d4eb6e8865a8a69ce294e50878cff8 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 8 Jun 2026 03:25:26 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202I=20=E2=80=94=20KDS=20Kitchen?= =?UTF-8?q?=20Display=20Screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- local_backend/main.py | 2 + local_backend/routers/kds.py | 128 +++++++++ local_backend/routers/orders.py | 4 +- manager_dashboard/src/App.jsx | 2 + manager_dashboard/src/components/Sidebar.jsx | 3 +- manager_dashboard/src/pages/KdsPage.jsx | 278 +++++++++++++++++++ 6 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 local_backend/routers/kds.py create mode 100644 manager_dashboard/src/pages/KdsPage.jsx diff --git a/local_backend/main.py b/local_backend/main.py index d05058c..533c26b 100644 --- a/local_backend/main.py +++ b/local_backend/main.py @@ -41,6 +41,7 @@ from routers.expenses import contacts_router, expenses_router from routers import customers as customers_router from routers import tabs as tabs_router from routers import waste as waste_router +from routers import kds as kds_router 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(tabs_router.router, prefix="/api/tabs", tags=["tabs"]) app.include_router(waste_router.router, prefix="/api/waste", tags=["waste"]) +app.include_router(kds_router.router, prefix="/api/kds", tags=["kds"]) diff --git a/local_backend/routers/kds.py b/local_backend/routers/kds.py new file mode 100644 index 0000000..597bb6b --- /dev/null +++ b/local_backend/routers/kds.py @@ -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} diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py index 322bffc..c397e8a 100644 --- a/local_backend/routers/orders.py +++ b/local_backend/routers/orders.py @@ -374,7 +374,7 @@ def pay_items(order_id: int, body: PayItemsRequest, db: Session = Depends(get_db items = db.query(OrderItem).filter( OrderItem.id.in_(body.item_ids), OrderItem.order_id == order_id, - OrderItem.status == "active", + OrderItem.status.in_(["active", "ready"]), # ready items are also payable ).all() now = datetime.now(timezone.utc) 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 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() order.status = "paid" if active_remaining == 0 else "partially_paid" diff --git a/manager_dashboard/src/App.jsx b/manager_dashboard/src/App.jsx index 26764b9..f1fb887 100644 --- a/manager_dashboard/src/App.jsx +++ b/manager_dashboard/src/App.jsx @@ -17,6 +17,7 @@ import ExpensesPage from './pages/ExpensesPage' import CustomersPage from './pages/CustomersPage' import TabsPage from './pages/TabsPage' import WastePage from './pages/WastePage' +import KdsPage from './pages/KdsPage' import client from './api/client' function Spinner() { @@ -93,6 +94,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/manager_dashboard/src/components/Sidebar.jsx b/manager_dashboard/src/components/Sidebar.jsx index ac1d47f..fda63cc 100644 --- a/manager_dashboard/src/components/Sidebar.jsx +++ b/manager_dashboard/src/components/Sidebar.jsx @@ -1,6 +1,6 @@ import { NavLink } from 'react-router-dom' 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' export default function Sidebar() { @@ -32,6 +32,7 @@ export default function Sidebar() { { to: '/customers', icon: Users, label: 'Πελάτες' }, { to: '/tabs', icon: CreditCard, label: 'Καρτέλες' }, { to: '/waste', icon: Trash2, label: 'Αποβλήτα' }, + { to: '/kds', icon: ChefHat, label: 'KDS' }, { to: '/settings', icon: Settings, label: 'Ρυθμίσεις' }, ] diff --git a/manager_dashboard/src/pages/KdsPage.jsx b/manager_dashboard/src/pages/KdsPage.jsx new file mode 100644 index 0000000..4ec5eb8 --- /dev/null +++ b/manager_dashboard/src/pages/KdsPage.jsx @@ -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 ( +
!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 */} +
+ + {item.table_name ?? `#${item.order_id}`} + + + {elapsed} + +
+ + {/* Product name + qty */} +
+ + {item.product_name} + + {item.quantity > 1 && ( + + ×{item.quantity} + + )} +
+ + {/* Notes */} + {item.notes && ( +
+ {item.notes} +
+ )} + + {/* Tap hint */} +
+ Πατήστε για ✓ Έτοιμο +
+
+ ) +} + +// ── Zone column ─────────────────────────────────────────────────────────────── + +function ZoneColumn({ zone, onMarkReady, markingIds }) { + return ( +
+ {/* Zone header */} +
+ + {zone.zone_name} + + + {zone.items.length} + +
+ + {/* Items */} +
+ {zone.items.length === 0 ? ( +
+ Όλα έτοιμα ✓ +
+ ) : ( + zone.items.map(item => ( + + )) + )} +
+
+ ) +} + +// ── 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 ( +
+ {/* Top bar */} +
+
+ + KDS — Κουζίνα + + {totalPending > 0 && ( + = 10 ? '#dc2626' : '#d97706', + color: 'white', + }}> + {totalPending} εκκρεμή + + )} + {totalPending === 0 && !isLoading && ( + ✓ Όλα έτοιμα + )} +
+
+ + {clock.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + + +
+
+ + {/* Zone columns */} +
+ {isLoading && ( +
Φόρτωση…
+ )} + {!isLoading && zones.length === 0 && ( +
+
+ Δεν υπάρχουν εκκρεμείς παραγγελίες. +
+ )} + {zones.map(zone => ( + markReady.mutate({ order_id: item.order_id, item_id: item.id })} + markingIds={markingIds} + /> + ))} +
+
+ ) +}