- Added DateRangePicker component for selecting date ranges in OrderAssignment. - Updated OrderAssignment to fetch bookings based on selected date range. - Enhanced status handling in OrderAssignment with new status chip display logic. - Refactored KPI card implementation in RiderRoutes and Riders to use shared StatCard component. - Improved ProfileDrawer to retain rider data during close transition. - Fixed minor text formatting in Routing component.
847 lines
51 KiB
JavaScript
847 lines
51 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||
import {
|
||
Box, Typography, Card, Avatar, Chip, Stack, Button, Grid,
|
||
IconButton, List, ListItemButton, ListItemText, Collapse, Tooltip, Divider,
|
||
LinearProgress, Menu, MenuItem, Drawer, Paper
|
||
} from '@mui/material';
|
||
import { alpha } from '@mui/material/styles';
|
||
import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip as LTooltip, useMap } from 'react-leaflet';
|
||
import L from 'leaflet';
|
||
import 'leaflet/dist/leaflet.css';
|
||
|
||
import StatCard from '@/components/StatCard';
|
||
import { getRiderRoutes } from '@/api/hub';
|
||
import { getHubContext } from '@/auth/session';
|
||
|
||
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
|
||
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
|
||
import PauseRoundedIcon from '@mui/icons-material/PauseRounded';
|
||
import ReplayRoundedIcon from '@mui/icons-material/ReplayRounded';
|
||
import SpeedRoundedIcon from '@mui/icons-material/SpeedRounded';
|
||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
|
||
import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded';
|
||
import ExpandLessRoundedIcon from '@mui/icons-material/ExpandLessRounded';
|
||
import WarehouseRoundedIcon from '@mui/icons-material/WarehouseRounded';
|
||
import FlagRoundedIcon from '@mui/icons-material/FlagRounded';
|
||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
|
||
import CancelRoundedIcon from '@mui/icons-material/CancelRounded';
|
||
import DeliveryDiningRoundedIcon from '@mui/icons-material/DeliveryDiningRounded';
|
||
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
|
||
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||
import StraightenRoundedIcon from '@mui/icons-material/StraightenRounded';
|
||
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
|
||
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
|
||
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
||
import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
|
||
import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined';
|
||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||
import ScaleOutlinedIcon from '@mui/icons-material/ScaleOutlined';
|
||
import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined';
|
||
import NotesOutlinedIcon from '@mui/icons-material/NotesOutlined';
|
||
import CallOutlinedIcon from '@mui/icons-material/CallOutlined';
|
||
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined';
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════════
|
||
// Leaflet base-icon fix (same approach as TrackingMap.jsx)
|
||
// ════════════════════════════════════════════════════════════════════════════════
|
||
delete L.Icon.Default.prototype._getIconUrl;
|
||
L.Icon.Default.mergeOptions({
|
||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||
});
|
||
|
||
// Keeps Leaflet's canvas sized correctly inside a flex layout / on sidebar toggle.
|
||
function MapResizeHandler() {
|
||
const map = useMap();
|
||
useEffect(() => {
|
||
const fix = () => map.invalidateSize();
|
||
const t = setTimeout(fix, 250);
|
||
window.addEventListener('resize', fix);
|
||
const ro = new ResizeObserver(fix);
|
||
ro.observe(map.getContainer());
|
||
return () => { clearTimeout(t); window.removeEventListener('resize', fix); ro.disconnect(); };
|
||
}, [map]);
|
||
return null;
|
||
}
|
||
|
||
// Imperatively fits the map to a set of latlng points whenever they change.
|
||
function FitBounds({ points }) {
|
||
const map = useMap();
|
||
useEffect(() => {
|
||
if (!points || points.length === 0) return;
|
||
const bounds = L.latLngBounds(points.map((p) => [p.lat, p.lng]));
|
||
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15 });
|
||
}, [points, map]);
|
||
return null;
|
||
}
|
||
|
||
// Pans/zooms to a single point when an order is focused (clicked).
|
||
function FlyTo({ target }) {
|
||
const map = useMap();
|
||
useEffect(() => {
|
||
if (target) map.flyTo([target.lat, target.lng], Math.max(map.getZoom(), 14), { duration: 0.6 });
|
||
}, [target, map]);
|
||
return null;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════════
|
||
// Leaflet DivIcons
|
||
// ════════════════════════════════════════════════════════════════════════════════
|
||
const hubIcon = new L.DivIcon({
|
||
className: 'rr-icon',
|
||
html: `<div style="background:#C01227;width:34px;height:34px;border-radius:10px;border:3px solid #fff;box-shadow:0 4px 12px rgba(0,0,0,.25);display:flex;align-items:center;justify-content:center;">
|
||
<svg fill="#fff" width="18" height="18" viewBox="0 0 24 24"><path d="M12 3 2 8l10 5 10-5-10-5Zm0 7L4.21 6.11 12 2.22l7.79 3.89L12 10Z"/><path d="M2 12l10 5 10-5M2 16l10 5 10-5" stroke="#fff" stroke-width="1.6" fill="none"/></svg>
|
||
</div>`,
|
||
iconSize: [34, 34],
|
||
iconAnchor: [17, 17],
|
||
});
|
||
|
||
const flagIcon = new L.DivIcon({
|
||
className: 'rr-icon',
|
||
html: `<div style="background:#1E8E3E;width:30px;height:30px;border-radius:50%;border:3px solid #fff;box-shadow:0 4px 10px rgba(0,0,0,.25);display:flex;align-items:center;justify-content:center;">
|
||
<svg fill="#fff" width="15" height="15" viewBox="0 0 24 24"><path d="M14.4 6 14 4H5v17h2v-7h5.6l.4 2h7V6z"/></svg>
|
||
</div>`,
|
||
iconSize: [30, 30],
|
||
iconAnchor: [15, 15],
|
||
});
|
||
|
||
// Numbered stop pin in the rider's colour. Pickups get a square pin, deliveries a
|
||
// round one — so the two journey types are distinguishable at a glance on the map.
|
||
const stopIcon = (n, color, focused, isPickup) => new L.DivIcon({
|
||
className: 'rr-icon',
|
||
html: `<div style="background:${color};width:${focused ? 30 : 24}px;height:${focused ? 30 : 24}px;border-radius:${isPickup ? '6px' : '50%'};border:2.5px solid #fff;box-shadow:0 3px 8px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;color:#fff;font-family:Arial,sans-serif;font-weight:700;font-size:${focused ? 14 : 12}px;">${n}</div>`,
|
||
iconSize: focused ? [30, 30] : [24, 24],
|
||
iconAnchor: focused ? [15, 15] : [12, 12],
|
||
});
|
||
|
||
// The animated "moving rider" puck in the rider's colour.
|
||
const moverIcon = (color) => new L.DivIcon({
|
||
className: 'rr-icon rr-mover',
|
||
html: `<div style="background:${color};width:30px;height:30px;border-radius:50%;border:3px solid #fff;box-shadow:0 0 0 6px ${color}33, 0 4px 12px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;">
|
||
<svg fill="#fff" width="17" height="17" viewBox="0 0 24 24"><path d="M19 7c0-1.1-.9-2-2-2h-3v2h3v2.65L13.52 14H10V9H6c-2.21 0-4 1.79-4 4v3h2c0 1.66 1.34 3 3 3s3-1.34 3-3h4.48L19 10.35V7ZM7 17c-.55 0-1-.45-1-1h2c0 .55-.45 1-1 1Z"/></svg>
|
||
</div>`,
|
||
iconSize: [30, 30],
|
||
iconAnchor: [15, 15],
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════════
|
||
// Rider routes — loaded live from GET /hub/rider-routes (see the load effect below).
|
||
// Each rider has a PICKUP trip for the day with its own ordered stops and distance;
|
||
// every order stop carries the detail shown in the side drawer when clicked.
|
||
//
|
||
// Pickup: merchant collections → hub.
|
||
// ════════════════════════════════════════════════════════════════════════════════
|
||
// The hub all pickup legs return to. Label comes from the logged-in hub context;
|
||
// the coordinate is only the initial map centre (the map auto-fits to the stops).
|
||
const HUB = { lat: 11.0168, lng: 76.9558, label: getHubContext().hubname || 'Hub' };
|
||
|
||
// Palette assigned to milers round-robin so each route line is a distinct colour.
|
||
const ROUTE_COLORS = ['#1A73E8', '#8E24AA', '#1E8E3E', '#E8710A', '#C01227', '#00838F'];
|
||
|
||
// Map an API rider-route (from GET /hub/rider-routes) into the structure this
|
||
// page renders: a `pickup` trip with a list of order stops. Fields the API does
|
||
// not provide (customer, weight, COD, slot, instructions) default gracefully.
|
||
const mapRoute = (r, i) => {
|
||
// Be tolerant of backend field-name variants: the stops list, coordinates, ids
|
||
// and distance can arrive under a few different keys. `??` only falls back when
|
||
// the primary key is missing, so the documented shape still works unchanged.
|
||
const apiStops = [r.stops, r.route, r.pickups, r.orders, r.legs].find(Array.isArray) || [];
|
||
const mileruserid = r.mileruserid ?? r.userid ?? r.miler_user_id ?? r.id;
|
||
const stops = apiStops.map((s) => {
|
||
const bookingid = s.bookingid ?? s.consignmentid ?? s.booking_id;
|
||
return {
|
||
kind: 'order',
|
||
orderId: bookingid != null ? `BK-${bookingid}` : `Stop ${s.seq ?? ''}`,
|
||
bookingid,
|
||
customer: s.customer || s.sendername || s.name || '',
|
||
phone: s.phone || '',
|
||
address: s.address || '—',
|
||
lat: s.lat ?? s.latitude,
|
||
lng: s.lon ?? s.lng ?? s.longitude,
|
||
time: s.eta_minutes != null ? `${s.eta_minutes} min` : '',
|
||
status: s.status === 'completed' ? 'Picked' : s.status === 'in_progress' ? 'In progress' : 'Pending',
|
||
// Optional fields: keep whatever the API sends; `null`/'' means "unknown" and
|
||
// the UI hides it rather than showing a fake 0. No placeholder/mock values.
|
||
items: s.items ?? s.parcels ?? null,
|
||
weight: s.weight ?? '',
|
||
// Backend (buildMilerRoute) emits `timeslot` and `legdistance_km`; keep the
|
||
// other aliases as fallbacks in case the shape ever changes.
|
||
slot: s.timeslot ?? s.slot ?? s.time_slot ?? '',
|
||
cod: s.cod ?? s.cod_amount ?? 0,
|
||
payment: s.payment ?? s.payment_mode ?? '',
|
||
legKm: s.legdistance_km ?? s.legKm ?? s.leg_km ?? s.distance_km ?? null,
|
||
instructions: s.instructions ?? s.notes ?? ''
|
||
};
|
||
});
|
||
return {
|
||
id: `RDR-${mileruserid}`,
|
||
mileruserid,
|
||
name: r.milername || r.displayname || r.name || `Miler ${mileruserid}`,
|
||
color: ROUTE_COLORS[i % ROUTE_COLORS.length],
|
||
vehicle: '—',
|
||
vehicleNo: '—',
|
||
phone: '',
|
||
pickup: {
|
||
startTime: '',
|
||
endTime: '',
|
||
distanceKm: r.totaldistance_km ?? r.total_distance_km ?? r.distance_km ?? r.distance ?? 0,
|
||
stops
|
||
}
|
||
};
|
||
};
|
||
|
||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||
const initials = (n) => n.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
|
||
const inr = (n) => `₹${(Number(n) || 0).toLocaleString('en-IN')}`;
|
||
const lerpPoint = (a, b, t) => ({ lat: a.lat + (b.lat - a.lat) * t, lng: a.lng + (b.lng - a.lng) * t });
|
||
|
||
const STATUS_META = {
|
||
Picked: { color: '#1A73E8', icon: CheckCircleRoundedIcon, label: 'Picked up' },
|
||
Missed: { color: '#D93025', icon: CancelRoundedIcon, label: 'Missed' },
|
||
'In progress': { color: '#F29900', icon: DeliveryDiningRoundedIcon, label: 'In progress' },
|
||
Pending: { color: '#80868B', icon: ScheduleOutlinedIcon, label: 'Pending' },
|
||
};
|
||
|
||
const MODES = {
|
||
pickup: { label: 'Pickups', icon: StorefrontOutlinedIcon, doneLabel: 'Picked up', doneStatus: 'Picked', failStatus: 'Missed', pointLabel: 'Pickup', dash: '8 8' },
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════════
|
||
// Route resolution — OSRM road geometry (free, no API key); straight-line fallback.
|
||
// ════════════════════════════════════════════════════════════════════════════════
|
||
async function fetchRoadRoute(stops) {
|
||
const coords = stops.map((s) => `${s.lng},${s.lat}`).join(';');
|
||
const url = `https://router.project-osrm.org/route/v1/driving/${coords}?overview=full&geometries=geojson`;
|
||
const res = await fetch(url);
|
||
const json = await res.json();
|
||
if (json.routes && json.routes[0]) {
|
||
return json.routes[0].geometry.coordinates.map(([lng, lat]) => ({ lat, lng }));
|
||
}
|
||
throw new Error('No route');
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════════
|
||
// Small presentational pieces
|
||
// ════════════════════════════════════════════════════════════════════════════════
|
||
// KPI card — aliased to the shared StatCard so this strip matches every other page.
|
||
const KpiCard = (props) => <StatCard {...props} />;
|
||
|
||
function DetailRow({ icon: Icon, label, value, valueColor }) {
|
||
if (value === undefined || value === null || value === '') return null;
|
||
return (
|
||
<Stack direction="row" spacing={2} alignItems="flex-start">
|
||
<Icon sx={{ fontSize: 20, color: '#9AA0A6', mt: 0.25 }} />
|
||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{label}</Typography>
|
||
<Typography sx={{ fontWeight: 600, color: valueColor || '#343A40', wordBreak: 'break-word' }}>{value}</Typography>
|
||
</Box>
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
// Full order/pickup detail panel — rendered INLINE in the left column (not a modal)
|
||
// so the map stays visible and zoomed to the stop while these details are read.
|
||
function OrderDetailPanel({ data, onBack }) {
|
||
const { order, rider, mode, index } = data;
|
||
const meta = STATUS_META[order.status] || {};
|
||
const StatusIcon = meta.icon || CheckCircleRoundedIcon;
|
||
const isPickup = mode === 'pickup';
|
||
|
||
return (
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', bgcolor: '#fff' }}>
|
||
{/* Header */}
|
||
<Box sx={{ background: `linear-gradient(135deg, ${rider.color} 0%, ${alpha(rider.color, 0.82)} 100%)`, px: 2.75, py: 2.25, color: '#fff', flexShrink: 0 }}>
|
||
<Button onClick={onBack} startIcon={<ArrowBackRoundedIcon />} size="small"
|
||
sx={{ color: 'rgba(255,255,255,0.95)', textTransform: 'none', fontWeight: 600, mb: 1.25, ml: -0.5, '&:hover': { bgcolor: 'rgba(255,255,255,0.12)' } }}>
|
||
Close
|
||
</Button>
|
||
<Stack direction="row" alignItems="center" spacing={0.75} useFlexGap sx={{ flexWrap: 'wrap', mb: 1 }}>
|
||
<Chip size="small" icon={(isPickup ? <StorefrontOutlinedIcon /> : <DeliveryDiningRoundedIcon />)} label={isPickup ? 'Pickup' : 'Delivery'}
|
||
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700, '& .MuiChip-icon': { color: '#fff' } }} />
|
||
<Chip size="small" icon={<StatusIcon sx={{ color: '#fff !important' }} />} label={meta.label || order.status}
|
||
sx={{ bgcolor: 'rgba(255,255,255,0.18)', color: '#fff', fontWeight: 700 }} />
|
||
</Stack>
|
||
<Typography variant="h6" sx={{ fontWeight: 800, lineHeight: 1.25 }}>{order.customer}</Typography>
|
||
<Typography variant="body2" sx={{ opacity: 0.9, fontFamily: 'monospace', mt: 0.25 }}>#{order.orderId}</Typography>
|
||
</Box>
|
||
|
||
{/* Body */}
|
||
<Box sx={{ p: 2, bgcolor: '#F8F9FB', flex: 1, overflow: 'auto' }}>
|
||
<Stack spacing={1.75}>
|
||
|
||
{/* Contact */}
|
||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}>
|
||
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}>
|
||
{isPickup ? 'MERCHANT / SENDER' : 'CUSTOMER'}
|
||
</Typography>
|
||
<Stack spacing={1.75}>
|
||
<DetailRow icon={isPickup ? StorefrontOutlinedIcon : PersonOutlineOutlinedIcon} label="Name" value={order.customer} />
|
||
<DetailRow icon={PhoneOutlinedIcon} label="Phone" value={order.phone} />
|
||
<DetailRow icon={PlaceOutlinedIcon} label="Pickup address" value={order.address} />
|
||
</Stack>
|
||
</Paper>
|
||
|
||
{/* Pickup summary */}
|
||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}>
|
||
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}>
|
||
PICKUP SUMMARY
|
||
</Typography>
|
||
<Grid container rowSpacing={2} columnSpacing={1.5}>
|
||
<Grid item xs={6}><DetailRow icon={AccessTimeOutlinedIcon} label="Picked at" value={order.time} /></Grid>
|
||
<Grid item xs={6}><DetailRow icon={ScheduleOutlinedIcon} label="Time slot" value={order.slot} /></Grid>
|
||
<Grid item xs={6}><DetailRow icon={Inventory2OutlinedIcon} label="Items" value={order.items != null ? `${order.items} ${order.items === 1 ? 'parcel' : 'parcels'}` : ''} /></Grid>
|
||
<Grid item xs={6}><DetailRow icon={ScaleOutlinedIcon} label="Weight" value={order.weight} /></Grid>
|
||
<Grid item xs={6}><DetailRow icon={StraightenRoundedIcon} label="Leg distance" value={order.legKm != null ? `${order.legKm} km` : ''} /></Grid>
|
||
<Grid item xs={6}><DetailRow icon={PaymentsOutlinedIcon} label="Payment" value={order.payment} /></Grid>
|
||
</Grid>
|
||
</Paper>
|
||
|
||
{order.cod > 0 && (
|
||
<Box sx={{ p: 2, borderRadius: 2, bgcolor: alpha('#F29900', 0.1), border: `1px solid ${alpha('#F29900', 0.3)}` }}>
|
||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||
<PaymentsOutlinedIcon sx={{ color: '#B06000' }} />
|
||
<Box>
|
||
<Typography variant="caption" sx={{ color: '#B06000', fontWeight: 700 }}>CASH ON PICKUP</Typography>
|
||
<Typography sx={{ fontWeight: 800, color: '#B06000' }}>{inr(order.cod)}</Typography>
|
||
</Box>
|
||
</Stack>
|
||
</Box>
|
||
)}
|
||
|
||
{/* Route & location */}
|
||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}>
|
||
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}>
|
||
ROUTE & LOCATION
|
||
</Typography>
|
||
<Stack spacing={1.75}>
|
||
<DetailRow icon={isPickup ? StorefrontOutlinedIcon : WarehouseRoundedIcon} label="Route leg"
|
||
value={isPickup ? `${order.customer} → ${HUB.label}` : `${HUB.label} → ${order.customer}`} />
|
||
<DetailRow icon={MyLocationOutlinedIcon} label="Coordinates"
|
||
value={order.lat != null && order.lng != null ? `${order.lat.toFixed(4)}, ${order.lng.toFixed(4)}` : ''} />
|
||
{order.instructions && <DetailRow icon={NotesOutlinedIcon} label="Instructions" value={order.instructions} valueColor="#5F6368" />}
|
||
</Stack>
|
||
</Paper>
|
||
|
||
{/* Assigned miler */}
|
||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#fff' }}>
|
||
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, fontSize: '0.7rem', display: 'block', mb: 1.5 }}>
|
||
ASSIGNED MILER
|
||
</Typography>
|
||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||
<Avatar sx={{ width: 40, height: 40, bgcolor: alpha(rider.color, 0.14), color: rider.color, fontWeight: 700 }}>{initials(rider.name)}</Avatar>
|
||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||
<Typography sx={{ fontWeight: 700 }} noWrap>{rider.name}</Typography>
|
||
<Typography variant="caption" color="text.secondary">{rider.vehicle} · {rider.vehicleNo} · Stop {index}</Typography>
|
||
</Box>
|
||
</Stack>
|
||
</Paper>
|
||
|
||
</Stack>
|
||
</Box>
|
||
|
||
{/* Footer */}
|
||
<Box sx={{ p: 2, borderTop: '1px solid #E9ECEF', bgcolor: '#fff', display: 'flex', gap: 1.25, flexShrink: 0 }}>
|
||
<Button fullWidth variant="outlined" startIcon={<CallOutlinedIcon />} href={`tel:${order.phone}`} sx={{ borderRadius: 2 }}>Call</Button>
|
||
<Button fullWidth variant="contained" startIcon={<CheckCircleRoundedIcon />} onClick={onBack} sx={{ borderRadius: 2, bgcolor: rider.color, '&:hover': { bgcolor: rider.color } }}>Done</Button>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════════
|
||
// MAIN COMPONENT
|
||
// ════════════════════════════════════════════════════════════════════════════════
|
||
export default function RiderRoutes() {
|
||
const [mode] = useState('pickup'); // pickups only
|
||
const [riders, setRiders] = useState([]); // loaded from /hub/rider-routes
|
||
const [loading, setLoading] = useState(true);
|
||
const [visible, setVisible] = useState({});
|
||
const [routes, setRoutes] = useState({}); // keyed `${id}__${mode}`
|
||
const [expanded, setExpanded] = useState(null);
|
||
const [focusedStop, setFocusedStop] = useState(null); // `${riderId}-${index}`
|
||
const [detail, setDetail] = useState(null); // { order, rider, mode, index }
|
||
const [flyTarget, setFlyTarget] = useState(null);
|
||
|
||
// Animation state.
|
||
const [playing, setPlaying] = useState(null);
|
||
const [progress, setProgress] = useState(0);
|
||
const [speed, setSpeed] = useState(1);
|
||
const [speedAnchor, setSpeedAnchor] = useState(null);
|
||
const rafRef = useRef(null);
|
||
const lastTsRef = useRef(0);
|
||
const resolvedRef = useRef({}); // keys we've already fetched/attempted
|
||
const mapRef = useRef(null); // map column — scrolled into view when playback starts on mobile
|
||
|
||
const modeCfg = MODES[mode];
|
||
|
||
// Load today's rider routes for this hub.
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
getRiderRoutes()
|
||
.then((res) => {
|
||
if (cancelled) return;
|
||
const mapped = (res?.data || []).map(mapRoute);
|
||
setRiders(mapped);
|
||
setVisible(Object.fromEntries(mapped.map((r) => [r.id, true])));
|
||
setExpanded(mapped[0]?.id ?? null);
|
||
})
|
||
.catch(() => !cancelled && setRiders([]))
|
||
.finally(() => !cancelled && setLoading(false));
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
// Resolve road routes for the active mode (cached per id+mode). A ref guards
|
||
// against re-fetching keys we've already resolved when the tab is revisited.
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
await Promise.all(
|
||
riders.map(async (r) => {
|
||
const key = `${r.id}__${mode}`;
|
||
if (resolvedRef.current[key]) return;
|
||
resolvedRef.current[key] = true;
|
||
const stops = r[mode].stops;
|
||
let path;
|
||
try { path = await fetchRoadRoute(stops); }
|
||
catch { path = stops.map((s) => ({ lat: s.lat, lng: s.lng })); }
|
||
if (!cancelled) setRoutes((prev) => ({ ...prev, [key]: path }));
|
||
})
|
||
);
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [mode, riders]);
|
||
|
||
const pathFor = useCallback(
|
||
(r) => routes[`${r.id}__${mode}`] || r[mode].stops.map((s) => ({ lat: s.lat, lng: s.lng })),
|
||
[routes, mode]
|
||
);
|
||
|
||
const stopsOf = useCallback((r) => r[mode].stops, [mode]);
|
||
|
||
// Auto-fit points = visible riders' stops for the active mode.
|
||
const fitPoints = useMemo(() => {
|
||
const pts = [];
|
||
riders.forEach((r) => { if (visible[r.id]) stopsOf(r).forEach((s) => pts.push(s)); });
|
||
return pts;
|
||
}, [riders, visible, stopsOf]);
|
||
|
||
// ── Analysis KPIs for the active mode ─────────────────────────────────────────
|
||
const kpi = useMemo(() => {
|
||
let orders = 0, done = 0, fail = 0, km = 0, cod = 0, activeRiders = 0;
|
||
riders.forEach((r) => {
|
||
const trip = r[mode];
|
||
const orderStops = trip.stops.filter((s) => s.kind === 'order');
|
||
if (orderStops.length) activeRiders += 1;
|
||
orders += orderStops.length;
|
||
done += orderStops.filter((s) => s.status === modeCfg.doneStatus).length;
|
||
fail += orderStops.filter((s) => s.status === modeCfg.failStatus).length;
|
||
km += trip.distanceKm;
|
||
cod += orderStops.reduce((s, o) => s + (o.cod || 0), 0);
|
||
});
|
||
return { orders, done, fail, km: km.toFixed(1), cod, activeRiders };
|
||
}, [riders, mode, modeCfg]);
|
||
|
||
// ── Animation driver ──────────────────────────────────────────────────────────
|
||
useEffect(() => {
|
||
if (!playing) { if (rafRef.current) cancelAnimationFrame(rafRef.current); return; }
|
||
lastTsRef.current = 0;
|
||
const DURATION = 14000;
|
||
const tick = (ts) => {
|
||
if (!lastTsRef.current) lastTsRef.current = ts;
|
||
const dt = ts - lastTsRef.current;
|
||
lastTsRef.current = ts;
|
||
setProgress((p) => {
|
||
const nextP = p + (dt * speed) / DURATION;
|
||
if (nextP >= 1) { setPlaying(null); return 1; }
|
||
return nextP;
|
||
});
|
||
rafRef.current = requestAnimationFrame(tick);
|
||
};
|
||
rafRef.current = requestAnimationFrame(tick);
|
||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||
}, [playing, speed]);
|
||
|
||
// Stop any animation when switching tabs.
|
||
useEffect(() => { setPlaying(null); setProgress(0); }, [mode]);
|
||
|
||
const startAnim = (riderId) => {
|
||
setVisible((v) => ({ ...v, [riderId]: true }));
|
||
if (playing === riderId) { setPlaying(null); return; }
|
||
if (playing !== riderId) setProgress(0);
|
||
setPlaying(riderId);
|
||
// On the stacked layout (< lg) the map sits below the list, so the moving
|
||
// marker would animate off-screen. Bring the map into view when playback starts.
|
||
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1199px)').matches) {
|
||
requestAnimationFrame(() => mapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
||
}
|
||
};
|
||
const resetAnim = () => { setPlaying(null); setProgress(0); };
|
||
const toggleVisible = (id) => setVisible((v) => ({ ...v, [id]: !v[id] }));
|
||
|
||
const openDetail = (rider, order, index) => {
|
||
setDetail({ order, rider, mode, index });
|
||
setExpanded(rider.id);
|
||
setFocusedStop(`${rider.id}-${index}`);
|
||
setFlyTarget({ lat: order.lat, lng: order.lng });
|
||
};
|
||
const closeDetail = () => { setDetail(null); setFlyTarget(null); setFocusedStop(null); };
|
||
|
||
const playState = useMemo(() => {
|
||
if (!playing) return null;
|
||
const rider = riders.find((r) => r.id === playing);
|
||
if (!rider) return null;
|
||
const path = pathFor(rider);
|
||
if (path.length < 2) return null;
|
||
const totalSegs = path.length - 1;
|
||
const exact = progress * totalSegs;
|
||
const i = Math.min(Math.floor(exact), totalSegs - 1);
|
||
const frac = exact - i;
|
||
const pos = lerpPoint(path[i], path[i + 1], frac);
|
||
const travelled = [...path.slice(0, i + 1), pos];
|
||
return { rider, path, travelled, pos };
|
||
}, [playing, progress, pathFor, riders]);
|
||
|
||
return (
|
||
<Box sx={{ pt: { xs: 1, md: 1 }, pb: 1 }}>
|
||
{/* ── Header ── */}
|
||
<Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" alignItems={{ md: 'center' }} gap={1} mb={2} sx={{ mb: 2 }}>
|
||
<Stack direction="row" alignItems="center" spacing={2.5}>
|
||
<Avatar sx={{ bgcolor: alpha('#C01227', 0.1), color: '#C01227', width: 56, height: 56, borderRadius: 2 }}>
|
||
<RouteOutlinedIcon sx={{ fontSize: 30 }} />
|
||
</Avatar>
|
||
<Box>
|
||
<Typography variant="h4" sx={{ fontWeight: 800, letterSpacing: '-0.4px', lineHeight: 1.2 }}>Rider Routes</Typography>
|
||
<Typography variant="body1" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||
Pickups each miler covered today open any order for full details, or press play to replay the trip.
|
||
</Typography>
|
||
</Box>
|
||
</Stack>
|
||
|
||
</Stack>
|
||
|
||
{!loading && riders.length === 0 && (
|
||
<Card elevation={0} sx={{ borderRadius: 2, border: '1px dashed #CED4DA', py: 8, textAlign: 'center', mt: 3 }}>
|
||
<RouteOutlinedIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
|
||
<Typography variant="h6" sx={{ fontWeight: 700, color: '#495057' }}>No rider routes today</Typography>
|
||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||
Once milers are assigned pickups, their planned stops will show up here.
|
||
</Typography>
|
||
</Card>
|
||
)}
|
||
|
||
{/* ── Analysis KPI strip ── */}
|
||
<Grid container spacing={{ xs: 1.5, sm: 2 }} mt={4} sx={{ mb: 3 }}>
|
||
{[
|
||
{ icon: GroupsOutlinedIcon, label: 'Active Milers', value: kpi.activeRiders, sub: `of ${riders.length}`, color: '#1A73E8', bg: '#E8F0FE' },
|
||
{ icon: modeCfg.icon, label: modeCfg.label, value: kpi.orders, sub: 'pickups covered', color: '#C01227', bg: alpha('#C01227', 0.1) },
|
||
{ icon: CheckCircleRoundedIcon, label: modeCfg.doneLabel, value: kpi.done, sub: `${kpi.fail} missed`, color: '#1E8E3E', bg: '#E6F4EA' },
|
||
{ icon: StraightenRoundedIcon, label: 'Distance', value: `${kpi.km} km`, sub: 'fleet total today', color: '#8E24AA', bg: '#F3E5F5' },
|
||
].map((k, i) => (
|
||
<Grid size={{ xs: 6, md: 3 }} key={i}><KpiCard {...k} /></Grid>
|
||
))}
|
||
</Grid>
|
||
|
||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', lg: 'row' }, gap: 3, pb: 4 }}>
|
||
{/* ── Left control panel — the milers list; clicking an order opens the
|
||
full detail in a right-side drawer (below). ── */}
|
||
<Box sx={{ width: { xs: '100%', lg: 380 }, flexShrink: 0 }}>
|
||
<Card sx={{ borderRadius: 2, border: '1px solid #ECEEF1', boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }}>
|
||
<Box sx={{ px: 3, py: 2.25, borderBottom: '1px solid #F1F3F5', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<Typography sx={{ fontWeight: 700 }}>Milers & their {modeCfg.label.toLowerCase()}</Typography>
|
||
{(playing || progress > 0) && (
|
||
<Button size="small" startIcon={<ReplayRoundedIcon />} onClick={resetAnim} sx={{ textTransform: 'none', color: '#5F6368' }}>Reset</Button>
|
||
)}
|
||
</Box>
|
||
|
||
<List disablePadding sx={{ maxHeight: { lg: 620 }, overflow: 'auto' }}>
|
||
{riders.map((rider) => {
|
||
const trip = rider[mode];
|
||
const orders = trip.stops.filter((s) => s.kind === 'order');
|
||
const done = orders.filter((o) => o.status === modeCfg.doneStatus).length;
|
||
const isOpen = expanded === rider.id;
|
||
const isPlaying = playing === rider.id;
|
||
const isVisible = visible[rider.id];
|
||
return (
|
||
<Box key={rider.id} sx={{ borderBottom: '1px solid #F4F5F7' }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, py: 1.5, gap: 1, opacity: isVisible ? 1 : 0.5 }}>
|
||
<Box sx={{ width: 6, height: 40, borderRadius: 2, bgcolor: rider.color, flexShrink: 0 }} />
|
||
<Avatar sx={{ width: 38, height: 38, bgcolor: alpha(rider.color, 0.12), color: rider.color, fontWeight: 700, fontSize: 14 }}>{initials(rider.name)}</Avatar>
|
||
<ListItemButton disableGutters onClick={() => setExpanded(isOpen ? null : rider.id)} sx={{ flex: 1, borderRadius: 2, px: 1, py: 0.5, minWidth: 0 }}>
|
||
<ListItemText
|
||
primary={<Typography sx={{ fontWeight: 700, fontSize: '0.92rem' }} noWrap>{rider.name}</Typography>}
|
||
secondary={<Typography variant="caption" color="text.secondary" noWrap>{done}/{orders.length} {modeCfg.doneLabel.toLowerCase()} · {trip.distanceKm} km</Typography>}
|
||
/>
|
||
{isOpen ? <ExpandLessRoundedIcon sx={{ color: '#9AA0A6' }} /> : <ExpandMoreRoundedIcon sx={{ color: '#9AA0A6' }} />}
|
||
</ListItemButton>
|
||
</Box>
|
||
|
||
{/* per-rider stat chips (analysis) */}
|
||
<Stack direction="row" gap={1} flexWrap="wrap" sx={{ px: 2, pb: 1.5 }}>
|
||
<Chip size="small" icon={<Inventory2OutlinedIcon sx={{ fontSize: '14px !important' }} />} label={`${orders.length} ${modeCfg.label.toLowerCase()}`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
||
<Chip size="small" icon={<CheckCircleRoundedIcon sx={{ fontSize: '14px !important', color: '#1E8E3E !important' }} />} label={done} sx={{ height: 24, fontWeight: 600, bgcolor: alpha('#1E8E3E', 0.1), color: '#1E8E3E' }} />
|
||
<Chip size="small" icon={<StraightenRoundedIcon sx={{ fontSize: '14px !important' }} />} label={`${trip.distanceKm} km`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
||
<Chip size="small" icon={<AccessTimeOutlinedIcon sx={{ fontSize: '14px !important' }} />} label={`${trip.startTime}–${trip.endTime}`} sx={{ height: 24, fontWeight: 600, bgcolor: '#F1F3F5' }} />
|
||
</Stack>
|
||
|
||
{/* action row */}
|
||
<Stack direction="row" gap={1.25} sx={{ px: 2, pb: 2 }}>
|
||
<Button
|
||
size="small"
|
||
variant={isPlaying ? 'contained' : 'outlined'}
|
||
startIcon={isPlaying ? <PauseRoundedIcon /> : <PlayArrowRoundedIcon />}
|
||
onClick={() => startAnim(rider.id)}
|
||
sx={{
|
||
textTransform: 'none', fontWeight: 600, borderRadius: 2, flex: 1,
|
||
...(isPlaying
|
||
? { bgcolor: rider.color, '&:hover': { bgcolor: rider.color } }
|
||
: { color: rider.color, borderColor: alpha(rider.color, 0.5), '&:hover': { borderColor: rider.color, bgcolor: alpha(rider.color, 0.06) } }),
|
||
}}
|
||
>
|
||
{isPlaying ? 'Playing…' : 'Animate route'}
|
||
</Button>
|
||
<Tooltip title={isVisible ? 'Hide route' : 'Show route'}>
|
||
<IconButton size="small" onClick={() => toggleVisible(rider.id)} sx={{ border: '1px solid #E9ECEF', borderRadius: 2 }}>
|
||
{isVisible ? <VisibilityOutlinedIcon fontSize="small" /> : <VisibilityOffOutlinedIcon fontSize="small" />}
|
||
</IconButton>
|
||
</Tooltip>
|
||
</Stack>
|
||
|
||
{isPlaying && (
|
||
<Box sx={{ px: 2, pb: 1.5 }}>
|
||
<LinearProgress variant="determinate" value={progress * 100} sx={{ height: 6, borderRadius: 2, bgcolor: '#EDEFF2', '& .MuiLinearProgress-bar': { bgcolor: rider.color, borderRadius: 2 } }} />
|
||
</Box>
|
||
)}
|
||
|
||
{/* expandable stops — rich order cards; click to open detail */}
|
||
<Collapse in={isOpen} unmountOnExit>
|
||
<Box sx={{ px: 2, pb: 2, pt: 0.5 }}>
|
||
<Stack spacing={1.25}>
|
||
{trip.stops.map((s, i) => {
|
||
const key = `${rider.id}-${i}`;
|
||
const isHub = s.kind === 'hub';
|
||
const meta = STATUS_META[s.status];
|
||
const StatusIcon = meta?.icon;
|
||
|
||
// Hub return — compact row, not a card.
|
||
if (isHub) {
|
||
return (
|
||
<Stack key={key} direction="row" alignItems="center" gap={1.25} sx={{ px: 0.5, py: 0.5 }}>
|
||
<Box sx={{ width: 28, height: 28, borderRadius: 2, flexShrink: 0, bgcolor: alpha('#C01227', 0.12), color: '#C01227', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<WarehouseRoundedIcon sx={{ fontSize: 16 }} />
|
||
</Box>
|
||
<Box sx={{ minWidth: 0 }}>
|
||
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>{s.label}</Typography>
|
||
<Typography variant="caption" color="text.secondary">Back to hub · {s.time}</Typography>
|
||
</Box>
|
||
</Stack>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Box
|
||
key={key}
|
||
onMouseEnter={() => setFocusedStop(key)}
|
||
onMouseLeave={() => setFocusedStop((f) => (f === key ? null : f))}
|
||
onClick={() => openDetail(rider, s, i)}
|
||
sx={{
|
||
p: 1.5, borderRadius: 2, cursor: 'pointer', transition: 'all .15s',
|
||
border: '1px solid', borderColor: focusedStop === key ? alpha(rider.color, 0.55) : '#ECEEF1',
|
||
bgcolor: focusedStop === key ? alpha(rider.color, 0.04) : '#fff',
|
||
boxShadow: '0 1px 3px rgba(0,0,0,0.03)',
|
||
'&:hover': { borderColor: alpha(rider.color, 0.55), boxShadow: '0 4px 14px rgba(0,0,0,0.07)' },
|
||
}}
|
||
>
|
||
{/* order id + status */}
|
||
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
|
||
<Stack direction="row" alignItems="center" gap={1} sx={{ minWidth: 0 }}>
|
||
<Box sx={{ width: 26, height: 26, borderRadius: 2, flexShrink: 0, bgcolor: alpha(rider.color, 0.14), color: rider.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 800, fontSize: 12 }}>{i}</Box>
|
||
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>
|
||
<Box component="span" sx={{ color: '#9AA0A6', fontWeight: 600 }}>Order </Box>#{s.orderId}
|
||
</Typography>
|
||
</Stack>
|
||
{meta && (
|
||
<Chip size="small" icon={<StatusIcon sx={{ fontSize: '14px !important', color: `${meta.color} !important` }} />} label={meta.label}
|
||
sx={{ height: 22, fontSize: '0.68rem', fontWeight: 700, color: meta.color, bgcolor: alpha(meta.color, 0.1), flexShrink: 0 }} />
|
||
)}
|
||
</Stack>
|
||
|
||
{/* rider + time */}
|
||
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1} sx={{ mt: 1 }}>
|
||
<Stack direction="row" alignItems="center" gap={0.75} sx={{ minWidth: 0 }}>
|
||
<DeliveryDiningRoundedIcon sx={{ fontSize: 16, color: '#9AA0A6', flexShrink: 0 }} />
|
||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }} noWrap>{rider.name}</Typography>
|
||
</Stack>
|
||
<Stack direction="row" alignItems="center" gap={0.5} sx={{ flexShrink: 0 }}>
|
||
<AccessTimeOutlinedIcon sx={{ fontSize: 14, color: '#9AA0A6' }} />
|
||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{s.time}</Typography>
|
||
</Stack>
|
||
</Stack>
|
||
|
||
<Divider sx={{ my: 1.25 }} />
|
||
|
||
{/* merchant + address */}
|
||
<Stack direction="row" alignItems="center" gap={0.75}>
|
||
<StorefrontOutlinedIcon sx={{ fontSize: 16, color: rider.color, flexShrink: 0 }} />
|
||
<Typography sx={{ fontWeight: 700, fontSize: '0.82rem' }} noWrap>{s.customer}</Typography>
|
||
</Stack>
|
||
<Stack direction="row" alignItems="flex-start" gap={0.75} sx={{ mt: 0.5 }}>
|
||
<PlaceOutlinedIcon sx={{ fontSize: 16, color: '#9AA0A6', flexShrink: 0, mt: '1px' }} />
|
||
<Typography variant="caption" color="text.secondary" noWrap sx={{ flex: 1, minWidth: 0 }}>{s.address}</Typography>
|
||
</Stack>
|
||
|
||
{/* metric chips — only render fields the API actually returned */}
|
||
{(s.legKm != null || s.weight || s.items != null) && (
|
||
<Stack direction="row" sx={{ flexWrap: 'wrap', gap: 0.75, mt: 1.25 }}>
|
||
{s.legKm != null && <Chip size="small" icon={<StraightenRoundedIcon sx={{ fontSize: '13px !important' }} />} label={`${s.legKm} km`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
|
||
{s.weight && <Chip size="small" icon={<ScaleOutlinedIcon sx={{ fontSize: '13px !important' }} />} label={s.weight} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
|
||
{s.items != null && <Chip size="small" icon={<Inventory2OutlinedIcon sx={{ fontSize: '13px !important' }} />} label={`${s.items} ${s.items === 1 ? 'parcel' : 'parcels'}`} sx={{ height: 22, fontSize: '0.68rem', fontWeight: 600, bgcolor: '#F1F3F5' }} />}
|
||
</Stack>
|
||
)}
|
||
</Box>
|
||
);
|
||
})}
|
||
</Stack>
|
||
</Box>
|
||
</Collapse>
|
||
</Box>
|
||
);
|
||
})}
|
||
</List>
|
||
</Card>
|
||
</Box>
|
||
|
||
{/* ── Map ── */}
|
||
<Box ref={mapRef} sx={{ flex: 1, minWidth: 0, scrollMarginTop: 72 }}>
|
||
<Card sx={{ height: { xs: 460, sm: 560, lg: 700 }, borderRadius: 2, border: '1px solid #ECEEF1', overflow: 'hidden', position: 'relative', boxShadow: '0 4px 20px rgba(0,0,0,0.04)' }}>
|
||
{playState && (
|
||
<Box sx={{ position: 'absolute', top: 14, left: 14, zIndex: 1000, minWidth: 230, bgcolor: 'rgba(255,255,255,0.97)', borderRadius: 2, p: 1.75, boxShadow: '0 8px 28px rgba(0,0,0,0.14)', border: `1px solid ${alpha(playState.rider.color, 0.3)}` }}>
|
||
<Stack direction="row" alignItems="center" spacing={1.25} mb={1}>
|
||
<Avatar sx={{ width: 30, height: 30, bgcolor: playState.rider.color, fontSize: 12, fontWeight: 700 }}>{initials(playState.rider.name)}</Avatar>
|
||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||
<Typography sx={{ fontWeight: 700, fontSize: '0.85rem' }} noWrap>{playState.rider.name}</Typography>
|
||
<Typography variant="caption" color="text.secondary">Replaying {modeCfg.label.toLowerCase().replace(/s$/, '')} · {Math.round(progress * 100)}%</Typography>
|
||
</Box>
|
||
<IconButton size="small" onClick={() => setPlaying(null)}><PauseRoundedIcon fontSize="small" /></IconButton>
|
||
<Tooltip title="Speed"><IconButton size="small" onClick={(e) => setSpeedAnchor(e.currentTarget)}><SpeedRoundedIcon fontSize="small" /></IconButton></Tooltip>
|
||
</Stack>
|
||
<LinearProgress variant="determinate" value={progress * 100} sx={{ height: 6, borderRadius: 2, bgcolor: '#EDEFF2', '& .MuiLinearProgress-bar': { bgcolor: playState.rider.color, borderRadius: 2 } }} />
|
||
</Box>
|
||
)}
|
||
<Menu anchorEl={speedAnchor} open={Boolean(speedAnchor)} onClose={() => setSpeedAnchor(null)}>
|
||
{[0.5, 1, 2, 4].map((s) => (<MenuItem key={s} selected={speed === s} onClick={() => { setSpeed(s); setSpeedAnchor(null); }}>{s}× speed</MenuItem>))}
|
||
</Menu>
|
||
|
||
<MapContainer center={[HUB.lat, HUB.lng]} zoom={11} scrollWheelZoom style={{ height: '100%', width: '100%' }}>
|
||
<MapResizeHandler />
|
||
{!playing && !flyTarget && <FitBounds points={fitPoints} />}
|
||
<FlyTo target={flyTarget} />
|
||
<TileLayer
|
||
url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
|
||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
||
/>
|
||
|
||
<Marker position={[HUB.lat, HUB.lng]} icon={hubIcon}>
|
||
<Popup><b>{HUB.label}</b><br />Pickups return here</Popup>
|
||
</Marker>
|
||
|
||
{riders.map((rider) => {
|
||
if (!visible[rider.id]) return null;
|
||
const path = pathFor(rider);
|
||
const stops = stopsOf(rider);
|
||
const dimmed = playing && playing !== rider.id;
|
||
const endStop = stops[stops.length - 1];
|
||
return (
|
||
<React.Fragment key={rider.id}>
|
||
<Polyline positions={path.map((p) => [p.lat, p.lng])} pathOptions={{ color: rider.color, weight: 5, opacity: dimmed ? 0.15 : 0.85, dashArray: modeCfg.dash }} />
|
||
{stops.map((s, i) => {
|
||
if (s.kind === 'hub' || s.lat == null || s.lng == null) return null;
|
||
const key = `${rider.id}-${i}`;
|
||
return (
|
||
<Marker
|
||
key={key}
|
||
position={[s.lat, s.lng]}
|
||
icon={stopIcon(i, rider.color, focusedStop === key, mode === 'pickup')}
|
||
opacity={dimmed ? 0.3 : 1}
|
||
zIndexOffset={focusedStop === key ? 1000 : 0}
|
||
eventHandlers={{ click: () => openDetail(rider, s, i) }}
|
||
>
|
||
<Popup>
|
||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{modeCfg.pointLabel} {i} · {s.customer}</Typography>
|
||
<Typography variant="caption" display="block">{s.address}</Typography>
|
||
<Typography variant="caption" display="block">#{s.orderId} · {s.time} · {STATUS_META[s.status]?.label}</Typography>
|
||
<Typography variant="caption" display="block" sx={{ color: rider.color, fontWeight: 700, cursor: 'pointer' }} onClick={() => openDetail(rider, s, i)}>View full details →</Typography>
|
||
</Popup>
|
||
</Marker>
|
||
);
|
||
})}
|
||
{!dimmed && endStop && (
|
||
<Marker position={[endStop.lat, endStop.lng]} icon={flagIcon} zIndexOffset={-100}>
|
||
<LTooltip direction="top" offset={[0, -14]}>{mode === 'pickup' ? 'Returned to hub' : 'Trip end'} · {rider[mode].endTime}</LTooltip>
|
||
</Marker>
|
||
)}
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
|
||
{playState && (
|
||
<>
|
||
<Polyline positions={playState.travelled.map((p) => [p.lat, p.lng])} pathOptions={{ color: playState.rider.color, weight: 7, opacity: 1 }} />
|
||
<Marker position={[playState.pos.lat, playState.pos.lng]} icon={moverIcon(playState.rider.color)} zIndexOffset={2000}>
|
||
<LTooltip direction="top" offset={[0, -16]} permanent>{playState.rider.name}</LTooltip>
|
||
</Marker>
|
||
</>
|
||
)}
|
||
</MapContainer>
|
||
</Card>
|
||
|
||
{/* Legend */}
|
||
<Stack direction="row" flexWrap="wrap" gap={2} sx={{ mt: 2 }}>
|
||
{riders.map((r) => (
|
||
<Stack key={r.id} direction="row" alignItems="center" gap={0.75} sx={{ opacity: visible[r.id] ? 1 : 0.4, cursor: 'pointer' }} onClick={() => toggleVisible(r.id)}>
|
||
<Box sx={{ width: 18, height: 4, borderRadius: 2, bgcolor: r.color }} />
|
||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{r.name}</Typography>
|
||
</Stack>
|
||
))}
|
||
<Box sx={{ flex: 1 }} />
|
||
<Stack direction="row" alignItems="center" gap={0.75}>
|
||
<FlagRoundedIcon sx={{ fontSize: 16, color: '#1E8E3E' }} />
|
||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>{mode === 'pickup' ? 'Hub return' : 'Trip end'}</Typography>
|
||
</Stack>
|
||
<Stack direction="row" alignItems="center" gap={0.75}>
|
||
<WarehouseRoundedIcon sx={{ fontSize: 16, color: '#C01227' }} />
|
||
<Typography variant="caption" sx={{ fontWeight: 600, color: '#5F6368' }}>Hub</Typography>
|
||
</Stack>
|
||
</Stack>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* ── Order detail — right-side drawer ── */}
|
||
<Drawer
|
||
anchor="right"
|
||
open={Boolean(detail)}
|
||
onClose={closeDetail}
|
||
sx={{
|
||
// Temporary drawers render at zIndex.drawer (1200) here, below the app bar
|
||
// (drawer + 1). On mobile the sheet starts at top:0, so its Close button
|
||
// would hide under the app bar. Lift the whole modal above it.
|
||
zIndex: (t) => t.zIndex.modal,
|
||
'& .MuiDrawer-paper': {
|
||
width: { xs: '100%', sm: 360 },
|
||
maxWidth: '100%',
|
||
top: { xs: 0, sm: 64 },
|
||
height: { xs: '100%', sm: 'calc(100% - 64px)' },
|
||
borderTopLeftRadius: { sm: 16 },
|
||
overflow: 'hidden',
|
||
boxShadow: '-8px 0 30px rgba(0,0,0,0.12)',
|
||
},
|
||
}}
|
||
>
|
||
{detail && <OrderDetailPanel data={detail} onBack={closeDetail} />}
|
||
</Drawer>
|
||
</Box>
|
||
);
|
||
}
|