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:
@@ -63,6 +63,38 @@ async def _mirror_status_to_cloud(cloud_order_id: int, new_status: str, rejectio
|
|||||||
|
|
||||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
# ── 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])
|
@router.get("/orders/incoming", response_model=list[OrderOut])
|
||||||
def get_incoming_orders(
|
def get_incoming_orders(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import OrderDetailPage from './pages/OrderDetailPage'
|
|||||||
import ManagementPage from './pages/ManagementPage'
|
import ManagementPage from './pages/ManagementPage'
|
||||||
import ReportsPage from './pages/reports/ReportsPage'
|
import ReportsPage from './pages/reports/ReportsPage'
|
||||||
import SettingsPage from './pages/Settings/SettingsPage'
|
import SettingsPage from './pages/Settings/SettingsPage'
|
||||||
|
import OnlineOrdersPage from './pages/OnlineOrdersPage'
|
||||||
import client from './api/client'
|
import client from './api/client'
|
||||||
|
|
||||||
function Spinner() {
|
function Spinner() {
|
||||||
@@ -80,6 +81,7 @@ export default function App() {
|
|||||||
<Route path="tables" element={<TablesPage />} />
|
<Route path="tables" element={<TablesPage />} />
|
||||||
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
<Route path="orders/:orderId" element={<OrderDetailPage />} />
|
||||||
<Route path="management" element={<ManagementPage />} />
|
<Route path="management" element={<ManagementPage />} />
|
||||||
|
<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 />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -23,3 +23,18 @@ client.interceptors.response.use(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export default client
|
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,18 +1,34 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { BarChart2, LayoutGrid, ClipboardList, Package, Settings, ChevronRight, ChevronLeft } from 'lucide-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 = [
|
const NAV = [
|
||||||
{ to: '/dashboard', icon: BarChart2, label: 'Dashboard' },
|
{ to: '/dashboard', icon: BarChart2, label: 'Dashboard' },
|
||||||
{ to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
|
{ to: '/tables', icon: LayoutGrid, label: 'Τραπέζια' },
|
||||||
|
{ to: '/online-orders', icon: ShoppingBag, label: 'Online Orders', badge: pendingCount },
|
||||||
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
|
{ to: '/reports', icon: ClipboardList, label: 'Αναφορές' },
|
||||||
{ to: '/management', icon: Package, label: 'Διαχείριση' },
|
{ to: '/management', icon: Package, label: 'Διαχείριση' },
|
||||||
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
|
{ to: '/settings', icon: Settings, label: 'Ρυθμίσεις' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Sidebar() {
|
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
|
<aside className={`flex flex-col bg-primary-800 text-white shrink-0 transition-all duration-200 ${collapsed ? 'w-16' : 'w-56'}`}>
|
||||||
{/* Logo / collapse toggle */}
|
{/* Logo / collapse toggle */}
|
||||||
@@ -28,7 +44,7 @@ export default function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 py-4 space-y-1 px-2">
|
<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
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
@@ -37,7 +53,14 @@ export default function Sidebar() {
|
|||||||
(isActive ? 'bg-primary-600 text-white' : 'text-primary-100 hover:bg-primary-700')
|
(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>}
|
{!collapsed && <span className="text-sm">{label}</span>}
|
||||||
</NavLink>
|
</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