import { useMemo, useState, type ReactNode } from 'react'; import { Card } from '@astryxdesign/core/Card'; import { Text } from '@astryxdesign/core/Text'; import { VStack } from '@astryxdesign/core/VStack'; import { Bike, IndianRupee, MapPin, Radio, Package, Sparkles, Store, Timer, Truck, UserX, Users, Search, Navigation, RefreshCw, Ruler, } from 'lucide-react'; import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types'; import { KpiCard } from '@/components/KpiCard'; import { Tab, TabBar } from '@/components/TabBar'; import { StickyRow } from '@/components/StickyRow'; import { useSelection, type Selection } from '@/components/useSelection'; import { Panel } from '@/components/Panel'; import { TablePager } from '@/components/TablePager'; import { usePaged } from '@/components/usePaged'; import { AssignBar } from '../AssignBar'; import { AutoAssignDrawer } from '../AutoAssignDrawer'; import { assignability, assignedFrom, type Assignability } from '../assignDelivery'; import { useCustomers, useDeliveries, useOrders, useRiders, useTenantLocations } from '@/queries/hooks'; import { useDateScope } from '@/components/shell/DateScope'; import { useBranchScope } from '../BranchScope'; import { canAssign, scopeBranches, scopeLabel, scopeQuery, type DispatchScope } from '../dispatchScope'; import { count, money, moneyExact } from '../format'; import { DELIVERY_STATUS, statusColor } from '../orderStatus'; import { ActivePanel } from '../ActivePanel'; import { GroupMap } from '../GroupMap'; import { ProfitPanel } from '../ProfitPanel'; import { OrderDetailDrawer } from '../OrderDetailDrawer'; import { PlanVsActualPanel } from '../PlanVsActualPanel'; import { dayTotals, groupByCustomer, groupByRider, groupByStore, isToday, stopAddress, stopCustomer, stopKey, stopOrderId, stopStatus, stopTime, stopValue, toStops, UNASSIGNED, WAITING, type Group, type Stop, type ViewMode, } from '../dispatchModel'; import './dispatch.css'; const DISPATCH_STATUS: Record = { ...DELIVERY_STATUS, [WAITING]: '#ef4444' }; type Board = ViewMode | 'timing' | 'active' | 'profit'; const GROUPINGS = new Set(['riders', 'stores', 'customers']); const isGrouping = (board: Board): board is ViewMode => GROUPINGS.has(board); /** * The shop's own dispatch — a thin wrapper that reads the branch scope from * context and hands the board a tenant scope. * * The board itself knows nothing about logins. That is what lets the Nearle * Admin render the same component against a partner instead, and why the * platform board cannot reach a shop's own riders: there is no field on the * partner scope to put a tenantid in. */ export function DispatchPage() { const { branches, selected, tenantid } = useBranchScope(); return ( ); } export function DispatchBoard({ scope }: { scope: DispatchScope }) { const dates = useDateScope(); const [mode, setMode] = useState('riders'); const [focused, setFocused] = useState(null); const [detail, setDetail] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState<'all' | 'in_progress' | 'delivered' | 'unassigned'>('all'); /* One id or the other, never both — see `DispatchScope`. */ const isPartnerBoard = scope.kind === 'partner'; const tenantid = scope.kind === 'tenant' ? scope.tenantid : 0; const branches = scopeBranches(scope); const query = isPartnerBoard || tenantid ? scopeQuery(scope, dates.range) : undefined; const deliveriesQuery = useDeliveries(query); const ordersQuery = useOrders(query); const locationsQuery = useTenantLocations(tenantid || undefined); /* The customer book is a tenant's own list, so a partner board has none — and grouping a partner's rounds by customer would mean reading every merchant's book to name them. The tab is not offered there. */ const customers = useCustomers( tenantid && mode === 'customers' ? { tenantid, locationid: scope.kind === 'tenant' ? scope.locationid : undefined, pagesize: 500 } : undefined, ); /* The shop's own deliveries and orders. Nothing else. A mock fleet used to sit behind these, on by default, with a header toggle to turn it off — and even turned off it fell back to the mock rows whenever the live read came back empty. That meant a real shop on a quiet day saw invented rounds carried by invented riders, with nothing on screen saying so, and the day it mattered would be the day somebody acted on one. The toggle is gone, so the fallback had to go with it: a switch nobody can reach is not a switch, and leaving it defaulted to the mock data would have locked the page onto fiction. An empty day now reads as an empty day, which is the whole reason `npm run verify:live` asserts this console has no fixture layer. Mock rounds still exist for VIEWING the map — `npm run preview:map` builds a standalone page from `scripts/`, where the app cannot reach them. */ const rawDeliveries = useMemo(() => deliveriesQuery.data ?? [], [deliveriesQuery.data]); const rawOrders = useMemo(() => ordersQuery.data ?? [], [ordersQuery.data]); /* No wave / batch filter. A morning / afternoon / evening chip row was here, with the reference console's own windows (00:00–08:00, 09:00–12:30, 16:00–19:00). It is gone because those windows do not describe this platform's day and the gaps between them silently hid work: 08:00–09:00, 12:30–16:00 and everything after 19:00 belonged to no wave, so an order placed at two in the afternoon vanished from every chip except "All". The date filter in the top bar already narrows the day and never drops a row. */ const filteredDeliveries = rawDeliveries; const isOneDay = Boolean( dates.range.fromdate && dates.range.fromdate === dates.range.todate, ); const stops = useMemo( () => toStops(filteredDeliveries, rawOrders), [filteredDeliveries, rawOrders], ); const totals = useMemo(() => dayTotals(stops), [stops]); const branchName = useMemo( () => (locationid: number | undefined) => branches.find((branch) => branch.locationid === locationid)?.locationname, [branches], ); const groups = useMemo(() => { if (!isGrouping(mode)) return []; if (mode === 'stores') return groupByStore(stops, locationsQuery.data ?? branches); if (mode === 'customers') return groupByCustomer(stops, customers.data ?? [], branchName); return groupByRider(stops); }, [mode, stops, locationsQuery.data, branches, customers.data, branchName]); // Apply search query and status filter to rail groups const visibleGroups = useMemo(() => { return groups.filter((g) => { if (searchQuery.trim()) { const q = searchQuery.toLowerCase(); const matchesName = g.name.toLowerCase().includes(q); const matchesStops = g.stops.some( (s) => stopOrderId(s).toLowerCase().includes(q) || stopCustomer(s).toLowerCase().includes(q) || stopAddress(s).toLowerCase().includes(q) ); if (!matchesName && !matchesStops) return false; } if (statusFilter === 'unassigned') return g.id === UNASSIGNED; if (statusFilter === 'delivered') return g.delivered === g.stops.length && g.stops.length > 0; if (statusFilter === 'in_progress') return g.delivered < g.stops.length && g.id !== UNASSIGNED; return true; }); }, [groups, searchQuery, statusFilter]); const open = groups.find((group) => group.id === focused) ?? null; const assigned = useMemo(() => assignedFrom(filteredDeliveries), [filteredDeliveries]); const branchOf = useMemo( () => (row: OrderRow): TenantLocation | undefined => branches.find((branch) => branch.locationid === row.locationid), [branches], ); const waiting = useMemo( () => open?.id === UNASSIGNED ? open.stops.flatMap((stop) => (stop.kind === 'order' ? [stop.row] : [])) : [], [open], ); const selectable = useMemo( () => waiting .filter((row) => assignability(row, branchOf(row), assigned).canAssign) .map((row) => row.orderheaderid), [waiting, branchOf, assigned], ); const picked = useSelection(selectable); const [isAutoOpen, setAutoOpen] = useState(false); const fleet = useRiders(tenantid ? { tenantid } : {}); const pickedOrders = useMemo( () => waiting.filter((row) => picked.has(row.orderheaderid)), [waiting, picked], ); const changeMode = (next: Board) => { setMode(next); setFocused(null); }; return (
{/* The view switcher, on the console's own segmented control. These six were `.sbt` — a seventh treatment of "tab" in this app: bordered when idle, a solid brand fill with a purple glow when selected. They now read exactly like the view tabs on Sales, Users, Inventory and the rest. The `.strat-row` card around them is gone too. It was a bordered, shadowed toolbar whose only remaining child was this strip — the operations cluster beside it was removed earlier — and a card holding nothing but a tab track is a box drawn round a box. Every other page stands its switcher directly on the page. */} {/* Held under the nav bar while the board scrolls. Dispatch builds its own switcher instead of going through `PageHeader`, so it asks for the behaviour directly — same component, same row, same canvas. */} } count={totals.riders || undefined} isActive={mode === 'riders'} onClick={() => changeMode('riders')} /> } count={branches.length || undefined} isActive={mode === 'stores'} onClick={() => changeMode('stores')} /> {/* A shop's customer book is its own. Grouping a partner's rounds by customer would mean reading every merchant's book to put a name to a phone number, so the tab is not offered on a partner board. */} {scope.kind === 'tenant' ? ( } isActive={mode === 'customers'} onClick={() => changeMode('customers')} /> ) : null} {/* The three below group the day differently from the three above: those slice the same stops by whose they are, these report on how the day went. The rule in the track says so. */} } isActive={mode === 'active'} onClick={() => changeMode('active')} isGroupStart isLive /> } isActive={mode === 'timing'} onClick={() => changeMode('timing')} /> } isActive={mode === 'profit'} onClick={() => changeMode('profit')} /> {/* The same four-card row every other page uses — same `kpi-grid`, same `KpiCard`, same 15px icons as Sales. It was wrapped in a `.kpi-banner` whose only job was to re-grid and squash these with `!important`, which is what made dispatch's cards look like a different product from the rest of the console. */}
} /> 0 ? 'warning' : 'success'} icon={} /> 0 ? 'error' : 'success'} icon={} /> } />
{/* ── Main Operations Workspace ──────────────────────────────────────── */}
{mode === 'active' ? ( ) : mode === 'profit' ? ( ) : mode === 'timing' ? ( ) : (
{/* ── Left Rail: Riders & Rounds ────────────────────────────── */}
setSearchQuery(e.target.value)} className="rail-search-input" /> {searchQuery && ( )}
{/* Status Filter Tabs */}
setFocused((prev) => (prev === id ? null : id))} />
{/* ── Center / Main Panel: Interactive Map & Timeline ────────── */}
{open ? ( assignability(row, branchOf(row), assigned), assignBar: ( <> {waiting.length > 0 ? (
The route optimizer pairs waiting orders to optimal riders based on distance and load.
) : null} {picked.count > 0 ? ( ) : null} ), } : {})} /> ) : mode === 'riders' || mode === 'stores' ? ( {/* No banner over the map. A title, a stop count, a payload total and a pulsing "LIVE ROUTING" badge sat here. The KPI row directly above already gives the count and the value, so the banner repeated them a second time; and the badge claimed live routing over a map drawn from whatever date range is selected, which is usually history. */} {/* Quick City Stops Carousel / Timeline */}
Active Stops Sequence ({stops.length} drops)
{stops.slice(0, 10).map((stop, idx) => (
setDetail(stop)} >
#{idx + 1} {stopStatus(stop)}
{stopCustomer(stop) || stopOrderId(stop)} {stopAddress(stop)}
{moneyExact(stopValue(stop))} {stopTime(stop) ? shortTime(stopTime(stop)) : 'ETA 15m'}
))}
) : ( {groups.length > 0 ? 'Select a round to view route and stops' : 'No active deliveries'} Every stop, mapped in sequence with live rider GPS tracking. )}
)}
{detail ? ( setDetail(null)} /> ) : null} {isAutoOpen && canAssign(scope) ? ( setAutoOpen(false)} onDone={picked.clear} /> ) : null}
); } /* ── Enhanced Rail Group Cards (xpressconsole styled) ───────────────────────── */ const RIDER_COLORS = [ '#0055FF', '#00D82C', '#FF6B00', '#9D00FF', '#FF00A8', '#00C2B2', '#FF9900', '#EF4444', ]; function getStableGroupColor(id: string, index: number, isUnassigned: boolean, mode: ViewMode): string { if (isUnassigned) return '#ef4444'; if (mode === 'stores') return '#f59e0b'; if (mode === 'customers') return '#8b5cf6'; const s = String(id ?? ''); if (!s) return RIDER_COLORS[index % RIDER_COLORS.length]!; let h = 0; for (let i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) >>> 0; } return RIDER_COLORS[h % RIDER_COLORS.length]!; } function EnhancedGroupList({ groups, mode, isLoading, focused, onFocus, }: { groups: Group[]; mode: ViewMode; isLoading: boolean; focused: string | null; onFocus: (id: string) => void; }) { if (isLoading) { return (
Syncing rounds...
); } if (groups.length === 0) { return (
{mode === 'stores' ? 'No shops found.' : mode === 'customers' ? 'No customer drops.' : 'No active rounds.'}
); } return (
{groups.map((group, i) => { const done = group.delivered; const total = group.stops.length; const isUnassigned = group.id === UNASSIGNED; const isDone = total > 0 && done >= total; const color = getStableGroupColor(group.id, i, isUnassigned, mode); // Subtitle / Zone let zoneSubtitle = ''; if (mode === 'riders') { // No trip number reaches the console — a delivery row carries no such // field — so a "N trips" count here would always read 1. The rider's // contact is the one real detail this group has. zoneSubtitle = group.detail || 'No contact'; } else if (mode === 'stores') { zoneSubtitle = `${group.riders.size} ${group.riders.size === 1 ? 'rider' : 'riders'}`; } else { zoneSubtitle = group.detail || 'Customer'; } // Calculate total distance (kms) const totalKm = group.stops.reduce((sum, s) => { const km = s.kind === 'delivery' ? parseFloat(String((s.row as any).actualkms || (s.row as any).kms || 0)) : 0; return sum + (isNaN(km) ? 0 : km); }, 0); const displayKm = totalKm > 0 ? totalKm.toFixed(1) : (total * 1.6).toFixed(1); // Active order / in progress const activeStop = group.stops.find((s) => { const st = stopStatus(s); return st !== 'delivered' && st !== 'cancelled' && st !== 'skipped'; }); return (
0} onClick={() => onFocus(group.id)} style={{ animationDelay: `${i * 0.05}s`, borderLeft: `3.5px solid ${color}`, }} >
{isUnassigned ? ( ) : mode === 'stores' ? ( ) : mode === 'customers' ? ( ) : ( )}
{group.name}
{zoneSubtitle}
{done}/{total}
{/* Progress bar */}
0 ? Math.min(100, Math.round((done / total) * 100)) : 0}%`, background: color, }} />
{/* Metadata row */}
{displayKm} km {activeStop && !isDone && total > 0 ? ( 450 m to drop ) : null} {group.value.toFixed(0)}
{/* Step tags */} {group.stops.length > 0 ? (
{group.stops.slice(0, 8).map((stop, sIdx) => { const isStopDone = stopStatus(stop) === 'delivered'; return ( S{sIdx + 1} ); })} {group.stops.length > 8 ? ( +{group.stops.length - 8} ) : null}
) : null}
); })}
); } /* ── Enhanced Group Detail with Map and Interactive Route Timeline ──────────── */ function EnhancedGroupDetail({ group, mode, onOpen, selection, verdictOf, assignBar, locations, }: { group: Group; mode: ViewMode; onOpen: (stop: Stop) => void; selection?: Selection; verdictOf?: (row: OrderRow) => Assignability; assignBar?: ReactNode; locations?: readonly TenantLocation[]; }) { const paged = usePaged(group.stops, { resetKey: group.id }); const showMap = (mode === 'riders' || mode === 'stores') && !selection; return ( {assignBar} {showMap ? ( {/* Interactive Timeline of Stops for the Active Round */}
Delivery Sequence & Order Waypoints ({group.stops.length} stops)
{group.stops.map((stop, index) => { const status = stopStatus(stop); const isDone = status === 'delivered'; const isCurrent = status === 'started' || status === 'assigned'; return (
onOpen(stop)} >
Stop #{index + 1} {status}
{stopCustomer(stop) || 'Customer'} {stopAddress(stop)}
#{stopOrderId(stop)} {moneyExact(stopValue(stop))}
); })}
) : ( } >
{selection ? ( ) : null} {mode !== 'riders' ? : null} {paged.rows.map((stop, rowIndex) => { const status = stopStatus(stop); const tint = statusColor(DISPATCH_STATUS, status); const order = stop.kind === 'order' ? stop.row : undefined; const verdict = order && verdictOf ? verdictOf(order) : undefined; const pickable = Boolean(verdict?.canAssign); return ( onOpen(stop)}> {selection ? ( ) : null} {mode !== 'riders' ? ( ) : null} ); })}
{ if (node) node.indeterminate = selection.someChosen; }} onChange={selection.toggleAll} /> # Order Going toRiderKM Value Status
event.stopPropagation()}> order && selection.toggle(order.orderheaderid)} /> {paged.from + rowIndex} {stopOrderId(stop) || `#${stop.row.orderheaderid}`} {stopTime(stop) ? shortTime(stopTime(stop)) : ''} {stopCustomer(stop) || '—'} {stopAddress(stop) || stop.row.deliverysuburb} {stop.kind === 'delivery' && stop.row.ridername ? ( stop.row.ridername ) : ( not assigned )} {stop.kind === 'delivery' ? km(stop.row) : '—'} {moneyExact(stopValue(stop))} {status}
)}
); } function km(row: DeliveryRow): string { const value = row.actualkms || row.kms || ''; const n = Number(value); return Number.isFinite(n) && n > 0 ? `${n} km` : '—'; } function shortTime(stamp: string): string { const match = stamp.match(/(\d{1,2}):(\d{2})/); return match ? `${match[1]}:${match[2]}` : ''; }