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

View File

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

View File

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

View File

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

View File

@@ -1532,6 +1532,92 @@
margin-left: 4px; 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 */ /* Body layout */
.dispatch-container #body { .dispatch-container #body {
flex: 1; flex: 1;
@@ -1949,6 +2035,46 @@
color: #16a34a; 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 { .dispatch-container .bar-bg {
background: var(--bg-sub); background: var(--bg-sub);
border-radius: 4px; border-radius: 4px;
@@ -2166,6 +2292,8 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap;
row-gap: 10px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: #fff; background: #fff;
} }
@@ -2186,6 +2314,95 @@
gap: 12px; 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 { .dispatch-container .step-wrap {
padding: 16px; padding: 16px;
} }
@@ -5523,6 +5740,30 @@
text-overflow: ellipsis; 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 { .dispatch-container .dispatch-popup .pu-delivery-id {
margin-top: 6px; margin-top: 6px;
font-size: 11px; font-size: 11px;

View File

@@ -21,6 +21,7 @@ import {
MdLocationOn, MdLocationOn,
MdMarkunreadMailbox, MdMarkunreadMailbox,
MdMoveToInbox, MdMoveToInbox,
MdPerson,
MdPlace, MdPlace,
MdTwoWheeler, MdTwoWheeler,
MdNotes, MdNotes,
@@ -478,7 +479,7 @@ const getStableRiderColor = (id) => {
// extracted CompareDataPanel component can import them without forcing // extracted CompareDataPanel component can import them without forcing
// a circular dependency on Dispatch.js. // a circular dependency on Dispatch.js.
const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => { const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey, extraPoints }) => {
const map = useMap(); const map = useMap();
// Last fit signature. We only call fitBounds when this changes — otherwise // Last fit signature. We only call fitBounds when this changes — otherwise
// every parent render (data refetch, sidebar tick, etc.) would refit the // 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') { if (viewMode === 'all') {
const oPairs = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); 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}`; return `${loc}m|${viewMode || ''}|${kPairs.length}|${kSig}`;
}, [focusedItem, viewMode, orders, kitchens, locationKey]); }, [focusedItem, viewMode, orders, kitchens, locationKey]);
@@ -539,10 +544,12 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey })
let pts = []; let pts = [];
if (focusedItem) { 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)]); 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))])); focusedItem.orders.forEach((o) => pts.push([toNum(pickupLat(o)), toNum(pickupLon(o))]));
} else { } else {
// Order-less focus target (a single kitchen, or a GPS-only active
// rider) — center on its own coordinate.
pts = [[focusedItem.lat, focusedItem.lon]]; pts = [[focusedItem.lat, focusedItem.lon]];
} }
} else if (viewMode === 'kitchens') { } else if (viewMode === 'kitchens') {
@@ -554,6 +561,9 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey })
} }
} else if (viewMode === 'all') { } else if (viewMode === 'all') {
pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); 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 { } else {
// No focus, viewMode is 'riders' / 'zones' / etc. — still fit to the // No focus, viewMode is 'riders' / 'zones' / etc. — still fit to the
// current hub's footprint so switching from Coimbatore → Nagercoil // 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 // bug that left Nagercoil (and every non-Coimbatore hub) stuck on the
// Coimbatore default during the brief window between picking the hub // Coimbatore default during the brief window between picking the hub
// and its data arriving. // and its data arriving.
}, [fitKey, focusedItem, viewMode, orders, kitchens, map]); }, [fitKey, focusedItem, viewMode, orders, kitchens, extraPoints, map]);
return null; 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 // 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. // SVG vertically centered with the adjacent text and inherits the parent color.
const Ico = ({ children }) => ( const Ico = ({ children }) => (
@@ -699,6 +828,12 @@ const Dispatch = ({
const [focusedZone, setFocusedZone] = useState(null); const [focusedZone, setFocusedZone] = useState(null);
// Single delivery stop pinned by clicking its sidebar row — overrides the rider's full-route bounds on the map. // Single delivery stop pinned by clicking its sidebar row — overrides the rider's full-route bounds on the map.
const [focusedStop, setFocusedStop] = useState(null); 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 // 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. // their popups when the user clicks a step in the focused-rider sidebar.
const orderMarkerRefs = useRef({}); const orderMarkerRefs = useRef({});
@@ -712,6 +847,13 @@ const Dispatch = ({
// popups use leaflet's marker-attached <Popup> (openPopup/closePopup) rather // popups use leaflet's marker-attached <Popup> (openPopup/closePopup) rather
// than the centered overlay used for order popups. // than the centered overlay used for order popups.
const pinnedLivePopupsRef = useRef(new Set()); 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. // 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 // Gives the cursor a ~200ms window to travel from the marker onto the popup
// or vice versa without immediately triggering a close. // or vice versa without immediately triggering a close.
@@ -1074,12 +1216,18 @@ const Dispatch = ({
// hub (latitude/longitude/logdate/status). We render those positions as // hub (latitude/longitude/logdate/status). We render those positions as
// markers on the main dispatch map so the operator sees where each rider // markers on the main dispatch map so the operator sees where each rider
// actually is — matching the Reports → Riders Logs page. // 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({ const { data: ridersLocationLogs } = useQuery({
queryKey: [selectedAppLocationId, selectedDate, ''], queryKey: [selectedAppLocationId, selectedDate, ''],
queryFn: fetchRidersLogs, queryFn: fetchRidersLogs,
refetchInterval: 15_000, refetchInterval: RIDER_LOG_POLL_MS,
refetchIntervalInBackground: false, refetchIntervalInBackground: false,
staleTime: 5 * 1000, staleTime: 1000,
refetchOnWindowFocus: false refetchOnWindowFocus: false
}); });
@@ -1105,6 +1253,15 @@ const Dispatch = ({
}) })
.filter(Boolean); .filter(Boolean);
}, [ridersLocationLogs]); }, [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 // 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 // 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) // 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) ? (selectedRiderId ? (riders.find((r) => r.id === selectedRiderId) || null) : null)
: internalFocusedRider; : 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. // 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 // 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. // updates local state; in controlled mode it only notifies the parent.
@@ -1473,6 +1684,19 @@ const Dispatch = ({
label: 'Focused Kitchen' 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 { return {
orders: stats.totalOrders, orders: stats.totalOrders,
riders: stats.totalRiders, riders: stats.totalRiders,
@@ -1480,7 +1704,7 @@ const Dispatch = ({
profit: stats.totalProfit, profit: stats.totalProfit,
label: 'Total Fleet' label: 'Total Fleet'
}; };
}, [focusedRider, focusedKitchen, stats]); }, [focusedRider, focusedKitchen, isAllActiveView, allViewOrders, visibleRiders, stats]);
// List of deliveryids we want GPS logs for. Drives two pipelines: // List of deliveryids we want GPS logs for. Drives two pipelines:
// • renderRoutes() — actual-route polylines on the main map for // • renderRoutes() — actual-route polylines on the main map for
@@ -2225,15 +2449,27 @@ const Dispatch = ({
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s); return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
}); });
const estMeters = activeOrder ? calculateEstMeters(r.id, activeOrder) : null; 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 ( 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-top">
<div className="rcard-emo" style={{ background: `${r.color}18`, borderColor: `${r.color}50`, color: r.color }}><MdTwoWheeler /></div> <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-info">
<div className="rcard-name">{r.riderName}</div> <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>
{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 <div
className={`rcard-badge ${isDone ? 'is-done' : ''}`} className={`rcard-badge ${isDone ? 'is-done' : ''}`}
style={isDone ? undefined : { background: `${r.color}18`, color: r.color }} style={isDone ? undefined : { background: `${r.color}18`, color: r.color }}
@@ -2241,7 +2477,10 @@ const Dispatch = ({
> >
{delivered}/{total} {delivered}/{total}
</div> </div>
)}
</div> </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="bar-bg"><div className="bar-fg" style={{ width: `${Math.min(100, (total / 15) * 100)}%`, background: r.color }}></div></div>
<div className="rcard-meta"> <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> <span><Ico><MdStraighten /></Ico>{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
@@ -2254,6 +2493,8 @@ const Dispatch = ({
<div className="step-ids"> <div className="step-ids">
{r.orders.slice(0, 15).map(o => <span key={o.orderid} className="step-id">S{o.step}</span>)} {r.orders.slice(0, 15).map(o => <span key={o.orderid} className="step-id">S{o.step}</span>)}
</div> </div>
</>
)}
</div> </div>
); );
}; };
@@ -2298,6 +2539,11 @@ const Dispatch = ({
<div className="pu-rider"> <div className="pu-rider">
<MdTwoWheeler /> <span>{o.rider_name || o.ridername || 'Unassigned'}</span> <MdTwoWheeler /> <span>{o.rider_name || o.ridername || 'Unassigned'}</span>
</div> </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 && ( {o.deliveryid != null && (
<div className="pu-delivery-id">Delivery #{o.deliveryid}</div> <div className="pu-delivery-id">Delivery #{o.deliveryid}</div>
)} )}
@@ -2412,7 +2658,9 @@ const Dispatch = ({
// duplicate, slightly-offset pins that clutter the view. // duplicate, slightly-offset pins that clutter the view.
if (compareOpen && focusedRider && compareViewMode === 'actual') return null; 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 (focusedZone) ordersToRender = focusedZone.orders;
if (focusedKitchen) ordersToRender = focusedKitchen.orders; if (focusedKitchen) ordersToRender = focusedKitchen.orders;
if (focusedRider) ordersToRender = focusedRider.orders; if (focusedRider) ordersToRender = focusedRider.orders;
@@ -2531,7 +2779,9 @@ const Dispatch = ({
const routes = []; const routes = [];
const zoneRiderIds = focusedZone ? new Set(focusedZone.riders.map((zr) => String(zr.rider_id))) : null; const zoneRiderIds = focusedZone ? new Set(focusedZone.riders.map((zr) => String(zr.rider_id))) : null;
if (hidePlanned) return routes; 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); const isActive = activeRiders.has(r.id);
if (focusedRider && focusedRider.id !== r.id) return; if (focusedRider && focusedRider.id !== r.id) return;
if (focusedKitchen && !focusedKitchen.riders.has(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); }} 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> ><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 === '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 <button
type="button" type="button"
className={`sbt sbt-rider-info ${viewMode === 'rider-info' ? 'active' : ''}`} className={`sbt sbt-rider-info ${viewMode === 'rider-info' ? 'active' : ''}`}
@@ -3574,10 +3824,31 @@ const Dispatch = ({
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s); return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
}); });
const activeOrderId = activeOrder ? activeOrder.orderid : null; 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; let prevKitchenKey = null;
return Object.entries(trips) return Object.entries(trips)
.sort(([a], [b]) => Number(a) - Number(b)) .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 key={tNum} className="trip-block">
<div className="trip-header" style={{ background: `${focusedRider.color}12`, borderColor: `${focusedRider.color}30` }}> <div className="trip-header" style={{ background: `${focusedRider.color}12`, borderColor: `${focusedRider.color}30` }}>
<span className="th-badge" style={{ background: focusedRider.color }}>Trip {tNum}</span> <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><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><Ico><MdStraighten /></Ico>{tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
</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>
<div className="zone-order-grid"> <div className="zone-order-grid">
{tOrders.map((o, idx) => { {displayOrders.map((o, idx) => {
const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim(); const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim();
const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey; const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey;
prevKitchenKey = kitchenKey; prevKitchenKey = kitchenKey;
@@ -3598,6 +3900,10 @@ const Dispatch = ({
const canFocus = Number.isFinite(lat) && Number.isFinite(lon); const canFocus = Number.isFinite(lat) && Number.isFinite(lon);
const statusStyle = getStatusStyle(o.orderstatus); const statusStyle = getStatusStyle(o.orderstatus);
const estMeters = calculateEstMeters(focusedRider.id, o); 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 ( return (
<React.Fragment key={o.orderid}> <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="kitchen-transition"><span className="kt-ico"><MdSwapHoriz /></span> Switch to <strong>{o.pickupcustomer}</strong></div>
)} )}
<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} role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined} tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined} onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
@@ -3714,7 +4020,8 @@ const Dispatch = ({
})} })}
</div> </div>
</div> </div>
)); );
});
})()} })()}
</> </>
) : ( ) : (
@@ -3996,6 +4303,7 @@ const Dispatch = ({
<div className="ph">{ <div className="ph">{
viewMode === 'zones' ? 'Zone dispatch' : viewMode === 'zones' ? 'Zone dispatch' :
viewMode === 'kitchens' ? 'Kitchen dispatch' : viewMode === 'kitchens' ? 'Kitchen dispatch' :
viewMode === 'all' ? 'Active rider dispatch' :
'Rider dispatch' 'Rider dispatch'
}</div> }</div>
<div id="rider-cards"> <div id="rider-cards">
@@ -4114,8 +4422,18 @@ const Dispatch = ({
</div> </div>
</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>
</div> </div>
@@ -4140,7 +4458,7 @@ const Dispatch = ({
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='&copy; OpenStreetMap contributors' /> <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='&copy; OpenStreetMap contributors' />
<ZoomControl position="bottomright" /> <ZoomControl position="bottomright" />
{compareOpen && <CaptureMap targetRef={leftMapRef} />} {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 {kitchens
.filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon)) .filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon))
.filter(k => !focusedRider || k.riders.has(focusedRider.id)) .filter(k => !focusedRider || k.riders.has(focusedRider.id))
@@ -4173,14 +4491,23 @@ const Dispatch = ({
{/* Live rider GPS markers from /partners/getriderlogs/. Mirrors the {/* Live rider GPS markers from /partners/getriderlogs/. Mirrors the
Reports → Riders Logs map: green pin when the rider's last log Reports → Riders Logs map: green pin when the rider's last log
row is `active`, red otherwise, with the rider's username as a row is `active`, red otherwise, with the rider's username as a
label. Scoped to riders who actually have orders in the label.
currently selected slot — `riders` is derived from
filteredLiveRows so it already reflects the slot filter. A "All Active Routes" view: show EVERY active rider's live GPS —
rider with zero orders in the current slot is hidden, even if including riders with no orders in the current slot (those get a
getriderlogs still returns their GPS row. When a specific bike marker only, no route). Riders with an active delivery also
rider is focused, only that one is shown. */} 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 {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)) .filter((r) => !focusedRider || String(focusedRider.id) === String(r.id))
.map((r) => { .map((r) => {
const isActive = r.status === 'active'; const isActive = r.status === 'active';
@@ -4204,23 +4531,47 @@ const Dispatch = ({
const nextDropArea = nextOrder const nextDropArea = nextOrder
? (nextOrder.deliverysuburb || extractArea(nextOrder.deliveryaddress)) ? (nextOrder.deliverysuburb || extractArea(nextOrder.deliveryaddress))
: null; : null;
const liveIcon = L.divIcon({ // 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: '', className: '',
iconSize: [140, 56], iconSize: [140, 56],
iconAnchor: [12, 41], iconAnchor: [12, 41],
popupAnchor: [58, -40], popupAnchor: [58, -40],
html: `<div class="live-rider-pin" style="--pin-color:${pinColor}"> html: `<div class="live-rider-pin" style="--pin-color:${pinColor}">
<div class="live-rider-pin-marker"></div> <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> <div class="live-rider-pin-label">${safeName}${safeOrder ? ` <span>#${safeOrder}</span>` : ''}</div>
</div>` </div>`
}); });
return ( iconEntry = { sig: iconSig, icon };
<Marker liveIconCacheRef.current.set(r.id, iconEntry);
key={`live-${r.id}`} }
position={[r.lat, r.lon]} const liveIcon = iconEntry.icon;
icon={liveIcon} // Shared interaction handlers — identical for both marker types.
zIndexOffset={2500} const liveEventHandlers = {
eventHandlers={{
click: (e) => { click: (e) => {
const idStr = String(r.id); const idStr = String(r.id);
if (pinnedLivePopupsRef.current.has(idStr)) { if (pinnedLivePopupsRef.current.has(idStr)) {
@@ -4230,13 +4581,32 @@ const Dispatch = ({
pinnedLivePopupsRef.current.add(idStr); pinnedLivePopupsRef.current.add(idStr);
e.target.openPopup(); 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); const match = riders.find((rd) => String(rd.id) === idStr);
if (match) handleRiderFocus(match); if (match) handleRiderFocus(match);
}, },
popupclose: () => { popupclose: () => {
pinnedLivePopupsRef.current.delete(String(r.id)); 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 (
<LiveMarker
key={`live-${r.id}`}
{...positionProps}
icon={liveIcon}
zIndexOffset={2500}
eventHandlers={liveEventHandlers}
> >
<Popup maxWidth={260} autoPan={true} autoPanPadding={[20, 20]} className="dispatch-popup live-rider-popup"> <Popup maxWidth={260} autoPan={true} autoPanPadding={[20, 20]} className="dispatch-popup live-rider-popup">
<div className="pu-hdr-live"> <div className="pu-hdr-live">
@@ -4322,7 +4692,7 @@ const Dispatch = ({
</div> </div>
</div> </div>
</Popup> </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 { import {
Box, Box,
Drawer, Drawer,
IconButton, IconButton,
Toolbar,
Typography, Typography,
AppBar,
useMediaQuery, useMediaQuery,
Divider,
List,
ListItem,
ListItemText,
useTheme, useTheme,
ListItemAvatar,
Avatar,
Tooltip, Tooltip,
TableCell, TableCell,
Chip,
Stack, Stack,
TableRow, TableRow,
TableBody, TableBody,
TableHead, TableHead,
Table, Table,
TableContainer, TableContainer,
Tabs, CircularProgress,
Tab, InputBase,
CircularProgress Paper,
Avatar,
ButtonBase
} from '@mui/material'; } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu'; import {
import SearchBar from 'components/nearle_components/SearchBar'; MdMenu,
MdSearch,
MdClear,
MdPlace,
MdStorefront,
MdMyLocation,
MdAccessTime,
MdLocalShipping,
MdHourglassEmpty,
MdCheckCircle,
MdCancel,
MdReceiptLong
} from 'react-icons/md';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { fetchOrders1, gettenantlocations } from '../api/api'; import { fetchOrders1, gettenantlocations } from '../api/api';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import CircularLoader from 'components/nearle_components/CircularLoader'; import CircularLoader from 'components/nearle_components/CircularLoader';
import { Empty, Skeleton } from 'antd'; import { Empty, Skeleton } from 'antd';
import MainCard from 'components/MainCard'; import {
import AccessTimeIcon from '@mui/icons-material/AccessTime'; DT,
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'; BRAND,
import { CancelOutlined, CheckCircleOutline } from '@mui/icons-material'; BRAND_LIGHT,
tint,
soft,
ring,
edge,
StatusBadge,
AccentAvatar
} from '../_shared/ordersDesign';
import axios from 'axios'; import axios from 'axios';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
var utc = require('dayjs/plugin/utc'); var utc = require('dayjs/plugin/utc');
@@ -45,6 +57,15 @@ dayjs.extend(utc);
const drawerWidth = 300; 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 ResponsiveLocationDrawer = () => {
const loadMoreRef = useRef(); const loadMoreRef = useRef();
const containerRef = useRef(); const containerRef = useRef();
@@ -71,6 +92,14 @@ const ResponsiveLocationDrawer = () => {
const [searchword, setSearchword] = useState(''); const [searchword, setSearchword] = useState('');
const [debouncedSearchword, setDebouncedSearchword] = 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(() => { useEffect(() => {
const handler = setTimeout(() => { const handler = setTimeout(() => {
setDebouncedSearchLocation(searchLocation); setDebouncedSearchLocation(searchLocation);
@@ -87,38 +116,11 @@ const ResponsiveLocationDrawer = () => {
return () => clearTimeout(handler); return () => clearTimeout(handler);
}, [searchword]); }, [searchword]);
const statusMap = [ const handleChangetab = (i) => {
{
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) => {
setSearchword(''); setSearchword('');
setRowsPerPage(10); setRowsPerPage(10);
setTabvalue(i); setTabvalue(i);
setCurrentStatus(statusMap[i].value); setCurrentStatus(STATUS_TABS[i].value);
setPage(0); setPage(0);
}; };
@@ -138,9 +140,7 @@ const ResponsiveLocationDrawer = () => {
// in the visible list, returning nothing and confusing the operator). // in the visible list, returning nothing and confusing the operator).
useEffect(() => { useEffect(() => {
if (!Array.isArray(locations) || locations.length === 0) return; if (!Array.isArray(locations) || locations.length === 0) return;
const stillVisible = const stillVisible = selectedLocation && locations.some((l) => l.locationid === selectedLocation.locationid);
selectedLocation &&
locations.some((l) => l.locationid === selectedLocation.locationid);
if (!stillVisible) setSelectedLocation(locations[0]); if (!stillVisible) setSelectedLocation(locations[0]);
}, [locations]); }, [locations]);
@@ -177,7 +177,7 @@ const ResponsiveLocationDrawer = () => {
} }
}, },
{ {
root: document.querySelector('.MuiTableContainer-root'), // 👈 or explicitly TableContainer root: document.querySelector('.MuiTableContainer-root'),
rootMargin: '0px', rootMargin: '0px',
threshold: 1.0 threshold: 1.0
} }
@@ -240,6 +240,139 @@ const ResponsiveLocationDrawer = () => {
errMessage && console.log(errMessage); errMessage && console.log(errMessage);
}, [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 ( return (
<React.Fragment> <React.Fragment>
{locationIsLoading && ( {locationIsLoading && (
@@ -248,9 +381,8 @@ const ResponsiveLocationDrawer = () => {
</> </>
)} )}
<Box sx={{ display: 'flex', width: '100%', height: '100%', position: 'relative' }}> <Box sx={{ display: 'flex', width: '100%', height: '100%', position: 'relative', bgcolor: DT.surfaceAlt }}>
{/* ---------------- LOCAL DRAWER ---------------- */} {/* ---------------- LOCATION SIDEBAR ---------------- */}
<Drawer <Drawer
variant={isDesktop ? 'persistent' : 'temporary'} variant={isDesktop ? 'persistent' : 'temporary'}
open={open} open={open}
@@ -264,246 +396,252 @@ const ResponsiveLocationDrawer = () => {
left: 0, left: 0,
top: 0, top: 0,
height: '100%', height: '100%',
overflowY: 'auto', overflow: 'hidden',
borderRight: `1px solid ${DT.borderSubtle}`,
transition: 'transform 0.35s ease-in-out', transition: 'transform 0.35s ease-in-out',
zIndex: 10, 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
}
} }
}} }}
> >
<Box sx={{ position: 'sticky', top: 0, zIndex: 11, border: 'none' }}> {sidebarContent}
<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>
</Drawer> </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 }}> {/* ---------------- MAIN PANEL ---------------- */}
{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 ---------------- */}
<Box <Box
sx={{ sx={{
flexGrow: 1, flexGrow: 1,
overflow: 'auto', height: '100%',
pt: '64px', // Height of AppBar display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
pl: isDesktop && open ? `${drawerWidth}px` : 0, pl: isDesktop && open ? `${drawerWidth}px` : 0,
transition: 'padding-left 0.3s ease', transition: 'padding-left 0.3s ease'
mt: -1 }}
>
{/* ---------------- GRADIENT HEADER ---------------- */}
<Paper
elevation={0}
sx={{
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%)`
}} }}
> >
<Stack <Stack
display={'flex'} direction={{ xs: 'column', md: 'row' }}
flexDirection={'row'} alignItems={{ xs: 'stretch', md: 'center' }}
justifyContent={'space-between'} justifyContent="space-between"
alignItems={'center'} spacing={{ xs: 1, md: 1.5 }}
flexWrap={'wrap-reverse'} >
gap={2} <Stack direction="row" alignItems="center" spacing={1.25}>
<Tooltip title={open ? 'Hide locations' : 'Show locations'} arrow>
<IconButton
onClick={toggleDrawer}
sx={{ sx={{
border: '1px solid ', width: 34,
borderBottom: 0, height: 34,
borderColor: 'bg.main', borderRadius: 1.5,
p: 1.5 bgcolor: '#fff',
border: `1px solid ${DT.borderSubtle}`,
color: BRAND,
'&:hover': { bgcolor: tint(BRAND), borderColor: BRAND }
}} }}
> >
{/* Tabs Wrapper */} <MdMenu size={18} />
</IconButton>
</Tooltip>
<Tabs value={tabvalue} onChange={handleChangetab} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile> <Avatar
{statusMap.map((item, index) => ( variant="rounded"
<Tab sx={{ width: 36, height: 36, bgcolor: BRAND, color: '#fff', borderRadius: 1.5, boxShadow: `0 4px 12px ${ring(BRAND)}` }}
key={index} >
label={ <MdMyLocation size={19} />
<Stack direction="row" alignItems="center" spacing={1}> </Avatar>
{item.icon} <Stack spacing={0.125}>
<span>{item.label}</span> <Typography
<Chip label={item.count} color="primary" variant="light" size="small" /> variant="h3"
</Stack> sx={{
} fontWeight: 800,
/> color: DT.textPrimary,
))} lineHeight: 1.1,
</Tabs> fontSize: { xs: '1.05rem', sm: '1.2rem', md: '1.3rem' }
</Stack> }}
<MainCard noWrap
content={false} >
{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={{ sx={{
overflow: 'hidden',
height: 'calc(100vh - 200px)', // adjust as needed
display: 'flex', display: 'flex',
flexDirection: 'column' 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 }
}}
/>
{searchword && (
<IconButton size="small" onClick={() => setSearchword('')} sx={{ p: 0.25, color: BRAND }}>
<MdClear size={14} />
</IconButton>
)}
</Box>
</Stack>
</Paper>
{/* ---------------- STATUS FILTER PILLS ---------------- */}
<Box
sx={{
flexShrink: 0,
px: { xs: 1, sm: 1.5 },
py: 1,
bgcolor: '#fff',
borderBottom: `1px solid ${DT.borderSubtle}`,
display: 'flex',
gap: 0.75,
overflowX: 'auto',
...scrollbarSx
}}
>
{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
}} }}
> >
<Fragment>
{/* Scrollable table container */}
<TableContainer <TableContainer
onScroll={handleScroll} onScroll={handleScroll}
ref={containerRef} ref={containerRef}
sx={{ flex: 1, overflow: 'auto', ...scrollbarSx }}
>
<Table stickyHeader size="small">
<TableHead>
<TableRow
sx={{ sx={{
width: '100%', '& th': {
flex: 1, backgroundColor: DT.surfaceAlt,
overflow: 'auto', color: DT.textSecondary,
borderBottom: 1, fontSize: 10.5,
maxHeight: 'calc(100vh - 225px)', fontWeight: 800,
borderColor: 'divider', letterSpacing: 0.5,
'&::-webkit-scrollbar': { width: '12px' }, textTransform: 'uppercase',
'&::-webkit-scrollbar-thumb': { whiteSpace: 'nowrap',
backgroundColor: theme.palette.primary.main, borderBottom: `1px solid ${DT.borderSubtle}`,
borderRadius: '8px' py: 0.75,
}, px: 1
'&::-webkit-scrollbar-thumb:hover': {
backgroundColor: theme.palette.primary.dark
},
'&::-webkit-scrollbar-track': {
backgroundColor: theme.palette.primary.lighter
} }
}} }}
> >
<Table stickyHeader> <TableCell sx={{ width: 36 }}>#</TableCell>
{/* HEADER */} <TableCell sx={{ minWidth: 150 }}>Order</TableCell>
<TableHead> <TableCell sx={{ minWidth: 150 }}>Pickup</TableCell>
<TableRow> <TableCell sx={{ minWidth: 150 }}>Drop</TableCell>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>S.No</TableCell> <TableCell sx={{ minWidth: 140 }}>Notes</TableCell>
<TableCell sx={{ backgroundColor: theme.palette.secondary.light, position: 'sticky !important' }}>Orders</TableCell> <TableCell sx={{ width: 120 }}>Status</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> </TableRow>
</TableHead> </TableHead>
{/* BODY */}
<TableBody> <TableBody>
{/* LOADING STATE */} {/* LOADING STATE */}
{loading && {loading &&
[...Array(10)].map((_, index) => ( Array.from({ length: 10 }).map((_, index) => (
<TableRow key={index}> <TableRow key={index}>
{[...Array(6)].map((__, i) => ( {Array.from({ length: 6 }).map((__, i) => (
<TableCell key={i}> <TableCell key={i} sx={{ borderBottom: `1px solid ${DT.divider}`, py: 0.625, px: 1 }}>
<Skeleton animation="wave" /> <Skeleton.Input active size="small" style={{ width: '100%', height: 18 }} />
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>
@@ -512,8 +650,16 @@ const ResponsiveLocationDrawer = () => {
{/* EMPTY STATE */} {/* EMPTY STATE */}
{!loading && rows?.length === 0 && ( {!loading && rows?.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={6} sx={{ minWidth: '100%', height: 500 }} align="center"> <TableCell colSpan={6} sx={{ py: 7, borderBottom: 'none' }}>
<Empty description={'No Orders'} /> <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> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -521,83 +667,101 @@ const ResponsiveLocationDrawer = () => {
{/* DATA ROWS */} {/* DATA ROWS */}
{!loading && {!loading &&
rows?.map((row, index) => ( rows?.map((row, index) => (
<TableRow key={index} sx={{ cursor: 'pointer' }}> <TableRow
<TableCell>{page * rowsPerPage + index + 1}</TableCell> 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 */} {/* Order Info */}
<TableCell> <TableCell>
<Typography variant="body2" noWrap> <Typography sx={{ fontSize: 12.5, fontWeight: 700, color: DT.textPrimary, lineHeight: 1.25 }} noWrap>
{row.orderid} {row.orderid}
</Typography> </Typography>
<Typography variant="caption" noWrap> <Stack direction="row" alignItems="center" spacing={0.5} sx={{ mt: 0.125 }}>
{dayjs(row.deliverydate).utc().format('DD/MM/YYYY')} <MdAccessTime size={10} style={{ color: DT.textMuted, flexShrink: 0 }} />
</Typography> <Typography sx={{ fontSize: 10.5, color: DT.textSecondary, fontWeight: 700 }} noWrap>
<Typography variant="caption" noWrap>
{dayjs(row.deliverydate).utc().format('hh:mm A')} {dayjs(row.deliverydate).utc().format('hh:mm A')}
</Typography> </Typography>
<Typography sx={{ fontSize: 10.5, color: DT.textMuted, fontWeight: 600 }} noWrap>
· {dayjs(row.deliverydate).utc().format('DD MMM YY')}
</Typography>
</Stack>
</TableCell> </TableCell>
{/* Pickup */} {/* Pickup */}
<TableCell> <TableCell>
<Stack direction="row" spacing={1}> <Stack spacing={0.125}>
<Avatar sx={{ width: 25, height: 25 }} /> <Typography sx={{ fontSize: 12.5, fontWeight: 700, color: DT.textPrimary, lineHeight: 1.25 }} noWrap>
<Stack> {row.pickupcustomer || '—'}
<Typography variant="caption">{row.pickupcustomer}</Typography> </Typography>
<Typography variant="caption">{row.pickupcontactno}</Typography> <Typography sx={{ fontSize: 11, color: DT.textSecondary, fontWeight: 600, lineHeight: 1.3 }} noWrap>
{row.pickupcontactno}
</Typography>
<Tooltip title={row.pickupaddress || ''}> <Tooltip title={row.pickupaddress || ''}>
<Typography variant="caption">{row.pickupsuburb || row.pickupaddress?.slice(0, 20) || '—'}</Typography> <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> </Tooltip>
</Stack> </Stack>
</Stack>
</TableCell> </TableCell>
{/* Drop */} {/* Drop */}
<TableCell> <TableCell>
<Stack direction="row" spacing={1}> <Stack spacing={0.125}>
<Avatar sx={{ width: 25, height: 25 }} /> <Typography sx={{ fontSize: 12.5, fontWeight: 700, color: DT.textPrimary, lineHeight: 1.25 }} noWrap>
<Stack> {row.deliverycustomer || '—'}
<Typography variant="caption">{row.deliverycustomer}</Typography> </Typography>
<Typography variant="caption">{row.deliverycontactno}</Typography> <Typography sx={{ fontSize: 11, color: DT.textSecondary, fontWeight: 600, lineHeight: 1.3 }} noWrap>
{row.deliverycontactno}
</Typography>
<Tooltip title={row.deliveryaddress || ''}> <Tooltip title={row.deliveryaddress || ''}>
<Typography variant="caption">{row.deliverysuburb || row.deliveryaddress?.slice(0, 20) || '—'}</Typography> <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> </Tooltip>
</Stack> </Stack>
</Stack>
</TableCell> </TableCell>
{/* Notes */} {/* Notes */}
<TableCell>{row.ordernotes}</TableCell> <TableCell>
<Typography sx={{ fontSize: 11.5, color: DT.textSecondary, fontWeight: 600, lineHeight: 1.35 }}>
{row.ordernotes || '—'}
</Typography>
</TableCell>
{/* Status */} {/* Status */}
<TableCell> <TableCell>
<Stack direction="row" spacing={1}> <StatusBadge status={row.orderstatus} />
{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>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
{rows?.length != 0 && ( {rows?.length != 0 && (
<TableRow> <TableRow>
<TableCell colSpan={6} rowSpan={3}> <TableCell colSpan={6} sx={{ borderBottom: 'none' }}>
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}> <Stack ref={loadMoreRef} alignItems="center" justifyContent="center" sx={{ height: 40 }}>
{isFetchingNextPage ? <CircularProgress /> : hasNextPage ? <CircularProgress /> : 'No More Orders'} {isFetchingNextPage || hasNextPage ? (
</div> <CircularProgress size={20} sx={{ color: BRAND }} />
) : (
<Typography sx={{ fontSize: 11.5, fontWeight: 700, color: DT.textMuted }}>No more orders</Typography>
)}
</Stack>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
</Fragment> </Paper>
</MainCard> </Box>
</Box> </Box>
</Box> </Box>
</React.Fragment> </React.Fragment>

View File

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