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:
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft } from 'lucide-react'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/dashboard', icon: BarChart2, label: 'Dashboard' },
|
||||
{ to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
|
||||
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
|
||||
{ to: '/management', icon: Package, label: 'Διαχείριση' },
|
||||
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
|
||||
]
|
||||
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: 'Ρυθμίσεις' },
|
||||
]
|
||||
|
||||
return (
|
||||
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
394
manager_dashboard/src/pages/OnlineOrdersPage.jsx
Normal file
394
manager_dashboard/src/pages/OnlineOrdersPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user