updates on the design and added the riders route page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 || ============================================= */}
|
||||
|
||||
Reference in New Issue
Block a user