Files
doormile_hub_console/src/pages/operations/RiderRoutes.jsx
Thiru-tenext ef0b14d254 feat: Implement date range picker and enhance order assignment functionality
- 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.
2026-07-09 16:28:45 +05:30

847 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &amp; 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 &amp; 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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <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>
);
}