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 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"])
|
||||
|
||||
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(
|
||||
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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user