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.
This commit is contained in:
2026-07-09 16:28:45 +05:30
parent f53578c520
commit ef0b14d254
10 changed files with 497 additions and 150 deletions

View File

@@ -44,6 +44,23 @@ export function getInboundToday() {
return http.get(`${V1}/hub/inbound/today`); 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. * Scan a parcel in.
* @param {number|string} bookingId Booking / consignment ID (the path :id). * @param {number|string} bookingId Booking / consignment ID (the path :id).
@@ -58,6 +75,24 @@ export function getUnassignedBookings() {
return http.get(`${V1}/hub/bookings/unassigned`); 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). */ /** Manual assign — hub staff picks the miler (hub-scoped, accepts the hub JWT). */
export function assignMiler(bookingId, mileruserid) { export function assignMiler(bookingId, mileruserid) {
return http.post(`${V1}/hub/bookings/${bookingId}/assign-miler`, { mileruserid }); return http.post(`${V1}/hub/bookings/${bookingId}/assign-miler`, { mileruserid });
@@ -73,6 +108,22 @@ export function getBatches() {
return http.get(`${V1}/hub/batches`); 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. * Create an outgoing batch.
* @param {object} payload { route, destination, vehicle, parcels_count, kind } * @param {object} payload { route, destination, vehicle, parcels_count, kind }

View File

@@ -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 (
<Box sx={{ width: 224 }}>
<Typography align="center" sx={{ fontWeight: 700, fontSize: '0.82rem', color: '#212529', mb: 0.75 }}>
{view.format('MMMM YYYY')}
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', mb: 0.25 }}>
{DAY_LABELS.map((d, i) => (
<Typography key={i} align="center" sx={{ fontSize: '0.62rem', fontWeight: 600, color: '#B0B5BA', py: 0.25 }}>
{d}
</Typography>
))}
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
{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 (
<Box key={d.format(DATE_FMT)} sx={{ display: 'flex', justifyContent: 'center', bgcolor: inRange ? alpha(BRAND, 0.07) : 'transparent',
borderTopLeftRadius: isStart ? 6 : 0, borderBottomLeftRadius: isStart ? 6 : 0,
borderTopRightRadius: isEnd ? 6 : 0, borderBottomRightRadius: isEnd ? 6 : 0 }}>
<Box
component="button"
type="button"
disabled={disabled}
onClick={() => 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()}
</Box>
</Box>
);
})}
</Box>
</Box>
);
}
// 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 (
<Box sx={{ px: 1.5, py: 1.5 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
<IconButton size="small" onClick={() => setView((v) => v.subtract(1, 'month'))} sx={{ color: '#9AA0A6', p: 0.5 }}>
<ChevronLeftRoundedIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => setView((v) => v.add(1, 'month'))} sx={{ color: '#9AA0A6', p: 0.5 }}>
<ChevronRightRoundedIcon fontSize="small" />
</IconButton>
</Stack>
<Stack direction="row" spacing={2}>
<MonthGrid view={view} start={start} end={end} max={max} onDay={handleDay} />
<Box sx={{ display: { xs: 'none', sm: 'block' } }}>
<MonthGrid view={view.add(1, 'month')} start={start} end={end} max={max} onDay={handleDay} />
</Box>
</Stack>
</Box>
);
}
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 (
<>
<Stack direction="row" spacing={1} alignItems="center" useFlexGap sx={{ flexWrap: 'wrap' }}>
{PRESETS.map((p) => {
const active = isPreset(p.days);
return (
<Chip
key={p.label}
label={p.label}
onClick={() => 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' }
}}
/>
);
})}
<Button
onClick={(e) => setAnchor(e.currentTarget)}
variant="outlined"
startIcon={<CalendarTodayOutlinedIcon sx={{ fontSize: 16 }} />}
sx={{
height: 36, px: 1.5, borderRadius: 2, textTransform: 'none', fontWeight: 600, fontSize: '0.825rem',
color: '#334155', borderColor: invalid ? '#EF4444' : '#E2E8F0', bgcolor: '#fff',
justifyContent: 'flex-start', minWidth: { sm: 210 },
'&:hover': { borderColor: BRAND, bgcolor: alpha(BRAND, 0.02) }
}}
>
<Stack direction="row" alignItems="center" spacing={0.75} sx={{ color: '#334155' }}>
<Box component="span">{dayjs(from).format('DD MMM YYYY')}</Box>
<ArrowRightAltRoundedIcon sx={{ fontSize: 16, color: '#94A3B8' }} />
<Box component="span">{dayjs(to).format('DD MMM YYYY')}</Box>
</Stack>
</Button>
</Stack>
<Popover
open={Boolean(anchor)}
anchorEl={anchor}
onClose={() => 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)' } }}
>
<Box sx={{ px: 2, pt: 1.75, pb: 0.5 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.9rem', color: '#212529' }}>Select date range</Typography>
<Typography variant="caption" sx={{ color: '#8A9099' }}>
{dayjs(from).format('DD MMM')} {dayjs(to).format('DD MMM YYYY')} · {dayCount} {dayCount === 1 ? 'day' : 'days'}
</Typography>
</Box>
<RangeCalendar from={from} to={to} maxDate={max} onSelect={(f, t) => onChange({ from: f, to: t })} />
<Divider />
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 2, py: 1.25 }}>
<Button size="small" onClick={() => applyPreset(1)} sx={{ textTransform: 'none', color: '#5F6368', fontWeight: 700 }}>
Reset to today
</Button>
<Button size="small" variant="contained" onClick={() => setAnchor(null)}
sx={{ textTransform: 'none', fontWeight: 700, bgcolor: BRAND, borderRadius: 2, '&:hover': { bgcolor: '#9E0E20' } }}>
Done
</Button>
</Stack>
</Popover>
</>
);
}

View File

@@ -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 (
<Card
elevation={0}
sx={{
borderRadius: 2,
border: '1px solid #ECEEF1',
height: '100%',
boxShadow: '0px 2px 14px rgba(38,38,38,0.03)',
transition: 'all .2s',
...(hover && { '&:hover': { boxShadow: '0 10px 30px rgba(0,0,0,0.08)', transform: 'translateY(-2px)' } })
}}
>
<CardContent sx={{ p: 2.25, '&:last-child': { pb: 2.25 } }}>
{/* 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. */}
<Stack direction="row" alignItems="center" spacing={1.75} sx={{ mb: 1.5 }}>
<Avatar variant="rounded" sx={{ bgcolor: bg || color + '15', color, width: 40, height: 40, borderRadius: 2, flexShrink: 0 }}>
{Icon && <Icon sx={{ fontSize: 21 }} />}
</Avatar>
<Typography sx={{ display: 'flex', alignItems: 'center', alignSelf: 'stretch', fontSize: '0.72rem', color: '#6C757D', fontWeight: 700, letterSpacing: 0.6, textTransform: 'uppercase', lineHeight: 1.2 }}>
{label}
</Typography>
</Stack>
{/* 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. */}
<Box sx={{ display: 'flex', justifyContent: 'center', width: 'fit-content', minWidth: 40, mb: sub ? 0.5 : 0 }}>
{loading ? (
<Skeleton variant="text" width={28} sx={{ fontSize: '1.6rem' }} />
) : (
<Typography sx={{ fontSize: '1.6rem', fontWeight: 800, color: '#1A1A2E', lineHeight: 1.15, whiteSpace: 'nowrap' }}>
{value}
</Typography>
)}
</Box>
{sub && (
<Stack direction="row" alignItems="center" spacing={0.75}>
{trend && <TrendingUpOutlinedIcon sx={{ fontSize: 15, color: '#1E8E3E', flexShrink: 0 }} />}
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 500 }}>{sub}</Typography>
</Stack>
)}
</CardContent>
</Card>
);
}

View File

@@ -22,7 +22,6 @@ import {
Divider, Divider,
Popover, Popover,
Alert, Alert,
Skeleton,
useMediaQuery, useMediaQuery,
Snackbar, Snackbar,
CircularProgress CircularProgress
@@ -46,6 +45,7 @@ import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded'; import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded';
import StatCard from '@/components/StatCard';
import { getDashboard, getInboundVehicles, getActivity, getZones, getHubReport } from '@/api/hub'; import { getDashboard, getInboundVehicles, getActivity, getZones, getHubReport } from '@/api/hub';
import { getHubContext } from '@/auth/session'; import { getHubContext } from '@/auth/session';
@@ -510,33 +510,9 @@ export default function Dashboard() {
mb: { xs: 3, md: 5 } mb: { xs: 3, md: 5 }
}} }}
> >
{kpiCards.map((s) => { {kpiCards.map((s) => (
const Icon = s.icon; <StatCard key={s.label} icon={s.icon} label={s.label} value={s.value} sub={s.sub} color={s.color} loading={loading} />
return ( ))}
<Card key={s.label} sx={{ height: '100%', position: 'relative', overflow: 'hidden', borderRadius: 2, boxShadow: '0px 2px 14px rgba(38,38,38,0.03)', border: '1px solid #ECEEF1' }}>
<CardContent sx={{ p: { xs: 2, sm: 2.5 }, '&:last-child': { pb: { xs: 2, sm: 2.5 } } }}>
<Stack direction="row" alignItems="center" spacing={1.75} sx={{ mb: 1.75 }}>
<Avatar sx={{ bgcolor: s.color + '15', color: s.color, width: { xs: 40, sm: 46 }, height: { xs: 40, sm: 46 }, borderRadius: 2, flexShrink: 0 }}>
<Icon fontSize="small" />
</Avatar>
<Typography sx={{ color: '#868E96', fontWeight: 700, lineHeight: 1.3, fontSize: { xs: '0.72rem', sm: '0.78rem' }, textTransform: 'uppercase', letterSpacing: 0.6 }}>
{s.label}
</Typography>
</Stack>
{loading ? (
<Skeleton variant="text" width="60%" sx={{ fontSize: { xs: '1.5rem', sm: '1.9rem' }, mb: 0.75 }} />
) : (
<Typography sx={{ fontWeight: 800, color: '#212529', fontSize: { xs: '1.5rem', sm: '1.9rem' }, lineHeight: 1.15, mb: 0.75 }}>
{s.value}
</Typography>
)}
<Typography variant="caption" sx={{ display: 'block', color: '#6c757d', fontWeight: 500 }}>
{s.sub}
</Typography>
</CardContent>
</Card>
);
})}
</Box> </Box>
{/* Operations Grid */} {/* Operations Grid */}

View File

@@ -38,8 +38,10 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined'; import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
import SwapHorizOutlinedIcon from '@mui/icons-material/SwapHorizOutlined'; import SwapHorizOutlinedIcon from '@mui/icons-material/SwapHorizOutlined';
import dayjs from 'dayjs';
import PageHeader from '@/components/PageHeader'; 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'; import { getHubContext } from '@/auth/session';
const STATUS_META = { const STATUS_META = {
@@ -94,6 +96,16 @@ export default function Dispatch() {
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [busyId, setBusyId] = useState(null); 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 // Create form state
const [newRoute, setNewRoute] = useState('Transfer to Mumbai Hub'); const [newRoute, setNewRoute] = useState('Transfer to Mumbai Hub');
const [newDestination, setNewDestination] = useState(''); const [newDestination, setNewDestination] = useState('');
@@ -107,14 +119,14 @@ export default function Dispatch() {
setLoading(true); setLoading(true);
setLoadError(''); setLoadError('');
try { try {
const res = await getBatches(); const res = await getBatchesRange(range.from, range.to);
setManifests((res?.data || []).map(mapBatch)); setManifests((res?.data || []).map(mapBatch));
} catch (err) { } catch (err) {
setLoadError(err?.message || 'Could not load batches.'); setLoadError(err?.message || 'Could not load batches.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [mapBatch]); }, [mapBatch, range.from, range.to]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -217,6 +229,7 @@ export default function Dispatch() {
icon={LocalShippingIcon} icon={LocalShippingIcon}
title="Dispatch & Transfer" 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." subtitle="Group parcels that go out together, check them, and send them either out for local delivery or transferred to another city hub."
action={<DateRangePicker value={range} onChange={setRange} />}
/> />
<Grid container spacing={3}> <Grid container spacing={3}>
@@ -225,7 +238,11 @@ export default function Dispatch() {
<Card> <Card>
<CardHeader <CardHeader
title="Outgoing Batches" title="Outgoing Batches"
subheader={`Each batch is a group of parcels leaving ${hubName} together`} subheader={
isToday
? `Each batch is a group of parcels leaving ${hubName} together`
: `${manifests.length} batch${manifests.length === 1 ? '' : 'es'} · ${rangeLabel}`
}
action={ action={
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenModal(true)}> <Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenModal(true)}>
New Batch New Batch
@@ -247,7 +264,9 @@ export default function Dispatch() {
) : manifests.length === 0 && !loadError ? ( ) : manifests.length === 0 && !loadError ? (
<Box sx={{ py: 8, textAlign: 'center' }}> <Box sx={{ py: 8, textAlign: 'center' }}>
<LocalShippingIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} /> <LocalShippingIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
<Typography variant="body2" color="text.secondary">No outgoing batches yet. Create one to get started.</Typography> <Typography variant="body2" color="text.secondary">
{isToday ? 'No outgoing batches yet. Create one to get started.' : `No batches ${rangeLabel}.`}
</Typography>
</Box> </Box>
) : isMdDown ? ( ) : isMdDown ? (
/* ── MOBILE / TABLET: spacious cards ── */ /* ── MOBILE / TABLET: spacious cards ── */

View File

@@ -21,8 +21,11 @@ import HomeWorkOutlinedIcon from '@mui/icons-material/HomeWorkOutlined';
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'; import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined'; import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
import dayjs from 'dayjs';
import PageHeader from '@/components/PageHeader'; 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'; 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']; 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 [loadError, setLoadError] = useState('');
const [toast, setToast] = useState({ open: false, msg: '', severity: 'success' }); 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 () => { const loadInbound = useCallback(async () => {
setLoading(true); setLoading(true);
setLoadError(''); setLoadError('');
try { try {
const res = await getInboundToday(); const res = await getInboundRange(range.from, range.to);
setInboundLogs((res?.data || []).map((r) => mapInbound(r, hubName))); setInboundLogs((res?.data || []).map((r) => mapInbound(r, hubName)));
} catch (err) { } catch (err) {
setLoadError(err?.message || 'Could not load todays inbound parcels.'); setLoadError(err?.message || 'Could not load inbound parcels.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
// hubName is derived from a stable localStorage read; safe to omit. // hubName is derived from a stable localStorage read; safe to omit.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [range.from, range.to]);
useEffect(() => { useEffect(() => {
loadInbound(); loadInbound();
@@ -215,28 +228,19 @@ export default function Inbound() {
icon={MoveToInboxOutlinedIcon} icon={MoveToInboxOutlinedIcon}
title="Receive Parcels" title="Receive Parcels"
subtitle="Scan each parcel as it arrives, note its condition, and we'll suggest which shelf to put it on." subtitle="Scan each parcel as it arrives, note its condition, and we'll suggest which shelf to put it on."
action={<DateRangePicker value={range} onChange={setRange} />}
/> />
{/* ── KPI strip ── */} {/* ── KPI strip ── */}
<Grid container spacing={{ xs: 1.5, sm: 2 }} sx={{ mb: 3 }}> <Grid container spacing={{ xs: 1.5, sm: 2 }} sx={{ mb: 3 }}>
{[ {[
{ 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: Inventory2OutlinedIcon, label: 'To Sort', value: stats.pendingSort, color: '#B06000', bg: '#FEF7E0' },
{ icon: WarningAmberOutlinedIcon, label: 'Needs Checking', value: stats.exceptions, color: '#D93025', bg: '#FCE8E6' }, { icon: WarningAmberOutlinedIcon, label: 'Needs Checking', value: stats.exceptions, color: '#D93025', bg: '#FCE8E6' },
{ icon: AcUnitOutlinedIcon, label: 'Cold Items', value: stats.coldChain, color: '#00838F', bg: '#E0F7FA' }, { icon: AcUnitOutlinedIcon, label: 'Cold Items', value: stats.coldChain, color: '#00838F', bg: '#E0F7FA' },
].map((s, i) => ( ].map((s, i) => (
<Grid size={{ xs: 6, md: 3 }} key={i}> <Grid size={{ xs: 6, md: 3 }} key={i}>
<Card elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1', height: '100%' }}> <StatCard icon={s.icon} label={s.label} value={s.value} color={s.color} bg={s.bg} loading={loading} />
<CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
<Stack direction="row" alignItems="center" spacing={2.5} sx={{ mb: 2 }}>
<Avatar variant="rounded" sx={{ bgcolor: s.bg, color: s.color, borderRadius: 1, width: 48, height: 48, flexShrink: 0 }}>
<s.icon sx={{ fontSize: 24 }} />
</Avatar>
<Typography sx={{ fontSize: '0.8rem', color: '#6C757D', fontWeight: 700, letterSpacing: 0.8, textTransform: 'uppercase', lineHeight: 1.4 }}>{s.label}</Typography>
</Stack>
<Typography sx={{ fontSize: '2rem', fontWeight: 800, color: '#1A1A2E', lineHeight: 1.15, mb: 1 }}>{s.value}</Typography>
</CardContent>
</Card>
</Grid> </Grid>
))} ))}
</Grid> </Grid>
@@ -348,7 +352,7 @@ export default function Inbound() {
</Avatar> </Avatar>
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 700, color: '#1A1A2E' }}>Recently Received</Typography> <Typography variant="h5" sx={{ fontWeight: 700, color: '#1A1A2E' }}>Recently Received</Typography>
<Typography variant="caption" color="text.secondary">Parcels logged at the hub today</Typography> <Typography variant="caption" color="text.secondary">Parcels logged at the hub {rangeLabel}</Typography>
</Box> </Box>
</Stack> </Stack>
<Chip label={`${inboundLogs.length} total`} size="small" <Chip label={`${inboundLogs.length} total`} size="small"
@@ -370,7 +374,9 @@ export default function Inbound() {
) : inboundLogs.length === 0 && !loadError ? ( ) : inboundLogs.length === 0 && !loadError ? (
<Box sx={{ py: 8, textAlign: 'center' }}> <Box sx={{ py: 8, textAlign: 'center' }}>
<MoveToInboxOutlinedIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} /> <MoveToInboxOutlinedIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
<Typography variant="body2" color="text.secondary">No parcels received yet today.</Typography> <Typography variant="body2" color="text.secondary">
{isToday ? 'No parcels received yet today.' : `No parcels received ${rangeLabel}.`}
</Typography>
</Box> </Box>
) : isMdDown ? ( ) : isMdDown ? (
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}> <Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>

View File

@@ -43,12 +43,32 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import PersonOutlineRoundedIcon from '@mui/icons-material/PersonOutlineRounded'; import PersonOutlineRoundedIcon from '@mui/icons-material/PersonOutlineRounded';
import StarRoundedIcon from '@mui/icons-material/StarRounded'; import StarRoundedIcon from '@mui/icons-material/StarRounded';
import dayjs from 'dayjs';
import PageHeader from '@/components/PageHeader'; 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'; import { getHubContext } from '@/auth/session';
const PENDING = 'Pending Assignment'; 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) => { const timeAgo = (iso) => {
if (!iso) return 'Recently'; if (!iso) return 'Recently';
const then = new Date(iso).getTime(); const then = new Date(iso).getTime();
@@ -68,6 +88,14 @@ const mapOrder = (b) => {
const pkg = parcels.length const pkg = parcels.length
? `${parcels.map((p) => p.itemcategory || 'Parcel').join(', ')}${totalWeight ? ` · ${totalWeight}kg` : ''}` ? `${parcels.map((p) => p.itemcategory || 'Parcel').join(', ')}${totalWeight ? ` · ${totalWeight}kg` : ''}`
: b.packagedescription || '—'; : 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 { return {
id: b.bookingid, id: b.bookingid,
customer: b.customer_name || b.customerName || 'Customer', customer: b.customer_name || b.customerName || 'Customer',
@@ -75,7 +103,7 @@ const mapOrder = (b) => {
drop: b.delivery_address || b.deliveryaddress || '—', drop: b.delivery_address || b.deliveryaddress || '—',
package: pkg, package: pkg,
time: timeAgo(b.created_at || b.createdat), time: timeAgo(b.created_at || b.createdat),
status: PENDING status
}; };
}; };
@@ -101,6 +129,16 @@ export default function OrderAssignment() {
const [selectedOrders, setSelectedOrders] = useState([]); 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 // Single Assign Dialog State
const [selectedOrderForAssign, setSelectedOrderForAssign] = useState(null); const [selectedOrderForAssign, setSelectedOrderForAssign] = useState(null);
const [assignDialogOpen, setAssignDialogOpen] = useState(false); const [assignDialogOpen, setAssignDialogOpen] = useState(false);
@@ -112,7 +150,7 @@ export default function OrderAssignment() {
setLoading(true); setLoading(true);
setLoadError(''); setLoadError('');
try { 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)); setOrders((bookings?.data || []).map(mapOrder));
if (milerRes?.data) setMilers(milerRes.data.map(mapMiler)); if (milerRes?.data) setMilers(milerRes.data.map(mapMiler));
} catch (err) { } catch (err) {
@@ -120,7 +158,7 @@ export default function OrderAssignment() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [range.from, range.to]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -219,12 +257,17 @@ export default function OrderAssignment() {
icon={AssignmentIndIcon} icon={AssignmentIndIcon}
title="Pickup Requests" 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." subtitle="Customers want these parcels collected. Pick a nearby miler for each one, or select several and assign them all at once."
action={<DateRangePicker value={range} onChange={setRange} />}
/> />
<Card> <Card>
<CardHeader <CardHeader
title="Waiting for a Miler" title={isToday ? 'Waiting for a Miler' : `Pickup requests · ${rangeLabel}`}
subheader={`New pickup requests around ${hub.city || 'your city'}`} subheader={
isToday
? `New pickup requests around ${hub.city || 'your city'}`
: `${orders.length} pickup request${orders.length === 1 ? '' : 's'} in this range around ${hub.city || 'your city'}`
}
avatar={<Avatar variant="rounded" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', borderRadius: 2 }}><AssignmentIndIcon /></Avatar>} avatar={<Avatar variant="rounded" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', borderRadius: 2 }}><AssignmentIndIcon /></Avatar>}
action={ action={
<Button <Button
@@ -255,7 +298,9 @@ export default function OrderAssignment() {
) : orders.length === 0 && !loadError ? ( ) : orders.length === 0 && !loadError ? (
<Box sx={{ py: 8, textAlign: 'center' }}> <Box sx={{ py: 8, textAlign: 'center' }}>
<AssignmentIndIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} /> <AssignmentIndIcon sx={{ fontSize: 56, color: '#CED4DA', mb: 1.5 }} />
<Typography variant="body2" color="text.secondary">No pickup requests waiting right now.</Typography> <Typography variant="body2" color="text.secondary">
{isToday ? 'No pickup requests waiting right now.' : `No pickup requests ${rangeLabel}.`}
</Typography>
</Box> </Box>
) : isMdDown ? ( ) : isMdDown ? (
/* ── MOBILE / TABLET: cards ── */ /* ── MOBILE / TABLET: cards ── */
@@ -277,9 +322,13 @@ export default function OrderAssignment() {
<Typography variant="caption" color="text.secondary">{row.time}</Typography> <Typography variant="caption" color="text.secondary">{row.time}</Typography>
</Box> </Box>
</Stack> </Stack>
{!isAssigned {(() => {
? <Chip size="small" label="Needs a miler" sx={{ fontWeight: 700, flexShrink: 0, bgcolor: '#FEF7E0', color: '#B06000' }} /> const c = statusChip(row.status);
: <Chip size="small" icon={<CheckCircleIcon sx={{ fontSize: '15px !important' }} />} label="Assigned" sx={{ fontWeight: 700, flexShrink: 0, bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} />} return (
<Chip size="small" icon={c.done && c.color === '#1E8E3E' ? <CheckCircleIcon sx={{ fontSize: '15px !important' }} /> : undefined}
label={c.label} sx={{ fontWeight: 700, flexShrink: 0, bgcolor: c.bg, color: c.color, '& .MuiChip-icon': { color: c.color } }} />
);
})()}
</Stack> </Stack>
<Stack spacing={1} sx={{ p: 1.5, bgcolor: '#FAFBFC', borderRadius: 2, mb: 1.5 }}> <Stack spacing={1} sx={{ p: 1.5, bgcolor: '#FAFBFC', borderRadius: 2, mb: 1.5 }}>
@@ -307,7 +356,7 @@ export default function OrderAssignment() {
Choose a Miler Choose a Miler
</Button> </Button>
) : ( ) : (
<Typography variant="body2" sx={{ textAlign: 'center', fontWeight: 700, color: '#1E8E3E' }}>{row.status}</Typography> <Typography variant="body2" sx={{ textAlign: 'center', fontWeight: 700, color: statusChip(row.status).color }}>{row.status}</Typography>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -357,9 +406,13 @@ export default function OrderAssignment() {
<TableCell sx={{ fontWeight: 600, color: '#495057' }}>{row.drop}</TableCell> <TableCell sx={{ fontWeight: 600, color: '#495057' }}>{row.drop}</TableCell>
<TableCell sx={{ color: '#495057' }}>{row.package}</TableCell> <TableCell sx={{ color: '#495057' }}>{row.package}</TableCell>
<TableCell> <TableCell>
{!isAssigned {(() => {
? <Chip size="small" label="Needs a miler" sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: '#FEF7E0', color: '#B06000' }} /> const c = statusChip(row.status);
: <Chip size="small" icon={<CheckCircleIcon sx={{ fontSize: '15px !important' }} />} label="Assigned" sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} />} return (
<Chip size="small" icon={c.done && c.color === '#1E8E3E' ? <CheckCircleIcon sx={{ fontSize: '15px !important' }} /> : undefined}
label={c.label} sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: c.bg, color: c.color, '& .MuiChip-icon': { color: c.color } }} />
);
})()}
</TableCell> </TableCell>
<TableCell align="right"> <TableCell align="right">
{!isAssigned ? ( {!isAssigned ? (
@@ -368,7 +421,7 @@ export default function OrderAssignment() {
Choose Miler Choose Miler
</Button> </Button>
) : ( ) : (
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1E8E3E', whiteSpace: 'nowrap' }}>{row.status}</Typography> <Typography variant="caption" sx={{ fontWeight: 700, color: statusChip(row.status).color, whiteSpace: 'nowrap' }}>{row.status}</Typography>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { 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, IconButton, List, ListItemButton, ListItemText, Collapse, Tooltip, Divider,
LinearProgress, Menu, MenuItem, Drawer, Paper LinearProgress, Menu, MenuItem, Drawer, Paper
} from '@mui/material'; } from '@mui/material';
@@ -9,6 +9,7 @@ import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip as LTooltip,
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import StatCard from '@/components/StatCard';
import { getRiderRoutes } from '@/api/hub'; import { getRiderRoutes } from '@/api/hub';
import { getHubContext } from '@/auth/session'; import { getHubContext } from '@/auth/session';
@@ -225,24 +226,8 @@ async function fetchRoadRoute(stops) {
// ════════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════════
// Small presentational pieces // Small presentational pieces
// ════════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════════
function KpiCard({ icon: Icon, label, value, sub, color, bg }) { // KPI card — aliased to the shared StatCard so this strip matches every other page.
return ( const KpiCard = (props) => <StatCard {...props} />;
<Card elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1', height: '100%' }}>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
<Stack direction="row" alignItems="center" spacing={1.25} sx={{ mb: 1 }}>
<Avatar variant="rounded" sx={{ bgcolor: bg, color, width: 34, height: 34, borderRadius: 2 }}>
<Icon sx={{ fontSize: 18 }} />
</Avatar>
<Typography sx={{ fontSize: '0.68rem', color: '#6C757D', fontWeight: 700, letterSpacing: 0.5, textTransform: 'uppercase' }}>
{label}
</Typography>
</Stack>
<Typography sx={{ fontSize: '1.5rem', fontWeight: 800, color: '#1A1A2E', lineHeight: 1.1 }}>{value}</Typography>
{sub && <Typography variant="caption" color="text.secondary">{sub}</Typography>}
</CardContent>
</Card>
);
}
function DetailRow({ icon: Icon, label, value, valueColor }) { function DetailRow({ icon: Icon, label, value, valueColor }) {
if (value === undefined || value === null || value === '') return null; if (value === undefined || value === null || value === '') return null;

View File

@@ -34,7 +34,6 @@ import PedalBikeOutlinedIcon from '@mui/icons-material/PedalBikeOutlined';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'; import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import AirportShuttleOutlinedIcon from '@mui/icons-material/AirportShuttleOutlined'; import AirportShuttleOutlinedIcon from '@mui/icons-material/AirportShuttleOutlined';
import InventoryOutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import InventoryOutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import TrendingUpOutlinedIcon from '@mui/icons-material/TrendingUpOutlined';
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined'; import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined'; import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined'; 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 HomeWorkOutlinedIcon from '@mui/icons-material/HomeWorkOutlined';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import StatCard from '@/components/StatCard';
import { getMilers, createMiler, updateMiler, deleteMiler } from '@/api/hub'; import { getMilers, createMiler, updateMiler, deleteMiler } from '@/api/hub';
import { getHubContext } from '@/auth/session'; import { getHubContext } from '@/auth/session';
@@ -228,45 +228,9 @@ function VehicleCell({ vehicle, vehicleNo }) {
); );
} }
// KPI Card - More spacious // KPI card — the shared StatCard is the single reference design (see components/StatCard).
function KpiCard({ icon: Icon, label, value, sub, color, bg, trend }) { // Kept as a thin alias so the call sites below stay unchanged; `hover` on for the lift effect.
return ( const KpiCard = (props) => <StatCard {...props} hover />;
<Card
elevation={0}
sx={{
borderRadius: 2,
border: '1px solid #ECEEF1',
height: '100%',
transition: 'all .2s',
'&:hover': {
boxShadow: '0 10px 30px rgba(0,0,0,0.08)',
transform: 'translateY(-2px)'
}
}}
>
<CardContent sx={{ p: 2.25, '&:last-child': { pb: 2.25 } }}>
{/* Icon + label sit together on a clean top row */}
<Stack direction="row" alignItems="center" spacing={1.75} sx={{ mb: 1.5 }}>
<Avatar variant="rounded" sx={{ bgcolor: bg, color, width: 40, height: 40, borderRadius: 2, flexShrink: 0 }}>
<Icon sx={{ fontSize: 21 }} />
</Avatar>
<Typography sx={{ fontSize: '0.72rem', color: '#6C757D', fontWeight: 700, letterSpacing: 0.6, textTransform: 'uppercase', lineHeight: 1.3 }}>
{label}
</Typography>
</Stack>
<Typography sx={{ fontSize: '1.6rem', fontWeight: 800, color: '#1A1A2E', lineHeight: 1.15, mb: 0.5 }}>
{value}
</Typography>
<Stack direction="row" alignItems="center" spacing={0.75}>
{trend && <TrendingUpOutlinedIcon sx={{ fontSize: 15, color: '#1E8E3E', flexShrink: 0 }} />}
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 500 }}>{sub}</Typography>
</Stack>
</CardContent>
</Card>
);
}
// ════════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════════
// Dialogs (Cleaner & Better Spaced) // 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 // 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 [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. // Reset the editable copy + mode whenever a different miler is opened.
useEffect(() => { useEffect(() => {
setForm(rider || {}); if (riderProp) {
setRetained(riderProp);
setForm(riderProp);
setEditing(startInEdit); setEditing(startInEdit);
}, [rider, startInEdit]); }
}, [riderProp, startInEdit]);
if (!rider) return null; const open = Boolean(riderProp); // drives the slide; false triggers the exit anim
const sr = successRate(rider); 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 })); const setField = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }));
@@ -519,15 +494,17 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
return ( return (
<Drawer <Drawer
anchor="right" anchor="right"
open={!!rider} open={open}
onClose={onClose} onClose={onClose}
// Slow the slide a touch (default ~225ms feels abrupt) for a smoother open/close.
transitionDuration={{ enter: 400, exit: 340 }}
sx={{ sx={{
// This build renders temporary drawers at zIndex.drawer (1200), which sits // This build renders temporary drawers at zIndex.drawer (1200), which sits
// BELOW the app bar (drawer + 1). On mobile the sheet starts at top:0, so its // BELOW the app bar (drawer + 1). On mobile the sheet starts at top:0, so its
// Close button would hide under the app bar. Lift the whole modal above it. // Close button would hide under the app bar. Lift the whole modal above it.
zIndex: (t) => t.zIndex.modal, zIndex: (t) => t.zIndex.modal,
'& .MuiDrawer-paper': { '& .MuiDrawer-paper': {
width: { xs: '100%', sm: 480 }, width: { xs: '100%', sm: 520 },
maxWidth: '100%', maxWidth: '100%',
// Run the sheet the full height of the viewport so there is no empty // Run the sheet the full height of the viewport so there is no empty
// strip above the red header (zIndex.modal keeps Close clickable). // strip above the red header (zIndex.modal keeps Close clickable).
@@ -541,6 +518,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
}, },
}} }}
> >
{rider && (<>
{/* Header */} {/* Header */}
<Box sx={{ background: 'linear-gradient(135deg, #C01227 0%, #8D0E1D 100%)', px: 2.75, py: 2.25, color: '#fff', flexShrink: 0 }}> <Box sx={{ background: 'linear-gradient(135deg, #C01227 0%, #8D0E1D 100%)', px: 2.75, py: 2.25, color: '#fff', flexShrink: 0 }}>
<Button onClick={onClose} startIcon={<ArrowBackRoundedIcon />} size="small" <Button onClick={onClose} startIcon={<ArrowBackRoundedIcon />} size="small"
@@ -605,7 +583,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
<Grid container spacing={1.75}> <Grid container spacing={1.75}>
{/* Today's Performance */} {/* Today's Performance */}
<Grid item xs={12}> <Grid size={12}>
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, display: 'block', mb: 1.5, fontSize: '0.75rem' }}> <Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, display: 'block', mb: 1.5, fontSize: '0.75rem' }}>
TODAY'S PERFORMANCE TODAY'S PERFORMANCE
</Typography> </Typography>
@@ -615,28 +593,22 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
{ label: "Failed", value: rider.deliveriesFailed, icon: WarningAmberOutlinedIcon, color: "#DC2626", bg: "#FEE2E2" }, { label: "Failed", value: rider.deliveriesFailed, icon: WarningAmberOutlinedIcon, color: "#DC2626", bg: "#FEE2E2" },
{ label: "COP", value: inr(rider.codCollected), icon: PaymentsOutlinedIcon, color: "#D97706", bg: "#FEF3C7" }, { label: "COP", value: inr(rider.codCollected), icon: PaymentsOutlinedIcon, color: "#D97706", bg: "#FEF3C7" },
].map((stat, i) => ( ].map((stat, i) => (
<Grid item xs={4} key={i}> <Grid size={4} key={i}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#FFFFFF', height: '100%' }}> <StatCard icon={stat.icon} label={stat.label} value={stat.value} color={stat.color} bg={stat.bg} />
<Box sx={{ p: 1, borderRadius: 2, bgcolor: stat.bg, display: 'inline-flex', mb: 1.25 }}>
<stat.icon sx={{ fontSize: 22, color: stat.color }} />
</Box>
<Typography variant="body2" sx={{ color: '#6C757D', display: 'block', lineHeight: 1.2 }}>{stat.label}</Typography>
<Typography sx={{ fontSize: '1.35rem', fontWeight: 800, color: '#1E293B', mt: 0.25, lineHeight: 1.1 }}>{stat.value}</Typography>
</Paper>
</Grid> </Grid>
))} ))}
</Grid> </Grid>
</Grid> </Grid>
{/* Live Load Panel */} {/* Live Load Panel */}
<Grid item xs={12}> <Grid size={12}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#FFFFFF' }}> <Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#FFFFFF' }}>
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, display: 'block', mb: 1.75, fontSize: '0.75rem' }}> <Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, display: 'block', mb: 1.75, fontSize: '0.75rem' }}>
LIVE LOAD LIVE LOAD
</Typography> </Typography>
<CapacityBar rider={rider} showLabel /> <CapacityBar rider={rider} showLabel />
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
<Stack direction="row" justifyContent="space-between" alignItems="center"> <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ gap: 2, width: '100%' }}>
<Typography variant="body2" sx={{ color: '#6C757D', fontWeight: 600 }}>Pending pickups</Typography> <Typography variant="body2" sx={{ color: '#6C757D', fontWeight: 600 }}>Pending pickups</Typography>
<Typography sx={{ fontSize: '1.5rem', fontWeight: 800, color: '#1E293B', lineHeight: 1 }}>{rider.pickupsPending}</Typography> <Typography sx={{ fontSize: '1.5rem', fontWeight: 800, color: '#1E293B', lineHeight: 1 }}>{rider.pickupsPending}</Typography>
</Stack> </Stack>
@@ -644,7 +616,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
</Grid> </Grid>
{/* Miler Details Panel */} {/* Miler Details Panel */}
<Grid item xs={12}> <Grid size={12}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#FFFFFF' }}> <Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#FFFFFF' }}>
<Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, display: 'block', mb: 2, fontSize: '0.75rem' }}> <Typography variant="overline" sx={{ color: '#6C757D', fontWeight: 700, letterSpacing: 1, display: 'block', mb: 2, fontSize: '0.75rem' }}>
MILER DETAILS MILER DETAILS
@@ -657,7 +629,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
{ icon: BadgeOutlinedIcon, label: "Vehicle", value: `${rider.vehicle} • ${rider.vehicleNo}` }, { icon: BadgeOutlinedIcon, label: "Vehicle", value: `${rider.vehicle} • ${rider.vehicleNo}` },
{ icon: AccessTimeOutlinedIcon, label: "Check-in", value: `${rider.checkInTime} (${rider.hoursToday}h)` }, { icon: AccessTimeOutlinedIcon, label: "Check-in", value: `${rider.checkInTime} (${rider.hoursToday}h)` },
].map((detail, idx) => ( ].map((detail, idx) => (
<Grid item xs={6} key={idx}> <Grid size={6} key={idx}>
<Stack direction="row" spacing={1.5} alignItems="flex-start"> <Stack direction="row" spacing={1.5} alignItems="flex-start">
<detail.icon sx={{ color: '#94A3B8', fontSize: 20, mt: '2px' }} /> <detail.icon sx={{ color: '#94A3B8', fontSize: 20, mt: '2px' }} />
<Box sx={{ minWidth: 0 }}> <Box sx={{ minWidth: 0 }}>
@@ -697,6 +669,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
</> </>
)} )}
</Box> </Box>
</>)}
</Drawer> </Drawer>
); );
} }
@@ -1358,7 +1331,7 @@ export default function Riders() {
<TableCell><VehicleCell vehicle={rider.vehicle} vehicleNo={rider.vehicleNo} /></TableCell> <TableCell><VehicleCell vehicle={rider.vehicle} vehicleNo={rider.vehicleNo} /></TableCell>
<TableCell><CapacityBar rider={rider} /></TableCell> <TableCell><CapacityBar rider={rider} /></TableCell>
<TableCell> <TableCell>
<Stack direction="row" gap={3}> <Stack direction="row" spacing={4}>
<Box> <Box>
<Typography variant="caption" color="text.secondary">Done</Typography> <Typography variant="caption" color="text.secondary">Done</Typography>
<Typography fontWeight={700} color="#1E8E3E">{rider.deliveriesDone}</Typography> <Typography fontWeight={700} color="#1E8E3E">{rider.deliveriesDone}</Typography>
@@ -1385,7 +1358,7 @@ export default function Riders() {
<Box sx={{ p: { xs: 2.5, md: 4 } }}> <Box sx={{ p: { xs: 2.5, md: 4 } }}>
<Grid container spacing={{ xs: 2.5, md: 3 }}> <Grid container spacing={{ xs: 2.5, md: 3 }}>
{filtered.map(r => ( {filtered.map(r => (
<Grid item xs={12} sm={6} lg={4} key={r.id}> <Grid size={{ xs: 12, sm: 6, lg: 4 }} key={r.id}>
<RiderCard rider={r} onView={openView} onMenu={openMenu} /> <RiderCard rider={r} onView={openView} onMenu={openMenu} />
</Grid> </Grid>
))} ))}

View File

@@ -90,7 +90,7 @@ export default function Routing() {
<Box sx={{ mb: 4 }}> <Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: '#212529', mb: 1 }}>Where Does It Go?</Typography> <Typography variant="h4" sx={{ fontWeight: 800, color: '#212529', mb: 1 }}>Where Does It Go?</Typography>
<Typography variant="body1" color="text.secondary"> <Typography variant="body1" color="text.secondary">
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.
</Typography> </Typography>
</Box> </Box>