Files
doormile_app_web/src/components/tracking/DeliveryQueue.jsx
2026-06-11 20:17:18 +05:30

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>
);
}