189 lines
8.9 KiB
JavaScript
189 lines
8.9 KiB
JavaScript
import { useMemo, useState } from 'react';
|
|
import { Card, Box, Stack, Typography, Tabs, Tab, TextField, InputAdornment, LinearProgress, Chip, MenuItem, IconButton, Button, Tooltip } from '@mui/material';
|
|
import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined';
|
|
import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined';
|
|
import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded';
|
|
import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
|
|
import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded';
|
|
|
|
import './tracking.css';
|
|
import StatusChip from '@/components/StatusChip';
|
|
import { vehicleTypes } from '@/data/mock';
|
|
import { vehicleIconComponents } from './vehicleMarker';
|
|
|
|
// ==============================|| OPERATIONAL DELIVERY QUEUE ||============================== //
|
|
// Left panel of the control tower: tabbed, searchable, city-filterable list of shipment cards.
|
|
// Selecting drives the map; per-card quick actions (Flag / Open 360) commit via `actions`.
|
|
|
|
const ETA_TONE = { 'on-time': '#00773B', 'at-risk': '#8A6500', delayed: '#A82216' };
|
|
const PRIORITY = { high: { fg: '#A82216', bg: '#FEEAE9' }, express: { fg: '#8A6500', bg: '#FFF7E0' }, standard: { fg: '#595959', bg: '#F0F0F0' } };
|
|
|
|
const matches = (d, q) =>
|
|
[d.id, d.rider, d.vehicle, d.origin, d.destination, d.city].some((f) => f.toLowerCase().includes(q));
|
|
|
|
function DeliveryCard({ d, selected, onSelect, actions }) {
|
|
const tone = ETA_TONE[d.etaStatus] || ETA_TONE['on-time'];
|
|
const vt = vehicleTypes[d.vehicle] || vehicleTypes.Bike;
|
|
const pr = PRIORITY[d.priority] || PRIORITY.standard;
|
|
const live = d.status !== 'Delivered';
|
|
const stop = (fn) => (e) => { e.stopPropagation(); fn(); };
|
|
|
|
return (
|
|
<Box
|
|
onClick={() => onSelect(d.id)}
|
|
sx={{
|
|
p: 1.5,
|
|
borderRadius: 2,
|
|
cursor: 'pointer',
|
|
border: '1px solid',
|
|
borderColor: selected ? 'primary.main' : 'grey.200',
|
|
borderLeft: '4px solid',
|
|
borderLeftColor: d.etaStatus === 'on-time' ? 'transparent' : tone,
|
|
bgcolor: selected ? 'rgba(192,18,39,0.03)' : 'background.paper',
|
|
transition: 'border-color .15s, background .15s',
|
|
'&:hover': { borderColor: 'primary.light' }
|
|
}}
|
|
>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ minWidth: 0 }}>
|
|
<Box sx={{ width: 30, height: 30, borderRadius: 1.5, bgcolor: vt.color, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
{(() => { const G = vehicleIconComponents[d.vehicle]; return G ? <G sx={{ fontSize: 17 }} /> : null; })()}
|
|
</Box>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.main', lineHeight: 1.1 }}>{d.id}</Typography>
|
|
<Typography variant="caption" color="text.secondary" noWrap>{d.rider} · {d.vehicle}</Typography>
|
|
</Box>
|
|
</Stack>
|
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ flexShrink: 0 }}>
|
|
<Box component="span" sx={{ px: 0.75, py: 0.25, borderRadius: 1, bgcolor: pr.bg, color: pr.fg, fontSize: 10, fontWeight: 700, textTransform: 'uppercase' }}>
|
|
{d.priority}
|
|
</Box>
|
|
{live && (
|
|
<Chip
|
|
size="small"
|
|
icon={<Box className="live-pulse" sx={{ color: '#00A854', width: 7, height: 7, ml: 0.5 }} />}
|
|
label="Live"
|
|
sx={{ height: 20, bgcolor: 'success.lighter', color: 'success.dark', fontWeight: 700, '& .MuiChip-label': { px: 0.75, fontSize: 11 } }}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
</Stack>
|
|
|
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 1, minWidth: 0 }}>
|
|
<Typography variant="caption" color="text.secondary" noWrap>{d.origin}</Typography>
|
|
<ArrowRightAltRoundedIcon sx={{ fontSize: 16, color: 'grey.400', flexShrink: 0 }} />
|
|
<Typography variant="caption" sx={{ fontWeight: 600 }} noWrap>{d.destination}</Typography>
|
|
</Stack>
|
|
|
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.75 }}>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={d.progress}
|
|
sx={{ flexGrow: 1, height: 6, borderRadius: 3, bgcolor: 'grey.100', '& .MuiLinearProgress-bar': { bgcolor: d.status === 'Delivered' ? 'success.main' : d.etaStatus === 'on-time' ? 'info.main' : tone } }}
|
|
/>
|
|
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', minWidth: 34, textAlign: 'right' }}>{d.progress}%</Typography>
|
|
</Stack>
|
|
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mt: 1 }}>
|
|
<StatusChip status={d.status} />
|
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
|
<ScheduleOutlinedIcon sx={{ fontSize: 14, color: tone }} />
|
|
<Typography variant="caption" sx={{ fontWeight: 700, color: tone }}>
|
|
{d.status === 'Delivered' ? `Delivered ${d.eta}` : `ETA ${d.eta}`}
|
|
{d.etaStatus !== 'on-time' && ` · +${d.delayMin}m`}
|
|
</Typography>
|
|
</Stack>
|
|
</Stack>
|
|
|
|
{/* quick actions */}
|
|
{actions && (
|
|
<Stack direction="row" spacing={0.5} alignItems="center" justifyContent="flex-end" sx={{ mt: 0.75 }}>
|
|
<Tooltip title="Flag exception">
|
|
<IconButton size="small" color="error" onClick={stop(() => actions.flag(d.id))} sx={{ p: 0.5 }}>
|
|
<FlagOutlinedIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Button size="small" endIcon={<OpenInNewRoundedIcon sx={{ fontSize: 13 }} />} onClick={stop(() => actions.open(d.id))} sx={{ fontSize: '0.7rem', fontWeight: 600, px: 0.75, minWidth: 0 }}>
|
|
360
|
|
</Button>
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
const TABS = [
|
|
{ key: 'active', label: 'Active', test: (d) => d.status !== 'Delivered' },
|
|
{ key: 'delayed', label: 'Delayed', test: (d) => d.etaStatus !== 'on-time' },
|
|
{ key: 'completed', label: 'Completed', test: (d) => d.status === 'Delivered' },
|
|
{ key: 'all', label: 'All', test: () => true }
|
|
];
|
|
|
|
export default function DeliveryQueue({ deliveries, selectedId, onSelect, actions }) {
|
|
const [tab, setTab] = useState(0);
|
|
const [q, setQ] = useState('');
|
|
const [city, setCity] = useState('all');
|
|
|
|
const cities = useMemo(() => [...new Set(deliveries.map((d) => d.city))], [deliveries]);
|
|
|
|
const rows = useMemo(() => {
|
|
const query = q.trim().toLowerCase();
|
|
return deliveries
|
|
.filter(TABS[tab].test)
|
|
.filter((d) => (city === 'all' ? true : d.city === city))
|
|
.filter((d) => (query ? matches(d, query) : true))
|
|
.sort((a, b) => Number(b.etaStatus !== 'on-time') - Number(a.etaStatus !== 'on-time'));
|
|
}, [deliveries, tab, q, city]);
|
|
|
|
const counts = useMemo(() => TABS.map((t) => deliveries.filter(t.test).length), [deliveries]);
|
|
|
|
return (
|
|
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
<Box sx={{ px: 2, pt: 2, pb: 1 }}>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>Active Deliveries</Typography>
|
|
<Typography variant="caption" color="text.secondary">{rows.length} shown</Typography>
|
|
</Stack>
|
|
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
|
|
<TextField
|
|
fullWidth
|
|
size="small"
|
|
placeholder="Search shipment, rider, location…"
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
InputProps={{ startAdornment: (<InputAdornment position="start"><SearchOutlinedIcon fontSize="small" /></InputAdornment>) }}
|
|
/>
|
|
<TextField select size="small" value={city} onChange={(e) => setCity(e.target.value)} sx={{ minWidth: 120 }}>
|
|
<MenuItem value="all">All cities</MenuItem>
|
|
{cities.map((c) => <MenuItem key={c} value={c}>{c}</MenuItem>)}
|
|
</TextField>
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Tabs
|
|
value={tab}
|
|
onChange={(_, v) => setTab(v)}
|
|
variant="fullWidth"
|
|
sx={{ px: 1, minHeight: 40, '& .MuiTab-root': { minHeight: 40, textTransform: 'none', fontWeight: 600, fontSize: 13 } }}
|
|
>
|
|
{TABS.map((t, i) => (
|
|
<Tab key={t.key} label={`${t.label} ${counts[i]}`} />
|
|
))}
|
|
</Tabs>
|
|
|
|
<Box sx={{ flexGrow: 1, minHeight: 0, overflowY: 'auto', px: 2, py: 1.5 }}>
|
|
<Stack spacing={1.25}>
|
|
{rows.map((d) => (
|
|
<DeliveryCard key={d.id} d={d} selected={d.id === selectedId} onSelect={onSelect} actions={actions} />
|
|
))}
|
|
{rows.length === 0 && (
|
|
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
|
No deliveries match this view.
|
|
</Typography>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
</Card>
|
|
);
|
|
}
|