feat(manager): Part 2 — Online Orders page and sidebar integration

- connect_orders.py: add GET /api/connect/orders?status=active|history endpoints
- api/client.js: add getIncomingOrders, getActiveOrders, getOrderHistory,
  acceptOnlineOrder, rejectOnlineOrder, updateOnlineOrderStatus
- OnlineOrdersPage.jsx: three-tab page (Incoming / Active / History) with
  order cards, action buttons (accept/reject/status progression),
  20 s polling, SSE listener for online_order_received/updated events,
  and optimistic UI updates
- App.jsx: add /online-orders route
- Sidebar.jsx: add Online Orders nav item with red badge showing pending count,
  polling every 20 s independently of the page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 09:59:21 +03:00
parent 37fb25c8c2
commit aed71a18d8
5 changed files with 478 additions and 12 deletions

View File

@@ -63,6 +63,38 @@ async def _mirror_status_to_cloud(cloud_order_id: int, new_status: str, rejectio
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/orders", response_model=list[OrderOut])
def get_orders_by_status(
status: str,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
?status=active → accepted | preparing | ready | out_for_delivery, newest first
?status=history → delivered | rejected, newest first, limit 50
"""
if status == "active":
statuses = ["accepted", "preparing", "ready", "out_for_delivery"]
orders = (
db.query(Order)
.filter(Order.source == "online", Order.online_status.in_(statuses))
.order_by(Order.opened_at.desc())
.all()
)
elif status == "history":
statuses = ["delivered", "rejected"]
orders = (
db.query(Order)
.filter(Order.source == "online", Order.online_status.in_(statuses))
.order_by(Order.opened_at.desc())
.limit(50)
.all()
)
else:
raise HTTPException(status_code=400, detail=f"Unknown status filter '{status}'. Use 'active' or 'history'.")
return orders
@router.get("/orders/incoming", response_model=list[OrderOut])
def get_incoming_orders(
db: Session = Depends(get_db),

View File

@@ -10,6 +10,7 @@ import OrderDetailPage from './pages/OrderDetailPage'
import ManagementPage from './pages/ManagementPage'
import ReportsPage from './pages/reports/ReportsPage'
import SettingsPage from './pages/Settings/SettingsPage'
import OnlineOrdersPage from './pages/OnlineOrdersPage'
import client from './api/client'
function Spinner() {
@@ -80,6 +81,7 @@ export default function App() {
<Route path="tables" element={<TablesPage />} />
<Route path="orders/:orderId" element={<OrderDetailPage />} />
<Route path="management" element={<ManagementPage />} />
<Route path="online-orders" element={<OnlineOrdersPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>

View File

@@ -23,3 +23,18 @@ client.interceptors.response.use(
)
export default client
// ── Online Orders (Xenia Connect) ─────────────────────────────────────────────
export const getIncomingOrders = () => client.get('/api/connect/orders/incoming')
export const getActiveOrders = () => client.get('/api/connect/orders?status=active')
export const getOrderHistory = () => client.get('/api/connect/orders?status=history')
export const acceptOnlineOrder = (id) =>
client.post(`/api/connect/orders/${id}/accept`)
export const rejectOnlineOrder = (id, reason) =>
client.post(`/api/connect/orders/${id}/reject`, { reason })
export const updateOnlineOrderStatus = (id, status) =>
client.post(`/api/connect/orders/${id}/status`, { status })

View File

@@ -1,18 +1,34 @@
import { NavLink } from 'react-router-dom'
import { useState } from 'react'
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft } from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft, ShoppingBag } from 'lucide-react'
import { getIncomingOrders } from '../api/client'
export default function Sidebar() {
const [collapsed, setCollapsed] = useState(false)
const [pendingCount, setPendingCount] = useState(0)
const pollRef = useRef(null)
useEffect(() => {
async function fetchPending() {
try {
const { data } = await getIncomingOrders()
setPendingCount(data.length)
} catch { /* silently ignore — sidebar badge is non-critical */ }
}
fetchPending()
pollRef.current = setInterval(fetchPending, 20_000)
return () => clearInterval(pollRef.current)
}, [])
const NAV = [
{ to: '/dashboard', icon: BarChart2, label: 'Dashboard' },
{ to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
{ to: '/online-orders', icon: ShoppingBag, label: 'Online Orders', badge: pendingCount },
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
{ to: '/management', icon: Package, label: 'Διαχείριση' },
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
]
export default function Sidebar() {
const [collapsed, setCollapsed] = useState(false)
return (
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
{/* Logo / collapse toggle */}
@@ -28,7 +44,7 @@ export default function Sidebar() {
</div>
<nav className="flex-1 py-4 space-y-1 px-2">
{NAV.map(({ to, icon: Icon, label }) => (
{NAV.map(({ to, icon: Icon, label, badge }) => (
<NavLink
key={to}
to={to}
@@ -37,7 +53,14 @@ export default function Sidebar() {
(isActive ? 'bg-primary-600 text-white' : 'text-primary-100 hover:bg-primary-700')
}
>
<Icon size={20} className="shrink-0" />
<div className="relative shrink-0">
<Icon size={20} />
{badge > 0 && (
<span className="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] font-bold w-4 h-4 rounded-full flex items-center justify-center leading-none">
{badge > 9 ? '9+' : badge}
</span>
)}
</div>
{!collapsed && <span className="text-sm">{label}</span>}
</NavLink>
))}

View File

@@ -0,0 +1,394 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import useAuthStore from '../store/authStore'
import {
getIncomingOrders,
getActiveOrders,
getOrderHistory,
acceptOnlineOrder,
rejectOnlineOrder,
updateOnlineOrderStatus,
} from '../api/client'
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(iso) {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
return `${Math.floor(diff / 3600)}h ago`
}
function calcTotals(items = []) {
return items.reduce((sum, it) => sum + (it.unit_price ?? 0) * (it.quantity ?? 1), 0)
}
const STATUS_LABELS = {
pending_acceptance: { label: 'Αναμονή', cls: 'bg-amber-100 text-amber-700' },
accepted: { label: 'Αποδεκτή', cls: 'bg-blue-100 text-blue-700' },
preparing: { label: 'Προετοιμασία', cls: 'bg-indigo-100 text-indigo-700' },
ready: { label: 'Έτοιμη', cls: 'bg-green-100 text-green-700' },
out_for_delivery: { label: 'Παράδοση', cls: 'bg-purple-100 text-purple-700' },
delivered: { label: 'Παραδόθηκε', cls: 'bg-gray-100 text-gray-500' },
rejected: { label: 'Απορρίφθηκε', cls: 'bg-red-100 text-red-600' },
}
function OnlineStatusBadge({ status }) {
const { label, cls } = STATUS_LABELS[status] ?? { label: status, cls: 'bg-gray-100 text-gray-600' }
return (
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full ${cls}`}>
{label}
</span>
)
}
// ── Order Card ────────────────────────────────────────────────────────────────
function OrderCard({ order, onAction }) {
const [rejectOpen, setRejectOpen] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [busy, setBusy] = useState(false)
const [tick, setTick] = useState(0)
// Refresh the "X min ago" label every 30 s without a full re-render of parent
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 30_000)
return () => clearInterval(id)
}, [])
const subtotal = calcTotals(order.items)
const isDelivery = order.online_order_type === 'delivery'
const st = order.online_status
async function act(fn) {
setBusy(true)
try { await fn() } finally { setBusy(false) }
}
return (
<div className="bg-white border border-gray-200 rounded-2xl shadow-sm overflow-hidden">
{/* Card header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50/60">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-800 text-base">
{order.online_order_ref ?? `#${order.id}`}
</span>
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
isDelivery ? 'bg-orange-100 text-orange-700' : 'bg-teal-100 text-teal-700'
}`}>
{isDelivery ? 'Delivery' : 'Dine-in'}
</span>
<OnlineStatusBadge status={st} />
</div>
<span className="text-xs text-gray-400">{timeAgo(order.opened_at)}</span>
</div>
{/* Customer info */}
<div className="px-4 py-3 space-y-0.5 border-b border-gray-100 text-sm text-gray-700">
{order.online_customer_name && (
<p className="font-medium">{order.online_customer_name}</p>
)}
{order.online_customer_phone && (
<p className="text-gray-500">{order.online_customer_phone}</p>
)}
{isDelivery && order.online_customer_address && (
<p className="text-gray-500">{order.online_customer_address}</p>
)}
{order.online_customer_notes && (
<p className="text-amber-700 bg-amber-50 rounded px-2 py-1 text-xs mt-1">
📝 {order.online_customer_notes}
</p>
)}
</div>
{/* Items */}
<div className="px-4 py-3 border-b border-gray-100">
<div className="space-y-1 text-sm text-gray-700">
{order.items.map((it, i) => (
<div key={i} className="flex justify-between">
<span>{it.quantity} × {it.product?.name ?? `#${it.product_id}`}</span>
<span className="text-gray-500">{(it.unit_price * it.quantity).toFixed(2)}</span>
</div>
))}
</div>
<div className="mt-2 pt-2 border-t border-gray-100 flex justify-between font-semibold text-sm">
<span>Σύνολο</span>
<span>{subtotal.toFixed(2)}</span>
</div>
</div>
{/* Actions */}
<div className="px-4 py-3 flex flex-wrap gap-2">
{st === 'pending_acceptance' && (
<>
<button
onClick={() => act(() => onAction('accept', order.id))}
disabled={busy}
className="btn btn-primary text-sm px-4 min-h-0 h-9 bg-green-600 hover:bg-green-700 border-green-600"
>
Αποδοχή
</button>
<button
onClick={() => setRejectOpen(r => !r)}
disabled={busy}
className="btn btn-danger text-sm px-4 min-h-0 h-9"
>
Απόρριψη
</button>
</>
)}
{st === 'accepted' && (
<button
onClick={() => act(() => onAction('status', order.id, 'preparing'))}
disabled={busy}
className="btn btn-secondary text-sm px-4 min-h-0 h-9"
>
Προετοιμασία
</button>
)}
{st === 'preparing' && (
isDelivery ? (
<button
onClick={() => act(() => onAction('status', order.id, 'out_for_delivery'))}
disabled={busy}
className="btn btn-secondary text-sm px-4 min-h-0 h-9"
>
Εξωτερική Παράδοση
</button>
) : (
<button
onClick={() => act(() => onAction('status', order.id, 'ready'))}
disabled={busy}
className="btn btn-secondary text-sm px-4 min-h-0 h-9"
>
Έτοιμη
</button>
)
)}
{(st === 'ready' || st === 'out_for_delivery') && (
<button
onClick={() => act(() => onAction('status', order.id, 'delivered'))}
disabled={busy}
className="btn btn-primary text-sm px-4 min-h-0 h-9"
>
Παραδόθηκε
</button>
)}
</div>
{/* Reject inline form */}
{rejectOpen && (
<div className="px-4 pb-4 flex gap-2 items-start border-t border-gray-100 pt-3">
<textarea
className="input flex-1 resize-none text-sm"
rows={2}
placeholder="Λόγος απόρριψης (προαιρετικό)"
value={rejectReason}
onChange={e => setRejectReason(e.target.value)}
/>
<div className="flex flex-col gap-2">
<button
onClick={() => act(() => onAction('reject', order.id, rejectReason))}
disabled={busy}
className="btn btn-danger text-sm px-3 min-h-0 h-9"
>
Επιβεβαίωση
</button>
<button
onClick={() => setRejectOpen(false)}
className="btn btn-secondary text-sm px-3 min-h-0 h-9"
>
Ακύρωση
</button>
</div>
</div>
)}
</div>
)
}
// ── Tab Panel ─────────────────────────────────────────────────────────────────
function OrderList({ orders, onAction, emptyText }) {
if (!orders.length) {
return <p className="text-sm text-gray-400 text-center py-12">{emptyText}</p>
}
return (
<div className="space-y-4">
{orders.map(o => (
<OrderCard key={o.id} order={o} onAction={onAction} />
))}
</div>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
const TABS = [
{ key: 'incoming', label: 'Εισερχόμενες' },
{ key: 'active', label: 'Ενεργές' },
{ key: 'history', label: 'Ιστορικό' },
]
export default function OnlineOrdersPage() {
const [activeTab, setActiveTab] = useState('incoming')
const [incoming, setIncoming] = useState([])
const [active, setActive] = useState([])
const [history, setHistory] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const token = useAuthStore(s => s.token)
const pollRef = useRef(null)
const fetchAll = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [inc, act, hist] = await Promise.all([
getIncomingOrders(),
getActiveOrders(),
getOrderHistory(),
])
setIncoming(inc.data)
setActive(act.data)
setHistory(hist.data)
} catch {
setError('Σφάλμα φόρτωσης παραγγελιών.')
} finally {
setLoading(false)
}
}, [])
// Initial fetch + 20 s polling
useEffect(() => {
fetchAll()
pollRef.current = setInterval(fetchAll, 20_000)
return () => clearInterval(pollRef.current)
}, [fetchAll])
// SSE listener — refetch on online_order_received or online_order_updated
useEffect(() => {
if (!token) return
const es = new EventSource(`/api/stream?token=${encodeURIComponent(token)}`)
es.onmessage = (e) => {
try {
const { type } = JSON.parse(e.data)
if (type === 'online_order_received' || type === 'online_order_updated') {
fetchAll()
}
} catch { /* ignore malformed frames */ }
}
return () => es.close()
}, [token, fetchAll])
async function handleAction(action, orderId, extra) {
try {
if (action === 'accept') {
const { data: updated } = await acceptOnlineOrder(orderId)
setIncoming(prev => prev.filter(o => o.id !== orderId))
setActive(prev => [updated, ...prev])
} else if (action === 'reject') {
const { data: updated } = await rejectOnlineOrder(orderId, extra)
setIncoming(prev => prev.filter(o => o.id !== orderId))
setHistory(prev => [updated, ...prev])
} else if (action === 'status') {
const { data: updated } = await updateOnlineOrderStatus(orderId, extra)
const isDone = updated.online_status === 'delivered' || updated.online_status === 'rejected'
if (isDone) {
setActive(prev => prev.filter(o => o.id !== orderId))
setHistory(prev => [updated, ...prev])
} else {
setActive(prev => prev.map(o => o.id === orderId ? updated : o))
}
}
} catch {
// Re-fetch to sync — avoids stale state after a failed optimistic update
fetchAll()
}
}
const pendingCount = incoming.length
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Page header */}
<div className="px-6 py-5 border-b border-gray-100 shrink-0 flex items-center gap-3">
<h1 className="text-xl font-bold text-gray-800">Online Παραγγελίες</h1>
{pendingCount > 0 && (
<span className="bg-red-500 text-white text-xs font-bold px-2 py-0.5 rounded-full">
{pendingCount}
</span>
)}
<button
onClick={fetchAll}
disabled={loading}
className="ml-auto btn btn-secondary text-sm px-3 min-h-0 h-8"
>
{loading ? '…' : '↻ Ανανέωση'}
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200 shrink-0 bg-white px-4">
{TABS.map(tab => {
const count = tab.key === 'incoming' ? incoming.length
: tab.key === 'active' ? active.length
: history.length
const isActive = activeTab === tab.key
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors flex items-center gap-1.5 ${
isActive
? 'border-primary-600 text-primary-700 bg-primary-50/50'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
{tab.label}
{count > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full font-mono ${
tab.key === 'incoming'
? 'bg-red-100 text-red-700'
: isActive ? 'bg-primary-100 text-primary-700' : 'bg-gray-100 text-gray-500'
}`}>
{count}
</span>
)}
</button>
)
})}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{activeTab === 'incoming' && (
<OrderList
orders={incoming}
onAction={handleAction}
emptyText="Δεν υπάρχουν εισερχόμενες παραγγελίες."
/>
)}
{activeTab === 'active' && (
<OrderList
orders={active}
onAction={handleAction}
emptyText="Δεν υπάρχουν ενεργές παραγγελίες."
/>
)}
{activeTab === 'history' && (
<OrderList
orders={history}
onAction={handleAction}
emptyText="Δεν υπάρχει ιστορικό παραγγελιών."
/>
)}
</div>
</div>
)
}