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:
@@ -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 }
|
||||
|
||||
214
src/components/DateRangePicker.jsx
Normal file
214
src/components/DateRangePicker.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
src/components/StatCard.jsx
Normal file
70
src/components/StatCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{kpiCards.map((s) => (
|
||||
<StatCard key={s.label} icon={s.icon} label={s.label} value={s.value} sub={s.sub} color={s.color} loading={loading} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Operations Grid */}
|
||||
|
||||
@@ -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={<DateRangePicker value={range} onChange={setRange} />}
|
||||
/>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
@@ -225,7 +238,11 @@ export default function Dispatch() {
|
||||
<Card>
|
||||
<CardHeader
|
||||
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={
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenModal(true)}>
|
||||
New Batch
|
||||
@@ -247,7 +264,9 @@ export default function Dispatch() {
|
||||
) : manifests.length === 0 && !loadError ? (
|
||||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
||||
<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>
|
||||
) : isMdDown ? (
|
||||
/* ── MOBILE / TABLET: spacious cards ── */
|
||||
|
||||
@@ -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={<DateRangePicker value={range} onChange={setRange} />}
|
||||
/>
|
||||
|
||||
{/* ── KPI strip ── */}
|
||||
<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: 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) => (
|
||||
<Grid size={{ xs: 6, md: 3 }} key={i}>
|
||||
<Card elevation={0} sx={{ borderRadius: 2, border: '1px solid #ECEEF1', height: '100%' }}>
|
||||
<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>
|
||||
<StatCard icon={s.icon} label={s.label} value={s.value} color={s.color} bg={s.bg} loading={loading} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
@@ -348,7 +352,7 @@ export default function Inbound() {
|
||||
</Avatar>
|
||||
<Box>
|
||||
<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>
|
||||
</Stack>
|
||||
<Chip label={`${inboundLogs.length} total`} size="small"
|
||||
@@ -370,7 +374,9 @@ export default function Inbound() {
|
||||
) : inboundLogs.length === 0 && !loadError ? (
|
||||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
||||
<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>
|
||||
) : isMdDown ? (
|
||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
|
||||
@@ -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={<DateRangePicker value={range} onChange={setRange} />}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Waiting for a Miler"
|
||||
subheader={`New pickup requests around ${hub.city || 'your city'}`}
|
||||
title={isToday ? 'Waiting for a Miler' : `Pickup requests · ${rangeLabel}`}
|
||||
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>}
|
||||
action={
|
||||
<Button
|
||||
@@ -255,7 +298,9 @@ export default function OrderAssignment() {
|
||||
) : orders.length === 0 && !loadError ? (
|
||||
<Box sx={{ py: 8, textAlign: 'center' }}>
|
||||
<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>
|
||||
) : isMdDown ? (
|
||||
/* ── MOBILE / TABLET: cards ── */
|
||||
@@ -277,9 +322,13 @@ export default function OrderAssignment() {
|
||||
<Typography variant="caption" color="text.secondary">{row.time}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
{!isAssigned
|
||||
? <Chip size="small" label="Needs a miler" sx={{ fontWeight: 700, flexShrink: 0, bgcolor: '#FEF7E0', color: '#B06000' }} />
|
||||
: <Chip size="small" icon={<CheckCircleIcon sx={{ fontSize: '15px !important' }} />} label="Assigned" sx={{ fontWeight: 700, flexShrink: 0, bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} />}
|
||||
{(() => {
|
||||
const c = statusChip(row.status);
|
||||
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 spacing={1} sx={{ p: 1.5, bgcolor: '#FAFBFC', borderRadius: 2, mb: 1.5 }}>
|
||||
@@ -307,7 +356,7 @@ export default function OrderAssignment() {
|
||||
Choose a Miler
|
||||
</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>
|
||||
</Card>
|
||||
@@ -357,9 +406,13 @@ export default function OrderAssignment() {
|
||||
<TableCell sx={{ fontWeight: 600, color: '#495057' }}>{row.drop}</TableCell>
|
||||
<TableCell sx={{ color: '#495057' }}>{row.package}</TableCell>
|
||||
<TableCell>
|
||||
{!isAssigned
|
||||
? <Chip size="small" label="Needs a miler" sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: '#FEF7E0', color: '#B06000' }} />
|
||||
: <Chip size="small" icon={<CheckCircleIcon sx={{ fontSize: '15px !important' }} />} label="Assigned" sx={{ fontWeight: 700, whiteSpace: 'nowrap', bgcolor: '#E6F4EA', color: '#1E8E3E', '& .MuiChip-icon': { color: '#1E8E3E' } }} />}
|
||||
{(() => {
|
||||
const c = statusChip(row.status);
|
||||
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 align="right">
|
||||
{!isAssigned ? (
|
||||
@@ -368,7 +421,7 @@ export default function OrderAssignment() {
|
||||
Choose Miler
|
||||
</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>
|
||||
</TableRow>
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
// KPI card — aliased to the shared StatCard so this strip matches every other page.
|
||||
const KpiCard = (props) => <StatCard {...props} />;
|
||||
|
||||
function DetailRow({ icon: Icon, label, value, valueColor }) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
// 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) => <StatCard {...props} hover />;
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// 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 (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={!!rider}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
// Slow the slide a touch (default ~225ms feels abrupt) for a smoother open/close.
|
||||
transitionDuration={{ enter: 400, exit: 340 }}
|
||||
sx={{
|
||||
// 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
|
||||
// Close button would hide under the app bar. Lift the whole modal above it.
|
||||
zIndex: (t) => 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 */}
|
||||
<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"
|
||||
@@ -605,7 +583,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
|
||||
<Grid container spacing={1.75}>
|
||||
|
||||
{/* 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' }}>
|
||||
TODAY'S PERFORMANCE
|
||||
</Typography>
|
||||
@@ -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) => (
|
||||
<Grid item xs={4} key={i}>
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #E2E8F0', bgcolor: '#FFFFFF', height: '100%' }}>
|
||||
<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 size={4} key={i}>
|
||||
<StatCard icon={stat.icon} label={stat.label} value={stat.value} color={stat.color} bg={stat.bg} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Live Load Panel */}
|
||||
<Grid item xs={12}>
|
||||
<Grid size={12}>
|
||||
<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' }}>
|
||||
LIVE LOAD
|
||||
</Typography>
|
||||
<CapacityBar rider={rider} showLabel />
|
||||
<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 sx={{ fontSize: '1.5rem', fontWeight: 800, color: '#1E293B', lineHeight: 1 }}>{rider.pickupsPending}</Typography>
|
||||
</Stack>
|
||||
@@ -644,7 +616,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
|
||||
</Grid>
|
||||
|
||||
{/* Miler Details Panel */}
|
||||
<Grid item xs={12}>
|
||||
<Grid size={12}>
|
||||
<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' }}>
|
||||
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) => (
|
||||
<Grid item xs={6} key={idx}>
|
||||
<Grid size={6} key={idx}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||
<detail.icon sx={{ color: '#94A3B8', fontSize: 20, mt: '2px' }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
@@ -697,6 +669,7 @@ function ProfileDrawer({ rider, onClose, onSave, startInEdit = false }) {
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</>)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -1358,7 +1331,7 @@ export default function Riders() {
|
||||
<TableCell><VehicleCell vehicle={rider.vehicle} vehicleNo={rider.vehicleNo} /></TableCell>
|
||||
<TableCell><CapacityBar rider={rider} /></TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" gap={3}>
|
||||
<Stack direction="row" spacing={4}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Done</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 } }}>
|
||||
<Grid container spacing={{ xs: 2.5, md: 3 }}>
|
||||
{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} />
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function Routing() {
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, color: '#212529', mb: 1 }}>Where Does It Go?</Typography>
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user