updates on the dispatch active page and the navbar design

This commit is contained in:
2026-06-08 20:21:36 +05:30
parent bbec0aa910
commit fd27ac92d8
8 changed files with 1261 additions and 434 deletions

View File

@@ -210,10 +210,14 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
const isSelected = selected === menu.id;
const borderIcon = level === 1 ? <BorderOutlined style={{ fontSize: '1rem' }} /> : false;
const Icon = menu.icon;
const menuIcon = menu.icon ? <Icon style={{ fontSize: drawerOpen ? '1rem' : '1.25rem' }} /> : borderIcon;
const iconSelectedColor = theme.palette.mode === ThemeMode.DARK && drawerOpen ? theme.palette.text.primary : theme.palette.primary.main;
const menuIcon = menu.icon ? <Icon style={{ fontSize: drawerOpen ? '1rem' : '1.25rem', color: 'white' }} /> : borderIcon;
// const textColor = theme.palette.mode === ThemeMode.DARK ? 'grey.400' : 'text.primary';
// const iconSelectedColor = theme.palette.mode === ThemeMode.DARK && drawerOpen ? theme.palette.text.primary : theme.palette.primary.main;
const popperId = miniMenuOpened ? `collapse-pop-${menu.id}` : undefined;
const FlexBox = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' };
const textColor = 'white';
const iconSelectedColor = 'white';
// const isSelected = true;
return (
<>
@@ -227,9 +231,11 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
sx={{
pl: drawerOpen ? `${level * 28}px` : 1.5,
py: !drawerOpen && level === 1 ? 1.25 : 1,
...(drawerOpen && {
'&:hover': {
bgcolor: theme.palette.mode === ThemeMode.DARK ? 'divider' : 'primary.light'
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'divider' : 'primary.lighter'
bgcolor: '#7b1fa2'
},
'&.Mui-selected': {
bgcolor: 'transparent',
@@ -239,13 +245,14 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
}),
...(!drawerOpen && {
'&:hover': {
bgcolor: 'primary.light'
bgcolor: 'transparent'
// bgcolor:'#7b1fa2'
},
'&.Mui-selected': {
'&:hover': {
bgcolor: 'white'
bgcolor: 'transparent'
},
bgcolor: 'white'
bgcolor: 'transparent'
}
})
}}
@@ -255,7 +262,10 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
onClick={handlerIconLink}
sx={{
minWidth: 28,
color: selected === menu.id ? 'primary.main' : 'white',
// color: selected === menu.id ? 'primary.main' : textColor,
// color: selected === menu.id ? textColor : textColor,
// bgcolor:'white',
// color:'white',
...(!drawerOpen && {
borderRadius: 1.5,
width: 36,
@@ -264,13 +274,17 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
justifyContent: 'center',
'&:hover': {
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'secondary.light' : 'secondary.lighter'
bgcolor: '#7b1fa2',
color: 'white'
}
}),
...(!drawerOpen &&
selected === menu.id && {
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.900' : 'primary.lighter',
bgcolor: 'primary.light',
color: 'primary.main',
'&:hover': {
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.darker' : 'primary.lighter'
bgcolor: '#7b1fa2',
color: 'primary.main'
}
})
}}
@@ -281,7 +295,12 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
{(drawerOpen || (!drawerOpen && level !== 1)) && (
<ListItemText
primary={
<Typography variant="h6" color={selected === menu.id ? 'white' : 'white'}>
<Typography
variant="h6"
// color={selected === menu.id ? 'primary' : textColor}
// color={'white'}
color={selected === menu.id ? textColor : textColor}
>
{menu.title}
</Typography>
}
@@ -296,9 +315,22 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
)}
{(drawerOpen || (!drawerOpen && level !== 1)) &&
(miniMenuOpened || open ? (
<UpOutlined style={{ fontSize: '0.625rem', marginLeft: 1, color: theme.palette.primary.main }} />
<UpOutlined
style={{
fontSize: '0.625rem',
marginLeft: 1,
// color: theme.palette.primary.main
color: 'white'
}}
/>
) : (
<DownOutlined style={{ fontSize: '0.625rem', marginLeft: 1, color: 'white' }} />
<DownOutlined
style={{
fontSize: '0.625rem',
marginLeft: 1,
color: 'white'
}}
/>
))}
{!drawerOpen && (
@@ -328,8 +360,8 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
mt: 1.5,
boxShadow: theme.customShadows.z1,
backgroundImage: 'none',
border: `1px solid ${theme.palette.primary.main}`,
bgcolor: 'primary.main'
border: `2px solid ${theme.palette.primary.main}`,
width: 'auto'
}}
>
<ClickAwayListener onClickAway={handleClose}>
@@ -373,7 +405,14 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
>
<Box onClick={handlerIconLink} sx={FlexBox}>
{menuIcon && (
<ListItemIcon sx={{ my: 'auto', minWidth: !menu.icon ? 18 : 36, color: theme.palette.secondary.dark }}>
<ListItemIcon
sx={{
my: 'auto',
minWidth: !menu.icon ? 18 : 36
// color: theme.palette.secondary.dark
// color:'white'
}}
>
{menuIcon}
</ListItemIcon>
)}
@@ -386,7 +425,12 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s
)}
<ListItemText
primary={
<Typography variant="body1" color="inherit" sx={{ my: 'auto' }}>
<Typography
variant="body1"
// color="inherit"
// color="white"
sx={{ my: 'auto' }}
>
{menu.title}
</Typography>
}

View File

@@ -26,7 +26,7 @@ import NavCollapse from './NavCollapse';
import SimpleBar from 'components/third-party/SimpleBar';
import Transitions from 'components/@extended/Transitions';
import { MenuOrientation, ThemeMode } from 'config';
import { MenuOrientation } from 'config';
import useConfig from 'hooks/useConfig';
import { dispatch, useSelector } from 'store';
import { activeID } from 'store/reducers/menu';
@@ -227,9 +227,9 @@ const NavGroup = ({ item, lastItem, remItems, lastItemId, setSelectedItems, sele
item.title &&
drawerOpen && (
<Box sx={{ pl: 3, mb: 1.5 }}>
<Typography variant="subtitle2"
// color={theme.palette.mode === ThemeMode.DARK ? 'textSecondary' : 'text.secondary'}
sx={{color:'#fff'}}
<Typography
variant="subtitle2"
sx={{ color: '#fff' }}
>
{item.title}
</Typography>

View File

@@ -9,9 +9,10 @@ import { Avatar, Chip, ListItemButton, ListItemIcon, ListItemText, Typography, u
// project import
import Dot from 'components/@extended/Dot';
import { MenuOrientation, ThemeMode } from 'config';
import useConfig from 'hooks/useConfig';
import { activeItem, openDrawer } from 'store/reducers/menu';
import { activeItem, openDrawer, setSelectedMenu } from 'store/reducers/menu';
// ==============================|| NAVIGATION - LIST ITEM ||============================== //
@@ -35,10 +36,15 @@ const NavItem = ({ item, level }) => {
}
const Icon = item.icon;
const itemIcon = item.icon ? <Icon style={{ fontSize: drawerOpen ? '1rem' : '1.25rem' }} /> : false;
const isSelected = openItem.findIndex((id) => id === item.id) > -1;
const itemIcon = item.icon ? (
<Icon style={{ fontSize: drawerOpen ? '1rem' : '1.25rem', color: isSelected ? '#662582' : '#fff' }} />
) : (
false
);
// const { pathname } = useLocation();
const pathname = document.location.pathname;
@@ -59,10 +65,15 @@ const NavItem = ({ item, level }) => {
if (pathname.includes(item.url)) {
dispatch(activeItem({ openItem: [item.id] }));
}
// eslint-disable-next-line
}, [pathname]);
const textColor = theme.palette.mode === ThemeMode.DARK ? 'grey.400' : 'text.primary';
useEffect(() => {
dispatch(setSelectedMenu(pathname));
}, [pathname]);
const textColor = theme.palette.mode === ThemeMode.DARK ? 'grey.400' : '#fff';
const iconSelectedColor = theme.palette.mode === ThemeMode.DARK && drawerOpen ? 'text.primary' : 'primary.main';
return (
@@ -72,14 +83,16 @@ const NavItem = ({ item, level }) => {
{...listItemProps}
disabled={item.disabled}
selected={isSelected}
onClick={() => {
// dispatch(setSelectedMenu(item));
}}
sx={{
zIndex: 1201,
pl: drawerOpen ? `${level * 28}px` : 1.5,
py: !drawerOpen && level === 1 ? 1.25 : 1,
...(drawerOpen && {
// bgcolor: 'primary.light',
'&:hover': {
bgcolor: theme.palette.mode === ThemeMode.DARK ? 'divider' : 'primary.light'
bgcolor: '#7b1fa2'
},
'&.Mui-selected': {
bgcolor: theme.palette.mode === ThemeMode.DARK ? 'divider' : 'primary.lighter',
@@ -92,41 +105,43 @@ const NavItem = ({ item, level }) => {
}
}),
...(!drawerOpen && {
bgcolor: '#662582',
'&:hover': {
bgcolor: 'primary.light'
bgcolor: '#662582'
},
'&.Mui-selected': {
'&:hover': {
bgcolor: 'white'
bgcolor: 'transparent'
},
bgcolor: 'white'
bgcolor: 'transparent'
}
})
}}
{...(downLG && {
onClick: () => dispatch(openDrawer(false))
onClick: () => {
dispatch(openDrawer(false));
}
})}
>
{itemIcon && (
<ListItemIcon
sx={{
minWidth: 28,
color: isSelected ? iconSelectedColor : 'white',
...(!drawerOpen && {
// borderRadius: 1.5,
borderRadius: 1.5,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center'
// '&:hover': {
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'secondary.light' : 'primary.lighter'
// }
justifyContent: 'center',
'&:hover': {
bgcolor: '#7b1fa2'
}
}),
...(!drawerOpen &&
isSelected && {
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.900' : 'primary.lighter',
bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.900' : 'primary.lighter',
'&:hover': {
// bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.darker' : 'primary.lighter'
bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.darker' : 'primary.lighter'
}
})
}}
@@ -137,13 +152,7 @@ const NavItem = ({ item, level }) => {
{(drawerOpen || (!drawerOpen && level !== 1)) && (
<ListItemText
primary={
<Typography
variant="h6"
sx={{
ml: 1,
color: isSelected ? theme.palette.primary.main : 'white'
}}
>
<Typography variant="h6" sx={{ color: isSelected ? iconSelectedColor : textColor, whiteSpace: 'nowrap' }}>
{item.title}
</Typography>
}

View File

@@ -9,7 +9,7 @@ import AppBarStyled from './AppBarStyled';
import HeaderContent from './HeaderContent';
import IconButton from 'components/@extended/IconButton';
import { MenuOrientation, ThemeMode } from 'config';
import { MenuOrientation } from 'config';
import useConfig from 'hooks/useConfig';
import { dispatch, useSelector } from 'store';
import { openDrawer } from 'store/reducers/menu';
@@ -32,9 +32,6 @@ const Header = () => {
// header content
const headerContent = useMemo(() => <HeaderContent />, []);
const iconBackColorOpen = theme.palette.mode === ThemeMode.DARK ? 'grey.200' : 'grey.300';
const iconBackColor = theme.palette.mode === ThemeMode.DARK ? 'background.default' : 'grey.100';
// common header
const mainHeader = (
<Toolbar>
@@ -43,9 +40,6 @@ const Header = () => {
aria-label="open drawer"
onClick={() => dispatch(openDrawer(!drawerOpen))}
edge="start"
// color="secondary"
// variant="light"
// sx={{ color: 'text.primary', bgcolor: drawerOpen ? iconBackColorOpen : iconBackColor, ml: { xs: 0, lg: -2 } }}
sx={{
color: '#fff',
bgcolor: 'transparent',

View File

@@ -1532,6 +1532,92 @@
margin-left: 4px;
}
/* ─── Live bike marker (Swiggy/Zomato/Rapido-style) ───────────────────────
Center-anchored rounded badge carrying a motorbike glyph, a pulsing "live"
ring, and a side label. The container itself is click-through; only the
badge + label capture clicks so the wide label area doesn't block the map. */
.dispatch-container .live-rider-bike {
--pin-color: #16a34a;
position: relative;
width: 160px;
height: 44px;
pointer-events: none;
}
.dispatch-container .live-rider-bike-badge {
position: absolute;
left: 4px;
top: 4px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--pin-color);
border: 3px solid #fff;
border-radius: 50%;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
pointer-events: auto;
z-index: 2;
}
.dispatch-container .live-rider-bike-pulse {
position: absolute;
left: 4px;
top: 4px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--pin-color);
opacity: 0.45;
z-index: 1;
}
.dispatch-container .live-rider-bike.is-active .live-rider-bike-pulse {
animation: live-rider-bike-pulse 1.6s ease-out infinite;
}
@keyframes live-rider-bike-pulse {
0% {
transform: scale(1);
opacity: 0.5;
}
70% {
transform: scale(2.2);
opacity: 0;
}
100% {
transform: scale(2.2);
opacity: 0;
}
}
.dispatch-container .live-rider-bike.is-idle .live-rider-bike-badge {
opacity: 0.85;
}
.dispatch-container .live-rider-bike-label {
position: absolute;
left: 46px;
top: 9px;
background: var(--pin-color);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
line-height: 1.2;
pointer-events: auto;
}
.dispatch-container .live-rider-bike-label span {
font-weight: 500;
opacity: 0.85;
margin-left: 4px;
}
/* Body layout */
.dispatch-container #body {
flex: 1;
@@ -1949,6 +2035,46 @@
color: #16a34a;
}
/* GPS-only rider (live on the road, no active delivery this slot). The card
collapses to just the live state — the badge mirrors the map's live bike
indicator so the operator reads "tracking position only" at a glance. */
.dispatch-container .rcard.is-gps-only {
border-style: dashed;
cursor: default;
}
/* GPS-only riders have no route to open, so the card is not interactive —
suppress the hover lift/shadow that signals "clickable". */
.dispatch-container .rcard.is-gps-only:hover {
transform: none;
box-shadow: var(--shadow);
border-color: var(--border);
}
.dispatch-container .rcard-badge.rcard-badge-live {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(22, 163, 74, 0.12);
color: #16a34a;
letter-spacing: 0.04em;
}
.dispatch-container .rcard-live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #16a34a;
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.55);
animation: rcard-live-pulse 1.6s ease-out infinite;
}
@keyframes rcard-live-pulse {
0% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.55); }
70% { box-shadow: 0 0 0 6px rgba(22, 163, 74, 0); }
100% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0); }
}
.dispatch-container .bar-bg {
background: var(--bg-sub);
border-radius: 4px;
@@ -2166,6 +2292,8 @@
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
row-gap: 10px;
border-bottom: 1px solid var(--border);
background: #fff;
}
@@ -2186,6 +2314,95 @@
gap: 12px;
}
/* Planned / By time segmented control inside the trip header. Wraps onto its
own full-width row below the badge + stats (the header is flex-wrap: wrap,
and flex-basis: 100% forces the break). Each pill = 50% of the row → a clear
"tab bar". Active pill is a white thumb; the mode's semantic color (indigo
for Planned, emerald for By time) lands on the active icon so the mode reads
at a glance. */
.dispatch-container .trip-sort-toggle {
display: flex;
align-items: stretch;
width: 100%;
flex-basis: 100%;
margin-left: 0;
padding: 3px;
background: #f1f5f9;
border-radius: 10px;
position: relative;
isolation: isolate;
}
.dispatch-container .trip-sort-pill {
flex: 1 1 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 10px;
min-height: 28px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1;
color: #64748b;
background: transparent;
border: 0;
border-radius: 7px;
cursor: pointer;
white-space: nowrap;
transition: color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
-webkit-appearance: none;
appearance: none;
}
.dispatch-container .trip-sort-pill svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
display: block;
color: #94a3b8;
transition: color 0.18s ease, transform 0.18s ease;
}
.dispatch-container .trip-sort-pill:hover:not(.is-active) {
color: #0f172a;
}
.dispatch-container .trip-sort-pill:hover:not(.is-active) svg {
color: #475569;
}
.dispatch-container .trip-sort-pill:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.4);
}
.dispatch-container .trip-sort-pill.is-active {
color: #0f172a;
background: #ffffff;
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.08),
0 0 0 1px rgba(15, 23, 42, 0.04),
0 2px 6px rgba(15, 23, 42, 0.06);
}
.dispatch-container .trip-sort-toggle[data-mode='planned']
.trip-sort-pill.is-active svg {
color: #6366f1;
}
.dispatch-container .trip-sort-toggle[data-mode='time']
.trip-sort-pill.is-active svg {
color: #10b981;
}
/* Undelivered cards while By time is active — sunk to the bottom and dimmed
so "not yet delivered" reads at a glance without checking each status pill. */
.dispatch-container .zone-order-card.is-pending-time {
opacity: 0.72;
}
.dispatch-container .step-wrap {
padding: 16px;
}
@@ -5523,6 +5740,30 @@
text-overflow: ellipsis;
}
/* Customer (recipient) name — sits directly under the rider name as a
secondary, lighter line so the operator sees who the drop is for. */
.dispatch-container .dispatch-popup .pu-customer {
margin-top: 4px;
padding: 0;
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9) !important;
display: flex;
align-items: center;
gap: 6px;
}
.dispatch-container .dispatch-popup .pu-customer svg {
font-size: 15px;
opacity: 0.85;
}
.dispatch-container .dispatch-popup .pu-customer span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dispatch-container .dispatch-popup .pu-delivery-id {
margin-top: 6px;
font-size: 11px;

View File

@@ -21,6 +21,7 @@ import {
MdLocationOn,
MdMarkunreadMailbox,
MdMoveToInbox,
MdPerson,
MdPlace,
MdTwoWheeler,
MdNotes,
@@ -478,7 +479,7 @@ const getStableRiderColor = (id) => {
// extracted CompareDataPanel component can import them without forcing
// a circular dependency on Dispatch.js.
const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => {
const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey, extraPoints }) => {
const map = useMap();
// Last fit signature. We only call fitBounds when this changes — otherwise
// every parent render (data refetch, sidebar tick, etc.) would refit the
@@ -529,7 +530,11 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey })
}
if (viewMode === 'all') {
const oPairs = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
return `${loc}a|${oPairs.length}|${centroidSig(oPairs)}`;
// Include active riders' live GPS so the fit reframes when an order-less
// (GPS-only) rider appears/moves and there are no drops to anchor to.
const ePairs = extraPoints || [];
const allPairs = oPairs.concat(ePairs);
return `${loc}a|${allPairs.length}|${centroidSig(allPairs)}`;
}
return `${loc}m|${viewMode || ''}|${kPairs.length}|${kSig}`;
}, [focusedItem, viewMode, orders, kitchens, locationKey]);
@@ -539,10 +544,12 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey })
let pts = [];
if (focusedItem) {
if (focusedItem.orders) {
if (focusedItem.orders && focusedItem.orders.length) {
pts = focusedItem.orders.map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
focusedItem.orders.forEach((o) => pts.push([toNum(pickupLat(o)), toNum(pickupLon(o))]));
} else {
// Order-less focus target (a single kitchen, or a GPS-only active
// rider) — center on its own coordinate.
pts = [[focusedItem.lat, focusedItem.lon]];
}
} else if (viewMode === 'kitchens') {
@@ -554,6 +561,9 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey })
}
} else if (viewMode === 'all') {
pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
// Frame order-less active riders (GPS-only) too — their live positions
// are the only thing to show for them.
pts = pts.concat(extraPoints || []);
} else {
// No focus, viewMode is 'riders' / 'zones' / etc. — still fit to the
// current hub's footprint so switching from Coimbatore → Nagercoil
@@ -593,11 +603,130 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey })
// bug that left Nagercoil (and every non-Coimbatore hub) stuck on the
// Coimbatore default during the brief window between picking the hub
// and its data arriving.
}, [fitKey, focusedItem, viewMode, orders, kitchens, map]);
}, [fitKey, focusedItem, viewMode, orders, kitchens, extraPoints, map]);
return null;
};
// Smoothly-moving rider marker — the Swiggy/Zomato/Rapido style "bike gliding
// down the road" effect. Instead of letting react-leaflet snap the marker to
// each new GPS fix, we keep the <Marker>'s `position` prop frozen at its mount
// coordinate (a stable ref, so react-leaflet never repositions it) and drive
// every subsequent move imperatively with marker.setLatLng() inside a
// requestAnimationFrame loop. Each time `target` changes we ease from the
// marker's current on-screen latlng to the new fix, so the rider visibly
// travels between points rather than teleporting. A large jump (GPS glitch /
// first real fix after a placeholder) snaps instead of crawling across the map.
//
// ADAPTIVE GLIDE — the critical bit for "live" feel:
// We poll the GPS feed every 1s, but the backend only emits a fresh coordinate
// every ~30s, so the same fix repeats for ~30 polls and then jumps. With a
// fixed glide we'd animate for ~1s and then sit frozen for ~29s — the bike
// would look like it teleports every 30s. Instead we MEASURE the real wall-time
// between distinct fixes and stretch the glide across that whole interval
// (clamped). So when fixes are 30s apart the bike eases continuously for the
// full 30s; if the backend ever speeds up to 1s the glide tightens to 1s
// automatically. Either way motion is smooth and never freezes mid-trip.
// `duration` is only the seed used for the very first segment (no prior fix to
// measure against yet).
const MIN_GLIDE_MS = 800; // floor: don't animate faster than this even on rapid fixes
const MAX_GLIDE_MS = 32_000; // ceiling: cover the ~30s backend cadence + small buffer
const AnimatedRiderMarker = ({ target, icon, duration = 950, zIndexOffset, eventHandlers, children, markerRef: externalRef }) => {
const markerRef = useRef(null);
const rafRef = useRef(null);
// Frozen mount position — never handed back to react-leaflet again, so it
// can't fight the imperative animation below.
const mountPosRef = useRef(target);
// performance.now() timestamp of the last DISTINCT fix we glided to. Lets us
// measure the true inter-fix interval and size the next glide to match it.
const prevFixTsRef = useRef(null);
// CRITICAL: depend on the primitive lat/lon, NOT the `target` array. The
// parent re-renders every second (clock tick + 1s GPS poll) and hands us a
// brand-new `[lat, lon]` array each time. If the effect keyed off that array
// it would cancel + restart the glide on every render — the ease keeps
// resetting to zero velocity and the bike visibly stutters/lags. Keying off
// the numbers means the glide only (re)starts when the rider's coordinate
// genuinely changes, so each 1s segment plays out uninterrupted.
const lat = Array.isArray(target) ? Number(target[0]) : NaN;
const lon = Array.isArray(target) ? Number(target[1]) : NaN;
useEffect(() => {
const marker = markerRef.current;
if (!marker || !Number.isFinite(lat) || !Number.isFinite(lon)) return undefined;
const to = L.latLng(lat, lon);
const from = marker.getLatLng();
if (!from) {
marker.setLatLng(to);
return undefined;
}
const dLat = to.lat - from.lat;
const dLng = to.lng - from.lng;
// No meaningful move (~<0.1m) — snap and skip the rAF.
if (Math.abs(dLat) < 1e-6 && Math.abs(dLng) < 1e-6) {
marker.setLatLng(to);
return undefined;
}
// Teleport on big jumps (>2km) so a bad fix doesn't drag the icon across town.
let bigJump = false;
try {
bigJump = from.distanceTo(to) > 2000;
} catch {
bigJump = false;
}
if (bigJump) {
marker.setLatLng(to);
prevFixTsRef.current = performance.now();
return undefined;
}
if (rafRef.current) cancelAnimationFrame(rafRef.current);
const startTs = performance.now();
// Size this glide to the real gap since the previous fix so the bike keeps
// moving for the whole interval instead of darting then freezing. First
// segment has no prior timestamp, so fall back to the `duration` seed.
const gap = prevFixTsRef.current == null ? duration : startTs - prevFixTsRef.current;
const segMs = Math.max(MIN_GLIDE_MS, Math.min(MAX_GLIDE_MS, gap));
prevFixTsRef.current = startTs;
const startLat = from.lat;
const startLng = from.lng;
const step = (now) => {
const t = Math.min(1, (now - startTs) / segMs);
// Linear interpolation → constant speed. Spanning the glide across the
// full inter-fix interval chains consecutive segments into one continuous
// motion (a vehicle moving down the road) rather than the accelerate/brake
// feel an easing curve gives, or the dart-then-freeze of a fixed duration.
marker.setLatLng([startLat + dLat * t, startLng + dLng * t]);
if (t < 1) rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [lat, lon, duration]);
// Cleanup any in-flight animation on unmount.
useEffect(() => () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
}, []);
return (
<Marker
ref={(inst) => {
markerRef.current = inst;
if (typeof externalRef === 'function') externalRef(inst);
else if (externalRef) externalRef.current = inst;
}}
position={mountPosRef.current}
icon={icon}
zIndexOffset={zIndexOffset}
eventHandlers={eventHandlers}
>
{children}
</Marker>
);
};
// Inline-icon wrapper used wherever a Material icon precedes some text — keeps the
// SVG vertically centered with the adjacent text and inherits the parent color.
const Ico = ({ children }) => (
@@ -699,6 +828,12 @@ const Dispatch = ({
const [focusedZone, setFocusedZone] = useState(null);
// Single delivery stop pinned by clicking its sidebar row — overrides the rider's full-route bounds on the map.
const [focusedStop, setFocusedStop] = useState(null);
// How the stops inside each trip block are ordered in the focused-rider sidebar:
// 'planned' → the dispatched route order (by step) — the default.
// 'time' → re-sorted by when each delivery was actually completed, so the
// operator can see which drop happened first regardless of the
// planned sequence. Undelivered stops sink to the bottom.
const [tripSortMode, setTripSortMode] = useState('planned');
// Holds leaflet marker instances keyed by orderid so we can imperatively open
// their popups when the user clicks a step in the focused-rider sidebar.
const orderMarkerRefs = useRef({});
@@ -712,6 +847,13 @@ const Dispatch = ({
// popups use leaflet's marker-attached <Popup> (openPopup/closePopup) rather
// than the centered overlay used for order popups.
const pinnedLivePopupsRef = useRef(new Set());
// Per-rider cache of the live bike L.divIcon, keyed by id. The page re-renders
// every second (clock tick + 1s GPS poll in the active view); without this
// cache we'd hand react-leaflet a brand-new icon object each tick, forcing a
// setIcon() that wipes the marker DOM and restarts the CSS pulse animation.
// Caching by a content signature keeps the icon reference stable when nothing
// changed, so the pulse animates smoothly and only the position eases.
const liveIconCacheRef = useRef(new Map());
// Short-lived close timer for the general map order/marker popups.
// Gives the cursor a ~200ms window to travel from the marker onto the popup
// or vice versa without immediately triggering a close.
@@ -1074,12 +1216,18 @@ const Dispatch = ({
// hub (latitude/longitude/logdate/status). We render those positions as
// markers on the main dispatch map so the operator sees where each rider
// actually is — matching the Reports → Riders Logs page.
// Poll cadence: in the "All Active Routes" view the operator is watching
// riders physically move, so we refresh the GPS feed every second (Swiggy /
// Rapido style live tracking). The AnimatedRiderMarker eases the bike between
// fixes so motion stays smooth even if a fix is unchanged. Other views don't
// need second-by-second churn, so they stay on the lighter 15s cadence.
const RIDER_LOG_POLL_MS = viewMode === 'all' ? 1_000 : 15_000;
const { data: ridersLocationLogs } = useQuery({
queryKey: [selectedAppLocationId, selectedDate, ''],
queryFn: fetchRidersLogs,
refetchInterval: 15_000,
refetchInterval: RIDER_LOG_POLL_MS,
refetchIntervalInBackground: false,
staleTime: 5 * 1000,
staleTime: 1000,
refetchOnWindowFocus: false
});
@@ -1105,6 +1253,15 @@ const Dispatch = ({
})
.filter(Boolean);
}, [ridersLocationLogs]);
// Set of rider ids whose latest GPS log row is `active` (i.e. on the road
// right now). The "All Active Routes" view (viewMode === 'all') uses this to
// show ONLY currently-active riders — their cards, routes, drop markers and
// live bike markers — and hide everyone who is offline/idle for the slot.
const activeRiderIdSet = useMemo(
() => new Set(liveRiderLocations.filter((r) => r.status === 'active').map((r) => String(r.id))),
[liveRiderLocations]
);
// Default to the slot containing the current wall-clock time. Use a
// fractional hour so 12:45 lands in the 12:30+ slot 2 (not slot 1). If
// the current time falls outside every slot window (e.g. before 8 AM)
@@ -1395,6 +1552,60 @@ const Dispatch = ({
? (selectedRiderId ? (riders.find((r) => r.id === selectedRiderId) || null) : null)
: internalFocusedRider;
// "All Active Routes" view scoping. In this mode we render only riders whose
// live GPS log is `active` right now — their cards, routes, drop markers and
// bike markers — so the operator sees the on-road fleet at a glance. Every
// other view (By Location / By Zone / By Rider) is unchanged.
const isAllActiveView = viewMode === 'all';
// Active riders the GPS feed reports as on-road RIGHT NOW that have no orders
// in the current data (i.e. nothing to deliver). We still want them on screen
// in "All Active Routes" — just their live bike marker, no route — so we
// synthesize order-less rider objects shaped like real ones. `gpsOnly` flags
// them so the card / route code treats them as "live position only".
const gpsOnlyActiveRiders = useMemo(() => {
if (!isAllActiveView) return [];
const haveOrders = new Set(riders.map((r) => String(r.id)));
return liveRiderLocations
.filter((r) => r.status === 'active' && !haveOrders.has(String(r.id)))
.map((r) => ({
id: r.id,
riderName: r.username || `Rider #${r.id}`,
orders: [],
color: getStableRiderColor(r.id),
gpsOnly: true,
// Live position — lets MapController center on the rider when their
// GPS-only card is clicked (they have no drops to fit to).
lat: r.lat,
lon: r.lon
}));
}, [isAllActiveView, liveRiderLocations, riders]);
// The riders we render in "All Active Routes": every rider whose live GPS is
// active — those with orders (real route shown) PLUS those without (GPS only).
// Every other view (By Location / By Zone / By Rider) is unchanged.
const visibleRiders = useMemo(
() =>
isAllActiveView
? [...riders.filter((r) => activeRiderIdSet.has(String(r.id))), ...gpsOnlyActiveRiders]
: riders,
[isAllActiveView, riders, activeRiderIdSet, gpsOnlyActiveRiders]
);
// Orders that belong to the riders we're actually showing — drives the drop
// markers and the map auto-fit bounds in the active view.
const allViewOrders = useMemo(
() => (isAllActiveView ? allOrders.filter((o) => activeRiderIdSet.has(String(o.rider_id))) : allOrders),
[isAllActiveView, allOrders, activeRiderIdSet]
);
// Live GPS coordinates of every active rider in the "All Active Routes" view.
// Fed to MapController's auto-fit so order-less (GPS-only) active riders are
// framed even when there are no drop markers to anchor the bounds.
const allViewLivePoints = useMemo(
() =>
isAllActiveView
? liveRiderLocations.filter((r) => r.status === 'active').map((r) => [r.lat, r.lon])
: [],
[isAllActiveView, liveRiderLocations]
);
// Per-rider canvas renderer for the actual (right) map in Compare mode.
// Single setter used by every interactive site in the UI. In uncontrolled mode it
// updates local state; in controlled mode it only notifies the parent.
@@ -1473,6 +1684,19 @@ const Dispatch = ({
label: 'Focused Kitchen'
};
}
// "All Active Routes": the header must reflect exactly what the list/map
// shows — the active fleet (riders with orders + GPS-only riders) and their
// orders — NOT the whole day's totals. Otherwise the top "Riders" tile (full
// fleet) disagrees with the rider list below (active-only).
if (isAllActiveView) {
return {
orders: allViewOrders.length,
riders: visibleRiders.length,
km: allViewOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0),
profit: allViewOrders.reduce((s, o) => s + parseFloat(o.profit || 0), 0),
label: 'Active Fleet'
};
}
return {
orders: stats.totalOrders,
riders: stats.totalRiders,
@@ -1480,7 +1704,7 @@ const Dispatch = ({
profit: stats.totalProfit,
label: 'Total Fleet'
};
}, [focusedRider, focusedKitchen, stats]);
}, [focusedRider, focusedKitchen, isAllActiveView, allViewOrders, visibleRiders, stats]);
// List of deliveryids we want GPS logs for. Drives two pipelines:
// • renderRoutes() — actual-route polylines on the main map for
@@ -2225,35 +2449,52 @@ const Dispatch = ({
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
});
const estMeters = activeOrder ? calculateEstMeters(r.id, activeOrder) : null;
// GPS-only rider: live on the road but no orders in this slot. Show a card
// that makes the "tracking position only, no active delivery" state obvious.
const isGpsOnly = r.gpsOnly || total === 0;
return (
<div key={r.id} className="rcard" onClick={() => handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
<div key={r.id} className={`rcard${isGpsOnly ? ' is-gps-only' : ''}`} onClick={isGpsOnly ? undefined : () => handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
<div className="rcard-top">
<div className="rcard-emo" style={{ background: `${r.color}18`, borderColor: `${r.color}50`, color: r.color }}><MdTwoWheeler /></div>
<div className="rcard-info">
<div className="rcard-name">{r.riderName}</div>
<div className="rcard-zone">{r.orders[0]?.zone_name || locationName || 'Local'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips</div>
<div className="rcard-zone">
{isGpsOnly
? 'Live GPS · no active delivery'
: `${r.orders[0]?.zone_name || locationName || 'Local'} · ${new Set(r.orders.map(o => o.trip_number || 1)).size} trips`}
</div>
</div>
<div
className={`rcard-badge ${isDone ? 'is-done' : ''}`}
style={isDone ? undefined : { background: `${r.color}18`, color: r.color }}
title={`${delivered} delivered of ${total} total`}
>
{delivered}/{total}
</div>
</div>
<div className="bar-bg"><div className="bar-fg" style={{ width: `${Math.min(100, (total / 15) * 100)}%`, background: r.color }}></div></div>
<div className="rcard-meta">
<span><Ico><MdStraighten /></Ico>{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
{estMeters !== null && (
<span className="rcard-est-meters" title="Estimated distance to next drop location">
<Ico><MdMyLocation /></Ico>{formatMeters(estMeters)} to drop
</span>
{isGpsOnly ? (
<div className="rcard-badge rcard-badge-live" title="Rider is live on GPS with no active delivery">
<span className="rcard-live-dot" /> LIVE
</div>
) : (
<div
className={`rcard-badge ${isDone ? 'is-done' : ''}`}
style={isDone ? undefined : { background: `${r.color}18`, color: r.color }}
title={`${delivered} delivered of ${total} total`}
>
{delivered}/{total}
</div>
)}
</div>
<div className="step-ids">
{r.orders.slice(0, 15).map(o => <span key={o.orderid} className="step-id">S{o.step}</span>)}
</div>
{!isGpsOnly && (
<>
<div className="bar-bg"><div className="bar-fg" style={{ width: `${Math.min(100, (total / 15) * 100)}%`, background: r.color }}></div></div>
<div className="rcard-meta">
<span><Ico><MdStraighten /></Ico>{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
{estMeters !== null && (
<span className="rcard-est-meters" title="Estimated distance to next drop location">
<Ico><MdMyLocation /></Ico>{formatMeters(estMeters)} to drop
</span>
)}
</div>
<div className="step-ids">
{r.orders.slice(0, 15).map(o => <span key={o.orderid} className="step-id">S{o.step}</span>)}
</div>
</>
)}
</div>
);
};
@@ -2298,6 +2539,11 @@ const Dispatch = ({
<div className="pu-rider">
<MdTwoWheeler /> <span>{o.rider_name || o.ridername || 'Unassigned'}</span>
</div>
{(o.deliverycustomer || o.customername) && (
<div className="pu-customer" title={o.deliverycustomer || o.customername}>
<MdPerson /> <span>{o.deliverycustomer || o.customername}</span>
</div>
)}
{o.deliveryid != null && (
<div className="pu-delivery-id">Delivery #{o.deliveryid}</div>
)}
@@ -2412,7 +2658,9 @@ const Dispatch = ({
// duplicate, slightly-offset pins that clutter the view.
if (compareOpen && focusedRider && compareViewMode === 'actual') return null;
let ordersToRender = allOrders;
// In "All Active Routes" view the base set is restricted to active riders'
// orders (allViewOrders); a focus selection still overrides as usual.
let ordersToRender = allViewOrders;
if (focusedZone) ordersToRender = focusedZone.orders;
if (focusedKitchen) ordersToRender = focusedKitchen.orders;
if (focusedRider) ordersToRender = focusedRider.orders;
@@ -2531,7 +2779,9 @@ const Dispatch = ({
const routes = [];
const zoneRiderIds = focusedZone ? new Set(focusedZone.riders.map((zr) => String(zr.rider_id))) : null;
if (hidePlanned) return routes;
riders.forEach(r => {
// visibleRiders === riders in every view except "All Active Routes", where
// it's pre-filtered to riders whose live GPS is currently active.
visibleRiders.forEach(r => {
const isActive = activeRiders.has(r.id);
if (focusedRider && focusedRider.id !== r.id) return;
if (focusedKitchen && !focusedKitchen.riders.has(r.id)) return;
@@ -3025,7 +3275,7 @@ const Dispatch = ({
onClick={() => { logger.info('View mode changed: By Zone'); setViewMode('zones'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}
><span className="sbt-icon"><MdMap /></span> By Zone</button>
<button className={`sbt ${viewMode === 'riders' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: By Rider'); setViewMode('riders'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdDirectionsBike /></span> By Rider</button>
<button className={`sbt ${viewMode === 'all' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: All Routes'); setViewMode('all'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPublic /></span> All Routes</button>
<button className={`sbt ${viewMode === 'all' ? 'active' : ''}`} onClick={() => { logger.info('View mode changed: All Active Routes'); setViewMode('all'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }}><span className="sbt-icon"><MdPublic /></span>Active</button>
<button
type="button"
className={`sbt sbt-rider-info ${viewMode === 'rider-info' ? 'active' : ''}`}
@@ -3574,10 +3824,31 @@ const Dispatch = ({
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
});
const activeOrderId = activeOrder ? activeOrder.orderid : null;
// Completion timestamp used by the "By time" sort — prefer the
// actual deliverytime, fall back to the expected one, and push
// rows with neither to the very end (MAX_SAFE_INTEGER).
const completionTs = (o) => {
const t = o.deliverytime || o.expecteddeliverytime;
if (!t) return Number.MAX_SAFE_INTEGER;
const d = dayjs(t);
return d.isValid() ? d.valueOf() : Number.MAX_SAFE_INTEGER;
};
const isTimeMode = tripSortMode === 'time';
let prevKitchenKey = null;
return Object.entries(trips)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([tNum, tOrders]) => (
.map(([tNum, tOrders]) => {
// 'planned' keeps the incoming step order; 'time' re-sorts
// inside the trip by completion time with step as tiebreaker
// so two drops logged the same minute stay in dispatch order.
const displayOrders = isTimeMode
? [...tOrders].sort((a, b) => {
const diff = completionTs(a) - completionTs(b);
if (diff !== 0) return diff;
return (a.step || 0) - (b.step || 0);
})
: tOrders;
return (
<div key={tNum} className="trip-block">
<div className="trip-header" style={{ background: `${focusedRider.color}12`, borderColor: `${focusedRider.color}30` }}>
<span className="th-badge" style={{ background: focusedRider.color }}>Trip {tNum}</span>
@@ -3585,9 +3856,40 @@ const Dispatch = ({
<span><Ico><MdLocationOn /></Ico>{tOrders.length} stops</span>
<span><Ico><MdStraighten /></Ico>{tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
</span>
{/* iOS-style segmented control: Planned (dispatched step
order) vs By time (completion order). The active item is
a white "thumb"; the rider color stays on the Trip badge
so the pills don't compete with it. */}
<div
className="trip-sort-toggle"
role="group"
aria-label="Sort stops by"
data-mode={isTimeMode ? 'time' : 'planned'}
>
<button
type="button"
className={`trip-sort-pill ${!isTimeMode ? 'is-active' : ''}`}
aria-pressed={!isTimeMode}
onClick={() => setTripSortMode('planned')}
title="Sort stops by planned step (dispatched order)"
>
<MdFormatListBulleted aria-hidden="true" />
<span>Planned</span>
</button>
<button
type="button"
className={`trip-sort-pill ${isTimeMode ? 'is-active' : ''}`}
aria-pressed={isTimeMode}
onClick={() => setTripSortMode('time')}
title="Sort stops by completion time (which delivery was done first)"
>
<MdAccessTime aria-hidden="true" />
<span>By time</span>
</button>
</div>
</div>
<div className="zone-order-grid">
{tOrders.map((o, idx) => {
{displayOrders.map((o, idx) => {
const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim();
const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey;
prevKitchenKey = kitchenKey;
@@ -3598,6 +3900,10 @@ const Dispatch = ({
const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
const statusStyle = getStatusStyle(o.orderstatus);
const estMeters = calculateEstMeters(focusedRider.id, o);
// In "By time" mode, stops with no actual delivery time
// were sunk to the bottom — dim them so it's obvious
// they're not yet delivered without reading the status.
const isUndeliveredInTimeMode = isTimeMode && !o.deliverytime;
return (
<React.Fragment key={o.orderid}>
@@ -3605,7 +3911,7 @@ const Dispatch = ({
<div className="kitchen-transition"><span className="kt-ico"><MdSwapHoriz /></span> Switch to <strong>{o.pickupcustomer}</strong></div>
)}
<div
className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''} ${isGoingOn ? 'going-on' : ''}`}
className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''} ${isGoingOn ? 'going-on' : ''} ${isUndeliveredInTimeMode ? 'is-pending-time' : ''}`}
role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
@@ -3714,7 +4020,8 @@ const Dispatch = ({
})}
</div>
</div>
));
);
});
})()}
</>
) : (
@@ -3996,7 +4303,8 @@ const Dispatch = ({
<div className="ph">{
viewMode === 'zones' ? 'Zone dispatch' :
viewMode === 'kitchens' ? 'Kitchen dispatch' :
'Rider dispatch'
viewMode === 'all' ? 'Active rider dispatch' :
'Rider dispatch'
}</div>
<div id="rider-cards">
{allOrders.length === 0 && !liveIsFetching ? (
@@ -4114,8 +4422,18 @@ const Dispatch = ({
</div>
</div>
))
) : isAllActiveView && visibleRiders.length === 0 ? (
<div className="empty-slot">
<div className="empty-slot-icon">
<MdTwoWheeler />
</div>
<div className="empty-slot-title">No active riders</div>
<div className="empty-slot-sub">
No riders are currently live on the road for this slot
</div>
</div>
) : (
riders.map(renderRiderCard)
visibleRiders.map(renderRiderCard)
)}
</div>
</div>
@@ -4140,7 +4458,7 @@ const Dispatch = ({
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='&copy; OpenStreetMap contributors' />
<ZoomControl position="bottomright" />
{compareOpen && <CaptureMap targetRef={leftMapRef} />}
<MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allOrders} kitchens={kitchens} locationKey={selectedAppLocationId} />
<MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allViewOrders} kitchens={kitchens} locationKey={selectedAppLocationId} extraPoints={allViewLivePoints} />
{kitchens
.filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon))
.filter(k => !focusedRider || k.riders.has(focusedRider.id))
@@ -4173,14 +4491,23 @@ const Dispatch = ({
{/* Live rider GPS markers from /partners/getriderlogs/. Mirrors the
Reports → Riders Logs map: green pin when the rider's last log
row is `active`, red otherwise, with the rider's username as a
label. Scoped to riders who actually have orders in the
currently selected slot — `riders` is derived from
filteredLiveRows so it already reflects the slot filter. A
rider with zero orders in the current slot is hidden, even if
getriderlogs still returns their GPS row. When a specific
rider is focused, only that one is shown. */}
label.
"All Active Routes" view: show EVERY active rider's live GPS —
including riders with no orders in the current slot (those get a
bike marker only, no route). Riders with an active delivery also
get their route drawn by renderRoutes(). Other views stay scoped
to riders who actually have orders in the slot (`riders` is
derived from filteredLiveRows so it already reflects the slot
filter); a rider with zero orders is hidden there even if
getriderlogs still returns their GPS row. When a specific rider
is focused, only that one is shown. */}
{liveRiderLocations
.filter((r) => riders.some((rd) => String(rd.id) === String(r.id)))
.filter((r) =>
isAllActiveView
? r.status === 'active'
: riders.some((rd) => String(rd.id) === String(r.id))
)
.filter((r) => !focusedRider || String(focusedRider.id) === String(r.id))
.map((r) => {
const isActive = r.status === 'active';
@@ -4204,39 +4531,82 @@ const Dispatch = ({
const nextDropArea = nextOrder
? (nextOrder.deliverysuburb || extractArea(nextOrder.deliveryaddress))
: null;
const liveIcon = L.divIcon({
className: '',
iconSize: [140, 56],
iconAnchor: [12, 41],
popupAnchor: [58, -40],
html: `<div class="live-rider-pin" style="--pin-color:${pinColor}">
<div class="live-rider-pin-marker"></div>
<div class="live-rider-pin-label">${(r.username || '').replace(/[<>&"']/g, '')}${r.orderid ? ` <span>#${String(r.orderid).replace(/[<>&"']/g, '')}</span>` : ''}</div>
// Marker icon. ONLY the "All Active Routes" view gets the
// Swiggy/Zomato/Rapido-style live bike badge (rounded glyph
// + pulsing ring) that smoothly glides between GPS fixes.
// Every other view keeps the original teardrop pin exactly
// as before. Icons are cached per rider (liveIconCacheRef);
// the sig includes the view so switching modes rebuilds it.
const safeName = (r.username || '').replace(/[<>&"']/g, '');
const safeOrder = r.orderid ? String(r.orderid).replace(/[<>&"']/g, '') : '';
const iconSig = `${isAllActiveView ? 'bike' : 'pin'}|${pinColor}|${safeName}|${safeOrder}|${isActive ? 1 : 0}`;
let iconEntry = liveIconCacheRef.current.get(r.id);
if (!iconEntry || iconEntry.sig !== iconSig) {
const icon = isAllActiveView
? L.divIcon({
className: '',
iconSize: [160, 44],
iconAnchor: [22, 22],
popupAnchor: [0, -22],
html: `<div class="live-rider-bike ${isActive ? 'is-active' : 'is-idle'}" style="--pin-color:${pinColor}">
<span class="live-rider-bike-pulse"></span>
<span class="live-rider-bike-badge">
<svg viewBox="0 0 24 24" width="20" height="20" fill="#fff" aria-hidden="true"><path d="M19.44 9.03 15.41 5H11v2h3.59l2 2H5c-2.8 0-5 2.2-5 5s2.2 5 5 5c2.46 0 4.45-1.69 4.9-4h1.65l2.77-2.77c-.21.54-.32 1.14-.32 1.77 0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.65-2.06-4.77-4.66-4.97ZM7.82 15C7.4 16.15 6.28 17 5 17c-1.63 0-3-1.37-3-3s1.37-3 3-3c1.28 0 2.4.85 2.82 2H5v2h2.82ZM19 17c-1.63 0-3-1.37-3-3s1.37-3 3-3 3 1.37 3 3-1.37 3-3 3Z"/></svg>
</span>
<span class="live-rider-bike-label">${safeName}${safeOrder ? ` <span>#${safeOrder}</span>` : ''}</span>
</div>`
});
})
: L.divIcon({
className: '',
iconSize: [140, 56],
iconAnchor: [12, 41],
popupAnchor: [58, -40],
html: `<div class="live-rider-pin" style="--pin-color:${pinColor}">
<div class="live-rider-pin-marker"></div>
<div class="live-rider-pin-label">${safeName}${safeOrder ? ` <span>#${safeOrder}</span>` : ''}</div>
</div>`
});
iconEntry = { sig: iconSig, icon };
liveIconCacheRef.current.set(r.id, iconEntry);
}
const liveIcon = iconEntry.icon;
// Shared interaction handlers — identical for both marker types.
const liveEventHandlers = {
click: (e) => {
const idStr = String(r.id);
if (pinnedLivePopupsRef.current.has(idStr)) {
pinnedLivePopupsRef.current.delete(idStr);
e.target.closePopup();
} else {
pinnedLivePopupsRef.current.add(idStr);
e.target.openPopup();
}
// Focus the rider behind this marker — only riders that
// actually have a delivery (i.e. exist in `riders`).
// GPS-only active riders have no route to focus, so their
// marker just shows the live popup and isn't clickable
// for focus.
const match = riders.find((rd) => String(rd.id) === idStr);
if (match) handleRiderFocus(match);
},
popupclose: () => {
pinnedLivePopupsRef.current.delete(String(r.id));
}
};
// Animated bike (with imperative gliding) only in the active
// view; the plain react-leaflet Marker everywhere else so
// existing views behave exactly as they did before.
const LiveMarker = isAllActiveView ? AnimatedRiderMarker : Marker;
const positionProps = isAllActiveView
? { target: [r.lat, r.lon], duration: 1200 }
: { position: [r.lat, r.lon] };
return (
<Marker
<LiveMarker
key={`live-${r.id}`}
position={[r.lat, r.lon]}
{...positionProps}
icon={liveIcon}
zIndexOffset={2500}
eventHandlers={{
click: (e) => {
const idStr = String(r.id);
if (pinnedLivePopupsRef.current.has(idStr)) {
pinnedLivePopupsRef.current.delete(idStr);
e.target.closePopup();
} else {
pinnedLivePopupsRef.current.add(idStr);
e.target.openPopup();
}
const match = riders.find((rd) => String(rd.id) === idStr);
if (match) handleRiderFocus(match);
},
popupclose: () => {
pinnedLivePopupsRef.current.delete(String(r.id));
}
}}
eventHandlers={liveEventHandlers}
>
<Popup maxWidth={260} autoPan={true} autoPanPadding={[20, 20]} className="dispatch-popup live-rider-popup">
<div className="pu-hdr-live">
@@ -4322,7 +4692,7 @@ const Dispatch = ({
</div>
</div>
</Popup>
</Marker>
</LiveMarker>
);
})}

View File

@@ -1,43 +1,55 @@
import React, { useState, useEffect, Fragment, useRef } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
Box,
Drawer,
IconButton,
Toolbar,
Typography,
AppBar,
useMediaQuery,
Divider,
List,
ListItem,
ListItemText,
useTheme,
ListItemAvatar,
Avatar,
Tooltip,
TableCell,
Chip,
Stack,
TableRow,
TableBody,
TableHead,
Table,
TableContainer,
Tabs,
Tab,
CircularProgress
CircularProgress,
InputBase,
Paper,
Avatar,
ButtonBase
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import SearchBar from 'components/nearle_components/SearchBar';
import {
MdMenu,
MdSearch,
MdClear,
MdPlace,
MdStorefront,
MdMyLocation,
MdAccessTime,
MdLocalShipping,
MdHourglassEmpty,
MdCheckCircle,
MdCancel,
MdReceiptLong
} from 'react-icons/md';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { fetchOrders1, gettenantlocations } from '../api/api';
import Loader from 'components/Loader';
import CircularLoader from 'components/nearle_components/CircularLoader';
import { Empty, Skeleton } from 'antd';
import MainCard from 'components/MainCard';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import { CancelOutlined, CheckCircleOutline } from '@mui/icons-material';
import {
DT,
BRAND,
BRAND_LIGHT,
tint,
soft,
ring,
edge,
StatusBadge,
AccentAvatar
} from '../_shared/ordersDesign';
import axios from 'axios';
import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc');
@@ -45,6 +57,15 @@ dayjs.extend(utc);
const drawerWidth = 300;
// Status filter tabs — colors aligned with STATUS_META in the shared design system
// (blue=created, amber=pending, green=delivered, red=cancelled).
const STATUS_TABS = [
{ label: 'Created', value: 'created', color: '#3b82f6', icon: MdLocalShipping },
{ label: 'Pending', value: 'pending', color: '#f59e0b', icon: MdHourglassEmpty },
{ label: 'Delivered', value: 'delivered', color: '#10b981', icon: MdCheckCircle },
{ label: 'Cancelled', value: 'cancelled', color: '#ef4444', icon: MdCancel }
];
const ResponsiveLocationDrawer = () => {
const loadMoreRef = useRef();
const containerRef = useRef();
@@ -71,6 +92,14 @@ const ResponsiveLocationDrawer = () => {
const [searchword, setSearchword] = useState('');
const [debouncedSearchword, setDebouncedSearchword] = useState('');
// Per-status counts keyed by tab value, so the filter pills can show a badge.
const statusCounts = {
created: createdLenght,
pending: pendingLenght,
delivered: deliveredlenght,
cancelled: cancelledLenght
};
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchLocation(searchLocation);
@@ -87,38 +116,11 @@ const ResponsiveLocationDrawer = () => {
return () => clearTimeout(handler);
}, [searchword]);
const statusMap = [
{
label: 'Created',
value: 'created',
count: createdLenght,
icon: <AccessTimeIcon color="primary" fontSize="small" />
},
{
label: 'Pending',
value: 'pending',
count: pendingLenght,
icon: <LocalShippingOutlinedIcon color="primary" fontSize="small" />
},
{
label: 'Delivered',
value: 'delivered',
count: deliveredlenght,
icon: <CheckCircleOutline color="primary" fontSize="small" />
},
{
label: 'Cancelled',
value: 'cancelled',
count: cancelledLenght,
icon: <CancelOutlined color="primary" fontSize="small" />
}
];
const handleChangetab = (e, i) => {
const handleChangetab = (i) => {
setSearchword('');
setRowsPerPage(10);
setTabvalue(i);
setCurrentStatus(statusMap[i].value);
setCurrentStatus(STATUS_TABS[i].value);
setPage(0);
};
@@ -138,9 +140,7 @@ const ResponsiveLocationDrawer = () => {
// in the visible list, returning nothing and confusing the operator).
useEffect(() => {
if (!Array.isArray(locations) || locations.length === 0) return;
const stillVisible =
selectedLocation &&
locations.some((l) => l.locationid === selectedLocation.locationid);
const stillVisible = selectedLocation && locations.some((l) => l.locationid === selectedLocation.locationid);
if (!stillVisible) setSelectedLocation(locations[0]);
}, [locations]);
@@ -177,7 +177,7 @@ const ResponsiveLocationDrawer = () => {
}
},
{
root: document.querySelector('.MuiTableContainer-root'), // 👈 or explicitly TableContainer
root: document.querySelector('.MuiTableContainer-root'),
rootMargin: '0px',
threshold: 1.0
}
@@ -240,6 +240,139 @@ const ResponsiveLocationDrawer = () => {
errMessage && console.log(errMessage);
}, [errMessage]);
// Brand-styled scrollbar reused on the sidebar + table.
const scrollbarSx = {
'&::-webkit-scrollbar': { width: 8, height: 8 },
'&::-webkit-scrollbar-thumb': {
backgroundColor: edge(BRAND),
borderRadius: 8,
'&:hover': { backgroundColor: BRAND }
},
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
};
// --------------------------------------------------------------------------
// Sidebar — searchable location list. Shared between the desktop persistent
// drawer and the mobile temporary drawer.
// --------------------------------------------------------------------------
const sidebarContent = (
<Stack sx={{ height: '100%', bgcolor: '#fff' }}>
{/* Sidebar header */}
<Box sx={{ px: 1.5, pt: 1.5, pb: 1 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.25 }}>
<Avatar
variant="rounded"
sx={{ width: 30, height: 30, bgcolor: BRAND, color: '#fff', borderRadius: 1.5, boxShadow: `0 4px 12px ${ring(BRAND)}` }}
>
<MdStorefront size={16} />
</Avatar>
<Stack spacing={0}>
<Typography sx={{ fontSize: 13.5, fontWeight: 800, color: DT.textPrimary, lineHeight: 1.1 }}>Locations</Typography>
<Typography sx={{ fontSize: 10.5, fontWeight: 600, color: DT.textMuted }}>
{Array.isArray(locations) ? `${locations.length} active` : '—'}
</Typography>
</Stack>
</Stack>
{/* Search pill */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.75,
borderRadius: 999,
bgcolor: tint(BRAND),
border: `1.5px solid ${edge(BRAND)}`,
transition: 'all 0.18s',
'&:focus-within': { borderColor: BRAND, boxShadow: `0 0 0 3px ${ring(BRAND)}` }
}}
>
<MdSearch size={16} style={{ color: BRAND, flexShrink: 0 }} />
<InputBase
placeholder="Search location"
value={searchLocation}
onChange={(e) => setSearchLocation(e.target.value)}
autoComplete="off"
sx={{
flex: 1,
fontSize: 13,
fontWeight: 600,
color: DT.textPrimary,
'& input::placeholder': { color: DT.textMuted, opacity: 1 }
}}
/>
{searchLocation && (
<IconButton size="small" onClick={() => setSearchLocation('')} sx={{ p: 0.25, color: BRAND }}>
<MdClear size={14} />
</IconButton>
)}
</Box>
</Box>
{/* Location list */}
<Box sx={{ flex: 1, overflowY: 'auto', px: 1, pb: 1, ...scrollbarSx }}>
{locationIsLoading &&
Array.from({ length: 8 }).map((_, i) => (
<Box key={i} sx={{ px: 1, py: 1 }}>
<Skeleton avatar active paragraph={{ rows: 1 }} title={false} />
</Box>
))}
{!locationIsLoading && Array.isArray(locations) && locations.length === 0 && (
<Box sx={{ py: 5 }}>
<Empty description="No locations" />
</Box>
)}
<Stack spacing={0.5}>
{locations?.map((row, index) => {
const isSelected = row.locationid === selectedLocation?.locationid;
return (
<ButtonBase
key={index}
onClick={() => setSelectedLocation(row)}
sx={{
width: '100%',
justifyContent: 'flex-start',
textAlign: 'left',
gap: 1,
px: 1,
py: 0.875,
borderRadius: 2,
position: 'relative',
transition: 'background-color 0.14s, box-shadow 0.14s',
bgcolor: isSelected ? tint(BRAND) : 'transparent',
boxShadow: isSelected ? `inset 3px 0 0 ${BRAND}` : 'none',
'&:hover': { bgcolor: isSelected ? tint(BRAND) : DT.surfaceAlt }
}}
>
<AccentAvatar color={BRAND} selected={isSelected} size={36}>
{row.locationname?.[0]?.toUpperCase() || '?'}
</AccentAvatar>
<Stack spacing={0} sx={{ minWidth: 0, flex: 1 }}>
<Typography
sx={{ fontSize: 13, fontWeight: 700, color: isSelected ? BRAND : DT.textPrimary, lineHeight: 1.2 }}
noWrap
>
{row.locationname}
</Typography>
<Stack direction="row" alignItems="center" spacing={0.375}>
<MdPlace size={11} style={{ color: DT.textMuted, flexShrink: 0 }} />
<Typography sx={{ fontSize: 11, fontWeight: 600, color: DT.textSecondary }} noWrap>
{row.suburb || '—'}
</Typography>
</Stack>
</Stack>
</ButtonBase>
);
})}
</Stack>
</Box>
</Stack>
);
return (
<React.Fragment>
{locationIsLoading && (
@@ -248,9 +381,8 @@ const ResponsiveLocationDrawer = () => {
</>
)}
<Box sx={{ display: 'flex', width: '100%', height: '100%', position: 'relative' }}>
{/* ---------------- LOCAL DRAWER ---------------- */}
<Box sx={{ display: 'flex', width: '100%', height: '100%', position: 'relative', bgcolor: DT.surfaceAlt }}>
{/* ---------------- LOCATION SIDEBAR ---------------- */}
<Drawer
variant={isDesktop ? 'persistent' : 'temporary'}
open={open}
@@ -264,246 +396,252 @@ const ResponsiveLocationDrawer = () => {
left: 0,
top: 0,
height: '100%',
overflowY: 'auto',
overflow: 'hidden',
borderRight: `1px solid ${DT.borderSubtle}`,
transition: 'transform 0.35s ease-in-out',
zIndex: 10,
/* vertical scrollbar */
'&::-webkit-scrollbar:vertical': {
width: '7px',
opacity: 0,
transition: 'opacity 0.3s'
},
/* horizontal scrollbar */
'&::-webkit-scrollbar:horizontal': {
height: '6px', // thinner horizontal bar
opacity: 0,
transition: 'opacity 0.3s'
},
/* show scrollbar when hovering drawer */
'&:hover::-webkit-scrollbar': {
opacity: 1
},
/* thumb styling */
'&::-webkit-scrollbar-thumb': {
backgroundColor: theme.palette.primary.main,
borderRadius: '8px'
},
'&::-webkit-scrollbar-thumb:hover': {
backgroundColor: theme.palette.primary.dark
},
/* track styling */
'&::-webkit-scrollbar-track': {
backgroundColor: theme.palette.primary.lighter
}
zIndex: 10
}
}}
>
<Box sx={{ position: 'sticky', top: 0, zIndex: 11, border: 'none' }}>
<SearchBar
value={searchLocation}
placeholder="Search Location"
onChange={(e) => setSearchLocation(e.target.value)}
sx={{
width: 'auto',
height: 60,
bgcolor: 'white',
'& .MuiOutlinedInput-notchedOutline': {
border: 'none',
borderBottom: '1px solid',
borderColor: theme.palette.secondary.light
}
}}
/>
</Box>
<List sx={{ border: 'none', mt: -1 }}>
{locations?.map((row, index) => (
<React.Fragment key={index}>
<ListItem
sx={{
cursor: 'pointer',
bgcolor: row.locationid == selectedLocation?.locationid ? theme.palette.secondary[200] : 'none',
'&:hover': {
bgcolor: theme.palette.secondary.lighter
}
}}
onClick={() => {
setSelectedLocation(row);
}}
>
<ListItemAvatar>
<Avatar
sx={{
bgcolor: 'primary.main', // background color
color: 'white' // text color
}}
>
{row.locationname?.[0]?.toUpperCase() || '?'}
</Avatar>{' '}
</ListItemAvatar>
<ListItemText primary={row.locationname} secondary={row.suburb} />
</ListItem>
<Divider />
</React.Fragment>
))}
</List>
{sidebarContent}
</Drawer>
{/* -------------- LOCAL PAGE APPBAR -------------- */}
<AppBar
elevation={0}
position="absolute"
sx={{
top: 0,
left: open && isDesktop ? `${drawerWidth}px` : 0,
width: open && isDesktop ? `calc(100% - ${drawerWidth}px)` : '100%',
transition: 'left 0.3s ease, width 0.3s ease',
zIndex: 1100, // BELOW drawer, ABOVE content
backgroundColor: 'white',
borderBottom: '1px solid',
borderColor: theme.palette.secondary.light
}}
>
<Toolbar>
<Stack
sx={{ width: '100%', borderBottom: '1px soild red' }}
display={'flex'}
flexDirection={'row'}
alignItems={'center'}
justifyContent={'space-between'}
flexWrap={'wrap'}
>
<Stack display={'flex'} flexDirection={'row'} alignItems={'center'}>
<IconButton color="primary" onClick={toggleDrawer} sx={{ mr: 1 }}>
<MenuIcon />
</IconButton>
<Typography variant="h5" color={'primary'} sx={{ whiteSpace: 'nowrap', ml: 2 }}>
{selectedLocation?.locationname}
</Typography>
</Stack>
<Stack>
<SearchBar
value={searchword}
placeholder={'Search Order Details'}
onChange={(e) => setSearchword(e.target.value)}
sx={{
width: 'auto',
height: 40,
bgcolor: 'white',
maxWidth: 800,
borderRadius: 1
// '& .MuiOutlinedInput-notchedOutline': {
// border: 'none'
// }
}}
/>
</Stack>
</Stack>
</Toolbar>
</AppBar>
{/* ---------------- PAGE SCROLLABLE CONTENT ---------------- */}
{/* ---------------- MAIN PANEL ---------------- */}
<Box
sx={{
flexGrow: 1,
overflow: 'auto',
pt: '64px', // Height of AppBar
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
pl: isDesktop && open ? `${drawerWidth}px` : 0,
transition: 'padding-left 0.3s ease',
mt: -1
transition: 'padding-left 0.3s ease'
}}
>
<Stack
display={'flex'}
flexDirection={'row'}
justifyContent={'space-between'}
alignItems={'center'}
flexWrap={'wrap-reverse'}
gap={2}
{/* ---------------- GRADIENT HEADER ---------------- */}
<Paper
elevation={0}
sx={{
border: '1px solid ',
borderBottom: 0,
borderColor: 'bg.main',
p: 1.5
flexShrink: 0,
px: { xs: 1.25, sm: 1.75 },
py: { xs: 1, sm: 1.25 },
borderRadius: 0,
borderBottom: `1px solid ${DT.borderSubtle}`,
background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`
}}
>
{/* Tabs Wrapper */}
<Stack
direction={{ xs: 'column', md: 'row' }}
alignItems={{ xs: 'stretch', md: 'center' }}
justifyContent="space-between"
spacing={{ xs: 1, md: 1.5 }}
>
<Stack direction="row" alignItems="center" spacing={1.25}>
<Tooltip title={open ? 'Hide locations' : 'Show locations'} arrow>
<IconButton
onClick={toggleDrawer}
sx={{
width: 34,
height: 34,
borderRadius: 1.5,
bgcolor: '#fff',
border: `1px solid ${DT.borderSubtle}`,
color: BRAND,
'&:hover': { bgcolor: tint(BRAND), borderColor: BRAND }
}}
>
<MdMenu size={18} />
</IconButton>
</Tooltip>
<Tabs value={tabvalue} onChange={handleChangetab} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile>
{statusMap.map((item, index) => (
<Tab
key={index}
label={
<Stack direction="row" alignItems="center" spacing={1}>
{item.icon}
<span>{item.label}</span>
<Chip label={item.count} color="primary" variant="light" size="small" />
</Stack>
}
<Avatar
variant="rounded"
sx={{ width: 36, height: 36, bgcolor: BRAND, color: '#fff', borderRadius: 1.5, boxShadow: `0 4px 12px ${ring(BRAND)}` }}
>
<MdMyLocation size={19} />
</Avatar>
<Stack spacing={0.125}>
<Typography
variant="h3"
sx={{
fontWeight: 800,
color: DT.textPrimary,
lineHeight: 1.1,
fontSize: { xs: '1.05rem', sm: '1.2rem', md: '1.3rem' }
}}
noWrap
>
{selectedLocation?.locationname || 'Select a location'}
</Typography>
<Stack direction="row" alignItems="center" spacing={0.75}>
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: '#10b981', boxShadow: '0 0 0 3px rgba(16,185,129,0.18)' }} />
<Typography sx={{ fontSize: 11.5, color: DT.textSecondary, fontWeight: 600 }} noWrap>
{selectedLocation?.suburb ? `${selectedLocation.suburb} · ` : ''}Live · {dayjs(startdate).format('DD MMM YYYY')}
</Typography>
</Stack>
</Stack>
</Stack>
{/* Order search pill */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.75,
borderRadius: 999,
bgcolor: '#fff',
border: `1.5px solid ${edge(BRAND)}`,
minWidth: { xs: '100%', md: 280 },
maxWidth: { md: 360 },
transition: 'all 0.18s',
'&:focus-within': { borderColor: BRAND, boxShadow: `0 0 0 3px ${ring(BRAND)}` }
}}
>
<MdSearch size={16} style={{ color: BRAND, flexShrink: 0 }} />
<InputBase
placeholder="Search order details"
value={searchword}
onChange={(e) => setSearchword(e.target.value)}
autoComplete="off"
sx={{
flex: 1,
fontSize: 13,
fontWeight: 600,
color: DT.textPrimary,
'& input::placeholder': { color: DT.textMuted, opacity: 1 }
}}
/>
))}
</Tabs>
</Stack>
<MainCard
content={false}
{searchword && (
<IconButton size="small" onClick={() => setSearchword('')} sx={{ p: 0.25, color: BRAND }}>
<MdClear size={14} />
</IconButton>
)}
</Box>
</Stack>
</Paper>
{/* ---------------- STATUS FILTER PILLS ---------------- */}
<Box
sx={{
overflow: 'hidden',
height: 'calc(100vh - 200px)', // adjust as needed
flexShrink: 0,
px: { xs: 1, sm: 1.5 },
py: 1,
bgcolor: '#fff',
borderBottom: `1px solid ${DT.borderSubtle}`,
display: 'flex',
flexDirection: 'column'
gap: 0.75,
overflowX: 'auto',
...scrollbarSx
}}
>
<Fragment>
{/* Scrollable table container */}
{STATUS_TABS.map((item, index) => {
const isActive = tabvalue === index;
const Icon = item.icon;
const count = statusCounts[item.value];
return (
<ButtonBase
key={index}
onClick={() => handleChangetab(index)}
sx={{
flexShrink: 0,
gap: 0.75,
px: 1.25,
py: 0.625,
borderRadius: 999,
fontWeight: 700,
transition: 'all 0.15s',
border: `1.5px solid ${isActive ? item.color : DT.borderSubtle}`,
bgcolor: isActive ? item.color : '#fff',
color: isActive ? '#fff' : DT.textSecondary,
boxShadow: isActive ? `0 4px 12px ${ring(item.color)}` : 'none',
'&:hover': {
borderColor: item.color,
color: isActive ? '#fff' : item.color,
bgcolor: isActive ? item.color : tint(item.color)
}
}}
>
<Icon size={14} />
<Typography sx={{ fontSize: 12.5, fontWeight: 700, lineHeight: 1 }}>{item.label}</Typography>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 20,
height: 18,
px: 0.625,
borderRadius: 999,
fontSize: 10.5,
fontWeight: 800,
bgcolor: isActive ? 'rgba(255,255,255,0.25)' : soft(item.color),
color: isActive ? '#fff' : item.color
}}
>
{count ?? 0}
</Box>
</ButtonBase>
);
})}
</Box>
{/* ---------------- ORDERS TABLE ---------------- */}
<Box sx={{ flex: 1, overflow: 'hidden', p: { xs: 1, sm: 1.5 } }}>
<Paper
elevation={0}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
borderRadius: 2,
border: `1px solid ${DT.borderSubtle}`,
overflow: 'hidden',
background: '#fff',
boxShadow: DT.shadowSoft
}}
>
<TableContainer
onScroll={handleScroll}
ref={containerRef}
sx={{
width: '100%',
flex: 1,
overflow: 'auto',
borderBottom: 1,
maxHeight: 'calc(100vh - 225px)',
borderColor: 'divider',
'&::-webkit-scrollbar': { width: '12px' },
'&::-webkit-scrollbar-thumb': {
backgroundColor: theme.palette.primary.main,
borderRadius: '8px'
},
'&::-webkit-scrollbar-thumb:hover': {
backgroundColor: theme.palette.primary.dark
},
'&::-webkit-scrollbar-track': {
backgroundColor: theme.palette.primary.lighter
}
}}
sx={{ flex: 1, overflow: 'auto', ...scrollbarSx }}
>
<Table stickyHeader>
{/* HEADER */}
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>S.No</TableCell>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>Orders</TableCell>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>Pickup</TableCell>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>Drop</TableCell>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>Notes</TableCell>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>Status</TableCell>
<TableRow
sx={{
'& th': {
backgroundColor: DT.surfaceAlt,
color: DT.textSecondary,
fontSize: 10.5,
fontWeight: 800,
letterSpacing: 0.5,
textTransform: 'uppercase',
whiteSpace: 'nowrap',
borderBottom: `1px solid ${DT.borderSubtle}`,
py: 0.75,
px: 1
}
}}
>
<TableCell sx={{ width: 36 }}>#</TableCell>
<TableCell sx={{ minWidth: 150 }}>Order</TableCell>
<TableCell sx={{ minWidth: 150 }}>Pickup</TableCell>
<TableCell sx={{ minWidth: 150 }}>Drop</TableCell>
<TableCell sx={{ minWidth: 140 }}>Notes</TableCell>
<TableCell sx={{ width: 120 }}>Status</TableCell>
</TableRow>
</TableHead>
{/* BODY */}
<TableBody>
{/* LOADING STATE */}
{loading &&
[...Array(10)].map((_, index) => (
Array.from({ length: 10 }).map((_, index) => (
<TableRow key={index}>
{[...Array(6)].map((__, i) => (
<TableCell key={i}>
<Skeleton animation="wave" />
{Array.from({ length: 6 }).map((__, i) => (
<TableCell key={i} sx={{ borderBottom: `1px solid ${DT.divider}`, py: 0.625, px: 1 }}>
<Skeleton.Input active size="small" style={{ width: '100%', height: 18 }} />
</TableCell>
))}
</TableRow>
@@ -512,8 +650,16 @@ const ResponsiveLocationDrawer = () => {
{/* EMPTY STATE */}
{!loading && rows?.length === 0 && (
<TableRow>
<TableCell colSpan={6} sx={{ minWidth: '100%', height: 500 }} align="center">
<Empty description={'No Orders'} />
<TableCell colSpan={6} sx={{ py: 7, borderBottom: 'none' }}>
<Stack alignItems="center" spacing={1.25}>
<Avatar variant="rounded" sx={{ width: 56, height: 56, bgcolor: soft('#94a3b8'), color: DT.textMuted, borderRadius: 2 }}>
<MdReceiptLong size={26} />
</Avatar>
<Typography sx={{ fontWeight: 700, color: DT.textPrimary, fontSize: 14 }}>No orders found</Typography>
<Typography sx={{ color: DT.textSecondary, fontSize: 12 }}>
{searchword ? 'Try a different keyword or clear the search.' : 'No orders in this status for the selected location.'}
</Typography>
</Stack>
</TableCell>
</TableRow>
)}
@@ -521,83 +667,101 @@ const ResponsiveLocationDrawer = () => {
{/* DATA ROWS */}
{!loading &&
rows?.map((row, index) => (
<TableRow key={index} sx={{ cursor: 'pointer' }}>
<TableCell>{page * rowsPerPage + index + 1}</TableCell>
<TableRow
key={index}
sx={{
cursor: 'pointer',
transition: 'background-color 0.12s, box-shadow 0.12s',
'& td': { borderBottom: `1px solid ${DT.divider}`, py: 0.75, px: 1, verticalAlign: 'top' },
'&:hover': { backgroundColor: tint(BRAND), boxShadow: `inset 3px 0 0 ${BRAND}` }
}}
>
<TableCell>
<Typography sx={{ fontWeight: 700, fontSize: 12, color: DT.textMuted }}>{page * rowsPerPage + index + 1}</Typography>
</TableCell>
{/* Order Info */}
<TableCell>
<Typography variant="body2" noWrap>
<Typography sx={{ fontSize: 12.5, fontWeight: 700, color: DT.textPrimary, lineHeight: 1.25 }} noWrap>
{row.orderid}
</Typography>
<Typography variant="caption" noWrap>
{dayjs(row.deliverydate).utc().format('DD/MM/YYYY')}
</Typography>
<Typography variant="caption" noWrap>
{dayjs(row.deliverydate).utc().format('hh:mm A')}
</Typography>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mt: 0.125 }}>
<MdAccessTime size={10} style={{ color: DT.textMuted, flexShrink: 0 }} />
<Typography sx={{ fontSize: 10.5, color: DT.textSecondary, fontWeight: 700 }} noWrap>
{dayjs(row.deliverydate).utc().format('hh:mm A')}
</Typography>
<Typography sx={{ fontSize: 10.5, color: DT.textMuted, fontWeight: 600 }} noWrap>
· {dayjs(row.deliverydate).utc().format('DD MMM YY')}
</Typography>
</Stack>
</TableCell>
{/* Pickup */}
<TableCell>
<Stack direction="row" spacing={1}>
<Avatar sx={{ width: 25, height: 25 }} />
<Stack>
<Typography variant="caption">{row.pickupcustomer}</Typography>
<Typography variant="caption">{row.pickupcontactno}</Typography>
<Tooltip title={row.pickupaddress || ''}>
<Typography variant="caption">{row.pickupsuburb || row.pickupaddress?.slice(0, 20) || '—'}</Typography>
</Tooltip>
</Stack>
<Stack spacing={0.125}>
<Typography sx={{ fontSize: 12.5, fontWeight: 700, color: DT.textPrimary, lineHeight: 1.25 }} noWrap>
{row.pickupcustomer || '—'}
</Typography>
<Typography sx={{ fontSize: 11, color: DT.textSecondary, fontWeight: 600, lineHeight: 1.3 }} noWrap>
{row.pickupcontactno}
</Typography>
<Tooltip title={row.pickupaddress || ''}>
<Typography sx={{ fontSize: 10.5, color: DT.textMuted, fontWeight: 600, lineHeight: 1.3 }} noWrap>
{row.pickupsuburb || (row.pickupaddress ? `${row.pickupaddress.slice(0, 20)}` : '—')}
</Typography>
</Tooltip>
</Stack>
</TableCell>
{/* Drop */}
<TableCell>
<Stack direction="row" spacing={1}>
<Avatar sx={{ width: 25, height: 25 }} />
<Stack>
<Typography variant="caption">{row.deliverycustomer}</Typography>
<Typography variant="caption">{row.deliverycontactno}</Typography>
<Tooltip title={row.deliveryaddress || ''}>
<Typography variant="caption">{row.deliverysuburb || row.deliveryaddress?.slice(0, 20) || '—'}</Typography>
</Tooltip>
</Stack>
<Stack spacing={0.125}>
<Typography sx={{ fontSize: 12.5, fontWeight: 700, color: DT.textPrimary, lineHeight: 1.25 }} noWrap>
{row.deliverycustomer || '—'}
</Typography>
<Typography sx={{ fontSize: 11, color: DT.textSecondary, fontWeight: 600, lineHeight: 1.3 }} noWrap>
{row.deliverycontactno}
</Typography>
<Tooltip title={row.deliveryaddress || ''}>
<Typography sx={{ fontSize: 10.5, color: DT.textMuted, fontWeight: 600, lineHeight: 1.3 }} noWrap>
{row.deliverysuburb || (row.deliveryaddress ? `${row.deliveryaddress.slice(0, 20)}` : '—')}
</Typography>
</Tooltip>
</Stack>
</TableCell>
{/* Notes */}
<TableCell>{row.ordernotes}</TableCell>
<TableCell>
<Typography sx={{ fontSize: 11.5, color: DT.textSecondary, fontWeight: 600, lineHeight: 1.35 }}>
{row.ordernotes || '—'}
</Typography>
</TableCell>
{/* Status */}
<TableCell>
<Stack direction="row" spacing={1}>
{row.orderstatus === 'pending' && <Chip label="Pending" color="warning" size="small" />}
{row.orderstatus === 'confirmed' && <Chip label="Confirmed" color="success" size="small" />}
{row.orderstatus === 'cancelled' && <Chip label="Cancelled" color="error" size="small" />}
{row.orderstatus === 'delivered' && <Chip label="Completed" color="primary" size="small" />}
{row.orderstatus === 'processing' && <Chip label="Processing" color="primary" size="small" />}
{row.orderstatus === 'ready' && <Chip label="Accepted" color="info" size="small" />}
{row.orderstatus === 'active' && <Chip label="Picked" color="info" size="small" />}
{row.orderstatus === 'closed' && <Chip label="Closed" color="info" size="small" />}
{row.orderstatus === 'created' && <Chip label="Created" color="secondary" size="small" />}
</Stack>
<StatusBadge status={row.orderstatus} />
</TableCell>
</TableRow>
))}
{rows?.length != 0 && (
<TableRow>
<TableCell colSpan={6} rowSpan={3}>
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
{isFetchingNextPage ? <CircularProgress /> : hasNextPage ? <CircularProgress /> : 'No More Orders'}
</div>
<TableCell colSpan={6} sx={{ borderBottom: 'none' }}>
<Stack ref={loadMoreRef} alignItems="center" justifyContent="center" sx={{ height: 40 }}>
{isFetchingNextPage || hasNextPage ? (
<CircularProgress size={20} sx={{ color: BRAND }} />
) : (
<Typography sx={{ fontSize: 11.5, fontWeight: 700, color: DT.textMuted }}>No more orders</Typography>
)}
</Stack>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Fragment>
</MainCard>
</Paper>
</Box>
</Box>
</Box>
</React.Fragment>

View File

@@ -9,6 +9,7 @@ const initialState = {
openItem: ['dashboard'],
openComponent: 'buttons',
selectedID: null,
selectedMenu: null,
drawerOpen: false,
componentDrawerOpen: true,
menu: {},
@@ -48,6 +49,10 @@ const menu = createSlice({
hasError(state, action) {
state.error = action.payload;
},
setSelectedMenu(state, action) {
state.selectedMenu = action.payload;
}
},
@@ -60,4 +65,4 @@ const menu = createSlice({
export default menu.reducer;
export const { activeItem, activeComponent, openDrawer, openComponentDrawer, activeID } = menu.actions;
export const { activeItem, activeComponent, openDrawer, openComponentDrawer, activeID, setSelectedMenu } = menu.actions;