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={
) : isMdDown ? (
/* ── MOBILE / TABLET: cards ── */
@@ -277,9 +322,13 @@ export default function OrderAssignment() {
{row.time}
- {!isAssigned
- ?
- : } label="Assigned" sx={{ fontWeight: 700, flexShrink: 0, bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} />}
+ {(() => {
+ const c = statusChip(row.status);
+ return (
+ : undefined}
+ label={c.label} sx={{ fontWeight: 700, flexShrink: 0, bgcolor: c.bg, color: c.color, '& .MuiChip-icon': { color: c.color } }} />
+ );
+ })()}
@@ -307,7 +356,7 @@ export default function OrderAssignment() {
Choose a Miler
) : (
- {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 */}
} size="small"
@@ -605,7 +583,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
{/* Today's Performance */}
-
+
TODAY'S PERFORMANCE
@@ -615,28 +593,22 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
{ label: "Failed", value: rider.deliveriesFailed, icon: WarningAmberOutlinedIcon, color: "#DC2626", bg: "#FEE2E2" },
{ label: "COP", value: inr(rider.codCollected), icon: PaymentsOutlinedIcon, color: "#D97706", bg: "#FEF3C7" },
].map((stat, i) => (
-
-
-
-
-
- {stat.label}
- {stat.value}
-
+
+
))}
{/* Live Load Panel */}
-
+
LIVE LOAD
-
+
Pending pickups
{rider.pickupsPending}
@@ -644,7 +616,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
{/* Miler Details Panel */}
-
+
MILER DETAILS
@@ -657,7 +629,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
{ icon: BadgeOutlinedIcon, label: "Vehicle", value: `${rider.vehicle} • ${rider.vehicleNo}` },
{ icon: AccessTimeOutlinedIcon, label: "Check-in", value: `${rider.checkInTime} (${rider.hoursToday}h)` },
].map((detail, idx) => (
-
+
@@ -697,6 +669,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
>
)}
+ >)}
);
}
@@ -1358,7 +1331,7 @@ export default function Riders() {
-
+
Done
{rider.deliveriesDone}
@@ -1385,7 +1358,7 @@ export default function Riders() {
{filtered.map(r => (
-
+
))}
diff --git a/src/pages/operations/Routing.jsx b/src/pages/operations/Routing.jsx
index 99fc10e..e8ff26c 100644
--- a/src/pages/operations/Routing.jsx
+++ b/src/pages/operations/Routing.jsx
@@ -90,7 +90,7 @@ export default function Routing() {
Where Does It Go?
- Scan a parcel and we'll tell you exactly what to do with it next — deliver locally, transfer to another city, or set it aside.
+ Scan a parcel and we'll tell you exactly what to do with it next deliver locally, transfer to another city, or set it aside.