updates on the design and added the riders route page

This commit is contained in:
2026-06-01 15:16:45 +05:30
parent 73b82877b8
commit e662154916
6 changed files with 562 additions and 135 deletions

View File

@@ -192,7 +192,7 @@ const KPI_META = [
const BATCH_OPTIONS = [
{ id: 'all', label: 'All Batches', range: 'Across the day', color: '#7c3aed', iconKey: 'all' },
{ id: 'morning', label: 'Morning Batch', range: '12 AM to 8 AM', color: '#0ea5e9', iconKey: 'morning', startHour: 0, endHour: 8 },
{ id: 'afternoon', label: 'Afternoon Batch', range: '9 AM to 12 PM', color: '#f59e0b', iconKey: 'afternoon', startHour: 9, endHour: 12 },
{ id: 'afternoon', label: 'Afternoon Batch', range: '9 AM to 12:30 PM', color: '#f59e0b', iconKey: 'afternoon', startHour: 9, endHour: 12.5 },
{ id: 'evening', label: 'Evening Batch', range: '4 PM to 7 PM', color: '#6366f1', iconKey: 'evening', startHour: 16, endHour: 19 }
];

View File

@@ -3786,7 +3786,7 @@
background: rgba(15, 23, 42, 0.02);
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 12px;
padding: 10px 14px;
padding: 18px 18px;
gap: 16px;
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.02);
}
@@ -3840,16 +3840,11 @@
position: relative;
}
/* Planned track overrides to align vertically centered since there are no ticks */
.dispatch-container .compare-timeline-track.is-planned .compare-step {
gap: 0;
padding: 0;
height: 32px;
}
/* Planned track now also carries a time tick under the circle, so it uses the
same column layout as the actual row (circle stacked above the tick). */
.dispatch-container .compare-timeline-track.is-planned .compare-step-spacer {
margin-bottom: 0;
align-self: center;
margin-bottom: 22px;
/* Centers spacer dynamically relative to the 32px circle (matches actual). */
}
/* Actual track overrides for the spacer alignment */
@@ -6848,7 +6843,7 @@
}
.dispatch-container .compare-timeline-container {
padding: 6px 10px;
padding: 12px 12px;
gap: 12px;
border-radius: 8px;
}
@@ -6894,14 +6889,10 @@
background: rgba(99, 102, 241, 0.5);
}
/* Planned track overrides to align vertically centered since there are no ticks */
.dispatch-container .compare-timeline-track.is-planned .compare-step {
height: 24px;
}
/* Planned track now also carries a time tick under the circle, so the spacer
aligns the same way as the actual row (mirrors the 24px circle center). */
.dispatch-container .compare-timeline-track.is-planned .compare-step-spacer {
margin-bottom: 0;
align-self: center;
margin-bottom: 14px;
}
/* Actual track overrides for the spacer alignment */
@@ -7098,7 +7089,7 @@
}
.dispatch-container .compare-timeline-container {
padding: 6px 10px;
padding: 12px 12px;
gap: 12px;
border-radius: 8px;
}
@@ -7124,14 +7115,10 @@
padding-bottom: 2px;
}
/* Planned track overrides to align vertically centered since there are no ticks */
.dispatch-container .compare-timeline-track.is-planned .compare-step {
height: 24px;
}
/* Planned track now also carries a time tick under the circle, so the spacer
aligns the same way as the actual row (mirrors the 24px circle center). */
.dispatch-container .compare-timeline-track.is-planned .compare-step-spacer {
margin-bottom: 0;
align-self: center;
margin-bottom: 14px;
}
/* Actual track overrides for the spacer alignment */
@@ -7312,6 +7299,8 @@
gap: 12px;
padding: 12px;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
transition: grid-template-columns 0.32s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.dispatch-container #body.compare-mode #sidebar,
@@ -7319,6 +7308,89 @@
display: none !important;
}
/* Collapsed-data-panel state — drop the right column entirely so the map
claims the full body width. The panel itself is masked via overflow on
the body grid; the peek tab below stays visible to re-open. */
.dispatch-container #body.compare-mode.compare-data-collapsed {
grid-template-columns: minmax(0, 1fr) 0;
gap: 0;
}
.dispatch-container #body.compare-mode.compare-data-collapsed .compare-data-panel {
opacity: 0;
pointer-events: none;
transform: translateX(20px);
}
.dispatch-container .compare-data-panel {
transition: opacity 0.24s ease, transform 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Peek tab for the right-side compare data panel — vertical pill mirroring
the left sidebar's toggle, but anchored to the panel's left edge. Tracks
the panel by sitting at right:0 when expanded (so it hugs the panel's
outside-left edge) and snaps flush to the viewport's right side when
collapsed. */
.dispatch-container .compare-data-toggle-tab {
position: absolute;
top: 50%;
/* Anchor flush against the panel's outside-left edge. Panel max width is
440px (see compare-mode grid-template-columns above) plus the 12px grid
gap; transform: translate(50%, …) re-centres the 22-wide pill on that
boundary so half of it sits on the panel side and half on the map side
— same visual treatment as the left sidebar's peek tab. */
right: calc(440px + 12px);
transform: translate(50%, -50%);
width: 22px;
height: 56px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--border, rgba(15, 23, 42, 0.12));
border-radius: 10px;
background: #fff;
color: var(--text, #0f172a);
font-size: 18px;
line-height: 1;
cursor: pointer;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12),
0 1px 3px rgba(15, 23, 42, 0.06);
z-index: 1200;
transition: right 0.32s cubic-bezier(0.4, 0, 0.2, 1),
background 0.18s ease,
color 0.18s ease,
transform 0.18s ease,
box-shadow 0.18s ease;
}
.dispatch-container .compare-data-toggle-tab:hover {
background: linear-gradient(135deg, #6366f1, #3b82f6);
color: #fff;
transform: translate(50%, -50%) scale(1.06);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.35);
}
.dispatch-container .compare-data-toggle-tab:focus-visible {
outline: 2px solid var(--accent, #3b82f6);
outline-offset: 2px;
}
.dispatch-container .compare-data-toggle-tab.is-collapsed {
right: 0;
transform: translate(0, -50%);
border-radius: 10px 0 0 10px;
border-right: none;
}
.dispatch-container .compare-data-toggle-tab.is-collapsed:hover {
transform: translate(0, -50%) scale(1.06);
}
.dispatch-container .compare-data-toggle-tab svg {
display: block;
}
/* Header strip — sits above the unified map (row 1, col 1) and
carries the rider title, the step timeline + load progress, and
the layer legend. The Sync toggle was removed when the second
@@ -7973,6 +8045,12 @@
grid-row: 3;
max-height: 50vh;
}
/* Single-column layout stacks the panel BELOW the map, so the
side-anchored peek tab no longer makes geometric sense — hide it. */
.dispatch-container .compare-data-toggle-tab {
display: none;
}
}
/* Hide filter chrome when Compare takes over the screen — view-mode
@@ -9062,46 +9140,53 @@
}
/* ============================================================
Top-level Live / Analysis tabs (standalone Dispatch only)
Top-level Live / Analysis tabs (pinned inside header, left of profit)
============================================================ */
.dispatch-container #dispatch-top-tabs {
display: flex;
gap: 6px;
padding: 8px 12px 0 12px;
border-bottom: 1px solid var(--border);
background: var(--bg);
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
background: transparent;
flex-shrink: 0;
}
.dispatch-container #dispatch-top-tabs.dtt-inline {
margin-right: 4px;
}
.dispatch-container .dtt-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: none;
background: transparent;
font-size: 13px;
font-weight: 600;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg);
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
line-height: 1;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.dispatch-container .dtt-tab:hover {
color: var(--text);
background: var(--bg-sub);
}
.dispatch-container .dtt-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
color: #fff;
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.25);
}
.dispatch-container .dtt-icon {
display: inline-flex;
align-items: center;
font-size: 15px;
font-size: 14px;
}
/* ============================================================
@@ -9477,6 +9562,19 @@
gap: 8px;
}
.dispatch-container .da-rec.da-rec-empty {
background: #f8fafc;
border: 1px dashed #e2e8f0;
color: #64748b;
}
.dispatch-container .da-rec.da-rec-empty .da-rec-action {
color: #64748b;
text-transform: none;
font-weight: 500;
letter-spacing: 0;
}
.dispatch-container .da-rec-head {
display: flex;
justify-content: space-between;

View File

@@ -115,18 +115,18 @@ const hasValidPickup = (o) => Number.isFinite(toNum(pickupLat(o))) && Number.isF
// FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported.
// Three named batches, bucketed by assigntime per spec:
// • Morning Batch: before 8 AM (00:00 → 08:00)
// • Afternoon Batch: 9 AM → 12 PM (09:00 → 12:00)
// • Afternoon Batch: 9 AM → 12:30 PM (09:00 → 12:30)
// • Evening Batch: 4 PM → 7 PM (16:00 → 19:00)
// Gaps (89 AM, 12 PM4 PM, 7 PM+) intentionally fall outside every batch.
// Gaps (89 AM, 12:30 PM4 PM, 7 PM+) intentionally fall outside every batch.
const BATCHES_DEFAULT_RAW = [
{ id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 },
{ id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12 },
{ id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
{ id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 }
];
// v7: three-named-batch layout (Morning / Afternoon / Evening).
// Bumping the key drops cached 5-slot layouts from v6 and earlier.
const SLOTS_STORAGE_KEY = 'dispatch.slots.v7';
// v8: afternoon batch extended to 12:30 PM. Bumping from v7 wipes the
// cached layouts that still hold the old endHour: 12 value.
const SLOTS_STORAGE_KEY = 'dispatch.slots.v8';
// Every prior storage key. Wiped once on mount so stale layouts
// from earlier code versions can't reappear on the next page load.
@@ -136,7 +136,8 @@ const LEGACY_SLOTS_STORAGE_KEYS = [
'dispatch.slots.v3',
'dispatch.slots.v4',
'dispatch.slots.v5',
'dispatch.slots.v6'
'dispatch.slots.v6',
'dispatch.slots.v7'
];
// Build a label like "Slot 1 · 8 AM" (or "Slot 2 · 12:30 PM") from a
@@ -611,7 +612,7 @@ const Ico = ({ children }) => (
// to the X-Batch-Window header value the backend expects.
const ANALYSIS_BATCH_WINDOWS = [
{ key: 'morning', label: 'Morning', timeRange: '12:00 AM 8:00 AM', sub: 'Early shift orders', color: '#f59e0b', bg: '#fffbeb', border: '#fde68a' },
{ key: 'afternoon', label: 'Noon', timeRange: '9:00 AM 12:00 PM', sub: 'Lunch rush window', color: '#10b981', bg: '#ecfdf5', border: '#a7f3d0' },
{ key: 'afternoon', label: 'Noon', timeRange: '9:00 AM 12:30 PM', sub: 'Lunch rush window', color: '#10b981', bg: '#ecfdf5', border: '#a7f3d0' },
{ key: 'evening', label: 'Evening', timeRange: '4:00 PM 7:00 PM', sub: 'Dinner & end-of-day', color: '#6366f1', bg: '#eef2ff', border: '#c7d2fe' }
];
@@ -1041,6 +1042,12 @@ const Dispatch = ({
const preCompareCollapsedRef = useRef(false);
const prevCompareOpenRef = useRef(false);
// Compare-data-panel collapse — mirrors the left sidebar peek tab on the
// opposite edge so the operator can hide the right rail and let the map
// claim the full width during compare. Resets to expanded each time
// Compare opens fresh.
const [compareDataCollapsed, setCompareDataCollapsed] = useState(false);
// Compare UI — step focus on the unified compare map.
// focusedCompareStep: null = "overall" (whole day); 1..N = drill into
// that single delivery. The unified map zooms to that step's bounds
@@ -1725,6 +1732,9 @@ const Dispatch = ({
if (compareOpen && !prevCompareOpenRef.current) {
preCompareCollapsedRef.current = sidebarCollapsed;
setSidebarCollapsed(true);
// Fresh compare-open always reveals the data panel — last-collapsed state
// shouldn't carry across compare sessions.
setCompareDataCollapsed(false);
} else if (!compareOpen && prevCompareOpenRef.current) {
setSidebarCollapsed(preCompareCollapsedRef.current);
}
@@ -2726,6 +2736,24 @@ const Dispatch = ({
)}
</div>
)}
<div id="dispatch-top-tabs" className="dtt-inline">
<button
type="button"
className={`dtt-tab ${topView === 'live' ? 'active' : ''}`}
onClick={() => setTopView('live')}
>
<span className="dtt-icon"><MdMap /></span>
Live
</button>
<button
type="button"
className={`dtt-tab ${topView === 'analysis' ? 'active' : ''}`}
onClick={() => setTopView('analysis')}
>
<span className="dtt-icon"><MdInsights /></span>
Analysis
</button>
</div>
</div>
{/* Header right-cluster: profit/loss chip, total-orders pill, date picker.
@@ -2970,27 +2998,6 @@ const Dispatch = ({
</div>
)}
{!embedded && (
<div id="dispatch-top-tabs">
<button
type="button"
className={`dtt-tab ${topView === 'live' ? 'active' : ''}`}
onClick={() => setTopView('live')}
>
<span className="dtt-icon"><MdMap /></span>
Live
</button>
<button
type="button"
className={`dtt-tab ${topView === 'analysis' ? 'active' : ''}`}
onClick={() => setTopView('analysis')}
>
<span className="dtt-icon"><MdInsights /></span>
Analysis
</button>
</div>
)}
{(embedded || topView === 'live') && (<>
<div id="strat-row">
<button className={`sbt ${viewMode === 'kitchens' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: By Location'); setViewMode('kitchens'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPlace /></span> By Location</button>
@@ -3439,7 +3446,7 @@ const Dispatch = ({
</div>
</div>
) : (
<div id="body" className={`${sidebarCollapsed ? 'sidebar-collapsed' : ''} ${compareOpen ? 'compare-mode' : ''}`.trim()}>
<div id="body" className={`${sidebarCollapsed ? 'sidebar-collapsed' : ''} ${compareOpen ? 'compare-mode' : ''} ${compareOpen && compareDataCollapsed ? 'compare-data-collapsed' : ''}`.trim()}>
<button
type="button"
className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`}
@@ -3449,6 +3456,17 @@ const Dispatch = ({
>
{sidebarCollapsed ? <MdChevronRight /> : <MdChevronLeft />}
</button>
{compareOpen && focusedRider && (
<button
type="button"
className={`compare-data-toggle-tab${compareDataCollapsed ? ' is-collapsed' : ''}`}
onClick={() => setCompareDataCollapsed((v) => !v)}
title={compareDataCollapsed ? 'Show details panel' : 'Hide details panel'}
aria-label={compareDataCollapsed ? 'Show details panel' : 'Hide details panel'}
>
{compareDataCollapsed ? <MdChevronLeft /> : <MdChevronRight />}
</button>
)}
<div id="sidebar">
{/* Sidebar header — replaces the top-bar meta line. Hidden when a specific
rider is focused, since the focused-rider view already shows that rider's
@@ -4859,12 +4877,18 @@ const Dispatch = ({
title={
`Planned Step ${plannedStepNum}` +
(d.deliverycustomer ? ` · ${d.deliverycustomer}` : '') +
(d.expectedTs ? ` · ${d.expectedTs.format('hh:mm A')}` : '') +
(d.anomaly ? ' · deviation flagged' : '')
}
>
<span className="compare-step-circle">
{isLoading ? <span className="compare-step-spin" /> : plannedStepNum}
</span>
{d.expectedTs && (
<span className="compare-step-tick">
{d.expectedTs.format('HH:mm')}
</span>
)}
</button>
</React.Fragment>
);
@@ -5176,6 +5200,8 @@ const Dispatch = ({
const riders = Array.isArray(raw.rider_timelines) ? raw.rider_timelines : [];
const subs = Array.isArray(raw.substitution_opportunities) ? raw.substitution_opportunities : [];
const rec = raw.top_recommendation;
const hasRecRider = !!(rec && (rec.idle_rider_name || rec.idle_rider_id));
const hasRec = !!(rec && rec.action && rec.action !== 'none' && hasRecRider);
const win = raw.window || {};
const fleetMetrics = [
@@ -5240,7 +5266,7 @@ const Dispatch = ({
</div>
</div>
{rec && (
{hasRec ? (
<div className="da-section">
<div className="da-section-label">Top Recommendation</div>
<div className="da-rec">
@@ -5289,6 +5315,16 @@ const Dispatch = ({
)}
</div>
</div>
) : (
<div className="da-section">
<div className="da-section-label">Top Recommendation</div>
<div className="da-rec da-rec-empty">
<div className="da-rec-action">
<MdInsights />
<span>Fleet is balanced, no reassignment needed right now.</span>
</div>
</div>
</div>
)}
{riders.length > 0 && (

View File

@@ -37,7 +37,7 @@ const Login = () => {
useEffect(() => {
if (localStorage.getItem('firstname')) {
navigate('/nearle/orders');
navigate('/nearle/dispatch');
}
}, []);
@@ -97,7 +97,7 @@ const Login = () => {
localStorage.setItem('userid', userinfo.userid);
localStorage.setItem('userfcmtoken', userinfo.userfcmtoken);
fetchAppLocations(userinfo.userid);
navigate('/nearle/orders');
navigate('/nearle/dispatch');
} else {
OpenToast(res.data.message, 'error', 3000);
}
@@ -119,7 +119,7 @@ const Login = () => {
localStorage.setItem('userfcmtoken', userinfo.userfcmtoken);
closeGlobalToast(); // to close the pin snackbar
navigate('/nearle/orders');
navigate('/nearle/dispatch');
};
const opentoast = (message) => {

View File

@@ -1,69 +1,285 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { GoogleMap, Polyline, Marker, useJsApiLoader } from '@react-google-maps/api';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { GoogleMap, Polyline, Marker, InfoWindow, useJsApiLoader } from '@react-google-maps/api';
import { Box, IconButton, Stack, Typography, CircularProgress } from '@mui/material';
import { MdClose, MdRoute } from 'react-icons/md';
const containerStyle = {
width: '100%',
height: '100%'
};
const containerStyle = { width: '100%', height: '100%' };
export default function RidersRoutes({ details }) {
// Renders a single rider's PLANNED route for the date range chosen on the
// Riders Summary page. `details` is an ordered array of waypoints (sorted by
// the planning step number) shaped as:
// { step, orderid, deliveryid, customer, address,
// dropLat, dropLng, pickLat, pickLng, expectedTime }
// `dropLat/dropLng` are required; pickup coords are optional and rendered as
// faded pre-stops if present.
export default function RidersRoutes({ details, loading, riderName, dateRange, onClose }) {
const mapRef = useRef(null);
const [focusedStep, setFocusedStep] = useState(null);
const [routePath, setRoutePath] = useState([]);
const [routeLoading, setRouteLoading] = useState(false);
const { isLoaded } = useJsApiLoader({
googleMapsApiKey: process.env.REACT_APP_GOOGLE_MAPS_KEY
});
// Convert dataset
const routePath = useMemo(
() =>
details?.map((p) => ({
lat: Number(p.latitude),
lng: Number(p.longitude)
})),
// Step-pin coordinates in planning order — what the polyline connects.
const dropPath = useMemo(
() => (details || []).map((d) => ({ lat: d.dropLat, lng: d.dropLng })),
[details]
);
const bikeIcon = {
path: 'M12 2c-2.2 0-4 1.8-4 4v3H5l-1 2h2l3.6 7.59c.34.58.96.94 1.64.94h2.52c.68 0 1.3-.36 1.64-.94L19 11h2l-1-2h-3V6c0-2.2-1.8-4-4-4z',
fillColor: '#9c27b0', // 🔥 purple
fillOpacity: 1,
strokeWeight: 0,
scale: 1.4,
anchor: new window.google.maps.Point(12, 24)
// Auto-fit map bounds to the full planned path once the map and data are
// both ready. Re-runs whenever the route changes (different rider / date).
useEffect(() => {
if (!isLoaded || !mapRef.current || dropPath.length === 0) return;
const bounds = new window.google.maps.LatLngBounds();
dropPath.forEach((p) => bounds.extend(p));
mapRef.current.fitBounds(bounds, 48);
}, [isLoaded, dropPath]);
// Resolve the rider's planned waypoints into an actual road-following path
// via the Directions API. Without this, the polyline would cut across
// buildings / aerial lines — operators have no way to read the real route.
// Directions has a 25-waypoint limit per request, so we chunk and stitch.
useEffect(() => {
if (!isLoaded || dropPath.length < 2) {
setRoutePath([]);
return;
}
let cancelled = false;
const ds = new window.google.maps.DirectionsService();
const MAX_WPS = 23; // origin + 23 waypoints + destination = 25 stops/chunk
const fetchSegment = (origin, destination, waypoints) =>
new Promise((resolve, reject) => {
ds.route(
{
origin,
destination,
waypoints: waypoints.map((p) => ({ location: p, stopover: true })),
travelMode: window.google.maps.TravelMode.DRIVING
},
(result, status) => {
if (status === 'OK') resolve(result);
else reject(new Error(status));
}
);
});
(async () => {
setRouteLoading(true);
try {
const points = dropPath;
const all = [];
let i = 0;
while (i < points.length - 1) {
const remaining = points.length - 1 - i;
const take = Math.min(remaining, MAX_WPS + 1);
const origin = points[i];
const destination = points[i + take];
const waypoints = points.slice(i + 1, i + take);
const res = await fetchSegment(origin, destination, waypoints);
const seg = res.routes[0].overview_path.map((ll) => ({
lat: ll.lat(),
lng: ll.lng()
}));
// Avoid duplicating the join point between adjacent chunks.
if (all.length > 0 && seg.length > 0) seg.shift();
all.push(...seg);
i += take;
}
if (!cancelled) setRoutePath(all);
} catch {
// Fall back to the straight-line skeleton on failure (quota, no route, etc.).
if (!cancelled) setRoutePath([]);
} finally {
if (!cancelled) setRouteLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [isLoaded, dropPath]);
// Numbered step icon as a data URL — drawn fresh per render so we can pass
// the step number into the SVG without juggling external assets. Color is
// a fixed indigo to match the planned-route polyline below.
const stepIcon = (n, isFocused) => {
const size = isFocused ? 38 : 32;
const color = isFocused ? '#4338ca' : '#6366f1';
const svg = encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="${size}" height="${size}">` +
`<circle cx="16" cy="16" r="14" fill="${color}" stroke="white" stroke-width="3"/>` +
`<text x="16" y="21" text-anchor="middle" font-family="Arial,sans-serif" font-size="14" font-weight="700" fill="white">${n}</text>` +
`</svg>`
);
return `data:image/svg+xml;charset=UTF-8,${svg}`;
};
// Auto fit bounds
useEffect(() => {
if (!mapRef.current || routePath.length === 0) return;
const headerBar = (
<Stack
direction="row"
alignItems="center"
spacing={1.5}
sx={{
px: 2,
py: 1.25,
borderBottom: '1px solid rgba(15, 23, 42, 0.08)',
background: 'linear-gradient(135deg, #6366f1 0%, #3b82f6 100%)',
color: '#fff',
flexShrink: 0
}}
>
<MdRoute size={20} />
<Stack sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontWeight: 700, fontSize: 15, lineHeight: 1.2 }}>
Planned route{riderName ? `${riderName}` : ''}
</Typography>
{dateRange && (
<Typography sx={{ fontSize: 12, opacity: 0.85 }}>{dateRange}</Typography>
)}
</Stack>
{details && details.length > 0 && (
<Typography sx={{ fontSize: 12, opacity: 0.9, fontWeight: 600 }}>
{details.length} {details.length === 1 ? 'stop' : 'stops'}
{routeLoading ? ' · resolving route…' : ''}
</Typography>
)}
{onClose && (
<IconButton size="small" onClick={onClose} sx={{ color: '#fff' }} aria-label="Close">
<MdClose />
</IconButton>
)}
</Stack>
);
const bounds = new window.google.maps.LatLngBounds();
routePath.forEach((p) => bounds.extend(p));
mapRef.current.fitBounds(bounds);
}, [routePath]);
// Loading state — route fetch in flight OR Google Maps script not ready yet.
if (loading || !isLoaded) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{headerBar}
<Stack alignItems="center" justifyContent="center" sx={{ flex: 1, gap: 1.5 }}>
<CircularProgress size={32} />
<Typography sx={{ color: '#64748b', fontSize: 13 }}>
{loading ? 'Loading planned route…' : 'Loading map…'}
</Typography>
</Stack>
</Box>
);
}
if (!isLoaded) return <div>Loading map...</div>;
// Empty state — fetched but rider has no deliveries with drop coords in the
// selected window.
if (!details || details.length === 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{headerBar}
<Stack alignItems="center" justifyContent="center" sx={{ flex: 1, gap: 1, p: 3 }}>
<Typography sx={{ color: '#1e293b', fontWeight: 700, fontSize: 16 }}>
No planned route for this rider
</Typography>
<Typography sx={{ color: '#64748b', fontSize: 13, textAlign: 'center', maxWidth: 360 }}>
There are no deliveries with drop coordinates assigned to this rider for the selected date range.
</Typography>
</Stack>
</Box>
);
}
return (
<GoogleMap mapContainerStyle={containerStyle} onLoad={(map) => (mapRef.current = map)} center={routePath[0]} zoom={16}>
{/* Route line */}
<Polyline
path={routePath}
options={{
strokeColor: '#196fd2',
strokeOpacity: 0.9,
strokeWeight: 5
}}
/>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{headerBar}
<Box sx={{ flex: 1, minHeight: 0 }}>
<GoogleMap
mapContainerStyle={containerStyle}
onLoad={(map) => (mapRef.current = map)}
center={dropPath[0]}
zoom={14}
options={{
streetViewControl: false,
mapTypeControl: false,
fullscreenControl: false
}}
>
{routePath.length > 0 ? (
<>
{/* Translucent backdrop so the route stays legible on busy tiles. */}
<Polyline
path={routePath}
options={{ strokeColor: '#6366f1', strokeOpacity: 0.25, strokeWeight: 8 }}
/>
{/* Road-following planned route from the Directions API. */}
<Polyline
path={routePath}
options={{ strokeColor: '#6366f1', strokeOpacity: 0.95, strokeWeight: 4 }}
/>
</>
) : (
// Fallback while Directions is in flight (or if it fails) — dashed
// straight-line skeleton between drop pins in step order.
<Polyline
path={dropPath}
options={{
strokeColor: '#6366f1',
strokeOpacity: 0,
strokeWeight: 0,
icons: [
{
icon: {
path: 'M 0,-1 0,1',
strokeOpacity: 0.6,
strokeColor: '#6366f1',
scale: 3
},
offset: '0',
repeat: '14px'
}
]
}}
/>
)}
{/* Start marker */}
<Marker
position={routePath[0]}
icon={{
url: 'http://maps.google.com/mapfiles/ms/icons/green-dot.png'
}}
/>
{/* End marker */}
<Marker position={routePath[routePath.length - 1]} icon={bikeIcon} />
</GoogleMap>
{details.map((d, i) => {
const stepNum = d.step || i + 1;
const isFocused = focusedStep === d.deliveryid;
return (
<Marker
key={`step-${d.deliveryid || d.orderid || i}`}
position={{ lat: d.dropLat, lng: d.dropLng }}
icon={{ url: stepIcon(stepNum, isFocused) }}
onClick={() => setFocusedStep(isFocused ? null : d.deliveryid)}
zIndex={isFocused ? 1000 : stepNum}
>
{isFocused && (
<InfoWindow onCloseClick={() => setFocusedStep(null)}>
<Box sx={{ minWidth: 180, fontFamily: 'inherit' }}>
<Typography sx={{ fontWeight: 800, fontSize: 13, color: '#0f172a' }}>
Step {stepNum} · {d.customer}
</Typography>
{d.address && (
<Typography sx={{ fontSize: 12, color: '#475569', mt: 0.5 }}>
{d.address}
</Typography>
)}
{d.expectedTime && (
<Typography sx={{ fontSize: 12, color: '#64748b', mt: 0.5 }}>
ETA {String(d.expectedTime).slice(11, 16) || d.expectedTime}
</Typography>
)}
{d.orderid && (
<Typography sx={{ fontSize: 11, color: '#94a3b8', mt: 0.5 }}>
Order #{d.orderid}
</Typography>
)}
</Box>
</InfoWindow>
)}
</Marker>
);
})}
</GoogleMap>
</Box>
</Box>
);
}

View File

@@ -193,6 +193,8 @@ export default function RidersSummary() {
const [loading, setLoading] = useState(false);
const [mapOpen, setMapOpen] = useState(false);
const [logDetails, setLogDetails] = useState(null);
const [selectedRider, setSelectedRider] = useState(null);
const [routeLoading, setRouteLoading] = useState(false);
const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
@@ -244,15 +246,71 @@ export default function RidersSummary() {
}
};
// ==============================|| rider delivery logs (for map) ||============================== //
// ==============================|| rider planned route (for map) ||============================== //
// Pulls every delivery the rider was assigned over the page's date range, then
// emits an ordered waypoint list sorted by `step` (the planning sequence). The
// map dialog renders this as the rider's PLANNED route — the path the
// optimizer told them to follow — not their actual GPS trail.
const getuserdeliverylogs = async (userid) => {
setRouteLoading(true);
try {
const response = await axios.get(
`${process.env.REACT_APP_URL}/deliveries/getuserdeliverylogs/?userid=${userid}&fromdate=2026-01-28&todate=2026-01-28 `
);
setLogDetails(response.data.details);
// /deliveries/getdeliveries treats applocationid=0 differently from a
// real location id — when appId===0 ("All") the backend expects the
// logged-in operator's userid via appuserid instead. Mirrors the
// branching in api.js#fetchDeliveries.
const loggedInUserId = typeof window !== 'undefined' ? localStorage.getItem('userid') || 0 : 0;
const scopeParam = appId === 0
? `appuserid=${loggedInUserId}`
: `applocationid=${appId}`;
const url =
`${process.env.REACT_APP_URL}/deliveries/getdeliveries/` +
`?${scopeParam}` +
`&status=all` +
`&fromdate=${startdate}` +
`&todate=${enddate}` +
`&pageno=1` +
`&pagesize=200` +
`&keyword=` +
`&tenantid=` +
`&locationid=` +
`&userid=${userid}`;
const response = await axios.get(url);
const rowsRaw = response?.data?.details || [];
const toNum = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
const planned = rowsRaw
.map((o) => {
const dropLat = toNum(o.droplat ?? o.deliverylat);
const dropLng = toNum(o.droplon ?? o.deliverylong);
const pickLat = toNum(o.pickuplat ?? o.pickuplatitude);
const pickLng = toNum(o.pickuplon ?? o.pickuplong ?? o.picklongitude);
if (dropLat == null || dropLng == null) return null;
return {
step: Number(o.step) || 0,
orderid: o.orderid,
deliveryid: o.deliveryid,
customer: o.deliverycustomer || o.customername || `Order ${o.orderid}`,
address: o.deliveryaddress || o.deliverysuburb || '',
dropLat,
dropLng,
pickLat: pickLat ?? null,
pickLng: pickLng ?? null,
// Expected delivery clock — used as a label under the step pin so
// the operator can sanity-check sequencing without clicking each
// marker.
expectedTime: o.expecteddeliverytime || null
};
})
.filter(Boolean)
.sort((a, b) => a.step - b.step);
setLogDetails(planned);
} catch (err) {
OpenToast(err?.message, 'error', 2000);
setLogDetails([]);
} finally {
setRouteLoading(false);
}
};
@@ -668,10 +726,15 @@ export default function RidersSummary() {
<TableCell align="right">
<Stack direction="row" spacing={0.75} justifyContent="flex-end">
<Tooltip title="View route" placement="top">
<Tooltip title="View planned route" placement="top">
<IconButton
size="small"
onClick={() => {
setSelectedRider({
userid: row?.userid,
name: `${row?.firstname || ''} ${row?.lastname || ''}`.trim() || `Rider ${row?.userid}`
});
setLogDetails(null);
setMapOpen(true);
getuserdeliverylogs(row?.userid);
}}
@@ -921,9 +984,23 @@ export default function RidersSummary() {
open={mapOpen}
onClose={() => {
setMapOpen(false);
setLogDetails(null);
setSelectedRider(null);
}}
>
<DialogContent>{logDetails && <RidersRoutes details={logDetails} />}</DialogContent>
<DialogContent sx={{ p: 0, height: '100vh' }}>
<RidersRoutes
details={logDetails}
loading={routeLoading}
riderName={selectedRider?.name}
dateRange={`${dayjs(startdate).format('DD/MM/YY')} ${dayjs(enddate).format('DD/MM/YY')}`}
onClose={() => {
setMapOpen(false);
setLogDetails(null);
setSelectedRider(null);
}}
/>
</DialogContent>
</Dialog>
{/* ============================================= || Date Filter Dialog || ============================================= */}