/** * @license * SPDX-License-Identifier: Apache-2.0 */ /** * Orders page — replicated from the operations console (nearle_console/orders), * rebuilt in the merchant stack against the shared console UI kit (`./consoleUi`) * so it matches the source design: brand purple #662582, gradient header, KPI * cards with gradient top-bars, pill status tabs, and a status-chip table. Wired * to the live Fiesta order endpoints (status-scoped, date-ranged, paginated). */ import React, { useMemo, useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react'; import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider, useFiestaNotifyRider } from '../services/fiestaQueries'; import { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError, num as fnum, str as fstr, ymd, type Row, } from '../services/fiestaApi'; import { shortTime } from '../services/fiestaMappers'; import { GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE, ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring, } from './consoleUi'; interface OrdersViewProps { searchQuery?: string; locationid?: number; /** Merchant tenant to scope to; defaults to the shared constant. */ tenantId?: number; /** * App-location to source assignable riders from. Riders are scoped by * app-location rather than tenant, so without one the rider list falls back * to whatever the visible orders name. Defaults to the platform constant. */ applocationid?: number; date?: string; } type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled'; const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [ { key: 'created', label: 'Created' }, { key: 'pending', label: 'Pending' }, { key: 'processing', label: 'Processing' }, { key: 'delivered', label: 'Delivered' }, { key: 'cancelled', label: 'Cancelled' }, ]; const PAGE_SIZE = 25; export default function OrdersView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, applocationid = FIESTA_APPLOCATION_ID, date, }: OrdersViewProps) { const today = new Date(); const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); const [fromdate, setFromdate] = useState(date || ymd(today)); const [todate, setTodate] = useState(date || ymd(today)); // Sync internal date range if the prop changes from the Hub header useEffect(() => { if (date) { setFromdate(date); setTodate(date); } }, [date]); const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); }; const dayAhead = (n: number) => { const d = new Date(); d.setDate(d.getDate() + n); return ymd(d); }; // NOTE: the backend lists orders by DELIVERY date (deliverytime), not creation // date — so an order created today for a future slot only appears once the range // covers its delivery date. "Next 7 Days" surfaces upcoming-delivery orders. // "All time" can't pass empty dates (the query is gated on from/to), so it uses // a wide window — from the platform's earliest plausible data to a year ahead. const presets = [ { key: 'today', label: 'Today', from: ymd(today), to: ymd(today) }, { key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) }, { key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) }, { key: 'month', label: 'This Month', from: ymd(monthStart), to: dayAhead(7) }, ]; const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom'; const [status, setStatus] = useState('created'); const [pageno, setPageno] = useState(1); const [localSearch, setLocalSearch] = useState(''); const [branch, setBranch] = useState(0); // applocationid filter (0 = all branches) const [detailOrder, setDetailOrder] = useState(null); // ── Multi-select rider assignment (parity with the ops console) ───────────── const [selected, setSelected] = useState>(new Set()); const [assignRiderId, setAssignRiderId] = useState(0); const [assignMsg, setAssignMsg] = useState(''); const [showSelected, setShowSelected] = useState(false); // full-page review of selection const assignMut = useFiestaAssignRider(); const notifyMut = useFiestaNotifyRider(); // Ctrl/Cmd+K focuses search; Escape blurs it (parity with the ops console). const searchRef = useRef(null); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); searchRef.current?.focus(); } else if (e.key === 'Escape' && document.activeElement === searchRef.current) { searchRef.current?.blur(); } }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, []); // Reset the selection whenever the visible result set changes, so an assign // can never act on rows the operator can no longer see. useEffect(() => { setSelected(new Set()); setAssignMsg(''); setShowSelected(false); }, [fromdate, todate, status, branch, pageno, locationid]); // 'all' lists every rider on duty at this app-location; 'partner' narrows to // the partner already carrying the selected orders. Defaults to 'all' — the // old default filtered to riders with no partnerid, which no on-duty rider has. const [riderSource, setRiderSource] = useState<'all' | 'partner'>('all'); // Scope to the user's store when a locationid is supplied (server-side per the // backend's getordersummary/getorders locationid param); tenant-wide otherwise. const summaryQ = useFiestaOrderSummary(tenantId, fromdate, todate, locationid); const ordersQ = useFiestaOrders({ tenantid: tenantId, status, fromdate, todate, locationid, pageno, pagesize: PAGE_SIZE }); const summary = summaryQ.data; const rawRows = ordersQ.data ?? []; // Riders are scoped by app-location, NOT by tenant. A rider record carries a // partnerid and an applocationid but leaves app_users.tenantid unset, so // /partners/getriders?tenantid=… returns an empty list for every tenant — // which is what the assign dropdown used to show. The app-location is taken // from the live order rows, falling back to the signed-in user's own. const orderPartnerId = useMemo(() => fnum(rawRows.find((r) => fnum(r.partnerid))?.partnerid), [rawRows]); const orderApplocationId = useMemo(() => fnum(rawRows.find((r) => fnum(r.applocationid))?.applocationid), [rawRows]); const riderApplocationId = orderApplocationId || applocationid || 0; // /partners/getriders is already a live-presence query, not a roster: the // backend filters on status='Active', onduty=1 and a riderlog dated today // with logstatus=0, joined to each rider's most recent GPS ping. So this // returns riders who are on shift and logged in right now, and it carries the // userfcmtoken needed to notify them. const ridersQ = useFiestaRiders({ applocationid: riderApplocationId || undefined, }); // The previous build also merged in getallusers?roleid=5 as an "own fleet". // There is no rider role: app_roles only defines 1-6 as Super admin / // Operations / Admin / Manager per configid, and riders are identified by // configid=6 inside getriders. roleid=5 matched a single user with two // deliveries in the platform's history, while the 29 users who actually drive // the bulk of deliveries carry roleid 0. Every on-duty rider also has // partnerid > 0, so the "own fleet" tab — the default — filtered that list // down to nothing and the dropdown was empty on open. const riderOptions = useMemo( () => (ridersQ.data ?? []) .filter((r) => { if (riderSource === 'all') return true; // Restrict to the partner already carrying these orders, when the // rows name one; otherwise there is nothing to narrow to. const pId = fnum(r.partnerid); return !orderPartnerId || pId === orderPartnerId; }) .map((r) => ({ id: fnum(r.userid), label: (fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`).trim() + (fstr(r.contactno) ? ` · ${fstr(r.contactno)}` : ''), // Carried so the assign can notify the rider, and so a rider with no // registered device can be called out rather than silently skipped. token: fstr(r.userfcmtoken), vehicle: fstr(r.vehiclename), })) .filter((o) => o.id > 0 && o.label), [ridersQ.data, riderSource, orderPartnerId], ); // Branches (app-locations) present in the data — drives the branch filter so the // operator can see which branch an order was placed at. Each order row carries // applocationid + applocation (the app-location name). const branches = useMemo(() => { const m = new Map(); for (const r of rawRows) { const id = fnum(r.applocationid); if (id && !m.has(id)) m.set(id, fstr(r.applocation) || fstr(r.locationname) || `Branch ${id}`); } return [...m.entries()].map(([id, name]) => ({ id, name })); }, [rawRows]); const rows = useMemo(() => { const term = (localSearch || searchQuery).toLowerCase(); return rawRows.filter((r) => { if (locationid && fnum(r.locationid) !== locationid) return false; if (branch && fnum(r.applocationid) !== branch) return false; if (!term) return true; // Broad match across every order field shown or relevant (mirrors the ops // console search): id, both parties + contacts + addresses, branch, rider, // status, and notes. return [ r.orderid, r.orderstatus, r.ordernotes, r.tenantname, r.pickupcustomer, r.pickupcontactno, r.pickupsuburb, r.pickupaddress, r.pickuplocation, r.deliverycustomer, r.deliverycontactno, r.deliverysuburb, r.deliveryaddress, r.deliverylocation, r.applocation, r.locationname, r.ridername, ].some((v) => fstr(v).toLowerCase().includes(term)); }); }, [rawRows, localSearch, searchQuery, locationid, branch]); // Footer totals across the filtered rows (parity with the ops console's // Total Charges / Total Amount summary). const totals = useMemo(() => { let cod = 0, charges = 0, amount = 0; for (const r of rows) { cod += fnum(r.collectionamt); charges += fnum(r.deliverycharge) || fnum(r.deliverycharges); amount += fnum(r.orderamount) || fnum(r.deliveryamt); } return { cod, charges, amount }; }, [rows]); const inr = (n: number) => `₹${n.toLocaleString('en-IN')}`; // Export the currently-filtered orders to CSV (RFC-4180 quoting). const exportCsv = () => { const headers = ['#', 'Order ID', 'Status', 'Branch', 'Order Date', 'Pickup', 'Pickup Contact', 'Pickup Address', 'Drop', 'Drop Contact', 'Drop Address', 'Qty', 'COD', 'KMs', 'Charges', 'Amount']; const esc = (v: unknown) => `"${fstr(v).replace(/"/g, '""')}"`; const lines = rows.map((r, i) => [ i + 1, fstr(r.orderid) || fstr(r.orderheaderid), fstr(r.orderstatus), fstr(r.applocation) || fstr(r.locationname), shortTime(r.orderdate || r.deliverydate), fstr(r.pickupcustomer) || fstr(r.tenantname), fstr(r.pickupcontactno), fstr(r.pickupaddress) || fstr(r.pickupsuburb), fstr(r.deliverycustomer), fstr(r.deliverycontactno), fstr(r.deliveryaddress) || fstr(r.deliverysuburb), fnum(r.quantity), fnum(r.collectionamt), fnum(r.kms), fnum(r.deliverycharge) || fnum(r.deliverycharges), fnum(r.orderamount) || fnum(r.deliveryamt), ].map(esc).join(',')); const blob = new Blob([[headers.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `Orders_${status}_${fromdate}_to_${todate}.csv`; a.click(); URL.revokeObjectURL(url); }; const hasNext = rawRows.length === PAGE_SIZE; const total = summary?.total ?? 0; const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0); const countFor = (key: StatusKey): number => (summary ? (summary[key] ?? 0) : 0); // Restrained, professional palette — deep muted tones (not neon) so the KPI // strip reads as a serious business dashboard rather than a colourful one. const kpis = [ { label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#6366f1', icon: , badge: `${pct(summary?.created ?? 0)}% of total` }, { label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: , badge: `${pct(summary?.pending ?? 0)}% of total` }, { label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: , badge: `${pct(summary?.delivered ?? 0)}% of total` }, { label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#f43f5e', icon: , badge: `${pct(summary?.cancelled ?? 0)}% of total` }, ]; const setScope = (next: Partial<{ status: StatusKey; from: string; to: string }>) => { if (next.status) setStatus(next.status); if (next.from) setFromdate(next.from); if (next.to) setTodate(next.to); setPageno(1); }; // ── Selection helpers ─────────────────────────────────────────────────────── const rowKey = (r: Row) => fstr(r.orderheaderid) || fstr(r.orderid); const assignableRows = rows.filter((r) => fstr(r.orderstatus).toLowerCase() === 'created'); const assignableKeys = assignableRows.map(rowKey); const allSelected = assignableKeys.length > 0 && assignableKeys.every((k) => selected.has(k)); const toggleRow = (k: string) => setSelected((prev) => { const n = new Set(prev); if (n.has(k)) n.delete(k); else n.add(k); return n; }); const toggleAll = () => setSelected((prev) => { const n = new Set(prev); if (allSelected) assignableKeys.forEach((k) => n.delete(k)); else assignableKeys.forEach((k) => n.add(k)); return n; }); const handleAssign = async () => { if (!assignRiderId || selected.size === 0) return; const toAssign = rows.filter((r) => selected.has(rowKey(r))); const option = riderOptions.find((o) => o.id === assignRiderId); const rider = option?.label ?? 'rider'; try { const res = await assignMut.mutateAsync({ userid: assignRiderId, orders: toAssign }); const assigned = res.failed ? `Assigned ${res.ok}/${res.total} to ${rider} · ${res.failed} failed` : `Assigned ${res.ok} order${res.ok === 1 ? '' : 's'} to ${rider}`; setSelected(new Set()); setShowSelected(false); // return to the board with the result shown in the bar // Notify only for work that actually landed. The push runs after the // write and is reported separately: the deliveries exist either way, so a // failed notification must not read as a failed assignment — but it must // still be visible, because a rider who was never told has work sitting // unseen. if (res.ok === 0) { setAssignMsg(assigned); return; } setAssignMsg(`${assigned} · notifying…`); try { await notifyMut.mutateAsync({ token: option?.token ?? '', body: RIDER_MESSAGES.assigned(res.ok), }); setAssignMsg(`${assigned} · rider notified`); } catch (err) { setAssignMsg( `${assigned} · NOT notified — ${ err instanceof RiderNotReachableError ? 'this rider has no device registered' : 'the push failed, tell them another way' }`, ); } } catch { setAssignMsg('Assignment failed — please retry.'); } }; // Rows currently selected (selection is always within the visible page). const selectedRows = useMemo(() => rows.filter((r) => selected.has(rowKey(r))), [rows, selected]); return (
{/* Date filter */}
View {presets.map((p) => ( setScope({ from: p.from, to: p.to })}>{p.label} ))}
setScope({ from: e.target.value })} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} /> setScope({ to: e.target.value })} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} />
{/* Status tabs + search */}
{STATUS_TABS.map((t) => { // Single brand accent for the tab row (calmer than per-status colours); // the per-status hue still appears on the row Status chip where it aids scanning. const color = BRAND; return ( setScope({ status: t.key })} count={summaryQ.isLoading ? '·' : countFor(t.key).toLocaleString('en-IN')}> {t.label} ); })}
{branches.length > 1 && ( )}
{/* Multi-select assign bar — shown while rows are selected (or to report a result) */} {(selected.size > 0 || assignMsg) && (
{selected.size} selected
{selected.size > 0 && ( )} {assignMsg && {assignMsg}}
)} {/* Table */}
{['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => ( ))} {ordersQ.isLoading ? ( ) : rows.length === 0 ? ( ) : ( rows.map((r, i) => { const st = fstr(r.orderstatus).toLowerCase(); const cod = fnum(r.collectionamt); const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges); return ( { if (!selected.has(rowKey(r))) e.currentTarget.style.background = SURFACE_ALT; }} onMouseLeave={(e) => { e.currentTarget.style.background = selected.has(rowKey(r)) ? tint(BRAND) : 'transparent'; }}> ); }) )}
{h}
Loading orders…
No orders found for this status, date range, or search.
{st === 'created' ? ( toggleRow(rowKey(r))} aria-label="Select order" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} /> ) : ( )} {(pageno - 1) * PAGE_SIZE + i + 1}

{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}

{shortTime(r.orderdate || r.deliverydate)}

{fstr(r.applocation) || '—'} {fstr(r.locationname) &&

{fstr(r.locationname)}

}

{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}

{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}

{fstr(r.deliverycustomer) || '—'}

{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}

{fnum(r.quantity) || '—'} 0 ? TEXT : TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'} {fnum(r.kms) ? fnum(r.kms).toFixed(1) : '—'} 0 ? TEXT : TEXT_3 }}>{charges > 0 ? `₹${charges.toLocaleString('en-IN')}` : '—'}
{/* Totals across the filtered rows */} {rows.length > 0 && (
Totals · {rows.length} order{rows.length === 1 ? '' : 's'} {totals.cod > 0 && }
)}
Page {pageno} · {rows.length} shown
setPageno((p) => Math.max(1, p - 1))}> Prev setPageno((p) => p + 1)}>Next
{detailOrder && setDetailOrder(null)} />} {/* Right-edge floating badge — only on the Created tab and only when MULTIPLE orders are selected (created orders are what get dispatched). Opens the full-page review/assign view on click. */} {status === 'created' && selected.size > 1 && !showSelected && createPortal( , document.body, )} {showSelected && createPortal( toggleRow(k)} onClose={() => setShowSelected(false)} />, document.body, )}
); } const DIVIDER_C = '#f1f5f9'; // ── Selected-orders review page (opened from the right-edge floating badge) ────── function SelectedOrdersPage({ rows, rowKey, riderOptions, ridersLoading, assignRiderId, setAssignRiderId, assigning, assignMsg, onAssign, onRemove, onClose, }: { rows: Row[]; rowKey: (r: Row) => string; riderOptions: { id: number; label: string }[]; ridersLoading: boolean; assignRiderId: number; setAssignRiderId: (n: number) => void; assigning: boolean; assignMsg: string; onAssign: () => void; onRemove: (k: string) => void; onClose: () => void; }) { return (
{/* Sticky page header with the assign controls */}

Selected Orders

{rows.length} order{rows.length === 1 ? '' : 's'} ready to assign

{assignMsg &&
{assignMsg}
} {rows.length === 0 ? (
No orders selected.
) : (
{['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => )} {rows.map((r, i) => { const st = fstr(r.orderstatus).toLowerCase(); return ( ); })}
{h}
{i + 1}

{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}

{shortTime(r.orderdate || r.deliverydate)}

{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}

{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}

{fstr(r.deliverycustomer) || '—'}

{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}

)}
); } function TotalChip({ label, value, color }: { label: string; value: string; color: string }) { return ( {label} {value} ); } function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) { return ( ); } // ── Order details modal ───────────────────────────────────────────────────────── function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) { const orderheaderid = order.orderheaderid ?? order.orderid; const detailsQ = useFiestaOrderDetails(orderheaderid as number | string); const lines = (detailsQ.data ?? []).map((row) => { const quantity = fnum(row.quantity) || fnum(row.qty) || fnum(row.orderqty); const price = fnum(row.price) || fnum(row.unitprice) || fnum(row.retailprice); return { name: fstr(row.productname) || fstr(row.itemname) || 'Item', quantity, price, lineTotal: fnum(row.amount) || fnum(row.productsumprice) || price * quantity }; }); const st = fstr(order.orderstatus).toLowerCase(); const total = fnum(order.deliveryamt) || fnum(order.orderamount); // Portal to so the overlay escapes any transformed / blurred / overflow // ancestor in the view tree — otherwise `fixed inset-0` resolves against that // ancestor (not the viewport) and the panel collapses to a sliver. The explicit // viewport-relative width is a belt-and-suspenders so sizing never depends on // percentage resolution against a broken containing block. return createPortal(
{ if (e.target === e.currentTarget) onClose(); }}>

Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}

{shortTime(order.orderdate || order.deliverydate)}
{fstr(order.deliverycustomer) || 'Customer'}
{fstr(order.deliverycontactno) &&
{fstr(order.deliverycontactno)}
}
{fstr(order.deliveryaddress) || fstr(order.deliverysuburb) || 'Address unavailable'}
Order Items
{detailsQ.isLoading &&
Loading line items…
} {!detailsQ.isLoading && lines.length === 0 &&
No line items returned for this order.
} {lines.map((item, idx) => (

{item.name}

Qty: {item.quantity} × ₹{item.price}

₹{item.lineTotal.toLocaleString('en-IN')}
))} {total > 0 && (
Order Total₹{total.toLocaleString('en-IN')}
)}
, document.body, ); } const BRAND_LIGHT_LOCAL = '#9255AB';