/** * @license * SPDX-License-Identifier: Apache-2.0 */ /** * Dispatch cockpit — integrates live deliveries, rider assignments, and route * visualization. Reuses the dispatch page stylesheet from the operations console * and reproduces its DOM structure: header, view-mode tabs, sidebar (KPI + group * cards), and map centrepiece with Leaflet. * * Features: * • Group deliveries by rider, zone, location, or status * • Focus on a specific group to see its trip blocks and detailed order cards * • Map-based route visualization with planned stops (actual GPS awaiting backend) * • Real-time KPI cards (orders, riders, completion %) * • Date navigation for historical dispatch view */ import React, { useMemo, useState, useEffect } from 'react'; import { Map as MapIcon, MapPin, Bike, Users, Phone, Store, Truck, Package, Ruler, Wallet, Crosshair, Clock, Utensils, Mailbox, StickyNote, ArrowLeftRight, ChevronLeft, ChevronRight, List, Play, } from 'lucide-react'; import { useFiestaDeliveries, useFiestaTenantLocations, useFiestaTenantCustomers, } from '../services/fiestaQueries'; import { FIESTA_TENANT_ID, num as fnum, str as fstr, customerName, customerStoreId, type Row, } from '../services/fiestaApi'; import { colorFor } from '../services/dispatchShared'; import DispatchMap, { type MapPoint } from './DispatchMap'; import CustomerDetailPanel from './CustomerDetailPanel'; import './DispatchView.css'; // Legacy direct utilities (will be migrated to dispatchShared) const STATUS_HEX: Record = { pending: '#f59e0b', accepted: '#6366f1', arrived: '#06b6d4', picked: '#8b5cf6', active: '#14b8a6', skipped: '#f97316', delivered: '#22c55e', cancelled: '#ef4444', }; function statusStyle(s: string): React.CSSProperties { const hex = STATUS_HEX[s.toLowerCase()] || '#64748b'; return { background: `${hex}1f`, color: hex }; } /** * Best available area label for a delivery row. `getdeliveries` has NO * `deliverysuburb` column — reading it left every "areas" count at 0 and hid the * suburb strip entirely. The feed does carry `locationsuburb` and a full * `deliveryaddress`, so fall back to the address's locality segment * ("12 Main St, Peelamedu, Coimbatore, 641004" → "Coimbatore"). */ function areaOf(r: Row): string { const direct = fstr(r.deliverysuburb).trim() || fstr(r.locationsuburb).trim(); if (direct) return direct; const parts = fstr(r.deliveryaddress) .split(',') .map((p) => p.trim()) .filter(Boolean) // Trailing postcode / country segments aren't areas. .filter((p) => !/^\d{4,6}$/.test(p) && p.toLowerCase() !== 'india'); return parts.length > 1 ? parts[parts.length - 1] : ''; } /** Drop coordinates from a delivery row (several field spellings), or null. */ function dropLatLon(r: Row): [number, number] | null { const lat = fnum(r.droplat) || fnum(r.deliverylat) || fnum(r.deliverylatitude); const lon = fnum(r.droplon) || fnum(r.deliverylong) || fnum(r.deliverylon) || fnum(r.deliverylongitude); return lat && lon ? [lat, lon] : null; } /** Pickup/hub coordinates from a delivery row, or null. */ function pickupLatLon(r: Row): [number, number] | null { const lat = fnum(r.pickuplat) || fnum(r.pickuplatitude); const lon = fnum(r.pickuplong) || fnum(r.picklongitude) || fnum(r.pickuplon); return lat && lon ? [lat, lon] : null; } // ── View modes ────────────────────────────────────────────────────────────────── // The tab row lives in DispatchHubView; only these two modes are reachable. type ViewMode = 'stores' | 'customers'; interface Group { id: string; name: string; color: string; orders: Row[]; delivered: number; totalKm: number; profit: number; riders: Set; suburbs: Map; statusCounts: Record; raw?: any; /** Customer view: the outlet this customer is registered against, for the card badge. */ storeName?: string; } interface DispatchViewProps { locationid?: number; tenantId?: number; date: string; viewMode: ViewMode; } export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, date, viewMode }: DispatchViewProps) { const [focusedId, setFocusedId] = useState(null); useEffect(() => { setFocusedId(null); }, [viewMode]); const [customerStoreFilter, setCustomerStoreFilter] = useState('all'); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned'); const [animateNonce, setAnimateNonce] = useState(0); const [animating, setAnimating] = useState(false); // Core dispatch data const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid }); const locationsQ = useFiestaTenantLocations(tenantId); // Customers. Admin (no locationid) gets every customer under the tenant and // filters client-side; a store user sends its locationid so the backend scopes // to that outlet server-side via `tenantcustomers.locationid`. const customersQ = useFiestaTenantCustomers({ tenantid: tenantId, locationid: locationid || 0 }); // Live deliveries only — no sample/demo fallback. When the feed is empty the // cockpit shows a genuine empty state rather than fabricated riders/stops. const allRows = deliveriesQ.data ?? []; const inScope = (r: Row) => !locationid || fnum(r.locationid) === locationid; const rows = useMemo( () => allRows.filter(inScope), // eslint-disable-next-line react-hooks/exhaustive-deps [allRows, locationid], ); /** locationid → outlet name, for stamping a store badge on customer cards. */ const storeNames = useMemo(() => { const m = new Map(); for (const loc of locationsQ.data ?? []) { const id = fnum(loc.locationid); if (id) m.set(id, fstr(loc.locationname).trim() || `Store ${id}`); } return m; }, [locationsQ.data]); // ── Grouping ──────────────────────────────────────────────────────────────── // Both modes seed from the roster first (every outlet / every customer under // the scope) and then fold the day's deliveries into those seeds. That's what // makes an outlet with no deliveries today, or a customer who hasn't ordered // today, still appear in the list rather than vanishing. const groups = useMemo(() => { const map = new Map(); const blank = (id: string, name: string): Group => ({ id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {}, }); if (viewMode === 'stores' && locationsQ.data) { // Admin: every outlet under the tenant. Store user: only its own, because // `locationid` is set. for (const loc of locationsQ.data) { if (locationid && fnum(loc.locationid) !== locationid) continue; const id = String(fnum(loc.locationid)).toLowerCase(); map.set(id, blank(id, fstr(loc.locationname) || `Store ${id}`)); } } if (viewMode === 'customers' && customersQ.data) { for (const cust of customersQ.data) { const storeId = customerStoreId(cust); // Admin's store dropdown. A store user never sees it (the backend has // already scoped the list), so this only ever narrows the admin view. if (customerStoreFilter !== 'all' && String(storeId) !== customerStoreFilter) continue; const id = String(fnum(cust.customerid) || fstr(cust.contactno)).toLowerCase(); map.set(id, { ...blank(id, customerName(cust) || `Customer ${id}`), raw: cust, storeName: storeNames.get(storeId), }); } } const keyOf = (r: Row): { id: string; name: string } => { if (viewMode === 'stores') { const locId = fstr(r.locationid) || fstr(r.pickuplocationid) || 'unknown'; const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || `Store ${locId}`; return { id: locId.toLowerCase(), name }; } const custId = fstr(r.customerid) || fstr(r.deliverycontactno) || 'unknown'; return { id: custId.toLowerCase(), name: customerName(r) || `Customer ${custId}` }; }; for (const r of rows) { const { id, name } = keyOf(r); let g = map.get(id); if (!g) { // A delivery whose customer isn't in the roster (not linked to this // tenant, or beyond the page we fetched). Keep it — dropping it would // hide real work from the board — but honour an active store filter. if (viewMode === 'customers' && customerStoreFilter !== 'all') continue; g = blank(id, name); if (viewMode === 'customers') { // Stand in for the missing customer record so the card still opens. // Without a `raw` the detail panel stayed on "Select a customer" no // matter how many times the card was clicked. g.raw = { customerid: fnum(r.customerid), firstname: fstr(r.deliverycustomer), contactno: fstr(r.deliverycontactno), address: fstr(r.deliveryaddress), tenantlocationid: fnum(r.locationid), locationname: fstr(r.locationname), }; g.storeName = storeNames.get(fnum(r.locationid)) || fstr(r.locationname) || undefined; } map.set(id, g); } g.orders.push(r); const st = fstr(r.orderstatus).toLowerCase(); if (st === 'delivered') g.delivered += 1; g.statusCounts[st] = (g.statusCounts[st] ?? 0) + 1; g.totalKm += fnum(r.kms); g.profit += fnum(r.profit); const rid = fstr(r.userid) || fstr(r.ridername); if (rid) g.riders.add(rid); const sub = areaOf(r); if (sub) g.suburbs.set(sub, (g.suburbs.get(sub) ?? 0) + 1); } return Array.from(map.values()).sort((a, b) => b.orders.length - a.orders.length || a.name.localeCompare(b.name)); }, [rows, viewMode, locationid, locationsQ.data, customersQ.data, customerStoreFilter, storeNames]); useEffect(() => { if (viewMode === 'stores' && locationid && groups.length === 1 && !focusedId) { setFocusedId(groups[0].id); } }, [viewMode, locationid, groups, focusedId]); const focused = groups.find((g) => g.id === focusedId) ?? null; // Trip blocks for the focused group, one per rider. const tripBlocks = useMemo(() => { if (!focused) return []; const map = new Map(); for (const r of focused.orders) { const rid = fstr(r.userid) || fstr(r.ridername) || 'unassigned'; const label = fstr(r.ridername) || (rid === 'unassigned' ? 'Unassigned' : `Rider ${rid}`); let blk = map.get(rid); if (!blk) { blk = { label, color: colorFor(rid), orders: [] }; map.set(rid, blk); } blk.orders.push(r); } const blocks = Array.from(map.values()); for (const blk of blocks) { blk.orders.sort((a, b) => { if (tripSort === 'time') { const ta = fstr(a.deliverytime) || fstr(a.expecteddeliverytime); const tb = fstr(b.deliverytime) || fstr(b.expecteddeliverytime); return ta.localeCompare(tb); } // `step` isn't in the getdeliveries response, so planned order falls back // to assignment time. Kept in case the backend starts sending it. const sa = fnum(a.step); const sb = fnum(b.step); if (sa && sb && sa !== sb) return sa - sb; return fstr(a.assigntime).localeCompare(fstr(b.assigntime)); }); } return blocks; }, [focused, tripSort]); // Map points: the focused group's ordered stops (with a route), else every stop // for the day (coloured per rider). Rows without coordinates are skipped. const mapPoints = useMemo(() => { const src = focused ? tripBlocks.flatMap((b) => b.orders) : rows; const out: MapPoint[] = []; src.forEach((r, i) => { const ll = dropLatLon(r); if (!ll) return; out.push({ id: fstr(r.deliveryid) || fstr(r.orderid) || String(i), lat: ll[0], lon: ll[1], step: fnum(r.step) || i + 1, color: focused ? focused.color : colorFor(fstr(r.userid) || fstr(r.ridername) || 'x'), title: customerName(r) || `Order ${fstr(r.orderid)}`, subtitle: areaOf(r) || fstr(r.deliveryaddress), status: fstr(r.orderstatus), raw: r, }); }); return out; }, [focused, tripBlocks, rows]); // Route start = the focused group's pickup/hub (so the road route originates there). const firstOrder = tripBlocks[0]?.orders[0] ?? focused?.orders[0]; const routeStart = focused && firstOrder ? pickupLatLon(firstOrder) : null; // The roster query matters as much as the delivery feed — gating on deliveries // alone flashed "No customers found" while the customer list was still loading. const isLoading = deliveriesQ.isLoading || (viewMode === 'customers' ? customersQ.isLoading : locationsQ.isLoading); // KPI scope. const totalOrders = rows.length; const totalDelivered = rows.filter((r) => fstr(r.orderstatus).toLowerCase() === 'delivered').length; const fmtTime = (raw: unknown): string => { const m = fstr(raw).match(/(\d{1,2}):(\d{2})/); return m ? `${m[1]}:${m[2]}` : ''; }; return (
{/* ── Body ── */}
{/* Sidebar */} {/* Main Content Area */} {viewMode === 'customers' ? (
{focused?.raw ? ( setFocusedId(null)} /> ) : (

Select a customer

Choose a customer from the list to view their billing history.

)}
) : (
{/* Live Leaflet route map */} {/* Contextual note overlaid on the map */} {mapPoints.length === 0 ? (
No drop coordinates in {focused ? 'this route' : 'these deliveries'} yet.
) : !focused ? (
Select a store to draw its route.
) : null} {/* bottom-right overlay controls (gated) */}
)}
); } // ── Store card ─────────────────────────────────────────────────────────────────── // One per outlet. Admin sees every outlet under the tenant, a store user sees // only its own. Outlets with no deliveries today are deliberately still listed — // "which of my stores is idle" is the question the admin list has to answer — so // they render a quiet idle state instead of a row of zeroes. function StoreCard({ g, onClick }: { g: Group; onClick: () => void }) { const suburbs = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s); const idle = g.orders.length === 0; return (
{g.name}
{idle ? 'No deliveries today' : `${g.riders.size} rider${g.riders.size === 1 ? '' : 's'} · ${g.orders.length} ${g.orders.length === 1 ? 'delivery' : 'deliveries'}`}
{!idle && ( <>
{Object.entries(g.statusCounts).map(([s, c]) => (
))}
{g.delivered}/{g.orders.length}
{/* Only render a stat the feed can actually populate — the areas and profit pills used to sit at a permanent 0 for every store. */} {g.suburbs.size > 0 && ( {g.suburbs.size} {g.suburbs.size === 1 ? 'area' : 'areas'} )} {g.totalKm > 0 && ( {g.totalKm.toFixed(0)} km )} {g.profit > 0 && ( ₹{g.profit.toLocaleString('en-IN')} profit )}
{suburbs.length > 0 && (
{suburbs.slice(0, 3).join(' · ')} {suburbs.length > 3 && +{suburbs.length - 3}}
)} )}
); } // ── Focused detail (trip blocks + order cards) ─────────────────────────────────── function FocusedDetail({ focused, tripBlocks, tripSort, setTripSort, onBack, fmtTime, }: { focused: Group; tripBlocks: Array<{ label: string; color: string; orders: Row[] }>; tripSort: 'planned' | 'time'; setTripSort: (v: 'planned' | 'time') => void; onBack?: () => void; fmtTime: (raw: unknown) => string; }) { return ( <> {onBack && ( )} {tripBlocks.length === 0 && (

No deliveries for {focused.name}

Nothing has been dispatched from this store on the selected date.

)} {tripBlocks.map((blk, bi) => (
{blk.label} {blk.orders.length} stops {blk.orders.reduce((a, o) => a + fnum(o.kms), 0).toFixed(1)} km
{blk.orders.map((o, i) => { const st = fstr(o.orderstatus).toLowerCase(); const step = fnum(o.step) || i + 1; const actual = fstr(o.deliverytime); const expected = fstr(o.expecteddeliverytime); const profit = fnum(o.profit); const km = fnum(o.kms); const charge = fnum(o.deliverycharge) || fnum(o.deliverycharges); return (
{step}
Order #{fstr(o.orderid) || fstr(o.deliveryid)}
{fstr(o.ridername) && (
{fstr(o.ridername)}
)}
{st && {st}} {(actual || expected) && ( {fmtTime(actual || expected)} )}
{customerName(o) || 'Customer'}
{fstr(o.pickupcustomer) && (
{fstr(o.pickupcustomer)}
)} {fstr(o.deliveryaddress) && (
{fstr(o.deliveryaddress)}
)} {fstr(o.ordernotes) && (
{fstr(o.ordernotes)}
)}
{km.toFixed(1)} km {profit !== 0 && ( ₹{Math.abs(profit).toLocaleString('en-IN')} )} {charge > 0 && ₹{charge} chg} S{step}
); })}
))} ); } // ── Customer card ─────────────────────────────────────────────────────────────────── function CustomerCard({ g, onClick, isSelected }: { g: Group; onClick: () => void; isSelected?: boolean }) { const customer = g.raw; // g.name is already resolved through customerName(); reading `customername` off // the raw row (a column gettenantcustomers doesn't return) made every card in // the list read "Unknown Customer". const name = g.name; const phone = customer ? fstr(customer.contactno) || fstr(customer.phone) : ''; const deliveries = g.orders.length; return (
{isSelected && (
)}
{name || 'Unknown Customer'}
{phone && (
{phone}
)} {(g.storeName || deliveries > 0) && (
{g.storeName && ( {g.storeName} )} {deliveries > 0 && ( {deliveries} today )}
)}
); }