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

@@ -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"])

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}

View File

@@ -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"

View File

@@ -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 />} />

View File

@@ -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: 'Ρυθμίσεις' },
] ]

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