From ef0b14d2544eaea9ca757008d62481e68ed71558 Mon Sep 17 00:00:00 2001 From: Thiru-tenext Date: Thu, 9 Jul 2026 16:28:45 +0530 Subject: [PATCH] feat: Implement date range picker and enhance order assignment functionality - Added DateRangePicker component for selecting date ranges in OrderAssignment. - Updated OrderAssignment to fetch bookings based on selected date range. - Enhanced status handling in OrderAssignment with new status chip display logic. - Refactored KPI card implementation in RiderRoutes and Riders to use shared StatCard component. - Improved ProfileDrawer to retain rider data during close transition. - Fixed minor text formatting in Routing component. --- src/api/hub.js | 51 ++++++ src/components/DateRangePicker.jsx | 214 +++++++++++++++++++++++ src/components/StatCard.jsx | 70 ++++++++ src/pages/Dashboard.jsx | 32 +--- src/pages/operations/Dispatch.jsx | 29 ++- src/pages/operations/Inbound.jsx | 42 +++-- src/pages/operations/OrderAssignment.jsx | 83 +++++++-- src/pages/operations/RiderRoutes.jsx | 23 +-- src/pages/operations/Riders.jsx | 101 ++++------- src/pages/operations/Routing.jsx | 2 +- 10 files changed, 497 insertions(+), 150 deletions(-) create mode 100644 src/components/DateRangePicker.jsx create mode 100644 src/components/StatCard.jsx diff --git a/src/api/hub.js b/src/api/hub.js index c1098c0..01d1a5d 100644 --- a/src/api/hub.js +++ b/src/api/hub.js @@ -44,6 +44,23 @@ export function getInboundToday() { return http.get(`${V1}/hub/inbound/today`); } +/** + * Parcels received in a date range (inclusive), for the "Receive Parcels" history view. + * @param {string} from YYYY-MM-DD (inclusive) + * @param {string} to YYYY-MM-DD (inclusive of the whole day) + * Backend contract: GET /hub/inbound?from=&to= → same row shape as /hub/inbound/today. + * Until that route ships, we transparently fall back to /hub/inbound/today so the page + * keeps working (it just shows today regardless of the picked range). + */ +export async function getInboundRange(from, to) { + try { + return await http.get(`${V1}/hub/inbound?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`); + } catch (err) { + if (err?.status === 404) return http.get(`${V1}/hub/inbound/today`); + throw err; + } +} + /** * Scan a parcel in. * @param {number|string} bookingId Booking / consignment ID (the path :id). @@ -58,6 +75,24 @@ export function getUnassignedBookings() { return http.get(`${V1}/hub/bookings/unassigned`); } +/** + * All pickup requests (bookings) created in a date range (inclusive), each with its + * current assignment status — powers the "Pickup Requests" history view. + * @param {string} from YYYY-MM-DD (inclusive) + * @param {string} to YYYY-MM-DD (inclusive of the whole day) + * Backend contract: GET /hub/bookings?from=&to= → array of bookings with a `status` + * field (e.g. "pending" | "assigned" | ...) plus the same fields /bookings/unassigned + * returns. Until it ships, we fall back to /bookings/unassigned so the page keeps working. + */ +export async function getBookingsRange(from, to) { + try { + return await http.get(`${V1}/hub/bookings?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`); + } catch (err) { + if (err?.status === 404) return http.get(`${V1}/hub/bookings/unassigned`); + throw err; + } +} + /** Manual assign — hub staff picks the miler (hub-scoped, accepts the hub JWT). */ export function assignMiler(bookingId, mileruserid) { return http.post(`${V1}/hub/bookings/${bookingId}/assign-miler`, { mileruserid }); @@ -73,6 +108,22 @@ export function getBatches() { return http.get(`${V1}/hub/batches`); } +/** + * Outgoing batches created in a date range (inclusive) — powers the Dispatch history view. + * @param {string} from YYYY-MM-DD (inclusive) + * @param {string} to YYYY-MM-DD (inclusive of the whole day) + * Backend contract: GET /hub/batches?from=&to= → same row shape as GET /hub/batches, + * filtered by createdat in the range. Falls back to /hub/batches until that ships. + */ +export async function getBatchesRange(from, to) { + try { + return await http.get(`${V1}/hub/batches?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`); + } catch (err) { + if (err?.status === 404) return http.get(`${V1}/hub/batches`); + throw err; + } +} + /** * Create an outgoing batch. * @param {object} payload { route, destination, vehicle, parcels_count, kind } diff --git a/src/components/DateRangePicker.jsx b/src/components/DateRangePicker.jsx new file mode 100644 index 0000000..db1b202 --- /dev/null +++ b/src/components/DateRangePicker.jsx @@ -0,0 +1,214 @@ +import { useState } from 'react'; +import { Box, Stack, Typography, IconButton, Button, Popover, Divider, Chip, useMediaQuery } from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import dayjs from 'dayjs'; +import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined'; +import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded'; +import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; +import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded'; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared date-range picker — the same two-month brand calendar used on the +// Dashboard, packaged for reuse. Controlled: pass `value={{ from, to }}` (both +// 'YYYY-MM-DD') and an `onChange({ from, to })` handler. Optionally cap the +// selectable range with `maxDate` (defaults to today, so no future dates). +// ───────────────────────────────────────────────────────────────────────────── + +export const DATE_FMT = 'YYYY-MM-DD'; +const BRAND = '#C01227'; +const DAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + +// A single compact month grid. +function MonthGrid({ view, start, end, max, onDay }) { + const gridStart = view.startOf('month').subtract(view.startOf('month').day(), 'day'); + const cells = Array.from({ length: 42 }, (_, i) => gridStart.add(i, 'day')); + + return ( + + + {view.format('MMMM YYYY')} + + + {DAY_LABELS.map((d, i) => ( + + {d} + + ))} + + + {cells.map((d) => { + const inMonth = d.month() === view.month(); + const isStart = start && d.isSame(start, 'day'); + const isEnd = end && d.isSame(end, 'day'); + const isEndpoint = isStart || isEnd; + const inRange = start && end && d.isAfter(start, 'day') && d.isBefore(end, 'day'); + const isToday = d.isSame(dayjs(), 'day'); + const disabled = max && d.isAfter(max, 'day'); + return ( + + onDay(d)} + sx={{ + width: 28, height: 28, m: '1px', border: 'none', cursor: disabled ? 'default' : 'pointer', + borderRadius: 1.5, fontSize: '0.72rem', fontFamily: 'inherit', + fontWeight: isEndpoint ? 700 : 500, + color: disabled ? '#D5D9DD' : isEndpoint ? '#fff' : inMonth ? '#3C4043' : '#C4C9CE', + bgcolor: isEndpoint ? BRAND : 'transparent', + boxShadow: isToday && !isEndpoint ? `inset 0 0 0 1.5px ${BRAND}` : 'none', + transition: 'background-color .12s', + '&:hover': { bgcolor: disabled ? 'transparent' : isEndpoint ? '#9E0E20' : alpha(BRAND, 0.1) } + }} + > + {d.date()} + + + ); + })} + + + ); +} + +// Click a day to set the start, click again to set the end (auto-swaps if reversed). +function RangeCalendar({ from, to, maxDate, onSelect }) { + const [view, setView] = useState(dayjs(to || from || undefined).startOf('month')); + const [anchorDate, setAnchorDate] = useState(null); + + const start = from ? dayjs(from) : null; + const end = to ? dayjs(to) : null; + const max = maxDate ? dayjs(maxDate) : null; + + const handleDay = (d) => { + if (!anchorDate) { + setAnchorDate(d); + onSelect(d.format(DATE_FMT), d.format(DATE_FMT)); + } else { + const a = anchorDate; + const lo = d.isBefore(a) ? d : a; + const hi = d.isBefore(a) ? a : d; + onSelect(lo.format(DATE_FMT), hi.format(DATE_FMT)); + setAnchorDate(null); + } + }; + + return ( + + + setView((v) => v.subtract(1, 'month'))} sx={{ color: '#9AA0A6', p: 0.5 }}> + + + setView((v) => v.add(1, 'month'))} sx={{ color: '#9AA0A6', p: 0.5 }}> + + + + + + + + + + + ); +} + +const PRESETS = [ + { label: 'Today', days: 1 }, + { label: 'Last 7 days', days: 7 }, + { label: 'Last 30 days', days: 30 } +]; + +export default function DateRangePicker({ value, onChange, maxDate }) { + const today = dayjs().format(DATE_FMT); + const max = maxDate ?? today; + const [anchor, setAnchor] = useState(null); + const isMobile = useMediaQuery('(max-width:600px)'); + + const from = value?.from || today; + const to = value?.to || today; + const invalid = dayjs(to).isBefore(dayjs(from)); + + const dayCount = (() => { + const f = dayjs(from); + const t = dayjs(to); + if (!f.isValid() || !t.isValid() || t.isBefore(f)) return 1; + return t.diff(f, 'day') + 1; + })(); + + const applyPreset = (days) => onChange({ from: dayjs().subtract(days - 1, 'day').format(DATE_FMT), to: today }); + const isPreset = (days) => to === today && from === dayjs().subtract(days - 1, 'day').format(DATE_FMT); + + return ( + <> + + {PRESETS.map((p) => { + const active = isPreset(p.days); + return ( + applyPreset(p.days)} + sx={{ + height: 36, fontSize: '0.825rem', fontWeight: 600, borderRadius: 2, + bgcolor: active ? BRAND : '#F1F5F9', color: active ? '#fff' : '#475569', + boxShadow: active ? '0 4px 10px rgba(192,18,39,0.15)' : 'none', + '& .MuiChip-label': { px: 1.5 }, + '&:hover': { bgcolor: active ? '#9E0E20' : '#E2E8F0' } + }} + /> + ); + })} + + + + + setAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: isMobile ? 'left' : 'right' }} + transformOrigin={{ vertical: 'top', horizontal: isMobile ? 'left' : 'right' }} + marginThreshold={12} + PaperProps={{ sx: { mt: 1, borderRadius: 2.5, border: '1px solid #EEF0F2', boxShadow: '0 8px 28px rgba(0,0,0,0.10)', overflow: 'hidden', maxWidth: 'calc(100vw - 24px)' } }} + > + + Select date range + + {dayjs(from).format('DD MMM')} – {dayjs(to).format('DD MMM YYYY')} · {dayCount} {dayCount === 1 ? 'day' : 'days'} + + + onChange({ from: f, to: t })} /> + + + + + + + + ); +} diff --git a/src/components/StatCard.jsx b/src/components/StatCard.jsx new file mode 100644 index 0000000..6fd6afe --- /dev/null +++ b/src/components/StatCard.jsx @@ -0,0 +1,70 @@ +import { Card, CardContent, Stack, Avatar, Typography, Skeleton, Box } from '@mui/material'; +import TrendingUpOutlinedIcon from '@mui/icons-material/TrendingUpOutlined'; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared KPI / stat card — the single reference design used across every page so +// the stat strips read as one system: +// • icon tile + uppercase label on a top row, label centered on the icon's height +// • big number centered on the icon's vertical axis (a 40px column under the icon) +// • optional caption below (with an optional green trend arrow) +// `bg` defaults to a soft tint of `color`. `hover` adds the lift-on-hover effect. +// ───────────────────────────────────────────────────────────────────────────── + +export default function StatCard({ + icon: Icon, + label, + value, + sub, + color = '#1A73E8', + bg, + trend = false, + loading = false, + hover = false +}) { + return ( + + + {/* Icon + label on a clean top row; label stretches to the icon height and + centers its text so the caption is optically centered against the icon. */} + + + {Icon && } + + + {label} + + + + {/* Value sits under the icon: a box that is the icon's width (40px) so a short + number centers on the icon's axis, but grows with wider values ("0.0 km", + "₹1,234") so they left-align under the icon instead of overflowing the card. */} + + {loading ? ( + + ) : ( + + {value} + + )} + + + {sub && ( + + {trend && } + {sub} + + )} + + + ); +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 25f03b5..ebd0859 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -22,7 +22,6 @@ import { Divider, Popover, Alert, - Skeleton, useMediaQuery, Snackbar, CircularProgress @@ -46,6 +45,7 @@ import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded'; import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded'; +import StatCard from '@/components/StatCard'; import { getDashboard, getInboundVehicles, getActivity, getZones, getHubReport } from '@/api/hub'; import { getHubContext } from '@/auth/session'; @@ -510,33 +510,9 @@ export default function Dashboard() { mb: { xs: 3, md: 5 } }} > - {kpiCards.map((s) => { - const Icon = s.icon; - return ( - - - - - - - - {s.label} - - - {loading ? ( - - ) : ( - - {s.value} - - )} - - {s.sub} - - - - ); - })} + {kpiCards.map((s) => ( + + ))} {/* Operations Grid */} diff --git a/src/pages/operations/Dispatch.jsx b/src/pages/operations/Dispatch.jsx index 7720b5d..26d4317 100644 --- a/src/pages/operations/Dispatch.jsx +++ b/src/pages/operations/Dispatch.jsx @@ -38,8 +38,10 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined'; import SwapHorizOutlinedIcon from '@mui/icons-material/SwapHorizOutlined'; +import dayjs from 'dayjs'; import PageHeader from '@/components/PageHeader'; -import { getBatches, createBatch, updateBatchStatus } from '@/api/hub'; +import DateRangePicker, { DATE_FMT } from '@/components/DateRangePicker'; +import { getBatchesRange, createBatch, updateBatchStatus } from '@/api/hub'; import { getHubContext } from '@/auth/session'; const STATUS_META = { @@ -94,6 +96,16 @@ export default function Dispatch() { const [creating, setCreating] = useState(false); const [busyId, setBusyId] = useState(null); + // Date range for the batch history. Defaults to today. + const today = dayjs().format(DATE_FMT); + const [range, setRange] = useState({ from: today, to: today }); + const isToday = range.from === today && range.to === today; + const rangeLabel = isToday + ? 'today' + : range.from === range.to + ? dayjs(range.from).format('DD MMM') + : `${dayjs(range.from).format('DD MMM')} – ${dayjs(range.to).format('DD MMM')}`; + // Create form state const [newRoute, setNewRoute] = useState('Transfer to Mumbai Hub'); const [newDestination, setNewDestination] = useState(''); @@ -107,14 +119,14 @@ export default function Dispatch() { setLoading(true); setLoadError(''); try { - const res = await getBatches(); + const res = await getBatchesRange(range.from, range.to); setManifests((res?.data || []).map(mapBatch)); } catch (err) { setLoadError(err?.message || 'Could not load batches.'); } finally { setLoading(false); } - }, [mapBatch]); + }, [mapBatch, range.from, range.to]); useEffect(() => { load(); @@ -217,6 +229,7 @@ export default function Dispatch() { icon={LocalShippingIcon} title="Dispatch & Transfer" subtitle="Group parcels that go out together, check them, and send them either out for local delivery or transferred to another city hub." + action={} /> @@ -225,7 +238,11 @@ export default function Dispatch() { } onClick={() => setOpenModal(true)}> New Batch @@ -247,7 +264,9 @@ export default function Dispatch() { ) : manifests.length === 0 && !loadError ? ( - No outgoing batches yet. Create one to get started. + + {isToday ? 'No outgoing batches yet. Create one to get started.' : `No batches ${rangeLabel}.`} + ) : isMdDown ? ( /* ── MOBILE / TABLET: spacious cards ── */ diff --git a/src/pages/operations/Inbound.jsx b/src/pages/operations/Inbound.jsx index ee9f2e4..83d39e9 100644 --- a/src/pages/operations/Inbound.jsx +++ b/src/pages/operations/Inbound.jsx @@ -21,8 +21,11 @@ import HomeWorkOutlinedIcon from '@mui/icons-material/HomeWorkOutlined'; import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'; import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined'; +import dayjs from 'dayjs'; import PageHeader from '@/components/PageHeader'; -import { getInboundToday, inboundBooking } from '@/api/hub'; +import DateRangePicker, { DATE_FMT } from '@/components/DateRangePicker'; +import StatCard from '@/components/StatCard'; +import { getInboundRange, inboundBooking } from '@/api/hub'; import { getHubContext } from '@/auth/session'; const SHELVES = ['Zone A (Shelf 1)', 'Zone A (Shelf 2)', 'Zone B (Shelf 1)', 'Zone C (Cold Room)', 'Exception Area']; @@ -98,20 +101,30 @@ export default function Inbound() { const [loadError, setLoadError] = useState(''); const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' }); + // Date range for the "Recently Received" history. Defaults to today. + const today = dayjs().format(DATE_FMT); + const [range, setRange] = useState({ from: today, to: today }); + const isToday = range.from === today && range.to === today; + const rangeLabel = isToday + ? 'today' + : range.from === range.to + ? dayjs(range.from).format('DD MMM') + : `${dayjs(range.from).format('DD MMM')} – ${dayjs(range.to).format('DD MMM')}`; + const loadInbound = useCallback(async () => { setLoading(true); setLoadError(''); try { - const res = await getInboundToday(); + const res = await getInboundRange(range.from, range.to); setInboundLogs((res?.data || []).map((r) => mapInbound(r, hubName))); } catch (err) { - setLoadError(err?.message || 'Could not load today’s inbound parcels.'); + setLoadError(err?.message || 'Could not load inbound parcels.'); } finally { setLoading(false); } // hubName is derived from a stable localStorage read; safe to omit. // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [range.from, range.to]); useEffect(() => { loadInbound(); @@ -215,28 +228,19 @@ export default function Inbound() { icon={MoveToInboxOutlinedIcon} title="Receive Parcels" subtitle="Scan each parcel as it arrives, note its condition, and we'll suggest which shelf to put it on." + action={} /> {/* ── KPI strip ── */} {[ - { icon: MoveToInboxOutlinedIcon, label: 'Received Today', value: stats.received, color: '#1A73E8', bg: '#E8F0FE' }, + { icon: MoveToInboxOutlinedIcon, label: isToday ? 'Received Today' : 'Received', value: stats.received, color: '#1A73E8', bg: '#E8F0FE' }, { icon: Inventory2OutlinedIcon, label: 'To Sort', value: stats.pendingSort, color: '#B06000', bg: '#FEF7E0' }, { icon: WarningAmberOutlinedIcon, label: 'Needs Checking', value: stats.exceptions, color: '#D93025', bg: '#FCE8E6' }, { icon: AcUnitOutlinedIcon, label: 'Cold Items', value: stats.coldChain, color: '#00838F', bg: '#E0F7FA' }, ].map((s, i) => ( - - - - - - - {s.label} - - {s.value} - - + ))} @@ -348,7 +352,7 @@ export default function Inbound() { Recently Received - Parcels logged at the hub today + Parcels logged at the hub {rangeLabel} - No parcels received yet today. + + {isToday ? 'No parcels received yet today.' : `No parcels received ${rangeLabel}.`} + ) : isMdDown ? ( diff --git a/src/pages/operations/OrderAssignment.jsx b/src/pages/operations/OrderAssignment.jsx index c22fa4c..dc227a3 100644 --- a/src/pages/operations/OrderAssignment.jsx +++ b/src/pages/operations/OrderAssignment.jsx @@ -43,12 +43,32 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import PersonOutlineRoundedIcon from '@mui/icons-material/PersonOutlineRounded'; import StarRoundedIcon from '@mui/icons-material/StarRounded'; +import dayjs from 'dayjs'; import PageHeader from '@/components/PageHeader'; -import { getUnassignedBookings, getMilers, assignMiler, autoAssignBooking } from '@/api/hub'; +import DateRangePicker, { DATE_FMT } from '@/components/DateRangePicker'; +import { getBookingsRange, getMilers, assignMiler, autoAssignBooking } from '@/api/hub'; import { getHubContext } from '@/auth/session'; const PENDING = 'Pending Assignment'; +// Treat any status that isn't explicitly pending/unassigned as already handled. +const isPendingStatus = (s) => { + const v = (s || '').toLowerCase(); + return !v || v === 'pending' || v === 'unassigned' || v === 'pending assignment'; +}; + +// Backend normalizes booking status to pending|assigned|picked_up|delivered|cancelled. +// Map the page's display status → the chip label + colours (and whether it's "done"). +const statusChip = (status) => { + if (status === PENDING) return { label: 'Needs a miler', bg: '#FEF7E0', color: '#B06000', done: false }; + if (/^cancel/i.test(status)) return { label: 'Cancelled', bg: '#FCE8E6', color: '#D93025', done: true }; + if (/deliver/i.test(status)) return { label: 'Delivered', bg: '#E6F4EA', color: '#1E8E3E', done: true }; + if (/picked/i.test(status)) return { label: 'Picked up', bg: '#E8F0FE', color: '#1A73E8', done: true }; + if (/no miler/i.test(status)) return { label: 'No miler in range', bg: '#FCE8E6', color: '#D93025', done: false }; + if (/assigning/i.test(status)) return { label: 'Assigning…', bg: '#E8F0FE', color: '#1A73E8', done: true }; + return { label: 'Assigned', bg: '#E6F4EA', color: '#1E8E3E', done: true }; // "Assigned to X" +}; + const timeAgo = (iso) => { if (!iso) return 'Recently'; const then = new Date(iso).getTime(); @@ -68,6 +88,14 @@ const mapOrder = (b) => { const pkg = parcels.length ? `${parcels.map((p) => p.itemcategory || 'Parcel').join(', ')}${totalWeight ? ` · ${totalWeight}kg` : ''}` : b.packagedescription || '—'; + // Ranged results carry a real status; the live "unassigned" fallback has none → PENDING. + const raw = (b.status || '').toLowerCase(); + let status; + if (isPendingStatus(raw)) status = PENDING; + else if (raw === 'cancelled') status = 'Cancelled'; + else if (raw === 'delivered') status = 'Delivered'; + else if (raw === 'picked_up') status = 'Picked up'; + else status = b.milername ? `Assigned to ${b.milername}` : 'Assigned'; return { id: b.bookingid, customer: b.customer_name || b.customerName || 'Customer', @@ -75,7 +103,7 @@ const mapOrder = (b) => { drop: b.delivery_address || b.deliveryaddress || '—', package: pkg, time: timeAgo(b.created_at || b.createdat), - status: PENDING + status }; }; @@ -101,6 +129,16 @@ export default function OrderAssignment() { const [selectedOrders, setSelectedOrders] = useState([]); + // Date range for the pickup-request history. Defaults to today. + const today = dayjs().format(DATE_FMT); + const [range, setRange] = useState({ from: today, to: today }); + const isToday = range.from === today && range.to === today; + const rangeLabel = isToday + ? 'today' + : range.from === range.to + ? dayjs(range.from).format('DD MMM') + : `${dayjs(range.from).format('DD MMM')} – ${dayjs(range.to).format('DD MMM')}`; + // Single Assign Dialog State const [selectedOrderForAssign, setSelectedOrderForAssign] = useState(null); const [assignDialogOpen, setAssignDialogOpen] = useState(false); @@ -112,7 +150,7 @@ export default function OrderAssignment() { setLoading(true); setLoadError(''); try { - const [bookings, milerRes] = await Promise.all([getUnassignedBookings(), getMilers().catch(() => null)]); + const [bookings, milerRes] = await Promise.all([getBookingsRange(range.from, range.to), getMilers().catch(() => null)]); setOrders((bookings?.data || []).map(mapOrder)); if (milerRes?.data) setMilers(milerRes.data.map(mapMiler)); } catch (err) { @@ -120,7 +158,7 @@ export default function OrderAssignment() { } finally { setLoading(false); } - }, []); + }, [range.from, range.to]); useEffect(() => { load(); @@ -219,12 +257,17 @@ export default function OrderAssignment() { icon={AssignmentIndIcon} title="Pickup Requests" subtitle="Customers want these parcels collected. Pick a nearby miler for each one, or select several and assign them all at once." + action={} /> } action={ ) : ( - {row.status} + {row.status} )} @@ -357,9 +406,13 @@ export default function OrderAssignment() { {row.drop} {row.package} - {!isAssigned - ? - : } label="Assigned" sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} />} + {(() => { + const c = statusChip(row.status); + return ( + : undefined} + label={c.label} sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: c.bg, color: c.color, '& .MuiChip-icon': { color: c.color } }} /> + ); + })()} {!isAssigned ? ( @@ -368,7 +421,7 @@ export default function OrderAssignment() { Choose Miler ) : ( - {row.status} + {row.status} )} diff --git a/src/pages/operations/RiderRoutes.jsx b/src/pages/operations/RiderRoutes.jsx index 79ca69e..4a59302 100644 --- a/src/pages/operations/RiderRoutes.jsx +++ b/src/pages/operations/RiderRoutes.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { - Box, Typography, Card, CardContent, Avatar, Chip, Stack, Button, Grid, + Box, Typography, Card, Avatar, Chip, Stack, Button, Grid, IconButton, List, ListItemButton, ListItemText, Collapse, Tooltip, Divider, LinearProgress, Menu, MenuItem, Drawer, Paper } from '@mui/material'; @@ -9,6 +9,7 @@ import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip as LTooltip, import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; +import StatCard from '@/components/StatCard'; import { getRiderRoutes } from '@/api/hub'; import { getHubContext } from '@/auth/session'; @@ -225,24 +226,8 @@ async function fetchRoadRoute(stops) { // ════════════════════════════════════════════════════════════════════════════════ // Small presentational pieces // ════════════════════════════════════════════════════════════════════════════════ -function KpiCard({ icon: Icon, label, value, sub, color, bg }) { - return ( - - - - - - - - {label} - - - {value} - {sub && {sub}} - - - ); -} +// KPI card — aliased to the shared StatCard so this strip matches every other page. +const KpiCard = (props) => ; function DetailRow({ icon: Icon, label, value, valueColor }) { if (value === undefined || value === null || value === '') return null; diff --git a/src/pages/operations/Riders.jsx b/src/pages/operations/Riders.jsx index db1b6c2..faa227c 100644 --- a/src/pages/operations/Riders.jsx +++ b/src/pages/operations/Riders.jsx @@ -34,7 +34,6 @@ import PedalBikeOutlinedIcon from '@mui/icons-material/PedalBikeOutlined'; import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'; import AirportShuttleOutlinedIcon from '@mui/icons-material/AirportShuttleOutlined'; import InventoryOutlinedIcon from '@mui/icons-material/Inventory2Outlined'; -import TrendingUpOutlinedIcon from '@mui/icons-material/TrendingUpOutlined'; import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined'; import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined'; import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined'; @@ -48,6 +47,7 @@ import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; import HomeWorkOutlinedIcon from '@mui/icons-material/HomeWorkOutlined'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +import StatCard from '@/components/StatCard'; import { getMilers, createMiler, updateMiler, deleteMiler } from '@/api/hub'; import { getHubContext } from '@/auth/session'; @@ -228,45 +228,9 @@ function VehicleCell({ vehicle, vehicleNo }) { ); } -// KPI Card - More spacious -function KpiCard({ icon: Icon, label, value, sub, color, bg, trend }) { - return ( - - - {/* Icon + label sit together on a clean top row */} - - - - - - {label} - - - - - {value} - - - - {trend && } - {sub} - - - - ); -} +// KPI card — the shared StatCard is the single reference design (see components/StatCard). +// Kept as a thin alias so the call sites below stay unchanged; `hover` on for the lift effect. +const KpiCard = (props) => ; // ════════════════════════════════════════════════════════════════════════════════ // Dialogs (Cleaner & Better Spaced) @@ -494,18 +458,29 @@ function RiderFormDialog({ open, onClose, onSave, initial, mode }) { } // Profile Drawer - view + inline edit, in the same right-side sheet -function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) { +function ProfileDrawer({ rider: riderProp, onClose, onSave, startInEdit = false }) { const [editing, setEditing] = useState(startInEdit); - const [form, setForm] = useState(rider || {}); + // Retain the last opened miler so the sheet keeps rendering its content while it + // slides OUT — the prop becomes null on close, and unmounting here (an early + // `return null`) would kill the exit animation and make closing feel instant. + const [retained, setRetained] = useState(riderProp); + const [form, setForm] = useState(riderProp || {}); // Reset the editable copy + mode whenever a different miler is opened. useEffect(() => { - setForm(rider || {}); - setEditing(startInEdit); - }, [rider, startInEdit]); + if (riderProp) { + setRetained(riderProp); + setForm(riderProp); + setEditing(startInEdit); + } + }, [riderProp, startInEdit]); - if (!rider) return null; - const sr = successRate(rider); + const open = Boolean(riderProp); // drives the slide; false triggers the exit anim + const rider = riderProp || retained; // keep content during the close transition + // NOTE: we deliberately do NOT early-return when there's no rider. The Drawer stays + // mounted (open=false) from first render, so the first click is a real false→true + // transition and animates — otherwise the first open mounts already-open and skips it. + const sr = rider ? successRate(rider) : 0; const setField = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value })); @@ -519,15 +494,17 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) { return ( t.zIndex.modal, '& .MuiDrawer-paper': { - width: { xs: '100%', sm: 480 }, + width: { xs: '100%', sm: 520 }, maxWidth: '100%', // Run the sheet the full height of the viewport so there is no empty // strip above the red header (zIndex.modal keeps Close clickable). @@ -541,6 +518,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) { }, }} > + {rider && (<> {/* Header */}