update on the user page regardinga the dispatch and order page and the deliveries page
This commit is contained in:
727
src/components/DispatchView.tsx
Normal file
727
src/components/DispatchView.tsx
Normal file
@@ -0,0 +1,727 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* external optimisation services. Those need a mapping stack + dispatch backends
|
||||
* this tenant doesn't expose, so the `#map-wrap` plots the real planned stop
|
||||
* order and marks the live-GPS / compare / AI-assign layers as awaiting backend —
|
||||
* no fabricated telemetry. Everything else is driven by the live Fiesta feed.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Bike,
|
||||
Globe,
|
||||
Info,
|
||||
Package,
|
||||
Ruler,
|
||||
Wallet,
|
||||
Crosshair,
|
||||
Clock,
|
||||
Utensils,
|
||||
Mailbox,
|
||||
StickyNote,
|
||||
ArrowLeftRight,
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
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';
|
||||
|
||||
// ── Status colours (match the console palette) ───────────────────────────────────
|
||||
const STATUS_HEX: Record<string, string> = {
|
||||
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 };
|
||||
}
|
||||
|
||||
// Stable rider/zone colour.
|
||||
const COLORS = ['#3b82f6', '#a855f7', '#10b981', '#f59e0b', '#ef4444', '#6366f1', '#14b8a6', '#ec4899', '#f97316', '#06b6d4'];
|
||||
function colorFor(key: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < key.length; i++) hash = key.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return COLORS[Math.abs(hash) % COLORS.length];
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
// ── Batch / wave model (canonical half-open hour ranges, local time) ─────────────
|
||||
// Mirrors Dispatch.js BATCH_OPTIONS: gaps (8–9, 12:30–16, 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';
|
||||
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 },
|
||||
];
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
orders: Row[];
|
||||
delivered: number;
|
||||
totalKm: number;
|
||||
profit: number;
|
||||
riders: Set<string>;
|
||||
suburbs: Map<string, number>;
|
||||
statusCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
interface DispatchViewProps {
|
||||
locationid?: 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) {
|
||||
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);
|
||||
const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned');
|
||||
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 });
|
||||
|
||||
// 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;
|
||||
|
||||
const rows = useMemo(
|
||||
() => allRows.filter((r) => inScope(r) && inBatch(r, batch)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[allRows, batch, locationid, usingMock],
|
||||
);
|
||||
|
||||
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 keyOf = (r: Row): { id: string; name: string } => {
|
||||
if (viewMode === 'riders' || viewMode === 'rider-info') {
|
||||
const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
||||
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) };
|
||||
}
|
||||
if (viewMode === 'kitchens') {
|
||||
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup';
|
||||
return { id: name.toLowerCase(), name };
|
||||
}
|
||||
if (viewMode === 'all') return { id: 'all', name: 'All Routes' };
|
||||
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
|
||||
return { id: name.toLowerCase(), name };
|
||||
};
|
||||
for (const r of rows) {
|
||||
const { id, name } = keyOf(r);
|
||||
let g = map.get(id);
|
||||
if (!g) {
|
||||
g = { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {} };
|
||||
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 = fstr(r.deliverysuburb);
|
||||
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);
|
||||
}, [rows, viewMode]);
|
||||
|
||||
const focused = groups.find((g) => g.id === focusedId) ?? null;
|
||||
const groupedByRider = viewMode === 'zones' || viewMode === 'kitchens' || viewMode === 'all';
|
||||
|
||||
// Trip blocks for the focused group: by trip# (rider view) or by rider (zone/all view).
|
||||
const tripBlocks = useMemo(() => {
|
||||
if (!focused) return [];
|
||||
const map = new Map<string, { label: string; color: string; orders: Row[] }>();
|
||||
for (const r of focused.orders) {
|
||||
let key: string;
|
||||
let label: string;
|
||||
let color: string;
|
||||
if (groupedByRider) {
|
||||
const rid = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
||||
key = rid;
|
||||
label = fstr(r.ridername) || fstr(r.username) || (rid === 'unassigned' ? 'Unassigned' : `Rider ${rid}`);
|
||||
color = colorFor(rid);
|
||||
} else {
|
||||
key = fstr(r.trip_number) || '1';
|
||||
label = `Trip ${key}`;
|
||||
color = focused.color;
|
||||
}
|
||||
let blk = map.get(key);
|
||||
if (!blk) { blk = { label, color, orders: [] }; map.set(key, 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);
|
||||
}
|
||||
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, 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.
|
||||
const mapPoints = useMemo<MapPoint[]>(() => {
|
||||
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: fstr(r.deliverycustomer) || `Order ${fstr(r.orderid)}`,
|
||||
subtitle: fstr(r.deliverysuburb) || 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;
|
||||
|
||||
// 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';
|
||||
|
||||
// Date chip helpers.
|
||||
const isToday = date === ymd(today);
|
||||
const dateObj = new Date(`${date}T00:00:00`);
|
||||
const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`;
|
||||
const shiftDate = (delta: number) => {
|
||||
const d = new Date(`${date}T00:00:00`);
|
||||
d.setDate(d.getDate() + delta);
|
||||
if (d > today) return;
|
||||
setDate(ymd(d));
|
||||
setFocusedId(null);
|
||||
};
|
||||
|
||||
const fmtTime = (raw: unknown): string => {
|
||||
const m = fstr(raw).match(/(\d{1,2}):(\d{2})/);
|
||||
return m ? `${m[1]}:${m[2]}` : '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0 }}>
|
||||
<div className="dispatch-container embedded">
|
||||
{/* ── Header ── */}
|
||||
<div id="hdr">
|
||||
<div className="logo">
|
||||
<div className="logo-badge">D</div>
|
||||
<div className="logo-name">Dispatch</div>
|
||||
<div className="logo-city-wrap">
|
||||
<span className="logo-city" style={{ cursor: 'default' }}>
|
||||
<MapPin size={13} />
|
||||
<span className="logo-city-text">Coimbatore</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hdr-stats">
|
||||
{deliveriesQ.isLoading ? (
|
||||
<span className="live-status">
|
||||
<span className="live-dot" /> Syncing
|
||||
</span>
|
||||
) : deliveriesQ.isError ? (
|
||||
<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
|
||||
</span>
|
||||
) : (
|
||||
<span className="live-status live-status-ready">
|
||||
<span className="live-dot ready" /> Live · {totalOrders} orders
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className={`date-chip${isToday ? ' is-today' : ''}`}>
|
||||
<button className="date-chip-nav" onClick={() => shiftDate(-1)} title="Previous day">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<div className="date-chip-main" style={{ position: 'relative' }}>
|
||||
<span className="date-chip-icon"><Calendar size={14} /></span>
|
||||
<span className="date-chip-text">
|
||||
<span className="date-chip-label">
|
||||
Date {isToday && <span className="date-chip-today-pill">Today</span>}
|
||||
</span>
|
||||
<span className="date-chip-value">{prettyDate}</span>
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
max={ymd(today)}
|
||||
onChange={(e) => { setDate(e.target.value); setFocusedId(null); }}
|
||||
style={{ position: 'absolute', inset: 0, opacity: 0, cursor: 'pointer', width: '100%', height: '100%' }}
|
||||
aria-label="Pick date"
|
||||
/>
|
||||
</div>
|
||||
<button className="date-chip-nav" onClick={() => shiftDate(1)} disabled={isToday} title="Next day">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── View-mode tabs ── */}
|
||||
<div id="strat-row">
|
||||
{VIEW_TABS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`sbt ${viewMode === t.id ? 'active' : ''}${t.id === 'rider-info' ? ' sbt-rider-info' : ''}`}
|
||||
onClick={() => { setViewMode(t.id); setFocusedId(null); }}
|
||||
>
|
||||
<span className="sbt-icon"><Icon size={15} /></span>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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
|
||||
className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`}
|
||||
onClick={() => setSidebarCollapsed((c) => !c)}
|
||||
title={sidebarCollapsed ? 'Show panel' : 'Hide panel'}
|
||||
>
|
||||
{sidebarCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div id="sidebar">
|
||||
<div className="sb-header">
|
||||
<div className="sb-header-top">
|
||||
<div className="sb-header-title">
|
||||
<span className="sb-title-bar" aria-hidden="true" />
|
||||
<span className="sb-title-text">RIDER DISPATCH</span>
|
||||
</div>
|
||||
<span className="sb-header-scope">
|
||||
<span className="sb-scope-dot" />
|
||||
{scopeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sb-header-tiles">
|
||||
<div className="sb-tile sb-tile-orders">
|
||||
<span className="sb-tile-icon"><Package size={16} /></span>
|
||||
<div className="sb-tile-body">
|
||||
<div className="sb-tile-value">{totalOrders}</div>
|
||||
<div className="sb-tile-label">Orders</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sb-tile sb-tile-riders">
|
||||
<span className="sb-tile-icon"><Bike size={16} /></span>
|
||||
<div className="sb-tile-body">
|
||||
<div className="sb-tile-value">{activeRiders}{fleetSize ? `/${fleetSize}` : ''}</div>
|
||||
<div className="sb-tile-label">Riders</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="riders-panel">
|
||||
{deliveriesQ.isLoading ? (
|
||||
<div className="ph">Loading dispatch feed…</div>
|
||||
) : focused ? (
|
||||
<FocusedDetail
|
||||
focused={focused}
|
||||
tripBlocks={tripBlocks}
|
||||
groupedByRider={groupedByRider}
|
||||
tripSort={tripSort}
|
||||
setTripSort={setTripSort}
|
||||
onBack={() => setFocusedId(null)}
|
||||
fmtTime={fmtTime}
|
||||
/>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="ph">No deliveries in this wave</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ph">
|
||||
{viewMode === 'riders' || viewMode === 'rider-info' ? 'Riders' : viewMode === 'kitchens' ? 'Pickup points' : viewMode === 'all' ? 'All routes' : 'Zones'} ({groups.length})
|
||||
</div>
|
||||
{groups.map((g) => (
|
||||
<React.Fragment key={g.id}>
|
||||
{viewMode === 'riders' || viewMode === 'rider-info'
|
||||
? <RiderCard g={g} onClick={() => setFocusedId(g.id)} />
|
||||
: <ZoneCard g={g} onClick={() => setFocusedId(g.id)} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map area */}
|
||||
<div id="map-wrap">
|
||||
{/* Live Leaflet route map */}
|
||||
<DispatchMap
|
||||
points={mapPoints}
|
||||
route={Boolean(focused)}
|
||||
routeColor={focused?.color || '#581c87'}
|
||||
start={routeStart}
|
||||
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}|${batch}`}
|
||||
animateNonce={animateNonce}
|
||||
/>
|
||||
|
||||
{/* Contextual note overlaid on the map */}
|
||||
{viewMode === 'rider-info' ? (
|
||||
<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.
|
||||
</div>
|
||||
) : !focused ? (
|
||||
<div className="dmp-overlay-note">
|
||||
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : 'rider'} to draw its route.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* bottom-right overlay controls (gated) */}
|
||||
<div id="ov-br">
|
||||
<button
|
||||
className={`sbt ${animating ? 'active' : ''}`}
|
||||
disabled={!focused || mapPoints.length < 2}
|
||||
onClick={() => {
|
||||
if (!focused || mapPoints.length < 2) return;
|
||||
setAnimating(true);
|
||||
setAnimateNonce((n) => n + 1);
|
||||
window.setTimeout(() => setAnimating(false), 2300);
|
||||
}}
|
||||
title={focused ? 'Replay the route draw' : 'Select a rider to animate its route'}
|
||||
>
|
||||
<span className="sbt-icon"><Play size={14} /></span> {animating ? 'Animating…' : 'Animate Routes'}
|
||||
</button>
|
||||
<button className="sbt" disabled title="Planned-vs-actual compare needs rider GPS telemetry (awaiting backend)">
|
||||
<span className="sbt-icon"><ArrowLeftRight size={14} /></span> Compare
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Rider card ───────────────────────────────────────────────────────────────────
|
||||
function RiderCard({ g, onClick }: { g: Group; onClick: () => void }) {
|
||||
const total = g.orders.length;
|
||||
const percent = total ? Math.round((g.delivered / total) * 100) : 0;
|
||||
const isDone = total > 0 && g.delivered === total;
|
||||
const zoneName = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 'Mixed';
|
||||
const trips = new Set(g.orders.map((o) => fstr(o.trip_number) || '1')).size;
|
||||
return (
|
||||
<div className="rcard" onClick={onClick}>
|
||||
<div className="rcard-top">
|
||||
<div className="rcard-emo" style={{ background: `${g.color}18`, color: g.color }}>
|
||||
<Bike size={18} />
|
||||
</div>
|
||||
<div className="rcard-info">
|
||||
<div className="rcard-name">{g.name}</div>
|
||||
<div className="rcard-zone">{zoneName} · {trips} trip{trips > 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div className={`rcard-badge ${isDone ? 'is-done' : ''}`}>{g.delivered}/{total}</div>
|
||||
</div>
|
||||
<div className="bar-bg">
|
||||
<div className="bar-fg" style={{ width: `${percent}%`, background: g.color }} />
|
||||
</div>
|
||||
<div className="rcard-meta">
|
||||
<span><Ruler size={11} /> {g.totalKm.toFixed(1)} km</span>
|
||||
{g.profit > 0 && <span><Wallet size={11} /> ₹{g.profit.toLocaleString('en-IN')}</span>}
|
||||
</div>
|
||||
<div className="step-ids">
|
||||
{g.orders.slice(0, 16).map((o, i) => (
|
||||
<span key={fstr(o.orderid) || i} className="step-id">S{fnum(o.step) || i + 1}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Zone card (also used for By Location / All Routes) ───────────────────────────
|
||||
function ZoneCard({ g, onClick }: { g: Group; onClick: () => void }) {
|
||||
const suburbs = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s);
|
||||
return (
|
||||
<div className="rcard zone-card" onClick={onClick}>
|
||||
<div className="zone-card-header">
|
||||
<div className="zone-card-emoji" style={{ color: g.color }}><MapIcon size={16} /></div>
|
||||
<div className="zone-card-titles">
|
||||
<div className="zone-card-name">{g.name}</div>
|
||||
<div className="zone-card-sub">{g.riders.size} rider{g.riders.size === 1 ? '' : 's'} · {g.orders.length} orders</div>
|
||||
</div>
|
||||
<span className="zone-card-arrow" aria-hidden="true">→</span>
|
||||
</div>
|
||||
{g.orders.length > 0 && (
|
||||
<div className="zone-progress-row">
|
||||
<div className="zone-status-bar">
|
||||
{Object.entries(g.statusCounts).map(([s, c]) => (
|
||||
<div key={s} className="zone-status-seg" style={{ flex: c, background: STATUS_HEX[s] || '#cbd5e1' }} title={`${s}: ${c}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="zone-progress-label">{g.delivered}/{g.orders.length}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="zone-stat-pills">
|
||||
<span className="zone-stat-pill">
|
||||
<span className="zone-stat-icon"><MapPin size={12} /></span>
|
||||
<span className="zone-stat-value">{g.suburbs.size}</span>
|
||||
<span className="zone-stat-label">areas</span>
|
||||
</span>
|
||||
<span className="zone-stat-pill">
|
||||
<span className="zone-stat-icon"><Ruler size={12} /></span>
|
||||
<span className="zone-stat-value">{g.totalKm.toFixed(0)}</span>
|
||||
<span className="zone-stat-label">km</span>
|
||||
</span>
|
||||
{g.profit > 0 && (
|
||||
<span className="zone-stat-pill">
|
||||
<span className="zone-stat-icon"><Wallet size={12} /></span>
|
||||
<span className="zone-stat-value">₹{g.profit.toLocaleString('en-IN')}</span>
|
||||
<span className="zone-stat-label">profit</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{suburbs.length > 0 && (
|
||||
<div className="zone-card-suburbs">
|
||||
<span className="zone-card-suburbs-text">{suburbs.slice(0, 3).join(' · ')}</span>
|
||||
{suburbs.length > 3 && <span className="zone-card-suburbs-more">+{suburbs.length - 3}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Focused detail (trip blocks + order cards) ───────────────────────────────────
|
||||
function FocusedDetail({
|
||||
focused,
|
||||
tripBlocks,
|
||||
groupedByRider,
|
||||
tripSort,
|
||||
setTripSort,
|
||||
onBack,
|
||||
fmtTime,
|
||||
}: {
|
||||
focused: Group;
|
||||
tripBlocks: Array<{ label: string; color: string; orders: Row[] }>;
|
||||
groupedByRider: boolean;
|
||||
tripSort: 'planned' | 'time';
|
||||
setTripSort: (v: 'planned' | 'time') => void;
|
||||
onBack: () => void;
|
||||
fmtTime: (raw: unknown) => string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<button className="sbt" onClick={onBack} style={{ marginBottom: 12 }}>
|
||||
<span className="sbt-icon"><ChevronLeft size={15} /></span> Back to list
|
||||
</button>
|
||||
|
||||
{tripBlocks.map((blk, bi) => (
|
||||
<div className="trip-block" key={bi}>
|
||||
<div className="trip-header" style={{ background: `${blk.color}12`, borderColor: `${blk.color}40` }}>
|
||||
<span className="th-badge" style={{ background: blk.color }}>{blk.label}</span>
|
||||
<span className="trip-stats">
|
||||
<span><MapPin size={11} /> {blk.orders.length} stops</span>
|
||||
<span><Ruler size={11} /> {blk.orders.reduce((a, o) => a + fnum(o.kms), 0).toFixed(1)} km</span>
|
||||
</span>
|
||||
<div className="trip-sort-toggle" role="group">
|
||||
<button className={`trip-sort-pill ${tripSort === 'planned' ? 'is-active' : ''}`} onClick={() => setTripSort('planned')}>
|
||||
<List size={12} /> <span>Planned</span>
|
||||
</button>
|
||||
<button className={`trip-sort-pill ${tripSort === 'time' ? 'is-active' : ''}`} onClick={() => setTripSort('time')}>
|
||||
<Clock size={12} /> <span>By time</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="zone-order-grid">
|
||||
{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 (
|
||||
<div className={`zone-order-card ${st === 'delivered' ? '' : 'is-pending-time'}`} key={fstr(o.deliveryid) || fstr(o.orderid) || i}>
|
||||
<div className="zone-order-card-head">
|
||||
<div className="zone-order-num" style={{ background: `${blk.color}15`, color: blk.color }}>{step}</div>
|
||||
<div className="zone-order-id-block">
|
||||
<div className="zone-order-id">Order #{fstr(o.orderid) || fstr(o.deliveryid)}</div>
|
||||
{groupedByRider && fstr(o.ridername) && (
|
||||
<div className="zone-order-rider"><Bike size={10} /> {fstr(o.ridername)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="zone-order-status-stack">
|
||||
{st && <span className="zone-order-status" style={statusStyle(st)}>{st}</span>}
|
||||
{(actual || expected) && (
|
||||
<span className={`zone-order-time ${actual ? '' : 'is-expected'}`}>
|
||||
<Clock size={10} /> {fmtTime(actual || expected)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="zone-order-customer"><Mailbox size={11} /> {fstr(o.deliverycustomer) || 'Customer'}</div>
|
||||
{fstr(o.pickupcustomer) && (
|
||||
<div className="zone-order-line"><Utensils size={11} /> {fstr(o.pickupcustomer)}</div>
|
||||
)}
|
||||
{(fstr(o.deliverysuburb) || fstr(o.deliveryaddress)) && (
|
||||
<div className="zone-order-line"><MapPin size={11} /> {fstr(o.deliverysuburb) || fstr(o.deliveryaddress)}</div>
|
||||
)}
|
||||
{fstr(o.ordernotes) && (
|
||||
<div className="zone-order-line zone-order-notes"><StickyNote size={11} /> {fstr(o.ordernotes)}</div>
|
||||
)}
|
||||
|
||||
<div className="zone-order-stats">
|
||||
<span className="zone-order-chip"><Ruler size={10} /> {km.toFixed(1)} km</span>
|
||||
{profit !== 0 && (
|
||||
<span className={`zone-order-chip ${profit < 0 ? 'is-loss' : 'is-profit'}`}>
|
||||
<Wallet size={10} /> ₹{Math.abs(profit).toLocaleString('en-IN')}
|
||||
</span>
|
||||
)}
|
||||
{charge > 0 && <span className="zone-order-chip">₹{charge} chg</span>}
|
||||
<span className="zone-order-chip zone-order-trip"><Crosshair size={10} /> S{step}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user