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() {