dispatch page

This commit is contained in:
Gokul
2026-06-12 14:45:06 +05:30
parent d8c1517239
commit 5378f2df1f
34 changed files with 4451 additions and 1744 deletions

View File

@@ -7,9 +7,9 @@
* Dispatch page — a faithful port of the operations console's dispatch cockpit
* (nearle_console/dispatch). It reuses that page's actual stylesheet verbatim
* (`./DispatchView.css`, copied from Dispatch.css) and reproduces the same DOM /
* class structure: the `#hdr` bar, `#strat-row` view tabs, `#batch-row` wave
* selector, the 400px `#sidebar` (RIDER DISPATCH header + KPI tiles + rider/zone
* cards + per-trip order cards), and the `#map-wrap` centrepiece.
* class structure: the `#hdr` bar, `#strat-row` view tabs, the 400px `#sidebar`
* (RIDER DISPATCH header + KPI tiles + rider/zone cards + per-trip order cards),
* and the `#map-wrap` centrepiece.
*
* The source map is a Leaflet canvas of planned-vs-actual rider routes (OSRM
* road-snapping, Kalman-smoothed GPS) plus AI rider-assignment posting to
@@ -24,8 +24,8 @@ import {
Map as MapIcon,
MapPin,
Bike,
Globe,
Info,
ShoppingBag,
Truck,
Package,
Ruler,
Wallet,
@@ -40,11 +40,9 @@ import {
ChevronRight,
List,
Play,
PlugZap,
} from 'lucide-react';
import { useFiestaDeliveries, useFiestaRiders } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { MOCK_DELIVERIES, MOCK_RIDERS } from '../services/dispatchMockData';
import DispatchMap, { type MapPoint } from './DispatchMap';
import './DispatchView.css';
@@ -86,45 +84,14 @@ function pickupLatLon(r: Row): [number, number] | null {
return lat && lon ? [lat, lon] : null;
}
// ── Batch / wave model (canonical half-open hour ranges, local time) ─────────────
// Mirrors Dispatch.js BATCH_OPTIONS: gaps (89, 12:3016, after 19) are intentional.
type BatchId = 'all' | 'morning' | 'afternoon' | 'evening';
const BATCHES: Array<{ id: BatchId; label: string; range: string }> = [
{ id: 'all', label: 'All', range: 'Full day' },
{ id: 'morning', label: 'Morning', range: '12 AM 8 AM' },
{ id: 'afternoon', label: 'Afternoon', range: '9 AM 12:30 PM' },
{ id: 'evening', label: 'Evening', range: '4 PM 7 PM' },
];
function rowHourFrac(r: Row): number | null {
const raw = fstr(r.assigntime) || fstr(r.deliverytime) || fstr(r.deliverydate);
const m = raw.match(/[ T](\d{1,2}):(\d{2})/);
if (!m) return null;
return Number(m[1]) + Number(m[2]) / 60;
}
function inBatch(r: Row, b: BatchId): boolean {
if (b === 'all') return true;
const h = rowHourFrac(r);
if (h == null) return false;
if (b === 'morning') return h >= 0 && h < 8;
if (b === 'afternoon') return h >= 9 && h < 12.5;
return h >= 16 && h < 19; // evening
}
function initialBatch(): BatchId {
const h = new Date().getHours();
if (h >= 0 && h < 8) return 'morning';
if (h >= 9 && h < 12.5) return 'afternoon';
if (h >= 16 && h < 19) return 'evening';
return 'all';
}
// ── View modes (match #strat-row tabs) ───────────────────────────────────────────
type ViewMode = 'kitchens' | 'zones' | 'riders' | 'all' | 'rider-info';
type ViewMode = 'kitchens' | 'zones' | 'riders' | 'orders' | 'deliveries';
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
{ id: 'kitchens', label: 'By Location', icon: MapPin },
{ id: 'zones', label: 'By Zone', icon: MapIcon },
{ id: 'riders', label: 'By Rider', icon: Bike },
{ id: 'all', label: 'All Routes', icon: Globe },
{ id: 'rider-info', label: 'Rider Info', icon: Info },
{ id: 'orders', label: 'By Orders', icon: ShoppingBag },
{ id: 'deliveries', label: 'By Deliveries', icon: Truck },
];
interface Group {
@@ -142,15 +109,15 @@ interface Group {
interface DispatchViewProps {
locationid?: number;
tenantId?: number;
}
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export default function DispatchView({ locationid }: DispatchViewProps) {
export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID }: DispatchViewProps) {
const today = new Date();
const [date, setDate] = useState<string>(ymd(today));
const [batch, setBatch] = useState<BatchId>(initialBatch());
const [viewMode, setViewMode] = useState<ViewMode>('riders');
const [focusedId, setFocusedId] = useState<string | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
@@ -158,37 +125,26 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
const [animateNonce, setAnimateNonce] = useState(0);
const [animating, setAnimating] = useState(false);
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate: date, todate: date });
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid });
const ridersQ = useFiestaRiders({ tenantid: tenantId });
// Sample-data fallback: when the live feed returns nothing, render the demo set
// so the cockpit isn't blank. The header labels it "Sample data" so it's never
// mistaken for live (see services/dispatchMockData.ts).
const liveRows = deliveriesQ.data ?? [];
const usingMock = !deliveriesQ.isLoading && !deliveriesQ.isError && liveRows.length === 0;
const allRows = usingMock ? MOCK_DELIVERIES : liveRows;
// Sample rows aren't tied to the signed-in store, so skip the outlet filter for them.
const inScope = (r: Row) => usingMock || !locationid || fnum(r.locationid) === locationid;
// 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((r) => inScope(r) && inBatch(r, batch)),
() => allRows.filter(inScope),
// eslint-disable-next-line react-hooks/exhaustive-deps
[allRows, batch, locationid, usingMock],
[allRows, locationid],
);
const batchCounts = useMemo(() => {
const acc: Record<string, number> = { all: 0, morning: 0, afternoon: 0, evening: 0 };
const scoped = allRows.filter(inScope);
for (const b of BATCHES) acc[b.id] = scoped.filter((r) => inBatch(r, b.id)).length;
return acc;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allRows, locationid, usingMock]);
// ── Grouping ────────────────────────────────────────────────────────────────
const groups = useMemo<Group[]>(() => {
const map = new Map<string, Group>();
const titleCase = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
const keyOf = (r: Row): { id: string; name: string } => {
if (viewMode === 'riders' || viewMode === 'rider-info') {
if (viewMode === 'riders') {
const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) };
}
@@ -196,7 +152,16 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup';
return { id: name.toLowerCase(), name };
}
if (viewMode === 'all') return { id: 'all', name: 'All Routes' };
if (viewMode === 'orders') {
// Bucket by ORDER status (created / pending / processing / delivered / cancelled).
const s = fstr(r.orderstatus).toLowerCase() || 'unknown';
return { id: `o:${s}`, name: titleCase(s) };
}
if (viewMode === 'deliveries') {
// Bucket by DELIVERY/dispatch status (falls back to order status, then unassigned).
const s = (fstr(r.deliverystatus) || fstr(r.orderstatus)).toLowerCase() || 'unassigned';
return { id: `d:${s}`, name: titleCase(s) };
}
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
return { id: name.toLowerCase(), name };
};
@@ -222,7 +187,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
}, [rows, viewMode]);
const focused = groups.find((g) => g.id === focusedId) ?? null;
const groupedByRider = viewMode === 'zones' || viewMode === 'kitchens' || viewMode === 'all';
const groupedByRider = viewMode !== 'riders';
// Trip blocks for the focused group: by trip# (rider view) or by rider (zone/all view).
const tripBlocks = useMemo(() => {
@@ -264,7 +229,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
}, [focused, groupedByRider, tripSort]);
// Map points: the focused group's ordered stops (with a route), else every stop
// in the wave (coloured per rider). Rows without coordinates are skipped.
// for the day (coloured per rider). Rows without coordinates are skipped.
const mapPoints = useMemo<MapPoint[]>(() => {
const src = focused ? tripBlocks.flatMap((b) => b.orders) : rows;
const out: MapPoint[] = [];
@@ -293,8 +258,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
// KPI scope.
const totalOrders = rows.length;
const activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size;
const fleetSize = usingMock ? MOCK_RIDERS.length : (ridersQ.data ?? []).length;
const scopeLabel = BATCHES.find((b) => b.id === batch)?.label ?? 'All';
const fleetSize = (ridersQ.data ?? []).length;
// Date chip helpers.
const isToday = date === ymd(today);
@@ -338,9 +302,9 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
<span className="live-status live-status-error">
<span className="live-dot error" /> Offline
</span>
) : usingMock ? (
<span className="live-status" title="No live deliveries for this day — showing sample data">
<span className="live-dot" style={{ background: '#f59e0b' }} /> Sample data · {totalOrders} orders
) : totalOrders === 0 ? (
<span className="live-status" title="No deliveries dispatched for this day">
<span className="live-dot" style={{ background: '#94a3b8' }} /> No deliveries today
</span>
) : (
<span className="live-status live-status-ready">
@@ -383,7 +347,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
return (
<button
key={t.id}
className={`sbt ${viewMode === t.id ? 'active' : ''}${t.id === 'rider-info' ? ' sbt-rider-info' : ''}`}
className={`sbt ${viewMode === t.id ? 'active' : ''}`}
onClick={() => { setViewMode(t.id); setFocusedId(null); }}
>
<span className="sbt-icon"><Icon size={15} /></span>
@@ -393,24 +357,6 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
})}
</div>
{/* ── Batch / wave bar ── */}
<div id="batch-row">
<span className="batch-label">Batch</span>
<div className="batch-scroll">
{BATCHES.map((b) => (
<button
key={b.id}
className={`batch-btn batch-slot ${batch === b.id ? 'active' : ''}`}
onClick={() => { setBatch(b.id); setFocusedId(null); }}
title={`${b.label} (${b.range})`}
>
<span className="batch-btn-label">{b.label}</span>
<span className="batch-btn-count">{batchCounts[b.id] ?? 0}</span>
</button>
))}
</div>
</div>
{/* ── Body ── */}
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
<button
@@ -431,7 +377,7 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
</div>
<span className="sb-header-scope">
<span className="sb-scope-dot" />
{scopeLabel}
{totalOrders} stops
</span>
</div>
<div className="sb-header-tiles">
@@ -466,15 +412,15 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
fmtTime={fmtTime}
/>
) : groups.length === 0 ? (
<div className="ph">No deliveries in this wave</div>
<div className="ph">No deliveries for this day</div>
) : (
<>
<div className="ph">
{viewMode === 'riders' || viewMode === 'rider-info' ? 'Riders' : viewMode === 'kitchens' ? 'Pickup points' : viewMode === 'all' ? 'All routes' : 'Zones'} ({groups.length})
{viewMode === 'riders' ? 'Riders' : viewMode === 'kitchens' ? 'Pickup points' : viewMode === 'orders' ? 'Order statuses' : viewMode === 'deliveries' ? 'Delivery statuses' : 'Zones'} ({groups.length})
</div>
{groups.map((g) => (
<React.Fragment key={g.id}>
{viewMode === 'riders' || viewMode === 'rider-info'
{viewMode === 'riders'
? <RiderCard g={g} onClick={() => setFocusedId(g.id)} />
: <ZoneCard g={g} onClick={() => setFocusedId(g.id)} />}
</React.Fragment>
@@ -492,22 +438,18 @@ export default function DispatchView({ locationid }: DispatchViewProps) {
route={Boolean(focused)}
routeColor={focused?.color || '#581c87'}
start={routeStart}
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}|${batch}`}
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}`}
animateNonce={animateNonce}
/>
{/* Contextual note overlaid on the map */}
{viewMode === 'rider-info' ? (
{mapPoints.length === 0 ? (
<div className="dmp-overlay-note">
<PlugZap size={13} /> Live rider telemetry (battery · GPS · speed) awaiting backend map shows planned drops.
</div>
) : mapPoints.length === 0 ? (
<div className="dmp-overlay-note">
<MapIcon size={13} /> No drop coordinates in this {focused ? 'route' : 'wave'} yet.
<MapIcon size={13} /> No drop coordinates in {focused ? 'this route' : 'these deliveries'} yet.
</div>
) : !focused ? (
<div className="dmp-overlay-note">
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : 'rider'} to draw its route.
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : viewMode === 'riders' ? 'rider' : 'group'} to draw its route.
</div>
) : null}