diff --git a/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavCollapse.js b/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavCollapse.js index 4abb22b..3504851 100644 --- a/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavCollapse.js +++ b/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavCollapse.js @@ -210,10 +210,14 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s const isSelected = selected === menu.id; const borderIcon = level === 1 ? : false; const Icon = menu.icon; - const menuIcon = menu.icon ? : borderIcon; - const iconSelectedColor = theme.palette.mode === ThemeMode.DARK && drawerOpen ? theme.palette.text.primary : theme.palette.primary.main; + const menuIcon = menu.icon ? : borderIcon; + // const textColor = theme.palette.mode === ThemeMode.DARK ? 'grey.400' : 'text.primary'; + // const iconSelectedColor = theme.palette.mode === ThemeMode.DARK && drawerOpen ? theme.palette.text.primary : theme.palette.primary.main; const popperId = miniMenuOpened ? `collapse-pop-${menu.id}` : undefined; const FlexBox = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }; + const textColor = 'white'; + const iconSelectedColor = 'white'; + // const isSelected = true; return ( <> @@ -227,9 +231,11 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s sx={{ pl: drawerOpen ? `${level * 28}px` : 1.5, py: !drawerOpen && level === 1 ? 1.25 : 1, + ...(drawerOpen && { '&:hover': { - bgcolor: theme.palette.mode === ThemeMode.DARK ? 'divider' : 'primary.light' + // bgcolor: theme.palette.mode === ThemeMode.DARK ? 'divider' : 'primary.lighter' + bgcolor: '#7b1fa2' }, '&.Mui-selected': { bgcolor: 'transparent', @@ -239,13 +245,14 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s }), ...(!drawerOpen && { '&:hover': { - bgcolor: 'primary.light' + bgcolor: 'transparent' + // bgcolor:'#7b1fa2' }, '&.Mui-selected': { '&:hover': { - bgcolor: 'white' + bgcolor: 'transparent' }, - bgcolor: 'white' + bgcolor: 'transparent' } }) }} @@ -255,7 +262,10 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s onClick={handlerIconLink} sx={{ minWidth: 28, - color: selected === menu.id ? 'primary.main' : 'white', + // color: selected === menu.id ? 'primary.main' : textColor, + // color: selected === menu.id ? textColor : textColor, + // bgcolor:'white', + // color:'white', ...(!drawerOpen && { borderRadius: 1.5, width: 36, @@ -264,13 +274,17 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s justifyContent: 'center', '&:hover': { // bgcolor: theme.palette.mode === ThemeMode.DARK ? 'secondary.light' : 'secondary.lighter' + bgcolor: '#7b1fa2', + color: 'white' } }), ...(!drawerOpen && selected === menu.id && { - // bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.900' : 'primary.lighter', + bgcolor: 'primary.light', + color: 'primary.main', '&:hover': { - // bgcolor: theme.palette.mode === ThemeMode.DARK ? 'primary.darker' : 'primary.lighter' + bgcolor: '#7b1fa2', + color: 'primary.main' } }) }} @@ -281,7 +295,12 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s {(drawerOpen || (!drawerOpen && level !== 1)) && ( + {menu.title} } @@ -296,9 +315,22 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s )} {(drawerOpen || (!drawerOpen && level !== 1)) && (miniMenuOpened || open ? ( - + ) : ( - + ))} {!drawerOpen && ( @@ -328,8 +360,8 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s mt: 1.5, boxShadow: theme.customShadows.z1, backgroundImage: 'none', - border: `1px solid ${theme.palette.primary.main}`, - bgcolor: 'primary.main' + border: `2px solid ${theme.palette.primary.main}`, + width: 'auto' }} > @@ -373,7 +405,14 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s > {menuIcon && ( - + {menuIcon} )} @@ -386,7 +425,12 @@ const NavCollapse = ({ menu, level, parentId, setSelectedItems, selectedItems, s )} + {menu.title} } diff --git a/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavGroup.js b/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavGroup.js index 4cb2d4e..7017fe3 100644 --- a/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavGroup.js +++ b/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavGroup.js @@ -26,7 +26,7 @@ import NavCollapse from './NavCollapse'; import SimpleBar from 'components/third-party/SimpleBar'; import Transitions from 'components/@extended/Transitions'; -import { MenuOrientation, ThemeMode } from 'config'; +import { MenuOrientation } from 'config'; import useConfig from 'hooks/useConfig'; import { dispatch, useSelector } from 'store'; import { activeID } from 'store/reducers/menu'; @@ -227,9 +227,9 @@ const NavGroup = ({ item, lastItem, remItems, lastItemId, setSelectedItems, sele item.title && drawerOpen && ( - {item.title} diff --git a/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavItem.js b/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavItem.js index d74c854..61072d3 100644 --- a/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavItem.js +++ b/src/layout/MainLayout/Drawer/DrawerContent/Navigation/NavItem.js @@ -9,9 +9,10 @@ import { Avatar, Chip, ListItemButton, ListItemIcon, ListItemText, Typography, u // project import import Dot from 'components/@extended/Dot'; + import { MenuOrientation, ThemeMode } from 'config'; import useConfig from 'hooks/useConfig'; -import { activeItem, openDrawer } from 'store/reducers/menu'; +import { activeItem, openDrawer, setSelectedMenu } from 'store/reducers/menu'; // ==============================|| NAVIGATION - LIST ITEM ||============================== // @@ -35,10 +36,15 @@ const NavItem = ({ item, level }) => { } const Icon = item.icon; - const itemIcon = item.icon ? : false; const isSelected = openItem.findIndex((id) => id === item.id) > -1; + const itemIcon = item.icon ? ( + + ) : ( + false + ); + // const { pathname } = useLocation(); const pathname = document.location.pathname; @@ -59,10 +65,15 @@ const NavItem = ({ item, level }) => { if (pathname.includes(item.url)) { dispatch(activeItem({ openItem: [item.id] })); } + // eslint-disable-next-line }, [pathname]); - const textColor = theme.palette.mode === ThemeMode.DARK ? 'grey.400' : 'text.primary'; + useEffect(() => { + dispatch(setSelectedMenu(pathname)); + }, [pathname]); + + const textColor = theme.palette.mode === ThemeMode.DARK ? 'grey.400' : '#fff'; const iconSelectedColor = theme.palette.mode === ThemeMode.DARK && drawerOpen ? 'text.primary' : 'primary.main'; return ( @@ -72,14 +83,16 @@ const NavItem = ({ item, level }) => { {...listItemProps} disabled={item.disabled} selected={isSelected} + onClick={() => { + // dispatch(setSelectedMenu(item)); + }} sx={{ zIndex: 1201, pl: drawerOpen ? `${level * 28}px` : 1.5, py: !drawerOpen && level === 1 ? 1.25 : 1, ...(drawerOpen && { - // bgcolor: 'primary.light', '&:hover': { - bgcolor: theme.palette.mode === ThemeMode.DARK ? 'divider' : 'primary.light' + bgcolor: '#7b1fa2' }, '&.Mui-selected': { bgcolor: theme.palette.mode === ThemeMode.DARK ? 'divider' : 'primary.lighter', @@ -92,41 +105,43 @@ const NavItem = ({ item, level }) => { } }), ...(!drawerOpen && { + bgcolor: '#662582', '&:hover': { - bgcolor: 'primary.light' + bgcolor: '#662582' }, '&.Mui-selected': { '&:hover': { - bgcolor: 'white' + bgcolor: 'transparent' }, - bgcolor: 'white' + bgcolor: 'transparent' } }) }} {...(downLG && { - onClick: () => dispatch(openDrawer(false)) + onClick: () => { + dispatch(openDrawer(false)); + } })} > {itemIcon && ( { {(drawerOpen || (!drawerOpen && level !== 1)) && ( + {item.title} } diff --git a/src/layout/MainLayout/Header/index.js b/src/layout/MainLayout/Header/index.js index 0e1b3a4..e1cddfb 100644 --- a/src/layout/MainLayout/Header/index.js +++ b/src/layout/MainLayout/Header/index.js @@ -9,7 +9,7 @@ import AppBarStyled from './AppBarStyled'; import HeaderContent from './HeaderContent'; import IconButton from 'components/@extended/IconButton'; -import { MenuOrientation, ThemeMode } from 'config'; +import { MenuOrientation } from 'config'; import useConfig from 'hooks/useConfig'; import { dispatch, useSelector } from 'store'; import { openDrawer } from 'store/reducers/menu'; @@ -32,9 +32,6 @@ const Header = () => { // header content const headerContent = useMemo(() => , []); - const iconBackColorOpen = theme.palette.mode === ThemeMode.DARK ? 'grey.200' : 'grey.300'; - const iconBackColor = theme.palette.mode === ThemeMode.DARK ? 'background.default' : 'grey.100'; - // common header const mainHeader = ( @@ -43,9 +40,6 @@ const Header = () => { aria-label="open drawer" onClick={() => dispatch(openDrawer(!drawerOpen))} edge="start" - // color="secondary" - // variant="light" - // sx={{ color: 'text.primary', bgcolor: drawerOpen ? iconBackColorOpen : iconBackColor, ml: { xs: 0, lg: -2 } }} sx={{ color: '#fff', bgcolor: 'transparent', diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css index 76efa51..841dcd5 100644 --- a/src/pages/nearle/dispatch/Dispatch.css +++ b/src/pages/nearle/dispatch/Dispatch.css @@ -1532,6 +1532,92 @@ margin-left: 4px; } +/* ─── Live bike marker (Swiggy/Zomato/Rapido-style) ─────────────────────── + Center-anchored rounded badge carrying a motorbike glyph, a pulsing "live" + ring, and a side label. The container itself is click-through; only the + badge + label capture clicks so the wide label area doesn't block the map. */ +.dispatch-container .live-rider-bike { + --pin-color: #16a34a; + position: relative; + width: 160px; + height: 44px; + pointer-events: none; +} + +.dispatch-container .live-rider-bike-badge { + position: absolute; + left: 4px; + top: 4px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--pin-color); + border: 3px solid #fff; + border-radius: 50%; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + pointer-events: auto; + z-index: 2; +} + +.dispatch-container .live-rider-bike-pulse { + position: absolute; + left: 4px; + top: 4px; + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--pin-color); + opacity: 0.45; + z-index: 1; +} + +.dispatch-container .live-rider-bike.is-active .live-rider-bike-pulse { + animation: live-rider-bike-pulse 1.6s ease-out infinite; +} + +@keyframes live-rider-bike-pulse { + 0% { + transform: scale(1); + opacity: 0.5; + } + 70% { + transform: scale(2.2); + opacity: 0; + } + 100% { + transform: scale(2.2); + opacity: 0; + } +} + +.dispatch-container .live-rider-bike.is-idle .live-rider-bike-badge { + opacity: 0.85; +} + +.dispatch-container .live-rider-bike-label { + position: absolute; + left: 46px; + top: 9px; + background: var(--pin-color); + color: #fff; + font-size: 11px; + font-weight: 700; + padding: 3px 8px; + border-radius: 4px; + white-space: nowrap; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); + line-height: 1.2; + pointer-events: auto; +} + +.dispatch-container .live-rider-bike-label span { + font-weight: 500; + opacity: 0.85; + margin-left: 4px; +} + /* Body layout */ .dispatch-container #body { flex: 1; @@ -1949,6 +2035,46 @@ color: #16a34a; } +/* GPS-only rider (live on the road, no active delivery this slot). The card + collapses to just the live state — the badge mirrors the map's live bike + indicator so the operator reads "tracking position only" at a glance. */ +.dispatch-container .rcard.is-gps-only { + border-style: dashed; + cursor: default; +} + +/* GPS-only riders have no route to open, so the card is not interactive — + suppress the hover lift/shadow that signals "clickable". */ +.dispatch-container .rcard.is-gps-only:hover { + transform: none; + box-shadow: var(--shadow); + border-color: var(--border); +} + +.dispatch-container .rcard-badge.rcard-badge-live { + display: inline-flex; + align-items: center; + gap: 6px; + background: rgba(22, 163, 74, 0.12); + color: #16a34a; + letter-spacing: 0.04em; +} + +.dispatch-container .rcard-live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #16a34a; + box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.55); + animation: rcard-live-pulse 1.6s ease-out infinite; +} + +@keyframes rcard-live-pulse { + 0% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.55); } + 70% { box-shadow: 0 0 0 6px rgba(22, 163, 74, 0); } + 100% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0); } +} + .dispatch-container .bar-bg { background: var(--bg-sub); border-radius: 4px; @@ -2166,6 +2292,8 @@ display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; + row-gap: 10px; border-bottom: 1px solid var(--border); background: #fff; } @@ -2186,6 +2314,95 @@ gap: 12px; } +/* Planned / By time segmented control inside the trip header. Wraps onto its + own full-width row below the badge + stats (the header is flex-wrap: wrap, + and flex-basis: 100% forces the break). Each pill = 50% of the row → a clear + "tab bar". Active pill is a white thumb; the mode's semantic color (indigo + for Planned, emerald for By time) lands on the active icon so the mode reads + at a glance. */ +.dispatch-container .trip-sort-toggle { + display: flex; + align-items: stretch; + width: 100%; + flex-basis: 100%; + margin-left: 0; + padding: 3px; + background: #f1f5f9; + border-radius: 10px; + position: relative; + isolation: isolate; +} + +.dispatch-container .trip-sort-pill { + flex: 1 1 0; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 10px; + min-height: 28px; + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.01em; + line-height: 1; + color: #64748b; + background: transparent; + border: 0; + border-radius: 7px; + cursor: pointer; + white-space: nowrap; + transition: color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; + -webkit-appearance: none; + appearance: none; +} + +.dispatch-container .trip-sort-pill svg { + width: 14px; + height: 14px; + flex: 0 0 auto; + display: block; + color: #94a3b8; + transition: color 0.18s ease, transform 0.18s ease; +} + +.dispatch-container .trip-sort-pill:hover:not(.is-active) { + color: #0f172a; +} + +.dispatch-container .trip-sort-pill:hover:not(.is-active) svg { + color: #475569; +} + +.dispatch-container .trip-sort-pill:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.4); +} + +.dispatch-container .trip-sort-pill.is-active { + color: #0f172a; + background: #ffffff; + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.08), + 0 0 0 1px rgba(15, 23, 42, 0.04), + 0 2px 6px rgba(15, 23, 42, 0.06); +} + +.dispatch-container .trip-sort-toggle[data-mode='planned'] + .trip-sort-pill.is-active svg { + color: #6366f1; +} + +.dispatch-container .trip-sort-toggle[data-mode='time'] + .trip-sort-pill.is-active svg { + color: #10b981; +} + +/* Undelivered cards while By time is active — sunk to the bottom and dimmed + so "not yet delivered" reads at a glance without checking each status pill. */ +.dispatch-container .zone-order-card.is-pending-time { + opacity: 0.72; +} + .dispatch-container .step-wrap { padding: 16px; } @@ -5523,6 +5740,30 @@ text-overflow: ellipsis; } +/* Customer (recipient) name — sits directly under the rider name as a + secondary, lighter line so the operator sees who the drop is for. */ +.dispatch-container .dispatch-popup .pu-customer { + margin-top: 4px; + padding: 0; + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9) !important; + display: flex; + align-items: center; + gap: 6px; +} + +.dispatch-container .dispatch-popup .pu-customer svg { + font-size: 15px; + opacity: 0.85; +} + +.dispatch-container .dispatch-popup .pu-customer span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .dispatch-container .dispatch-popup .pu-delivery-id { margin-top: 6px; font-size: 11px; diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js index 4b6f55b..3499f9f 100644 --- a/src/pages/nearle/dispatch/Dispatch.js +++ b/src/pages/nearle/dispatch/Dispatch.js @@ -21,6 +21,7 @@ import { MdLocationOn, MdMarkunreadMailbox, MdMoveToInbox, + MdPerson, MdPlace, MdTwoWheeler, MdNotes, @@ -478,7 +479,7 @@ const getStableRiderColor = (id) => { // extracted CompareDataPanel component can import them without forcing // a circular dependency on Dispatch.js. -const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => { +const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey, extraPoints }) => { const map = useMap(); // Last fit signature. We only call fitBounds when this changes — otherwise // every parent render (data refetch, sidebar tick, etc.) would refit the @@ -529,7 +530,11 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) } if (viewMode === 'all') { const oPairs = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); - return `${loc}a|${oPairs.length}|${centroidSig(oPairs)}`; + // Include active riders' live GPS so the fit reframes when an order-less + // (GPS-only) rider appears/moves and there are no drops to anchor to. + const ePairs = extraPoints || []; + const allPairs = oPairs.concat(ePairs); + return `${loc}a|${allPairs.length}|${centroidSig(allPairs)}`; } return `${loc}m|${viewMode || ''}|${kPairs.length}|${kSig}`; }, [focusedItem, viewMode, orders, kitchens, locationKey]); @@ -539,10 +544,12 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) let pts = []; if (focusedItem) { - if (focusedItem.orders) { + if (focusedItem.orders && focusedItem.orders.length) { pts = focusedItem.orders.map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); focusedItem.orders.forEach((o) => pts.push([toNum(pickupLat(o)), toNum(pickupLon(o))])); } else { + // Order-less focus target (a single kitchen, or a GPS-only active + // rider) — center on its own coordinate. pts = [[focusedItem.lat, focusedItem.lon]]; } } else if (viewMode === 'kitchens') { @@ -554,6 +561,9 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) } } else if (viewMode === 'all') { pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); + // Frame order-less active riders (GPS-only) too — their live positions + // are the only thing to show for them. + pts = pts.concat(extraPoints || []); } else { // No focus, viewMode is 'riders' / 'zones' / etc. — still fit to the // current hub's footprint so switching from Coimbatore → Nagercoil @@ -593,11 +603,130 @@ const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) // bug that left Nagercoil (and every non-Coimbatore hub) stuck on the // Coimbatore default during the brief window between picking the hub // and its data arriving. - }, [fitKey, focusedItem, viewMode, orders, kitchens, map]); + }, [fitKey, focusedItem, viewMode, orders, kitchens, extraPoints, map]); return null; }; +// Smoothly-moving rider marker — the Swiggy/Zomato/Rapido style "bike gliding +// down the road" effect. Instead of letting react-leaflet snap the marker to +// each new GPS fix, we keep the '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 ( + { + 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} + + ); +}; + // Inline-icon wrapper used wherever a Material icon precedes some text — keeps the // SVG vertically centered with the adjacent text and inherits the parent color. const Ico = ({ children }) => ( @@ -699,6 +828,12 @@ const Dispatch = ({ const [focusedZone, setFocusedZone] = useState(null); // Single delivery stop pinned by clicking its sidebar row — overrides the rider's full-route bounds on the map. const [focusedStop, setFocusedStop] = useState(null); + // How the stops inside each trip block are ordered in the focused-rider sidebar: + // 'planned' → the dispatched route order (by step) — the default. + // 'time' → re-sorted by when each delivery was actually completed, so the + // operator can see which drop happened first regardless of the + // planned sequence. Undelivered stops sink to the bottom. + const [tripSortMode, setTripSortMode] = useState('planned'); // Holds leaflet marker instances keyed by orderid so we can imperatively open // their popups when the user clicks a step in the focused-rider sidebar. const orderMarkerRefs = useRef({}); @@ -712,6 +847,13 @@ const Dispatch = ({ // popups use leaflet's marker-attached (openPopup/closePopup) rather // than the centered overlay used for order popups. const pinnedLivePopupsRef = useRef(new Set()); + // Per-rider cache of the live bike L.divIcon, keyed by id. The page re-renders + // every second (clock tick + 1s GPS poll in the active view); without this + // cache we'd hand react-leaflet a brand-new icon object each tick, forcing a + // setIcon() that wipes the marker DOM and restarts the CSS pulse animation. + // Caching by a content signature keeps the icon reference stable when nothing + // changed, so the pulse animates smoothly and only the position eases. + const liveIconCacheRef = useRef(new Map()); // Short-lived close timer for the general map order/marker popups. // Gives the cursor a ~200ms window to travel from the marker onto the popup // or vice versa without immediately triggering a close. @@ -1074,12 +1216,18 @@ const Dispatch = ({ // hub (latitude/longitude/logdate/status). We render those positions as // markers on the main dispatch map so the operator sees where each rider // actually is — matching the Reports → Riders Logs page. + // Poll cadence: in the "All Active Routes" view the operator is watching + // riders physically move, so we refresh the GPS feed every second (Swiggy / + // Rapido style live tracking). The AnimatedRiderMarker eases the bike between + // fixes so motion stays smooth even if a fix is unchanged. Other views don't + // need second-by-second churn, so they stay on the lighter 15s cadence. + const RIDER_LOG_POLL_MS = viewMode === 'all' ? 1_000 : 15_000; const { data: ridersLocationLogs } = useQuery({ queryKey: [selectedAppLocationId, selectedDate, ''], queryFn: fetchRidersLogs, - refetchInterval: 15_000, + refetchInterval: RIDER_LOG_POLL_MS, refetchIntervalInBackground: false, - staleTime: 5 * 1000, + staleTime: 1000, refetchOnWindowFocus: false }); @@ -1105,6 +1253,15 @@ const Dispatch = ({ }) .filter(Boolean); }, [ridersLocationLogs]); + + // Set of rider ids whose latest GPS log row is `active` (i.e. on the road + // right now). The "All Active Routes" view (viewMode === 'all') uses this to + // show ONLY currently-active riders — their cards, routes, drop markers and + // live bike markers — and hide everyone who is offline/idle for the slot. + const activeRiderIdSet = useMemo( + () => new Set(liveRiderLocations.filter((r) => r.status === 'active').map((r) => String(r.id))), + [liveRiderLocations] + ); // Default to the slot containing the current wall-clock time. Use a // fractional hour so 12:45 lands in the 12:30+ slot 2 (not slot 1). If // the current time falls outside every slot window (e.g. before 8 AM) @@ -1395,6 +1552,60 @@ const Dispatch = ({ ? (selectedRiderId ? (riders.find((r) => r.id === selectedRiderId) || null) : null) : internalFocusedRider; + // "All Active Routes" view scoping. In this mode we render only riders whose + // live GPS log is `active` right now — their cards, routes, drop markers and + // bike markers — so the operator sees the on-road fleet at a glance. Every + // other view (By Location / By Zone / By Rider) is unchanged. + const isAllActiveView = viewMode === 'all'; + // Active riders the GPS feed reports as on-road RIGHT NOW that have no orders + // in the current data (i.e. nothing to deliver). We still want them on screen + // in "All Active Routes" — just their live bike marker, no route — so we + // synthesize order-less rider objects shaped like real ones. `gpsOnly` flags + // them so the card / route code treats them as "live position only". + const gpsOnlyActiveRiders = useMemo(() => { + if (!isAllActiveView) return []; + const haveOrders = new Set(riders.map((r) => String(r.id))); + return liveRiderLocations + .filter((r) => r.status === 'active' && !haveOrders.has(String(r.id))) + .map((r) => ({ + id: r.id, + riderName: r.username || `Rider #${r.id}`, + orders: [], + color: getStableRiderColor(r.id), + gpsOnly: true, + // Live position — lets MapController center on the rider when their + // GPS-only card is clicked (they have no drops to fit to). + lat: r.lat, + lon: r.lon + })); + }, [isAllActiveView, liveRiderLocations, riders]); + // The riders we render in "All Active Routes": every rider whose live GPS is + // active — those with orders (real route shown) PLUS those without (GPS only). + // Every other view (By Location / By Zone / By Rider) is unchanged. + const visibleRiders = useMemo( + () => + isAllActiveView + ? [...riders.filter((r) => activeRiderIdSet.has(String(r.id))), ...gpsOnlyActiveRiders] + : riders, + [isAllActiveView, riders, activeRiderIdSet, gpsOnlyActiveRiders] + ); + // Orders that belong to the riders we're actually showing — drives the drop + // markers and the map auto-fit bounds in the active view. + const allViewOrders = useMemo( + () => (isAllActiveView ? allOrders.filter((o) => activeRiderIdSet.has(String(o.rider_id))) : allOrders), + [isAllActiveView, allOrders, activeRiderIdSet] + ); + // Live GPS coordinates of every active rider in the "All Active Routes" view. + // Fed to MapController's auto-fit so order-less (GPS-only) active riders are + // framed even when there are no drop markers to anchor the bounds. + const allViewLivePoints = useMemo( + () => + isAllActiveView + ? liveRiderLocations.filter((r) => r.status === 'active').map((r) => [r.lat, r.lon]) + : [], + [isAllActiveView, liveRiderLocations] + ); + // Per-rider canvas renderer for the actual (right) map in Compare mode. // Single setter used by every interactive site in the UI. In uncontrolled mode it // updates local state; in controlled mode it only notifies the parent. @@ -1473,6 +1684,19 @@ const Dispatch = ({ label: 'Focused Kitchen' }; } + // "All Active Routes": the header must reflect exactly what the list/map + // shows — the active fleet (riders with orders + GPS-only riders) and their + // orders — NOT the whole day's totals. Otherwise the top "Riders" tile (full + // fleet) disagrees with the rider list below (active-only). + if (isAllActiveView) { + return { + orders: allViewOrders.length, + riders: visibleRiders.length, + km: allViewOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0), + profit: allViewOrders.reduce((s, o) => s + parseFloat(o.profit || 0), 0), + label: 'Active Fleet' + }; + } return { orders: stats.totalOrders, riders: stats.totalRiders, @@ -1480,7 +1704,7 @@ const Dispatch = ({ profit: stats.totalProfit, label: 'Total Fleet' }; - }, [focusedRider, focusedKitchen, stats]); + }, [focusedRider, focusedKitchen, isAllActiveView, allViewOrders, visibleRiders, stats]); // List of deliveryids we want GPS logs for. Drives two pipelines: // • renderRoutes() — actual-route polylines on the main map for @@ -2225,35 +2449,52 @@ const Dispatch = ({ return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s); }); const estMeters = activeOrder ? calculateEstMeters(r.id, activeOrder) : null; + // GPS-only rider: live on the road but no orders in this slot. Show a card + // that makes the "tracking position only, no active delivery" state obvious. + const isGpsOnly = r.gpsOnly || total === 0; return ( -
handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}> +
handleRiderFocus(r)} style={{ animationDelay: `${i * 0.05}s` }}>
{r.riderName}
-
{r.orders[0]?.zone_name || locationName || 'Local'} · {new Set(r.orders.map(o => o.trip_number || 1)).size} trips
+
+ {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`} +
-
- {delivered}/{total} -
-
-
-
- {r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km - {estMeters !== null && ( - - {formatMeters(estMeters)} to drop - + {isGpsOnly ? ( +
+ LIVE +
+ ) : ( +
+ {delivered}/{total} +
)}
-
- {r.orders.slice(0, 15).map(o => S{o.step})} -
+ {!isGpsOnly && ( + <> +
+
+ {r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km + {estMeters !== null && ( + + {formatMeters(estMeters)} to drop + + )} +
+
+ {r.orders.slice(0, 15).map(o => S{o.step})} +
+ + )}
); }; @@ -2298,6 +2539,11 @@ const Dispatch = ({
{o.rider_name || o.ridername || 'Unassigned'}
+ {(o.deliverycustomer || o.customername) && ( +
+ {o.deliverycustomer || o.customername} +
+ )} {o.deliveryid != null && (
Delivery #{o.deliveryid}
)} @@ -2412,7 +2658,9 @@ const Dispatch = ({ // duplicate, slightly-offset pins that clutter the view. if (compareOpen && focusedRider && compareViewMode === 'actual') return null; - let ordersToRender = allOrders; + // In "All Active Routes" view the base set is restricted to active riders' + // orders (allViewOrders); a focus selection still overrides as usual. + let ordersToRender = allViewOrders; if (focusedZone) ordersToRender = focusedZone.orders; if (focusedKitchen) ordersToRender = focusedKitchen.orders; if (focusedRider) ordersToRender = focusedRider.orders; @@ -2531,7 +2779,9 @@ const Dispatch = ({ const routes = []; const zoneRiderIds = focusedZone ? new Set(focusedZone.riders.map((zr) => String(zr.rider_id))) : null; if (hidePlanned) return routes; - riders.forEach(r => { + // visibleRiders === riders in every view except "All Active Routes", where + // it's pre-filtered to riders whose live GPS is currently active. + visibleRiders.forEach(r => { const isActive = activeRiders.has(r.id); if (focusedRider && focusedRider.id !== r.id) return; if (focusedKitchen && !focusedKitchen.riders.has(r.id)) return; @@ -3025,7 +3275,7 @@ const Dispatch = ({ onClick={() => { logger.info('View mode changed: By Zone'); setViewMode('zones'); handleRiderFocus(null); setFocusedKitchen(null); setFocusedZone(null); }} > By Zone - + + +
- {tOrders.map((o, idx) => { + {displayOrders.map((o, idx) => { const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim(); const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey; prevKitchenKey = kitchenKey; @@ -3598,6 +3900,10 @@ const Dispatch = ({ const canFocus = Number.isFinite(lat) && Number.isFinite(lon); const statusStyle = getStatusStyle(o.orderstatus); const estMeters = calculateEstMeters(focusedRider.id, o); + // In "By time" mode, stops with no actual delivery time + // were sunk to the bottom — dim them so it's obvious + // they're not yet delivered without reading the status. + const isUndeliveredInTimeMode = isTimeMode && !o.deliverytime; return ( @@ -3605,7 +3911,7 @@ const Dispatch = ({
Switch to {o.pickupcustomer}
)}
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined} @@ -3714,7 +4020,8 @@ const Dispatch = ({ })}
- )); + ); + }); })()} ) : ( @@ -3996,7 +4303,8 @@ const Dispatch = ({
{ viewMode === 'zones' ? 'Zone dispatch' : viewMode === 'kitchens' ? 'Kitchen dispatch' : - 'Rider dispatch' + viewMode === 'all' ? 'Active rider dispatch' : + 'Rider dispatch' }
{allOrders.length === 0 && !liveIsFetching ? ( @@ -4114,8 +4422,18 @@ const Dispatch = ({
)) + ) : isAllActiveView && visibleRiders.length === 0 ? ( +
+
+ +
+
No active riders
+
+ No riders are currently live on the road for this slot +
+
) : ( - riders.map(renderRiderCard) + visibleRiders.map(renderRiderCard) )} @@ -4140,7 +4458,7 @@ const Dispatch = ({ {compareOpen && } - + {kitchens .filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon)) .filter(k => !focusedRider || k.riders.has(focusedRider.id)) @@ -4173,14 +4491,23 @@ const Dispatch = ({ {/* Live rider GPS markers from /partners/getriderlogs/. Mirrors the Reports → Riders Logs map: green pin when the rider's last log row is `active`, red otherwise, with the rider's username as a - label. Scoped to riders who actually have orders in the - currently selected slot — `riders` is derived from - filteredLiveRows so it already reflects the slot filter. A - rider with zero orders in the current slot is hidden, even if - getriderlogs still returns their GPS row. When a specific - rider is focused, only that one is shown. */} + label. + + "All Active Routes" view: show EVERY active rider's live GPS — + including riders with no orders in the current slot (those get a + bike marker only, no route). Riders with an active delivery also + get their route drawn by renderRoutes(). Other views stay scoped + to riders who actually have orders in the slot (`riders` is + derived from filteredLiveRows so it already reflects the slot + filter); a rider with zero orders is hidden there even if + getriderlogs still returns their GPS row. When a specific rider + is focused, only that one is shown. */} {liveRiderLocations - .filter((r) => riders.some((rd) => String(rd.id) === String(r.id))) + .filter((r) => + isAllActiveView + ? r.status === 'active' + : riders.some((rd) => String(rd.id) === String(r.id)) + ) .filter((r) => !focusedRider || String(focusedRider.id) === String(r.id)) .map((r) => { const isActive = r.status === 'active'; @@ -4204,39 +4531,82 @@ const Dispatch = ({ const nextDropArea = nextOrder ? (nextOrder.deliverysuburb || extractArea(nextOrder.deliveryaddress)) : null; - const liveIcon = L.divIcon({ - className: '', - iconSize: [140, 56], - iconAnchor: [12, 41], - popupAnchor: [58, -40], - html: `
-
-
${(r.username || '').replace(/[<>&"']/g, '')}${r.orderid ? ` #${String(r.orderid).replace(/[<>&"']/g, '')}` : ''}
+ // 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: `
+ + + + + ${safeName}${safeOrder ? ` #${safeOrder}` : ''}
` - }); + }) + : L.divIcon({ + className: '', + iconSize: [140, 56], + iconAnchor: [12, 41], + popupAnchor: [58, -40], + html: `
+
+
${safeName}${safeOrder ? ` #${safeOrder}` : ''}
+
` + }); + iconEntry = { sig: iconSig, icon }; + liveIconCacheRef.current.set(r.id, iconEntry); + } + const liveIcon = iconEntry.icon; + // Shared interaction handlers — identical for both marker types. + const liveEventHandlers = { + click: (e) => { + const idStr = String(r.id); + if (pinnedLivePopupsRef.current.has(idStr)) { + pinnedLivePopupsRef.current.delete(idStr); + e.target.closePopup(); + } else { + pinnedLivePopupsRef.current.add(idStr); + e.target.openPopup(); + } + // Focus the rider behind this marker — only riders that + // actually have a delivery (i.e. exist in `riders`). + // GPS-only active riders have no route to focus, so their + // marker just shows the live popup and isn't clickable + // for focus. + const match = riders.find((rd) => String(rd.id) === idStr); + if (match) handleRiderFocus(match); + }, + popupclose: () => { + pinnedLivePopupsRef.current.delete(String(r.id)); + } + }; + // Animated bike (with imperative gliding) only in the active + // view; the plain react-leaflet Marker everywhere else so + // existing views behave exactly as they did before. + const LiveMarker = isAllActiveView ? AnimatedRiderMarker : Marker; + const positionProps = isAllActiveView + ? { target: [r.lat, r.lon], duration: 1200 } + : { position: [r.lat, r.lon] }; return ( - { - const idStr = String(r.id); - if (pinnedLivePopupsRef.current.has(idStr)) { - pinnedLivePopupsRef.current.delete(idStr); - e.target.closePopup(); - } else { - pinnedLivePopupsRef.current.add(idStr); - e.target.openPopup(); - } - const match = riders.find((rd) => String(rd.id) === idStr); - if (match) handleRiderFocus(match); - }, - popupclose: () => { - pinnedLivePopupsRef.current.delete(String(r.id)); - } - }} + eventHandlers={liveEventHandlers} >
@@ -4322,7 +4692,7 @@ const Dispatch = ({
-
+ ); })} diff --git a/src/pages/nearle/locations/ResponsiveLocationDrawer.js b/src/pages/nearle/locations/ResponsiveLocationDrawer.js index 5db2686..02ae3c3 100644 --- a/src/pages/nearle/locations/ResponsiveLocationDrawer.js +++ b/src/pages/nearle/locations/ResponsiveLocationDrawer.js @@ -1,43 +1,55 @@ -import React, { useState, useEffect, Fragment, useRef } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Box, Drawer, IconButton, - Toolbar, Typography, - AppBar, useMediaQuery, - Divider, - List, - ListItem, - ListItemText, useTheme, - ListItemAvatar, - Avatar, Tooltip, TableCell, - Chip, Stack, TableRow, TableBody, TableHead, Table, TableContainer, - Tabs, - Tab, - CircularProgress + CircularProgress, + InputBase, + Paper, + Avatar, + ButtonBase } from '@mui/material'; -import MenuIcon from '@mui/icons-material/Menu'; -import SearchBar from 'components/nearle_components/SearchBar'; +import { + MdMenu, + MdSearch, + MdClear, + MdPlace, + MdStorefront, + MdMyLocation, + MdAccessTime, + MdLocalShipping, + MdHourglassEmpty, + MdCheckCircle, + MdCancel, + MdReceiptLong +} from 'react-icons/md'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { fetchOrders1, gettenantlocations } from '../api/api'; import Loader from 'components/Loader'; import CircularLoader from 'components/nearle_components/CircularLoader'; import { Empty, Skeleton } from 'antd'; -import MainCard from 'components/MainCard'; -import AccessTimeIcon from '@mui/icons-material/AccessTime'; -import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'; -import { CancelOutlined, CheckCircleOutline } from '@mui/icons-material'; +import { + DT, + BRAND, + BRAND_LIGHT, + tint, + soft, + ring, + edge, + StatusBadge, + AccentAvatar +} from '../_shared/ordersDesign'; import axios from 'axios'; import dayjs from 'dayjs'; var utc = require('dayjs/plugin/utc'); @@ -45,6 +57,15 @@ dayjs.extend(utc); const drawerWidth = 300; +// Status filter tabs — colors aligned with STATUS_META in the shared design system +// (blue=created, amber=pending, green=delivered, red=cancelled). +const STATUS_TABS = [ + { label: 'Created', value: 'created', color: '#3b82f6', icon: MdLocalShipping }, + { label: 'Pending', value: 'pending', color: '#f59e0b', icon: MdHourglassEmpty }, + { label: 'Delivered', value: 'delivered', color: '#10b981', icon: MdCheckCircle }, + { label: 'Cancelled', value: 'cancelled', color: '#ef4444', icon: MdCancel } +]; + const ResponsiveLocationDrawer = () => { const loadMoreRef = useRef(); const containerRef = useRef(); @@ -71,6 +92,14 @@ const ResponsiveLocationDrawer = () => { const [searchword, setSearchword] = useState(''); const [debouncedSearchword, setDebouncedSearchword] = useState(''); + // Per-status counts keyed by tab value, so the filter pills can show a badge. + const statusCounts = { + created: createdLenght, + pending: pendingLenght, + delivered: deliveredlenght, + cancelled: cancelledLenght + }; + useEffect(() => { const handler = setTimeout(() => { setDebouncedSearchLocation(searchLocation); @@ -87,38 +116,11 @@ const ResponsiveLocationDrawer = () => { return () => clearTimeout(handler); }, [searchword]); - const statusMap = [ - { - label: 'Created', - value: 'created', - count: createdLenght, - icon: - }, - { - label: 'Pending', - value: 'pending', - count: pendingLenght, - icon: - }, - { - label: 'Delivered', - value: 'delivered', - count: deliveredlenght, - icon: - }, - { - label: 'Cancelled', - value: 'cancelled', - count: cancelledLenght, - icon: - } - ]; - - const handleChangetab = (e, i) => { + const handleChangetab = (i) => { setSearchword(''); setRowsPerPage(10); setTabvalue(i); - setCurrentStatus(statusMap[i].value); + setCurrentStatus(STATUS_TABS[i].value); setPage(0); }; @@ -138,9 +140,7 @@ const ResponsiveLocationDrawer = () => { // in the visible list, returning nothing and confusing the operator). useEffect(() => { if (!Array.isArray(locations) || locations.length === 0) return; - const stillVisible = - selectedLocation && - locations.some((l) => l.locationid === selectedLocation.locationid); + const stillVisible = selectedLocation && locations.some((l) => l.locationid === selectedLocation.locationid); if (!stillVisible) setSelectedLocation(locations[0]); }, [locations]); @@ -177,7 +177,7 @@ const ResponsiveLocationDrawer = () => { } }, { - root: document.querySelector('.MuiTableContainer-root'), // 👈 or explicitly TableContainer + root: document.querySelector('.MuiTableContainer-root'), rootMargin: '0px', threshold: 1.0 } @@ -240,6 +240,139 @@ const ResponsiveLocationDrawer = () => { errMessage && console.log(errMessage); }, [errMessage]); + // Brand-styled scrollbar reused on the sidebar + table. + const scrollbarSx = { + '&::-webkit-scrollbar': { width: 8, height: 8 }, + '&::-webkit-scrollbar-thumb': { + backgroundColor: edge(BRAND), + borderRadius: 8, + '&:hover': { backgroundColor: BRAND } + }, + '&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt } + }; + + // -------------------------------------------------------------------------- + // Sidebar — searchable location list. Shared between the desktop persistent + // drawer and the mobile temporary drawer. + // -------------------------------------------------------------------------- + const sidebarContent = ( + + {/* Sidebar header */} + + + + + + + Locations + + {Array.isArray(locations) ? `${locations.length} active` : '—'} + + + + + {/* Search pill */} + + + setSearchLocation(e.target.value)} + autoComplete="off" + sx={{ + flex: 1, + fontSize: 13, + fontWeight: 600, + color: DT.textPrimary, + '& input::placeholder': { color: DT.textMuted, opacity: 1 } + }} + /> + {searchLocation && ( + setSearchLocation('')} sx={{ p: 0.25, color: BRAND }}> + + + )} + + + + {/* Location list */} + + {locationIsLoading && + Array.from({ length: 8 }).map((_, i) => ( + + + + ))} + + {!locationIsLoading && Array.isArray(locations) && locations.length === 0 && ( + + + + )} + + + {locations?.map((row, index) => { + const isSelected = row.locationid === selectedLocation?.locationid; + return ( + 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 } + }} + > + + {row.locationname?.[0]?.toUpperCase() || '?'} + + + + {row.locationname} + + + + + {row.suburb || '—'} + + + + + ); + })} + + + + ); + return ( {locationIsLoading && ( @@ -248,9 +381,8 @@ const ResponsiveLocationDrawer = () => { )} - - {/* ---------------- LOCAL DRAWER ---------------- */} - + + {/* ---------------- LOCATION SIDEBAR ---------------- */} { left: 0, top: 0, height: '100%', - overflowY: 'auto', + overflow: 'hidden', + borderRight: `1px solid ${DT.borderSubtle}`, transition: 'transform 0.35s ease-in-out', - zIndex: 10, - - /* vertical scrollbar */ - '&::-webkit-scrollbar:vertical': { - width: '7px', - opacity: 0, - transition: 'opacity 0.3s' - }, - - /* horizontal scrollbar */ - '&::-webkit-scrollbar:horizontal': { - height: '6px', // thinner horizontal bar - opacity: 0, - transition: 'opacity 0.3s' - }, - - /* show scrollbar when hovering drawer */ - '&:hover::-webkit-scrollbar': { - opacity: 1 - }, - - /* thumb styling */ - '&::-webkit-scrollbar-thumb': { - backgroundColor: theme.palette.primary.main, - borderRadius: '8px' - }, - '&::-webkit-scrollbar-thumb:hover': { - backgroundColor: theme.palette.primary.dark - }, - - /* track styling */ - '&::-webkit-scrollbar-track': { - backgroundColor: theme.palette.primary.lighter - } + zIndex: 10 } }} > - - setSearchLocation(e.target.value)} - sx={{ - width: 'auto', - height: 60, - bgcolor: 'white', - - '& .MuiOutlinedInput-notchedOutline': { - border: 'none', - borderBottom: '1px solid', - borderColor: theme.palette.secondary.light - } - }} - /> - - - {locations?.map((row, index) => ( - - { - setSelectedLocation(row); - }} - > - - - {row.locationname?.[0]?.toUpperCase() || '?'} - {' '} - - - - - - ))} - + {sidebarContent} - {/* -------------- LOCAL PAGE APPBAR -------------- */} - - - - - - - - - {selectedLocation?.locationname} - - - - setSearchword(e.target.value)} - sx={{ - width: 'auto', - height: 40, - bgcolor: 'white', - maxWidth: 800, - borderRadius: 1 - // '& .MuiOutlinedInput-notchedOutline': { - // border: 'none' - // } - }} - /> - - - - - {/* ---------------- PAGE SCROLLABLE CONTENT ---------------- */} + {/* ---------------- MAIN PANEL ---------------- */} - - {/* Tabs Wrapper */} + + + + + + + - - {statusMap.map((item, index) => ( - - {item.icon} - {item.label} - - - } + + + + + + {selectedLocation?.locationname || 'Select a location'} + + + + + {selectedLocation?.suburb ? `${selectedLocation.suburb} · ` : ''}Live · {dayjs(startdate).format('DD MMM YYYY')} + + + + + + {/* Order search pill */} + + + setSearchword(e.target.value)} + autoComplete="off" + sx={{ + flex: 1, + fontSize: 13, + fontWeight: 600, + color: DT.textPrimary, + '& input::placeholder': { color: DT.textMuted, opacity: 1 } + }} /> - ))} - - - setSearchword('')} sx={{ p: 0.25, color: BRAND }}> + + + )} + + + + + {/* ---------------- STATUS FILTER PILLS ---------------- */} + - - {/* Scrollable table container */} + {STATUS_TABS.map((item, index) => { + const isActive = tabvalue === index; + const Icon = item.icon; + const count = statusCounts[item.value]; + return ( + 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) + } + }} + > + + {item.label} + + {count ?? 0} + + + ); + })} + + + {/* ---------------- ORDERS TABLE ---------------- */} + + - - {/* HEADER */} +
- - S.No - Orders - Pickup - Drop - Notes - Status + + # + Order + Pickup + Drop + Notes + Status - {/* BODY */} {/* LOADING STATE */} {loading && - [...Array(10)].map((_, index) => ( + Array.from({ length: 10 }).map((_, index) => ( - {[...Array(6)].map((__, i) => ( - - + {Array.from({ length: 6 }).map((__, i) => ( + + ))} @@ -512,8 +650,16 @@ const ResponsiveLocationDrawer = () => { {/* EMPTY STATE */} {!loading && rows?.length === 0 && ( - - + + + + + + No orders found + + {searchword ? 'Try a different keyword or clear the search.' : 'No orders in this status for the selected location.'} + + )} @@ -521,83 +667,101 @@ const ResponsiveLocationDrawer = () => { {/* DATA ROWS */} {!loading && rows?.map((row, index) => ( - - {page * rowsPerPage + index + 1} + + + {page * rowsPerPage + index + 1} + {/* Order Info */} - + {row.orderid} - - {dayjs(row.deliverydate).utc().format('DD/MM/YYYY')} - - - {dayjs(row.deliverydate).utc().format('hh:mm A')} - + + + + {dayjs(row.deliverydate).utc().format('hh:mm A')} + + + · {dayjs(row.deliverydate).utc().format('DD MMM YY')} + + {/* Pickup */} - - - - {row.pickupcustomer} - {row.pickupcontactno} - - {row.pickupsuburb || row.pickupaddress?.slice(0, 20) || '—'} - - + + + {row.pickupcustomer || '—'} + + + {row.pickupcontactno} + + + + {row.pickupsuburb || (row.pickupaddress ? `${row.pickupaddress.slice(0, 20)}…` : '—')} + + {/* Drop */} - - - - {row.deliverycustomer} - {row.deliverycontactno} - - {row.deliverysuburb || row.deliveryaddress?.slice(0, 20) || '—'} - - + + + {row.deliverycustomer || '—'} + + + {row.deliverycontactno} + + + + {row.deliverysuburb || (row.deliveryaddress ? `${row.deliveryaddress.slice(0, 20)}…` : '—')} + + {/* Notes */} - {row.ordernotes} + + + {row.ordernotes || '—'} + + {/* Status */} - - {row.orderstatus === 'pending' && } - {row.orderstatus === 'confirmed' && } - {row.orderstatus === 'cancelled' && } - {row.orderstatus === 'delivered' && } - {row.orderstatus === 'processing' && } - {row.orderstatus === 'ready' && } - {row.orderstatus === 'active' && } - {row.orderstatus === 'closed' && } - {row.orderstatus === 'created' && } - + ))} + {rows?.length != 0 && ( - -
- {isFetchingNextPage ? : hasNextPage ? : 'No More Orders'} -
+ + + {isFetchingNextPage || hasNextPage ? ( + + ) : ( + No more orders + )} +
)}
- - +
+
diff --git a/src/store/reducers/menu.js b/src/store/reducers/menu.js index ba79986..cce46bb 100644 --- a/src/store/reducers/menu.js +++ b/src/store/reducers/menu.js @@ -9,6 +9,7 @@ const initialState = { openItem: ['dashboard'], openComponent: 'buttons', selectedID: null, + selectedMenu: null, drawerOpen: false, componentDrawerOpen: true, menu: {}, @@ -48,6 +49,10 @@ const menu = createSlice({ hasError(state, action) { state.error = action.payload; + }, + + setSelectedMenu(state, action) { + state.selectedMenu = action.payload; } }, @@ -60,4 +65,4 @@ const menu = createSlice({ export default menu.reducer; -export const { activeItem, activeComponent, openDrawer, openComponentDrawer, activeID } = menu.actions; +export const { activeItem, activeComponent, openDrawer, openComponentDrawer, activeID, setSelectedMenu } = menu.actions;