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:
2026-06-08 03:25:26 +03:00
parent 9cfbde0e3e
commit 5e28273bb0
6 changed files with 414 additions and 3 deletions

View 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}