updates on the dispatch active page and the navbar design
This commit is contained in:
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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='© OpenStreetMap contributors' />
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='© 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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user