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