- 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>
395 lines
14 KiB
JavaScript
395 lines
14 KiB
JavaScript
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>
|
||
)
|
||
}
|