dispatch page
This commit is contained in:
@@ -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 (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';
|
||||
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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user