Files
xenia-pos-local/manager_dashboard/src/pages/OnlineOrdersPage.jsx
bonamin aed71a18d8 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>
2026-06-01 09:59:21 +03:00

395 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}