update ui admin
This commit is contained in:
@@ -17,8 +17,11 @@
|
||||
"@mui/material": "^5.15.20",
|
||||
"@mui/x-date-pickers": "^6.20.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.23.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
10
src/App.jsx
10
src/App.jsx
@@ -30,7 +30,7 @@ export default function App() {
|
||||
<Route path="/orders" element={load(() => import('@/pages/orders/OrdersList'))} />
|
||||
<Route path="/orders/create" element={load(() => import('@/pages/orders/CreateOrder'))} />
|
||||
<Route path="/orders/create-multiple" element={load(() => import('@/pages/orders/CreateMultipleOrders'))} />
|
||||
<Route path="/orders/assign" element={load(() => import('@/pages/orders/AssignOrders'))} />
|
||||
<Route path="/orders/assign" element={load(() => import('@/pages/dispatch/DispatchBoard'))} />
|
||||
<Route path="/orders/:id" element={load(() => import('@/pages/orders/OrderDetails'))} />
|
||||
|
||||
<Route path="/deliveries" element={load(() => import('@/pages/Deliveries'))} />
|
||||
@@ -42,7 +42,8 @@ export default function App() {
|
||||
<Route path="/hubs" element={load(() => import('@/pages/hubs/HubNetwork'))} />
|
||||
<Route path="/fleet" element={load(() => import('@/pages/fleet/Fleet'))} />
|
||||
<Route path="/dispatch" element={load(() => import('@/pages/dispatch/AiDispatch'))} />
|
||||
<Route path="/tracking" element={load(() => import('@/pages/tracking/LiveTracking'))} />
|
||||
<Route path="/tracking" element={load(() => import('@/pages/operations/DispatchTracking'))} />
|
||||
<Route path="/tracking/live" element={load(() => import('@/pages/tracking/LiveTracking'))} />
|
||||
<Route path="/tracking/journey" element={load(() => import('@/pages/tracking/ShipmentJourney'))} />
|
||||
<Route path="/tracking/journey/:id" element={load(() => import('@/pages/tracking/ShipmentJourney'))} />
|
||||
<Route path="/analytics" element={load(() => import('@/pages/analytics/Analytics'))} />
|
||||
@@ -51,7 +52,7 @@ export default function App() {
|
||||
<Route path="/tenants" element={load(() => import('@/pages/tenants/Tenants'))} />
|
||||
<Route path="/tenants/create" element={load(() => import('@/pages/tenants/CreateClient'))} />
|
||||
|
||||
<Route path="/customers" element={load(() => import('@/pages/customers/Customers'))} />
|
||||
<Route path="/customers" element={load(() => import('@/pages/business/CustomersHub'))} />
|
||||
<Route path="/customers/create" element={load(() => import('@/pages/customers/CreateCustomer'))} />
|
||||
|
||||
<Route path="/pricing" element={load(() => import('@/pages/Pricing'))} />
|
||||
@@ -60,12 +61,13 @@ export default function App() {
|
||||
<Route path="/riders/create" element={load(() => import('@/pages/riders/CreateRider'))} />
|
||||
<Route path="/riders/:id/edit" element={load(() => import('@/pages/riders/EditRider'))} />
|
||||
|
||||
<Route path="/reports" element={load(() => import('@/pages/reports/ReportsHub'))} />
|
||||
<Route path="/reports/orders-summary" element={load(() => import('@/pages/reports/OrdersSummary'))} />
|
||||
<Route path="/reports/orders-details" element={load(() => import('@/pages/reports/OrdersDetails'))} />
|
||||
<Route path="/reports/riders-summary" element={load(() => import('@/pages/reports/RidersSummary'))} />
|
||||
<Route path="/reports/riders-logs" element={load(() => import('@/pages/reports/RidersLogs'))} />
|
||||
|
||||
<Route path="/invoice" element={load(() => import('@/pages/invoice/Invoices'))} />
|
||||
<Route path="/invoice" element={load(() => import('@/pages/business/FinanceHub'))} />
|
||||
<Route path="/invoice/:id" element={load(() => import('@/pages/invoice/InvoicePreview'))} />
|
||||
|
||||
<Route path="/profile" element={load(() => import('@/pages/Profile'))} />
|
||||
|
||||
50
src/components/AiImpactSummary.jsx
Normal file
50
src/components/AiImpactSummary.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Card, Grid, Stack, Box, Typography, Avatar } from '@mui/material';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
|
||||
import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined';
|
||||
import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
|
||||
import LocalGasStationOutlinedIcon from '@mui/icons-material/LocalGasStationOutlined';
|
||||
import EnergySavingsLeafOutlinedIcon from '@mui/icons-material/EnergySavingsLeafOutlined';
|
||||
import SpeedOutlinedIcon from '@mui/icons-material/SpeedOutlined';
|
||||
|
||||
import { aiImpact } from '@/data/mock';
|
||||
|
||||
// ==============================|| AI IMPACT ||============================== //
|
||||
// What MileTruth AI delivered — six contribution metrics. Read-only, no action lists.
|
||||
|
||||
const ITEMS = [
|
||||
{ icon: RouteOutlinedIcon, value: `${aiImpact.routeSavings}%`, label: 'Route Savings', color: '#1D4ED8' },
|
||||
{ icon: ScheduleOutlinedIcon, value: aiImpact.delaysPrevented, label: 'Delays Prevented', color: '#00A2AE' },
|
||||
{ icon: TuneOutlinedIcon, value: aiImpact.optimizationActions, label: 'Optimization Actions', color: '#EA580C' },
|
||||
{ icon: LocalGasStationOutlinedIcon, value: `${aiImpact.fuelSavedL.toLocaleString('en-IN')} L`, label: 'Fuel Saved', color: '#8A6500' },
|
||||
{ icon: EnergySavingsLeafOutlinedIcon, value: `${(aiImpact.co2ReducedKg / 1000).toFixed(1)} t`, label: 'CO₂ Reduction', color: '#00A854' },
|
||||
{ icon: SpeedOutlinedIcon, value: `${aiImpact.networkEfficiency}%`, label: 'Network Efficiency', color: '#7C3AED' }
|
||||
];
|
||||
|
||||
export default function AiImpactSummary() {
|
||||
return (
|
||||
<Card sx={{ p: 2.5 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<AutoAwesomeOutlinedIcon fontSize="small" sx={{ color: '#EA580C' }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>AI Impact</Typography>
|
||||
<Typography variant="caption" color="text.secondary">MileTruth AI · last 30 days</Typography>
|
||||
</Stack>
|
||||
|
||||
<Grid container spacing={2.5}>
|
||||
{ITEMS.map((it) => (
|
||||
<Grid key={it.label} item xs={6} sm={4} lg={2}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Avatar variant="rounded" sx={{ bgcolor: 'grey.100', color: it.color, width: 42, height: 42 }}>
|
||||
<it.icon fontSize="small" />
|
||||
</Avatar>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, color: 'grey.900', lineHeight: 1.05 }}>{it.value}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>{it.label}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
63
src/components/AttentionCards.jsx
Normal file
63
src/components/AttentionCards.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Grid, Card, Stack, Box, Typography, Avatar } from '@mui/material';
|
||||
import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined';
|
||||
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
||||
import HubOutlinedIcon from '@mui/icons-material/HubOutlined';
|
||||
import AltRouteOutlinedIcon from '@mui/icons-material/AltRouteOutlined';
|
||||
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
|
||||
|
||||
import { attentionItems } from '@/data/mock';
|
||||
|
||||
// ==============================|| ATTENTION REQUIRED — DECISION CARDS ||============================== //
|
||||
// Visual, single-click cards. Answers "what needs action now?" — no tables, no detail dumps.
|
||||
|
||||
const ICON = { delay: ScheduleOutlinedIcon, alert: WarningAmberRoundedIcon, hub: HubOutlinedIcon, route: AltRouteOutlinedIcon };
|
||||
const SEV = {
|
||||
high: { fg: '#A82216', bg: '#FEEAE9', label: 'High', accent: '#F04134' },
|
||||
medium: { fg: '#8A6500', bg: '#FFF7E0', label: 'Medium', accent: '#FFBF00' },
|
||||
low: { fg: '#00727B', bg: '#E0F7F8', label: 'Low', accent: '#00A2AE' }
|
||||
};
|
||||
|
||||
export default function AttentionCards() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Grid container spacing={2.5}>
|
||||
{attentionItems.map((it) => {
|
||||
const Icon = ICON[it.icon] || WarningAmberRoundedIcon;
|
||||
const sev = SEV[it.severity] || SEV.low;
|
||||
return (
|
||||
<Grid key={it.key} item xs={12} sm={6} lg={3}>
|
||||
<Card
|
||||
onClick={() => navigate(it.to)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: sev.accent,
|
||||
transition: 'box-shadow .15s ease, transform .15s ease',
|
||||
'&:hover': { boxShadow: '0 4px 16px rgba(16,24,40,0.10)', transform: 'translateY(-1px)' }
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1.75} sx={{ p: 2 }}>
|
||||
<Avatar variant="rounded" sx={{ bgcolor: sev.bg, color: sev.fg, width: 46, height: 46, flexShrink: 0 }}>
|
||||
<Icon />
|
||||
</Avatar>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" alignItems="baseline" spacing={1}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 800, color: 'grey.900', lineHeight: 1 }}>{it.count}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: sev.fg }}>{sev.label}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600, mt: 0.25 }} noWrap>
|
||||
{it.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ChevronRightRoundedIcon sx={{ color: 'grey.400', flexShrink: 0 }} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
62
src/components/DateRangeFilter.jsx
Normal file
62
src/components/DateRangeFilter.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Menu, MenuItem, Divider, Stack } from '@mui/material';
|
||||
import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import FormDialog from '@/components/FormDialog';
|
||||
import { useFilters, buildRange, customRange, DATA_TODAY } from '@/store/Filters';
|
||||
|
||||
// ==============================|| DATE RANGE FILTER ||============================== //
|
||||
// A real, functional range picker bound to the global filter store. Presets + custom range;
|
||||
// every report that renders it shares (and persists) the same selection.
|
||||
|
||||
const PRESETS = [
|
||||
['today', 'Today'],
|
||||
['yesterday', 'Yesterday'],
|
||||
['last7', 'Last 7 Days'],
|
||||
['last30', 'Last 30 Days'],
|
||||
['thisMonth', 'This Month'],
|
||||
['prevMonth', 'Previous Month']
|
||||
];
|
||||
|
||||
export default function DateRangeFilter() {
|
||||
const { range, setRange } = useFilters();
|
||||
const [anchor, setAnchor] = useState(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [from, setFrom] = useState(range.start || DATA_TODAY.subtract(6, 'day'));
|
||||
const [to, setTo] = useState(range.end || DATA_TODAY);
|
||||
|
||||
const pick = (key) => { setRange(buildRange(key)); setAnchor(null); };
|
||||
const openCustom = () => { setAnchor(null); setFrom(range.start || DATA_TODAY.subtract(6, 'day')); setTo(range.end || DATA_TODAY); setCustomOpen(true); };
|
||||
const applyCustom = () => { if (from && to) setRange(customRange(dayjs(from), dayjs(to))); setCustomOpen(false); };
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<CalendarTodayOutlinedIcon />}
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
onClick={(e) => setAnchor(e.currentTarget)}
|
||||
sx={{ color: 'text.secondary', borderColor: 'grey.300', fontWeight: 600 }}
|
||||
>
|
||||
{range.label}
|
||||
</Button>
|
||||
<Menu anchorEl={anchor} open={Boolean(anchor)} onClose={() => setAnchor(null)} PaperProps={{ sx: { minWidth: 180 } }}>
|
||||
{PRESETS.map(([k, l]) => (
|
||||
<MenuItem key={k} selected={range.key === k} onClick={() => pick(k)}>{l}</MenuItem>
|
||||
))}
|
||||
<Divider />
|
||||
<MenuItem selected={range.key === 'custom'} onClick={openCustom}>Custom Range…</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<FormDialog open={customOpen} onClose={() => setCustomOpen(false)} title="Custom Date Range" onSubmit={applyCustom} submitLabel="Apply" maxWidth="xs">
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mt: 0.5 }}>
|
||||
<DatePicker label="From" value={from} onChange={setFrom} maxDate={to || undefined} slotProps={{ textField: { size: 'small', fullWidth: true } }} />
|
||||
<DatePicker label="To" value={to} onChange={setTo} minDate={from || undefined} slotProps={{ textField: { size: 'small', fullWidth: true } }} />
|
||||
</Stack>
|
||||
</FormDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
src/components/FilterSummary.jsx
Normal file
22
src/components/FilterSummary.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Stack, Typography, Chip } from '@mui/material';
|
||||
|
||||
import { useFilters } from '@/store/Filters';
|
||||
|
||||
// ==============================|| FILTER SUMMARY ||============================== //
|
||||
// "Showing data for: <range> · Location: <location> · <count> records" — confirms exactly which
|
||||
// filters are active so the data on screen is never ambiguous.
|
||||
|
||||
export default function FilterSummary({ count }) {
|
||||
const { range, location } = useFilters();
|
||||
return (
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexWrap: 'wrap', gap: 0.75, color: 'text.secondary' }}>
|
||||
<Typography variant="caption">Showing data for</Typography>
|
||||
<Chip size="small" label={range.label} sx={{ height: 22, fontWeight: 700, bgcolor: 'grey.100', color: 'grey.800' }} />
|
||||
<Typography variant="caption">· Location</Typography>
|
||||
<Chip size="small" label={location === 'all' ? 'All Locations' : location} sx={{ height: 22, fontWeight: 700, bgcolor: 'grey.100', color: 'grey.800' }} />
|
||||
{typeof count === 'number' && (
|
||||
<Typography variant="caption">· {count} record{count === 1 ? '' : 's'}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
72
src/components/KpiCard.jsx
Normal file
72
src/components/KpiCard.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Card, CardContent, Box, Typography, Avatar, Stack, Divider } from '@mui/material';
|
||||
import ArrowUpwardRoundedIcon from '@mui/icons-material/ArrowUpwardRounded';
|
||||
import ArrowDownwardRoundedIcon from '@mui/icons-material/ArrowDownwardRounded';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
|
||||
// ==============================|| EXECUTIVE KPI CARD ||============================== //
|
||||
// Taller operational KPI tile: headline metric + trend + an AI insight line, plus a secondary
|
||||
// breakdown strip so each card carries real context. `details` = [{ label, value }].
|
||||
|
||||
export default function KpiCard({ title, value, icon: Icon, trend, comparison, insight, details = [] }) {
|
||||
const trendUp = typeof trend === 'number' ? trend >= 0 : null;
|
||||
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent sx={{ p: 2.5, minHeight: 168, display: 'flex', flexDirection: 'column' }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h2" sx={{ mt: 1, fontWeight: 700, color: 'grey.900', lineHeight: 1.05 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
{(trendUp !== null || comparison) && (
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ mt: 1 }}>
|
||||
{trendUp !== null && (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={0.25}
|
||||
sx={{ px: 0.75, py: 0.125, borderRadius: 1, bgcolor: trendUp ? 'success.lighter' : 'error.lighter', color: trendUp ? 'success.dark' : 'error.dark' }}
|
||||
>
|
||||
{trendUp ? <ArrowUpwardRoundedIcon sx={{ fontSize: 14 }} /> : <ArrowDownwardRoundedIcon sx={{ fontSize: 14 }} />}
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>{Math.abs(trend)}%</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
{comparison && <Typography variant="caption" color="text.secondary">{comparison}</Typography>}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
{Icon && (
|
||||
<Avatar variant="rounded" sx={{ bgcolor: 'grey.100', color: 'grey.600', width: 42, height: 42 }}>
|
||||
<Icon fontSize="small" />
|
||||
</Avatar>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{insight && (
|
||||
<Stack direction="row" spacing={0.5} alignItems="flex-start" sx={{ mt: 1.25 }}>
|
||||
<AutoAwesomeOutlinedIcon sx={{ fontSize: 13, color: '#EA580C', mt: 0.15, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: 'grey.600', lineHeight: 1.3 }}>{insight}</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{details.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Divider sx={{ mt: 1.5, mb: 1.25 }} />
|
||||
<Stack direction="row" spacing={2} divider={<Divider orientation="vertical" flexItem />}>
|
||||
{details.map((d) => (
|
||||
<Box key={d.label} sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.2 }} noWrap>{d.value}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>{d.label}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
57
src/components/KpiStrip.jsx
Normal file
57
src/components/KpiStrip.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Card, Box, Stack, Typography, Divider } from '@mui/material';
|
||||
|
||||
// ==============================|| KPI STRIP ||============================== //
|
||||
// Operational KPI row shared across dashboard & table pages (Orders, Deliveries…).
|
||||
// Premium analytics styling: tall cells, prominent values, subtle separators + hover.
|
||||
// items: [{ label, value, color, icon }].
|
||||
|
||||
export default function KpiStrip({ items = [], sx }) {
|
||||
return (
|
||||
<Card sx={{ mb: 2, borderRadius: 3, ...sx }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="stretch"
|
||||
divider={<Divider orientation="vertical" flexItem sx={{ borderColor: 'grey.100' }} />}
|
||||
sx={{ overflowX: 'auto', flexWrap: { xs: 'wrap', lg: 'nowrap' } }}
|
||||
>
|
||||
{items.map((it) => {
|
||||
const Icon = it.icon;
|
||||
// Keep short metrics at full prominence; step long values (e.g. ₹3,84,200) down so
|
||||
// nothing truncates while six cards share one full-width row on laptop screens.
|
||||
const len = String(it.value).length;
|
||||
const valueSize = len <= 5 ? '2.125rem' : len <= 7 ? '1.85rem' : '1.6rem';
|
||||
return (
|
||||
<Stack
|
||||
key={it.label}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 180,
|
||||
minHeight: 100,
|
||||
px: 3,
|
||||
py: '18px',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
transition: 'background-color .15s ease',
|
||||
'&:hover': { bgcolor: 'grey.50' }
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}>
|
||||
<Typography sx={{ fontSize: '0.9375rem', fontWeight: 600, color: 'text.secondary', lineHeight: 1.25 }}>
|
||||
{it.label}
|
||||
</Typography>
|
||||
{Icon && (
|
||||
<Box sx={{ width: 34, height: 34, borderRadius: 1.5, bgcolor: 'grey.100', color: it.color || 'grey.600', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon sx={{ fontSize: 20 }} />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography sx={{ fontSize: valueSize, fontWeight: 800, lineHeight: 1.1, color: it.color || 'grey.900' }} noWrap>
|
||||
{it.value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
// ==============================|| DOORMILE WORDMARK LOGO ||============================== //
|
||||
// Uses the brand wordmark asset (white PNG). `onDark` shows it as-is on dark/red
|
||||
// surfaces; on light surfaces it is recoloured to near-black. `compact` (e.g. the
|
||||
// The asset is the Doormile wordmark as a transparent-background mask. We paint it with the
|
||||
// brand red on light surfaces and white on dark/red surfaces using a CSS mask, so a single
|
||||
// asset renders crisply on both the white header and the red sidebar. `compact` (e.g. the
|
||||
// collapsed sidebar) renders just the square "D" badge, since the wordmark won't fit.
|
||||
|
||||
const LOGO_SRC = '/Doormile-logo.png';
|
||||
const ASPECT = 748 / 100; // intrinsic ratio of the wordmark asset
|
||||
|
||||
export default function Logo({ onDark = false, compact = false, height = 26, sx }) {
|
||||
if (compact) {
|
||||
@@ -35,15 +37,21 @@ export default function Logo({ onDark = false, compact = false, height = 26, sx
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ...sx }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={LOGO_SRC}
|
||||
alt="Doormile"
|
||||
aria-label="Doormile"
|
||||
role="img"
|
||||
sx={{
|
||||
height,
|
||||
width: 'auto',
|
||||
display: 'block',
|
||||
// The asset is white; on light surfaces recolour it to near-black so it stays visible.
|
||||
filter: onDark ? 'none' : 'brightness(0) saturate(100%)'
|
||||
width: height * ASPECT,
|
||||
flexShrink: 0,
|
||||
bgcolor: onDark ? '#FFFFFF' : '#C01227',
|
||||
WebkitMaskImage: `url(${LOGO_SRC})`,
|
||||
maskImage: `url(${LOGO_SRC})`,
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskRepeat: 'no-repeat',
|
||||
WebkitMaskSize: 'contain',
|
||||
maskSize: 'contain',
|
||||
WebkitMaskPosition: 'left center',
|
||||
maskPosition: 'left center'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
91
src/components/NetworkFlow.jsx
Normal file
91
src/components/NetworkFlow.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Card, Box, Stack, Typography, LinearProgress } from '@mui/material';
|
||||
import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded';
|
||||
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
|
||||
import { threeMile } from '@/data/mock';
|
||||
|
||||
// ==============================|| OPERATIONS LAYER — FIRST → MID → LAST ||============================== //
|
||||
// Each stage: throughput · utilization · exception count · bottleneck · AI recommendation.
|
||||
|
||||
const statusFor = (onTime) =>
|
||||
onTime >= 98 ? { label: 'On Track', color: 'success.main' } : onTime >= 95 ? { label: 'Watch', color: 'warning.main' } : { label: 'At Risk', color: 'error.main' };
|
||||
|
||||
function FlowNode({ stage }) {
|
||||
const status = statusFor(stage.onTime);
|
||||
const utilColor = stage.utilization > 85 ? 'error' : stage.utilization > 70 ? 'warning' : 'success';
|
||||
return (
|
||||
<Card variant="outlined" sx={{ flex: 1, p: 2, border: '1px solid', borderColor: 'grey.200', boxShadow: 'none', height: '100%' }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'grey.900' }}>{stage.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{stage.subtitle}</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ flexShrink: 0 }}>
|
||||
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: status.color }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: status.color }}>{status.label}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={2.5} alignItems="flex-end" sx={{ mt: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, color: 'grey.900', lineHeight: 1 }}>{stage.metric}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{stage.metricLabel}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.1 }}>{stage.throughput.toLocaleString('en-IN')}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">throughput</Typography>
|
||||
</Box>
|
||||
<Box sx={{ ml: 'auto', textAlign: 'right' }}>
|
||||
<Stack direction="row" spacing={0.4} alignItems="center" justifyContent="flex-end" sx={{ color: stage.exceptions > 0 ? 'error.main' : 'success.main' }}>
|
||||
<WarningAmberRoundedIcon sx={{ fontSize: 15 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{stage.exceptions}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">exceptions</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* utilization */}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.25 }}>
|
||||
<Typography variant="caption" color="text.secondary">Utilization</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'grey.800' }}>{stage.utilization}%</Typography>
|
||||
</Stack>
|
||||
<LinearProgress variant="determinate" value={stage.utilization} color={utilColor} sx={{ height: 6, borderRadius: 3 }} />
|
||||
</Box>
|
||||
|
||||
{/* bottleneck */}
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 1 }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: stage.bottleneck ? 'error.main' : 'success.main', flexShrink: 0 }} />
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{stage.bottleneck ? `Bottleneck · ${stage.bottleneck}` : 'No active bottleneck'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* AI recommendation */}
|
||||
<Stack direction="row" spacing={0.75} alignItems="flex-start" sx={{ mt: 1, p: 1, borderRadius: 1.5, bgcolor: 'grey.50' }}>
|
||||
<AutoAwesomeOutlinedIcon sx={{ fontSize: 15, color: '#EA580C', mt: 0.1, flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ color: 'grey.700', lineHeight: 1.35 }}>{stage.aiRec}</Typography>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkFlow() {
|
||||
return (
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={0} alignItems="stretch">
|
||||
{threeMile.map((stage, i) => (
|
||||
<Stack key={stage.key} direction={{ xs: 'column', md: 'row' }} alignItems="stretch" sx={{ flex: 1 }}>
|
||||
<Box sx={{ width: '100%', display: 'flex' }}>
|
||||
<FlowNode stage={stage} />
|
||||
</Box>
|
||||
{i < threeMile.length - 1 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'grey.400', px: { md: 1 }, py: { xs: 0.25, md: 0 }, transform: { xs: 'rotate(90deg)', md: 'none' } }}>
|
||||
<ArrowRightAltRoundedIcon />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -19,9 +19,6 @@ export default function PageHeader({ title, breadcrumbs = [], action }) {
|
||||
</Typography>
|
||||
{breadcrumbs.length > 0 && (
|
||||
<Breadcrumbs separator={<NavigateNextIcon fontSize="small" />} sx={{ mt: 0.5 }}>
|
||||
<Link component={RouterLink} to="/dashboard" underline="hover" color="text.secondary" variant="caption">
|
||||
Home
|
||||
</Link>
|
||||
{breadcrumbs.map((b, i) =>
|
||||
b.to && i < breadcrumbs.length - 1 ? (
|
||||
<Link key={i} component={RouterLink} to={b.to} underline="hover" color="text.secondary" variant="caption">
|
||||
|
||||
46
src/components/PageToolbar.jsx
Normal file
46
src/components/PageToolbar.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Stack, TextField, MenuItem, InputAdornment, Box } from '@mui/material';
|
||||
import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined';
|
||||
|
||||
// ==============================|| PAGE TOOLBAR ||============================== //
|
||||
// Dense search + filters + actions row for table pages. filters: [{ value, onChange,
|
||||
// label, options:[{value,label}] }]. `search`/`onSearch` wire the search box; `actions`
|
||||
// renders on the right.
|
||||
|
||||
export default function PageToolbar({ search, onSearch, searchPlaceholder = 'Search…', filters = [], actions, sx }) {
|
||||
return (
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={1.25}
|
||||
alignItems={{ md: 'center' }}
|
||||
sx={{ p: 1.5, borderBottom: '1px solid', borderColor: 'divider', ...sx }}
|
||||
>
|
||||
{onSearch && (
|
||||
<TextField
|
||||
size="small"
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
sx={{ minWidth: { xs: '100%', md: 280 } }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchOutlinedIcon fontSize="small" /></InputAdornment> }}
|
||||
/>
|
||||
)}
|
||||
{filters.map((f) => (
|
||||
<TextField
|
||||
key={f.label}
|
||||
select
|
||||
size="small"
|
||||
label={f.label}
|
||||
value={f.value}
|
||||
onChange={(e) => f.onChange(e.target.value)}
|
||||
sx={{ minWidth: { xs: '100%', md: f.width || 160 } }}
|
||||
>
|
||||
{f.options.map((o) => (
|
||||
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
))}
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
{actions}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
85
src/components/ProcessTracker.jsx
Normal file
85
src/components/ProcessTracker.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Card, Box, Stack, Typography, LinearProgress } from '@mui/material';
|
||||
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined';
|
||||
import VerifiedUserOutlinedIcon from '@mui/icons-material/VerifiedUserOutlined';
|
||||
import HubOutlinedIcon from '@mui/icons-material/HubOutlined';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
|
||||
import { commandPipeline } from '@/data/mock';
|
||||
|
||||
// ==============================|| END-TO-END PROCESS TRACKER ||============================== //
|
||||
// Premium vertical timeline: connected progress rail, status-coloured nodes, completion %,
|
||||
// KPI badges, hover interactions.
|
||||
|
||||
const ICON = {
|
||||
order: ReceiptLongOutlinedIcon, trust: VerifiedUserOutlinedIcon, hub: HubOutlinedIcon, fleet: LocalShippingOutlinedIcon,
|
||||
ai: AutoAwesomeOutlinedIcon, rider: TwoWheelerOutlinedIcon, track: MyLocationOutlinedIcon, done: CheckRoundedIcon
|
||||
};
|
||||
const TONE = {
|
||||
done: { node: '#00A854', ring: 'rgba(0,168,84,0.16)', bar: 'success' },
|
||||
active: { node: '#C01227', ring: 'rgba(192,18,39,0.16)', bar: 'primary' },
|
||||
pending: { node: '#C7CDD6', ring: 'rgba(140,148,162,0.16)', bar: 'inherit' }
|
||||
};
|
||||
|
||||
export default function ProcessTracker({ steps = commandPipeline, title = 'End-to-End Flow' }) {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2.5, py: 1.75, borderBottom: '1px solid', borderColor: 'grey.100' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>{title}</Typography>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Box className="live-pulse" sx={{ color: 'success.main' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'grey.600' }}>LIVE</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ p: 2.5, pt: 1.5 }}>
|
||||
{steps.map((s, i) => {
|
||||
const Icon = s.status === 'done' ? CheckRoundedIcon : ICON[s.icon] || ReceiptLongOutlinedIcon;
|
||||
const tone = TONE[s.status] || TONE.pending;
|
||||
const last = i === steps.length - 1;
|
||||
const nextDone = !last && steps[i + 1] && steps[i].status === 'done';
|
||||
return (
|
||||
<Stack
|
||||
key={s.key}
|
||||
direction="row"
|
||||
spacing={1.75}
|
||||
sx={{ minHeight: last ? 'auto' : 58, borderRadius: 1.5, px: 1, mx: -1, transition: 'background .12s', '&:hover': { bgcolor: 'grey.50' } }}
|
||||
>
|
||||
{/* rail */}
|
||||
<Stack alignItems="center" sx={{ width: 30, pt: 0.75 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 30, height: 30, borderRadius: '50%', flexShrink: 0,
|
||||
bgcolor: tone.node, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: `0 0 0 4px ${tone.ring}`
|
||||
}}
|
||||
>
|
||||
<Icon sx={{ fontSize: 16 }} />
|
||||
</Box>
|
||||
{!last && <Box sx={{ flexGrow: 1, width: 2.5, my: 0.5, minHeight: 18, borderRadius: 2, bgcolor: nextDone ? 'success.main' : 'grey.200' }} />}
|
||||
</Stack>
|
||||
|
||||
{/* content */}
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0, pt: 0.5, pb: last ? 0.5 : 1.25 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'grey.900' }} noWrap>{s.label}</Typography>
|
||||
<Box sx={{ flexShrink: 0, px: 0.9, py: 0.15, borderRadius: 1, bgcolor: 'grey.100' }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 800, color: 'grey.800' }}>{s.count}</Typography>
|
||||
<Typography component="span" variant="caption" color="text.secondary" sx={{ ml: 0.4 }}>{s.sub}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mt: 0.6 }}>
|
||||
<LinearProgress variant="determinate" value={s.pct} color={tone.bar === 'inherit' ? 'inherit' : tone.bar} sx={{ flexGrow: 1, height: 5, borderRadius: 3, bgcolor: 'grey.100', ...(tone.bar === 'inherit' && { '& .MuiLinearProgress-bar': { bgcolor: 'grey.400' } }) }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'grey.600', minWidth: 30, textAlign: 'right' }}>{s.pct}%</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
41
src/components/TabbedWorkspace.jsx
Normal file
41
src/components/TabbedWorkspace.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Box, Tabs, Tab } from '@mui/material';
|
||||
|
||||
// ==============================|| TABBED WORKSPACE ||============================== //
|
||||
// Merges several existing pages into one workspace under a single nav item. The active tab is
|
||||
// synced to the `?tab=` query param (deep-linkable, keeps the sidebar item active) and only the
|
||||
// active panel is mounted — so heavy panels (e.g. the live map) don't run in the background.
|
||||
// tabs: [{ key, label, element }].
|
||||
|
||||
export default function TabbedWorkspace({ tabs }) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const keys = tabs.map((t) => t.key);
|
||||
const idx = Math.max(0, keys.indexOf(params.get('tab')));
|
||||
|
||||
const setIdx = (i) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set('tab', keys[i]);
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs
|
||||
value={idx}
|
||||
onChange={(_, v) => setIdx(v)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{ mb: 2, borderBottom: 1, borderColor: 'divider', '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9rem' } }}
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<Tab key={t.key} label={t.label} />
|
||||
))}
|
||||
</Tabs>
|
||||
<Box>{tabs[idx].element}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
118
src/components/tracking/ActiveRidersList.jsx
Normal file
118
src/components/tracking/ActiveRidersList.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card, Box, Stack, Typography, Chip, LinearProgress } from '@mui/material';
|
||||
import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined';
|
||||
|
||||
import './tracking.css';
|
||||
import { vehicleTypes, vehicleIconComponents } from './vehicleMarker';
|
||||
|
||||
// ==============================|| ACTIVE RIDERS LIST ||============================== //
|
||||
// Right-rail fleet roster — every active rider in one compact, filterable, scrollable list so a
|
||||
// dispatcher can switch focus instantly without leaving the map. Selecting drives map + timeline.
|
||||
|
||||
const liveOf = (d) => (d.status === 'Delivered' ? 'Completed' : d.etaStatus !== 'on-time' ? 'Delayed' : 'Live');
|
||||
const PILL = {
|
||||
Live: { bg: '#E3F6EC', fg: '#00773B' },
|
||||
Delayed: { bg: '#FEEAE9', fg: '#A82216' },
|
||||
Completed: { bg: '#EEF1F5', fg: '#475569' }
|
||||
};
|
||||
|
||||
const FILTERS = [
|
||||
{ key: 'all', label: 'All', test: () => true },
|
||||
{ key: 'live', label: 'Live', test: (d) => liveOf(d) === 'Live' },
|
||||
{ key: 'delayed', label: 'Delayed', test: (d) => liveOf(d) === 'Delayed' },
|
||||
{ key: 'completed', label: 'Completed', test: (d) => liveOf(d) === 'Completed' },
|
||||
{ key: 'priority', label: 'High Priority', test: (d) => d.priority === 'high' || d.priority === 'express' }
|
||||
];
|
||||
|
||||
function RiderCard({ d, selected, onSelect }) {
|
||||
const vt = vehicleTypes[d.vehicle] || vehicleTypes.Bike;
|
||||
const Glyph = vehicleIconComponents[d.vehicle];
|
||||
const state = liveOf(d);
|
||||
const pill = PILL[state];
|
||||
const barColor = state === 'Completed' ? 'success' : state === 'Delayed' ? 'error' : 'info';
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onSelect(d.id)}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
borderRadius: 2,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid',
|
||||
borderColor: selected ? 'primary.main' : 'grey.200',
|
||||
bgcolor: selected ? 'rgba(192,18,39,0.04)' : 'background.paper',
|
||||
transition: 'border-color .12s, background .12s',
|
||||
'&:hover': { borderColor: 'primary.light', bgcolor: 'grey.50' }
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box sx={{ width: 28, height: 28, borderRadius: 1.5, bgcolor: vt.color, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
{Glyph ? <Glyph sx={{ fontSize: 16 }} /> : null}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={0.5}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.main', lineHeight: 1.1 }}>{d.id}</Typography>
|
||||
<Box component="span" sx={{ px: 0.75, py: 0.15, borderRadius: 1, bgcolor: pill.bg, color: pill.fg, fontSize: 10, fontWeight: 800, flexShrink: 0 }}>{state}</Box>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>{d.rider} · {d.vehicle}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 0.75 }}>
|
||||
<LinearProgress variant="determinate" value={d.progress} color={barColor} sx={{ flexGrow: 1, height: 5, borderRadius: 3, bgcolor: 'grey.100' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'grey.600', minWidth: 30, textAlign: 'right' }}>{d.progress}%</Typography>
|
||||
<Stack direction="row" spacing={0.25} alignItems="center" sx={{ flexShrink: 0, color: pill.fg }}>
|
||||
<ScheduleOutlinedIcon sx={{ fontSize: 13 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>{d.eta}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ActiveRidersList({ deliveries, selectedId, onSelect }) {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const active = FILTERS.find((f) => f.key === filter) || FILTERS[0];
|
||||
const rows = useMemo(
|
||||
() => deliveries.filter(active.test).sort((a, b) => Number(liveOf(b) === 'Delayed') - Number(liveOf(a) === 'Delayed')),
|
||||
[deliveries, active]
|
||||
);
|
||||
const count = (f) => deliveries.filter(f.test).length;
|
||||
|
||||
return (
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<Box sx={{ px: 2, pt: 1.75, pb: 1 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>Active Riders</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{rows.length} shown</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.75} sx={{ flexWrap: 'wrap', gap: 0.75 }}>
|
||||
{FILTERS.map((f) => (
|
||||
<Chip
|
||||
key={f.key}
|
||||
size="small"
|
||||
label={`${f.label} ${count(f)}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
variant={filter === f.key ? 'filled' : 'outlined'}
|
||||
sx={{
|
||||
height: 24, fontWeight: 600, cursor: 'pointer',
|
||||
...(filter === f.key
|
||||
? { bgcolor: 'primary.main', color: '#fff', '&:hover': { bgcolor: 'primary.dark' } }
|
||||
: { borderColor: 'grey.300', color: 'grey.600' })
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1, minHeight: 0, overflowY: 'auto', px: 2, pb: 1.5 }}>
|
||||
<Stack spacing={1}>
|
||||
{rows.map((d) => (
|
||||
<RiderCard key={d.id} d={d} selected={d.id === selectedId} onSelect={onSelect} />
|
||||
))}
|
||||
{rows.length === 0 && <Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 3 }}>No riders match this filter.</Typography>}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
188
src/components/tracking/DeliveryQueue.jsx
Normal file
188
src/components/tracking/DeliveryQueue.jsx
Normal file
@@ -0,0 +1,188 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
366
src/components/tracking/FleetMap.jsx
Normal file
366
src/components/tracking/FleetMap.jsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Polyline, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet.markercluster';
|
||||
import { Box, Card, Stack, Typography, Button, IconButton, Tooltip, LinearProgress, Collapse } from '@mui/material';
|
||||
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded';
|
||||
import CallOutlinedIcon from '@mui/icons-material/CallOutlined';
|
||||
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
||||
import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded';
|
||||
import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded';
|
||||
import ExpandLessRoundedIcon from '@mui/icons-material/ExpandLessRounded';
|
||||
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css';
|
||||
import './tracking.css';
|
||||
|
||||
import { vehicleIcon, endpointIcon, clusterIcon, vehicleIconComponents } from './vehicleMarker';
|
||||
import { pointAlongRoute } from '@/utils/geo';
|
||||
import { vehicleTypes, fleetTotals, cityCenters, networkCorridors } from '@/data/mock';
|
||||
|
||||
const ll = (p) => (Array.isArray(p) ? p : [p.lat, p.lng]);
|
||||
|
||||
// ---- inter-city corridors: gentle arcs between the three metros + active-load chips ----
|
||||
function arc(a, b, bend = 0.16) {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const ctrl = [(a[0] + b[0]) / 2 - dy * bend, (a[1] + b[1]) / 2 + dx * bend];
|
||||
const pts = [];
|
||||
for (let i = 0; i <= 18; i += 1) {
|
||||
const t = i / 18;
|
||||
const u = 1 - t;
|
||||
pts.push([u * u * a[0] + 2 * u * t * ctrl[0] + t * t * b[0], u * u * a[1] + 2 * u * t * ctrl[1] + t * t * b[1]]);
|
||||
}
|
||||
const mid = [0.25 * a[0] + 0.5 * ctrl[0] + 0.25 * b[0], 0.25 * a[1] + 0.5 * ctrl[1] + 0.25 * b[1]];
|
||||
return { pts, mid };
|
||||
}
|
||||
|
||||
const laneChip = (count, from, to) =>
|
||||
L.divIcon({
|
||||
html: `<div class="lane-chip"><span class="lane-chip-n">${count}</span><span class="lane-chip-l">${from}–${to}</span></div>`,
|
||||
className: 'lane-chip-divicon',
|
||||
iconSize: [0, 0]
|
||||
});
|
||||
|
||||
// Precomputed once (cities & counts are static) so the live re-render never recomputes geometry.
|
||||
const CORRIDORS = networkCorridors.map((c) => {
|
||||
const { pts, mid } = arc([cityCenters[c.from].lat, cityCenters[c.from].lng], [cityCenters[c.to].lat, cityCenters[c.to].lng]);
|
||||
return { ...c, pts, mid, chip: laneChip(c.active, c.from.slice(0, 3).toUpperCase(), c.to.slice(0, 3).toUpperCase()) };
|
||||
});
|
||||
|
||||
function NetworkCorridors() {
|
||||
return (
|
||||
<>
|
||||
{CORRIDORS.map((c) => (
|
||||
<Fragment key={`${c.from}-${c.to}`}>
|
||||
<Polyline positions={c.pts} pathOptions={{ color: '#94A3B8', weight: 1.5, opacity: 0.5, dashArray: '2 7', lineCap: 'round' }} interactive={false} />
|
||||
<Marker position={c.mid} icon={c.chip} interactive={false} keyboard={false} />
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- ambient fleet rendered through a Leaflet marker-cluster group ----
|
||||
function FleetClusters({ vehicles }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const group = L.markerClusterGroup({
|
||||
iconCreateFunction: (c) => clusterIcon(c.getChildCount()),
|
||||
showCoverageOnHover: false,
|
||||
maxClusterRadius: 55,
|
||||
spiderfyOnMaxZoom: true
|
||||
});
|
||||
vehicles.forEach((v) => {
|
||||
const m = L.marker([v.lat, v.lng], { icon: vehicleIcon({ type: v.type, bearing: v.bearing }) });
|
||||
m.bindPopup(`<strong>${v.id}</strong><br/>${v.type} · ${v.rider}<br/>${v.status === 'idle' ? 'Idle' : 'On trip'}`);
|
||||
group.addLayer(m);
|
||||
});
|
||||
map.addLayer(group);
|
||||
return () => map.removeLayer(group);
|
||||
}, [map, vehicles]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- imperative effects: size correctly, frame the three cities, follow the selection ----
|
||||
// Refit only when the *selection* changes (not on every live-progress tick) so the map
|
||||
// doesn't constantly re-zoom while vehicles move.
|
||||
function MapEffects({ selectedId, selectedRoute, selectedPoint, homeBounds }) {
|
||||
const map = useMap();
|
||||
const routeRef = useRef(selectedRoute);
|
||||
routeRef.current = selectedRoute;
|
||||
const pointRef = useRef(selectedPoint);
|
||||
pointRef.current = selectedPoint;
|
||||
const prevSel = useRef(null);
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => {
|
||||
// The map mounts inside a flex container that may size after init — settle the size,
|
||||
// then perform a single gentle onboarding glide from the wide framing into the network.
|
||||
map.invalidateSize();
|
||||
map.flyToBounds(homeBounds, { padding: [50, 50], duration: 1.6, easeLinearity: 0.25 });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!mounted.current) { mounted.current = true; prevSel.current = selectedId; return; }
|
||||
map.invalidateSize();
|
||||
if (selectedId) {
|
||||
if (prevSel.current && prevSel.current !== selectedId && pointRef.current) {
|
||||
// switching between riders → keep the current zoom, just recenter (fleet-monitor UX)
|
||||
map.setView(pointRef.current, map.getZoom(), { animate: true });
|
||||
} else if (routeRef.current?.length) {
|
||||
// first focus from the network view → frame the route once
|
||||
map.fitBounds(routeRef.current, { padding: [60, 60], maxZoom: 14, duration: 0.8 });
|
||||
}
|
||||
} else {
|
||||
map.flyToBounds(homeBounds, { padding: [50, 50], duration: 0.9 });
|
||||
}
|
||||
prevSel.current = selectedId;
|
||||
}, [selectedId, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function FleetMap({ vehicles, deliveries, selectedId, onSelect, lastUpdated, actions }) {
|
||||
const [map, setMap] = useState(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
useEffect(() => { setExpanded(false); }, [selectedId]); // new rider opens compact
|
||||
|
||||
const tracked = useMemo(() => deliveries.filter((d) => d.route?.length), [deliveries]);
|
||||
const selected = useMemo(() => deliveries.find((d) => d.id === selectedId) || null, [deliveries, selectedId]);
|
||||
const focus = Boolean(selected); // Selected Tracking Mode
|
||||
const selPos = useMemo(() => (selected ? pointAlongRoute(selected.route, selected.progressExact ?? selected.progress) : null), [selected]);
|
||||
|
||||
// Cache vehicle icons by a coarse signature so the live animation only calls setLatLng
|
||||
// (smooth glide) instead of rebuilding the marker DOM every frame. Bearing is bucketed so
|
||||
// the icon stays stable along straight stretches.
|
||||
const iconCache = useRef(new Map());
|
||||
const getVehicleIcon = (d, bearing, isSel) => {
|
||||
const delayed = d.etaStatus !== 'on-time';
|
||||
const bucket = Math.round(bearing / 6) * 6;
|
||||
const key = `${d.vehicle}|${isSel}|${delayed}|${bucket}`;
|
||||
let icon = iconCache.current.get(key);
|
||||
if (!icon) {
|
||||
icon = vehicleIcon({ type: d.vehicle, bearing: bucket, selected: isSel, delayed });
|
||||
iconCache.current.set(key, icon);
|
||||
}
|
||||
return icon;
|
||||
};
|
||||
|
||||
// The service area is exactly the three cities — used for the initial fit, the recenter
|
||||
// control and the pan limits, so the operator never loses context.
|
||||
const homeBounds = useMemo(() => L.latLngBounds(Object.values(cityCenters).map((c) => [c.lat, c.lng])), []);
|
||||
const introBounds = useMemo(() => homeBounds.pad(0.35), [homeBounds]); // slightly wider first frame for the glide
|
||||
const maxBounds = useMemo(() => homeBounds.pad(0.5), [homeBounds]);
|
||||
|
||||
const recenter = () => map && map.fitBounds(homeBounds, { padding: [50, 50] });
|
||||
const trackRider = () => { if (map && selPos?.point) map.flyTo(selPos.point, Math.max(map.getZoom(), 14), { duration: 0.8 }); };
|
||||
|
||||
const legend = [
|
||||
{ label: 'Bike', type: 'Bike', color: vehicleTypes.Bike.color, count: fleetTotals.Bike },
|
||||
{ label: 'Auto', type: 'Auto', color: vehicleTypes.Auto.color, count: fleetTotals.Auto },
|
||||
{ label: 'Truck', type: 'Truck', color: vehicleTypes.Truck.color, count: fleetTotals.Truck },
|
||||
{ label: 'Van', type: 'Van', color: vehicleTypes.Van.color, count: fleetTotals.Van },
|
||||
{ label: 'Delayed', color: '#F04134', count: fleetTotals.delayed }
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', height: '100%', width: '100%', borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'grey.200' }}>
|
||||
<MapContainer
|
||||
ref={setMap}
|
||||
bounds={introBounds}
|
||||
boundsOptions={{ padding: [40, 40] }}
|
||||
minZoom={5}
|
||||
maxZoom={15}
|
||||
maxBounds={maxBounds}
|
||||
maxBoundsViscosity={0.85}
|
||||
zoomControl
|
||||
scrollWheelZoom
|
||||
zoomSnap={0.5}
|
||||
zoomDelta={0.5}
|
||||
wheelPxPerZoomLevel={140}
|
||||
wheelDebounceTime={45}
|
||||
bounceAtZoomLimits={false}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© OpenStreetMap © CARTO'
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
/>
|
||||
<MapEffects selectedId={selectedId} selectedRoute={selected?.route} selectedPoint={selPos?.point} homeBounds={homeBounds} />
|
||||
{/* Selected Tracking Mode: hide the ambient network so only the chosen shipment remains */}
|
||||
{!focus && <NetworkCorridors />}
|
||||
{!focus && <FleetClusters vehicles={vehicles} />}
|
||||
|
||||
{/* selected route: white casing → completed (green) under remaining (blue), clean round caps */}
|
||||
{selected && selPos && (
|
||||
<>
|
||||
<Polyline positions={selected.route} pathOptions={{ color: '#ffffff', weight: 7, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }} />
|
||||
<Polyline positions={selPos.remaining} pathOptions={{ color: '#2563EB', weight: 3.5, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }} />
|
||||
<Polyline positions={selPos.completed} pathOptions={{ color: '#16A34A', weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }} />
|
||||
<Marker position={ll(selected.pickup)} icon={endpointIcon('#16A34A')} />
|
||||
<Marker position={ll(selected.drop)} icon={endpointIcon('#EF4444')} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* tracked shipment vehicles (always individually visible & clickable) */}
|
||||
{tracked.map((d) => {
|
||||
const pos = pointAlongRoute(d.route, d.progressExact ?? d.progress);
|
||||
return (
|
||||
<Marker
|
||||
key={d.id}
|
||||
position={pos.point}
|
||||
icon={getVehicleIcon(d, pos.bearing, d.id === selectedId)}
|
||||
opacity={focus && d.id !== selectedId ? 0.7 : 1}
|
||||
eventHandlers={{ click: () => onSelect(d.id) }}
|
||||
zIndexOffset={d.id === selectedId ? 1000 : 500}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
|
||||
{/* live status — top-left */}
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0.75}
|
||||
alignItems="center"
|
||||
sx={{ position: 'absolute', top: 12, left: 52, zIndex: 1000, bgcolor: 'rgba(255,255,255,0.94)', px: 1.25, py: 0.5, borderRadius: 1.5, boxShadow: 1 }}
|
||||
>
|
||||
<Box className="live-pulse" sx={{ color: 'success.main' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'grey.800' }}>LIVE</Typography>
|
||||
<Typography variant="caption" color="text.secondary">· updated {lastUpdated}</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* recenter control — top-right (below default zoom on left) */}
|
||||
<Tooltip title="Center on fleet" placement="left">
|
||||
<IconButton
|
||||
onClick={recenter}
|
||||
size="small"
|
||||
sx={{ position: 'absolute', top: 12, right: 12, zIndex: 1000, bgcolor: 'rgba(255,255,255,0.94)', boxShadow: 1, '&:hover': { bgcolor: '#fff' } }}
|
||||
>
|
||||
<MyLocationOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* legend — bottom-right (hidden in Selected Tracking Mode to reduce clutter) */}
|
||||
{!focus && (
|
||||
<Card sx={{ position: 'absolute', bottom: 24, right: 12, zIndex: 1000, px: 1.5, py: 1, boxShadow: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'grey.700', display: 'block', mb: 0.5 }}>Fleet on map</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{legend.map((l) => {
|
||||
const Glyph = l.type ? vehicleIconComponents[l.type] : null;
|
||||
return (
|
||||
<Stack key={l.label} direction="row" spacing={1} alignItems="center">
|
||||
<Box sx={{ width: 20, display: 'flex', justifyContent: 'center' }}>
|
||||
{Glyph ? (
|
||||
<Glyph sx={{ fontSize: 16, color: l.color }} />
|
||||
) : (
|
||||
<Box sx={{ width: 11, height: 11, borderRadius: '50%', bgcolor: l.color }} />
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: 'grey.700', minWidth: 46 }}>{l.label}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: l.color }}>{l.count}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* compact, expandable tracking card — top-left, never covers the route */}
|
||||
{selected && (
|
||||
<Card
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 76,
|
||||
left: 12,
|
||||
zIndex: 1000,
|
||||
width: { xs: 244, sm: 288, lg: 304 },
|
||||
maxWidth: 'calc(100% - 24px)',
|
||||
boxShadow: 4,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
{/* identity row */}
|
||||
<Stack direction="row" alignItems="flex-start" spacing={1}>
|
||||
<Box sx={{ width: 30, height: 30, borderRadius: 1.5, bgcolor: (vehicleTypes[selected.vehicle] || {}).color, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
{(() => { const G = vehicleIconComponents[selected.vehicle]; return G ? <G sx={{ fontSize: 18 }} /> : null; })()}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Typography sx={{ fontWeight: 800, color: 'primary.main', fontSize: '0.95rem', lineHeight: 1.1 }} noWrap>{selected.id}</Typography>
|
||||
<Box className="live-pulse" sx={{ color: 'success.main', width: 7, height: 7, flexShrink: 0 }} />
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>{selected.rider} · {selected.vehicle}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={() => onSelect(null)} sx={{ m: -0.5, flexShrink: 0 }} aria-label="Close"><CloseRoundedIcon fontSize="small" /></IconButton>
|
||||
</Stack>
|
||||
|
||||
{/* compact metrics */}
|
||||
<Stack direction="row" sx={{ mt: 1.25 }}>
|
||||
{[
|
||||
{ l: 'Progress', v: `${selected.progress}%`, c: 'grey.900' },
|
||||
{ l: 'ETA', v: selected.eta, c: 'grey.900' },
|
||||
{ l: 'Delay', v: selected.etaStatus === 'on-time' ? 'On time' : `+${selected.delayMin} min`, c: selected.etaStatus === 'on-time' ? 'success.dark' : 'error.dark' }
|
||||
].map((m) => (
|
||||
<Box key={m.l} sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.2 }}>{m.l}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, color: m.c }} noWrap>{m.v}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* expanded detail */}
|
||||
<Collapse in={expanded} timeout={260} unmountOnExit>
|
||||
<Box sx={{ pt: 1.5, mt: 1.5, borderTop: '1px solid', borderColor: 'grey.100' }}>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ minWidth: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>{selected.origin}</Typography>
|
||||
<ArrowRightAltRoundedIcon sx={{ fontSize: 16, color: 'grey.400', flexShrink: 0 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }} noWrap>{selected.destination}</Typography>
|
||||
</Stack>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={selected.progress}
|
||||
sx={{ mt: 1.25, height: 7, borderRadius: 4, bgcolor: 'grey.100', '& .MuiLinearProgress-bar': { borderRadius: 4, bgcolor: selected.etaStatus === 'on-time' ? 'info.main' : '#F04134' } }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
||||
Last update · {lastUpdated}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 1.25 }}>
|
||||
<Tooltip title="Call rider">
|
||||
<IconButton size="small" onClick={() => actions?.call(selected)} sx={{ border: '1px solid', borderColor: 'grey.300' }}><CallOutlinedIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Message rider">
|
||||
<IconButton size="small" onClick={() => actions?.message(selected)} sx={{ border: '1px solid', borderColor: 'grey.300' }}><ChatBubbleOutlineRoundedIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Tooltip title="Center on rider">
|
||||
<IconButton size="small" onClick={trackRider} sx={{ border: '1px solid', borderColor: 'grey.300' }}><MyLocationOutlinedIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<Button fullWidth size="small" variant="contained" disableElevation endIcon={<OpenInNewRoundedIcon sx={{ fontSize: 15 }} />} onClick={() => actions?.open(selected.id)} sx={{ mt: 1 }}>
|
||||
Shipment Details
|
||||
</Button>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
{/* expand / collapse toggle */}
|
||||
<Button
|
||||
fullWidth
|
||||
size="small"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
endIcon={expanded ? <ExpandLessRoundedIcon /> : <ExpandMoreRoundedIcon />}
|
||||
sx={{ mt: 1, fontWeight: 700, color: 'text.secondary' }}
|
||||
>
|
||||
{expanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
49
src/components/tracking/MiniMap.jsx
Normal file
49
src/components/tracking/MiniMap.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Polyline, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './tracking.css';
|
||||
import { vehicleIcon, endpointIcon } from './vehicleMarker';
|
||||
import { pointAlongRoute } from '@/utils/geo';
|
||||
|
||||
// ==============================|| MINI MAP — single-shipment route ||============================== //
|
||||
// A focused, non-clustered real map for one shipment: route (completed/remaining), pickup/drop pins,
|
||||
// and the live vehicle marker placed from `progress`. Reuses the same Leaflet layer as FleetMap so the
|
||||
// fake `MapPlaceholder` grid can be retired wherever a real per-shipment map is needed (Shipment 360 …).
|
||||
|
||||
const ll = (p) => (Array.isArray(p) ? p : [p.lat, p.lng]);
|
||||
|
||||
function Fit({ route }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.invalidateSize();
|
||||
if (route?.length) map.fitBounds(route, { padding: [30, 30], maxZoom: 14 });
|
||||
}, [map, route]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function MiniMap({ route = [], progress = 0, vehicle = 'Bike', pickup, drop, height = 300 }) {
|
||||
const hasRoute = route && route.length > 0;
|
||||
const pos = hasRoute ? pointAlongRoute(route, progress) : null;
|
||||
const center = pos ? pos.point : [12.9716, 77.5946];
|
||||
|
||||
return (
|
||||
<Box sx={{ height, width: '100%', borderRadius: 2, overflow: 'hidden', border: '1px solid', borderColor: 'grey.200' }}>
|
||||
<MapContainer center={center} zoom={12} zoomControl scrollWheelZoom={false} style={{ height: '100%', width: '100%' }}>
|
||||
<TileLayer attribution="© OpenStreetMap" url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" />
|
||||
<Fit route={route} />
|
||||
{pos && (
|
||||
<>
|
||||
<Polyline positions={pos.remaining} pathOptions={{ color: '#1D4ED8', weight: 5, opacity: 0.85 }} />
|
||||
<Polyline positions={pos.completed} pathOptions={{ color: '#00A854', weight: 5, opacity: 0.95 }} />
|
||||
<Marker position={pos.point} icon={vehicleIcon({ type: vehicle, bearing: pos.bearing })} />
|
||||
</>
|
||||
)}
|
||||
{pickup && <Marker position={ll(pickup)} icon={endpointIcon('#00A854')} />}
|
||||
{drop && <Marker position={ll(drop)} icon={endpointIcon('#F04134')} />}
|
||||
</MapContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
111
src/components/tracking/RiderTimeline.jsx
Normal file
111
src/components/tracking/RiderTimeline.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Box, Card, Stack, Typography, IconButton, Divider } from '@mui/material';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import PriorityHighRoundedIcon from '@mui/icons-material/PriorityHighRounded';
|
||||
import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded';
|
||||
|
||||
import './tracking.css';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import { vehicleTypes } from '@/data/mock';
|
||||
import { vehicleIconComponents } from './vehicleMarker';
|
||||
|
||||
// ==============================|| RIDER TIMELINE ||============================== //
|
||||
// Replaces the generic activity feed in Selected Tracking Mode — a focused, Uber/Swiggy-style
|
||||
// lifecycle for one shipment, derived live from its progress and delay state.
|
||||
|
||||
function buildStages(d) {
|
||||
const p = d.progress;
|
||||
const delayed = d.etaStatus !== 'on-time';
|
||||
const stages = [
|
||||
{ key: 'assigned', label: 'Shipment Assigned', time: '10:02 AM', done: true },
|
||||
{ key: 'picked', label: 'Picked Up', time: '10:11 AM', done: p >= 5 },
|
||||
{ key: 'checkpoint', label: 'Reached Checkpoint', time: '10:29 AM', done: p >= 34 }
|
||||
];
|
||||
if (delayed) {
|
||||
stages.push({ key: 'delay', label: 'Delay Detected', time: `+${d.delayMin} min vs SLA`, done: p >= 38, warn: true });
|
||||
}
|
||||
stages.push({ key: 'approaching', label: 'Approaching Destination', time: p >= 85 ? 'Arriving now' : `Est. ${d.eta}`, done: p >= 85 });
|
||||
stages.push({ key: 'delivered', label: 'Delivered', time: d.status === 'Delivered' ? d.eta : `ETA ${d.eta}`, done: p >= 100 });
|
||||
return stages;
|
||||
}
|
||||
|
||||
export default function RiderTimeline({ delivery: d, onClose }) {
|
||||
if (!d) return null;
|
||||
const stages = buildStages(d);
|
||||
const activeIdx = stages.findIndex((s) => !s.done); // first incomplete = current step
|
||||
const Glyph = vehicleIconComponents[d.vehicle];
|
||||
const vc = (vehicleTypes[d.vehicle] || {}).color;
|
||||
|
||||
return (
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* header */}
|
||||
<Box sx={{ px: 2, pt: 2, pb: 1.5 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Rider Timeline</Typography>
|
||||
<IconButton size="small" onClick={onClose} aria-label="Exit tracking"><CloseRoundedIcon fontSize="small" /></IconButton>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 1.5 }}>
|
||||
<Box sx={{ width: 32, height: 32, borderRadius: 1.5, bgcolor: vc, color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
{Glyph ? <Glyph sx={{ fontSize: 18 }} /> : null}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.main', lineHeight: 1.15 }}>{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={{ mt: 1.25, 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: 1 }}>
|
||||
<StatusChip status={d.status} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: d.etaStatus === 'on-time' ? 'success.dark' : 'error.dark' }}>
|
||||
{d.progress}% · ETA {d.eta}{d.etaStatus !== 'on-time' ? ` · +${d.delayMin}m` : ''}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* timeline */}
|
||||
<Box sx={{ flexGrow: 1, minHeight: 0, overflowY: 'auto', px: 2, py: 2 }}>
|
||||
{stages.map((s, i) => {
|
||||
const active = i === activeIdx;
|
||||
const color = s.warn ? '#B45309' : s.done ? '#16A34A' : active ? '#2563EB' : '#9AA4B2';
|
||||
const last = i === stages.length - 1;
|
||||
return (
|
||||
<Stack key={s.key} direction="row" spacing={1.5} sx={{ minHeight: last ? 'auto' : 52 }}>
|
||||
<Stack alignItems="center" sx={{ flexShrink: 0 }}>
|
||||
<Box
|
||||
className={active ? 'tl-dot tl-dot-active' : 'tl-dot'}
|
||||
sx={{
|
||||
width: 22, height: 22, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
bgcolor: s.done || s.warn ? color : '#fff',
|
||||
border: '2px solid', borderColor: color, color: '#fff', mt: 0.25,
|
||||
'--tl-c': color
|
||||
}}
|
||||
>
|
||||
{s.done ? <CheckRoundedIcon sx={{ fontSize: 14 }} /> : s.warn ? <PriorityHighRoundedIcon sx={{ fontSize: 14 }} /> : <Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: active ? color : 'grey.300' }} />}
|
||||
</Box>
|
||||
{!last && <Box sx={{ flexGrow: 1, width: '2px', my: 0.5, bgcolor: s.done ? '#16A34A' : 'grey.200' }} />}
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ pb: last ? 0 : 1.5, pt: 0.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: s.done || active ? 700 : 500, color: s.warn ? '#B45309' : s.done || active ? 'text.primary' : 'text.secondary', lineHeight: 1.2 }}>
|
||||
{s.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: s.warn ? '#B45309' : active ? 'primary.main' : 'text.secondary', fontWeight: active || s.warn ? 600 : 400 }}>
|
||||
{active && !s.warn ? `In progress · ${s.time}` : s.time}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
77
src/components/tracking/TrackingControlBar.jsx
Normal file
77
src/components/tracking/TrackingControlBar.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Card, Box, Stack, Typography, Divider } from '@mui/material';
|
||||
|
||||
import './tracking.css';
|
||||
import { executionOps } from '@/data/mock';
|
||||
|
||||
// ==============================|| TRACKING CONTROL BAR ||============================== //
|
||||
// Operational KPI strip for the control tower — roomy cells with prominent values, live
|
||||
// indicators and alert tones. Single row on desktop, wraps on small screens.
|
||||
|
||||
function Kpi({ glyph, label, value, color, pulse }) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1.5}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
px: 2.5,
|
||||
py: 2.75,
|
||||
minHeight: 108,
|
||||
flex: 1,
|
||||
minWidth: 160,
|
||||
transition: 'background-color .15s ease',
|
||||
'&:hover': { bgcolor: 'grey.50' }
|
||||
}}
|
||||
>
|
||||
{pulse ? (
|
||||
<Box className="live-pulse" sx={{ color, flexShrink: 0, width: 11, height: 11 }} />
|
||||
) : (
|
||||
<Box component="span" sx={{ fontSize: 20, flexShrink: 0, lineHeight: 1 }}>{glyph}</Box>
|
||||
)}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.875rem', fontWeight: 600, color: 'text.secondary', lineHeight: 1.2, mb: 0.5, display: 'block' }} noWrap>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '2rem', fontWeight: 800, lineHeight: 1, color: color || 'grey.900' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrackingControlBar() {
|
||||
const o = executionOps;
|
||||
const items = [
|
||||
{ label: 'Active Deliveries', value: o.activeDeliveries, color: '#00A854', pulse: true },
|
||||
{ label: 'Delayed', value: o.delayed, color: '#F04134', pulse: true },
|
||||
{ label: 'On-Time Rate', value: `${o.onTime}%`, color: '#00773B', glyph: '🟢' },
|
||||
{ label: 'Active Riders', value: o.activeRiders, glyph: '🛵' },
|
||||
{ label: 'Re-routes Today', value: o.reroutes, glyph: '🔄' },
|
||||
{ label: 'Critical Alerts', value: o.criticalAlerts, color: '#B38600', glyph: '⚠️' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
mb: 2,
|
||||
flexShrink: 0,
|
||||
borderColor: 'grey.300',
|
||||
boxShadow: '0 1px 3px rgba(16,24,40,0.06), 0 1px 2px rgba(16,24,40,0.04)',
|
||||
transition: 'box-shadow .15s ease',
|
||||
'&:hover': { boxShadow: '0 4px 14px rgba(16,24,40,0.10)' }
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="stretch"
|
||||
divider={<Divider orientation="vertical" flexItem />}
|
||||
sx={{ overflowX: 'auto', flexWrap: { xs: 'wrap', md: 'nowrap' } }}
|
||||
>
|
||||
{items.map((it) => (
|
||||
<Kpi key={it.label} {...it} />
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
196
src/components/tracking/tracking.css
Normal file
196
src/components/tracking/tracking.css
Normal file
@@ -0,0 +1,196 @@
|
||||
/* ==============================|| LIVE TRACKING MAP — marker & overlay styles ||============================== */
|
||||
|
||||
/* Premium vehicle marker: a crisp white Material silhouette on a coloured disc, with a
|
||||
direction arrow that orbits to the travel heading. The disc stays upright (icon readable);
|
||||
only the arrow rotates. */
|
||||
.vm-divicon { background: none; border: none; }
|
||||
.vm {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.vm-disc {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: var(--vc);
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.22);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
.vm-disc svg { display: block; }
|
||||
.vm-rot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
transition: transform 0.5s ease-out;
|
||||
}
|
||||
.vm-arrow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-bottom: 7px solid var(--vc);
|
||||
opacity: 0.9;
|
||||
}
|
||||
/* Selected: a gentle scale + one slow expanding ring (no heavy glow, no gaming pulse). */
|
||||
.vm-sel { transform: scale(1.16); z-index: 1000; }
|
||||
.vm-sel .vm-disc { box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.9), 0 1px 4px rgba(0, 0, 0, 0.3); }
|
||||
.vm-sel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--vc);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: vm-ring 2.8s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes vm-ring {
|
||||
0% { opacity: 0.28; transform: translate(-50%, -50%) scale(1); }
|
||||
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.75); }
|
||||
}
|
||||
.vm-del .vm-disc { box-shadow: 0 0 0 1.5px rgba(240, 65, 52, 0.35), 0 1px 3px rgba(0, 0, 0, 0.24); }
|
||||
|
||||
/* Pulsing "live" dot used on KPIs, queue cards and the map endpoint markers. */
|
||||
.live-pulse {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.live-pulse::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: live-pulse-ring 1.6s ease-out infinite;
|
||||
}
|
||||
@keyframes live-pulse-ring {
|
||||
0% { transform: scale(1); opacity: 0.4; }
|
||||
100% { transform: scale(2.6); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Pickup / drop endpoint nodes — white core with a thick coloured ring */
|
||||
.ep-divicon { background: none; border: none; }
|
||||
.ep {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 4px solid var(--epc);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Cluster bubble — restrained, professional. A solid disc with a thin tinted ring. */
|
||||
.vcl-divicon { background: none; border: none; }
|
||||
.vcl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: #1D4ED8;
|
||||
border: 2.5px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.22), 0 0 0 3px rgba(29, 78, 216, 0.12);
|
||||
}
|
||||
|
||||
/* Rider-timeline current-step dot — one quiet halo, no gaming pulse */
|
||||
.tl-dot-active { box-shadow: 0 0 0 0 var(--tl-c); animation: tl-halo 2.2s ease-out infinite; }
|
||||
@keyframes tl-halo {
|
||||
0% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.35); }
|
||||
70% { box-shadow: 0 0 0 7px rgba(37, 99, 235, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); }
|
||||
}
|
||||
|
||||
/* Inter-city corridor load chip — quiet pill at the lane midpoint */
|
||||
.lane-chip-divicon { background: none; border: none; }
|
||||
.lane-chip {
|
||||
transform: translate(-50%, -50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #e2e6ec;
|
||||
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.1);
|
||||
}
|
||||
.lane-chip-n { font-size: 11px; font-weight: 800; color: #334155; line-height: 1; }
|
||||
.lane-chip-l { font-size: 8.5px; font-weight: 700; letter-spacing: 0.04em; color: #94a3b8; line-height: 1; }
|
||||
|
||||
/* Keep Leaflet controls visually aligned with the enterprise UI */
|
||||
.leaflet-container { font-family: inherit; background: #eef1f5; }
|
||||
.leaflet-bar { border: none !important; box-shadow: 0 1px 5px rgba(0,0,0,0.15) !important; }
|
||||
.leaflet-bar a { color: #434343; border-bottom-color: #f0f0f0 !important; }
|
||||
.leaflet-control-attribution { font-size: 10px; opacity: 0.7; }
|
||||
|
||||
/* ===== Hub Network — command-center map ===== */
|
||||
/* City wordmark watermark behind the hubs */
|
||||
.city-divicon { background: none; border: none; }
|
||||
.city-wm {
|
||||
transform: translate(-50%, -50%);
|
||||
color: rgba(120, 132, 150, 0.45);
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.95);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hub node: utilization disc + floating label */
|
||||
.hub-divicon { background: none; border: none; }
|
||||
.hub { display: flex; align-items: center; gap: 7px; }
|
||||
.hub-dot {
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--hc);
|
||||
border: 3px solid #fff;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.32), 0 0 0 4px var(--hh);
|
||||
}
|
||||
.hub-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.15;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border: 1px solid #e6e9ef;
|
||||
border-radius: 7px;
|
||||
padding: 2px 8px;
|
||||
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.14);
|
||||
}
|
||||
.hub-label b { font-size: 11px; font-weight: 700; color: #1f2937; white-space: nowrap; }
|
||||
.hub-label span { font-size: 9.5px; font-weight: 600; color: #8a94a6; white-space: nowrap; }
|
||||
|
||||
/* Ambient last-mile fleet dot */
|
||||
.fleetdot-divicon { background: none; border: none; }
|
||||
.fleetdot { width: 9px; height: 9px; border-radius: 50%; background: var(--fc); border: 1.5px solid #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.3); }
|
||||
|
||||
/* Animated line-haul corridor flow */
|
||||
.corridor-flow { animation: corridor-dash 0.9s linear infinite; }
|
||||
@keyframes corridor-dash { to { stroke-dashoffset: -28; } }
|
||||
75
src/components/tracking/vehicleMarker.js
Normal file
75
src/components/tracking/vehicleMarker.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import L from 'leaflet';
|
||||
import TwoWheelerRoundedIcon from '@mui/icons-material/TwoWheelerRounded';
|
||||
import ElectricRickshawRoundedIcon from '@mui/icons-material/ElectricRickshawRounded';
|
||||
import LocalShippingRoundedIcon from '@mui/icons-material/LocalShippingRounded';
|
||||
import AirportShuttleRoundedIcon from '@mui/icons-material/AirportShuttleRounded';
|
||||
|
||||
import { vehicleTypes } from '@/data/mock';
|
||||
|
||||
// Re-exported so tracking components can import type tokens from one place.
|
||||
export { vehicleTypes };
|
||||
|
||||
// ==============================|| VEHICLE MARKER FACTORY ||============================== //
|
||||
// Premium fleet markers: a crisp white Material vehicle silhouette on a coloured disc with a
|
||||
// direction arrow that rotates to the travel heading. SVG (not emoji) so it renders identically
|
||||
// everywhere and stays sharp at any zoom.
|
||||
|
||||
// Shared type → MUI icon component map (used by the legend, queue & info card for consistency).
|
||||
export const vehicleIconComponents = {
|
||||
Bike: TwoWheelerRoundedIcon,
|
||||
Auto: ElectricRickshawRoundedIcon,
|
||||
Truck: LocalShippingRoundedIcon,
|
||||
Van: AirportShuttleRoundedIcon
|
||||
};
|
||||
|
||||
// Matching Material (Rounded) path geometry, used to paint the silhouette inside map markers.
|
||||
const PATHS = {
|
||||
Bike: 'M20 11c-.18 0-.36.03-.53.05L17.41 9H19c.55 0 1-.45 1-1v-.38c0-.74-.78-1.23-1.45-.89l-2.28 1.14L13.7 5.3c-.18-.19-.44-.3-.7-.3h-3c-.55 0-1 .45-1 1s.45 1 1 1h2.17c.27 0 .52.11.71.29L14.59 9h-3.35c-.16 0-.31.04-.45.11l-3.14 1.57c-.38.19-.85.12-1.15-.19l-1.2-1.2C5.11 9.11 4.85 9 4.59 9H1c-.55 0-1 .45-1 1s.45 1 1 1h3C1.48 11-.49 13.32.11 15.94c.33 1.45 1.5 2.62 2.95 2.95C5.68 19.49 8 17.52 8 15l1.41 1.41c.38.38.89.59 1.42.59h1.01c.72 0 1.38-.38 1.74-1.01l2.91-5.09 1.01 1.01c-1.13.91-1.76 2.41-1.38 4.05.34 1.44 1.51 2.61 2.95 2.94 2.61.59 4.93-1.39 4.93-3.9 0-2.21-1.79-4-4-4M4 17c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2m16 0c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2',
|
||||
Auto: 'M21 11.18V9.72c0-.47-.16-.92-.46-1.28L16.6 3.72c-.38-.46-.94-.72-1.54-.72H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h.18C3.6 16.16 4.7 17 6 17s2.4-.84 2.82-2h8.37c.41 1.16 1.51 2 2.82 2 1.66 0 3-1.34 3-3-.01-1.3-.85-2.4-2.01-2.82M18.4 9H16V6.12zM4 5h3v4H3V6c0-.55.45-1 1-1m2 10c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1m3-2v-2h2c.55 0 1-.45 1-1s-.45-1-1-1H9V5h4c.55 0 1 .45 1 1v7zm11 2c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1M7 20h4v-2l6 3h-4v2z',
|
||||
Truck: 'M19.5 8H17V6c0-1.1-.9-2-2-2H3c-1.1 0-2 .9-2 2v9c0 1.1.9 2 2 2 0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h1c.55 0 1-.45 1-1v-3.33c0-.43-.14-.85-.4-1.2L20.3 8.4c-.19-.25-.49-.4-.8-.4M6 18c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1m13.5-8.5 1.96 2.5H17V9.5zM18 18c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1',
|
||||
Van: 'm22.41 10.41-4.83-4.83c-.37-.37-.88-.58-1.41-.58H3c-1.1 0-2 .89-2 2v7c0 1.1.9 2 2 2 0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3c1.1 0 2-.9 2-2v-2.17c0-.53-.21-1.04-.59-1.42M3 10V8c0-.55.45-1 1-1h3v4H4c-.55 0-1-.45-1-1m3 7.25c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25M13 11H9V7h4zm5 6.25c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25M15 11V7h1l4 4z'
|
||||
};
|
||||
|
||||
const svgCache = new Map();
|
||||
function vehicleSvg(type, color, size) {
|
||||
const k = `${type}|${color}|${size}`;
|
||||
if (svgCache.has(k)) return svgCache.get(k);
|
||||
const d = PATHS[type] || PATHS.Bike;
|
||||
const svg = `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="${color}" aria-hidden="true"><path d="${d}"/></svg>`;
|
||||
svgCache.set(k, svg);
|
||||
return svg;
|
||||
}
|
||||
|
||||
export function vehicleIcon({ type, bearing = 0, selected = false, delayed = false }) {
|
||||
const cfg = vehicleTypes[type] || vehicleTypes.Bike;
|
||||
const color = delayed ? '#F04134' : cfg.color;
|
||||
const cls = ['vm', selected ? 'vm-sel' : '', delayed ? 'vm-del' : ''].filter(Boolean).join(' ');
|
||||
const glyph = vehicleSvg(type, '#fff', selected ? 18 : 15);
|
||||
|
||||
const html = `
|
||||
<div class="${cls}" style="--vc:${color}">
|
||||
<div class="vm-rot" style="transform: rotate(${bearing}deg)"><i class="vm-arrow"></i></div>
|
||||
<div class="vm-disc">${glyph}</div>
|
||||
</div>`;
|
||||
|
||||
const size = 36;
|
||||
return L.divIcon({ html, className: 'vm-divicon', iconSize: [size, size], iconAnchor: [size / 2, size / 2] });
|
||||
}
|
||||
|
||||
export function endpointIcon(color) {
|
||||
return L.divIcon({
|
||||
html: `<div class="ep" style="--epc:${color}"></div>`,
|
||||
className: 'ep-divicon',
|
||||
iconSize: [18, 18],
|
||||
iconAnchor: [9, 9]
|
||||
});
|
||||
}
|
||||
|
||||
export function clusterIcon(count) {
|
||||
const size = count < 10 ? 36 : count < 50 ? 44 : 52;
|
||||
return L.divIcon({
|
||||
html: `<div class="vcl" style="width:${size}px;height:${size}px;font-size:${count < 50 ? 13 : 15}px">${count}</div>`,
|
||||
className: 'vcl-divicon',
|
||||
iconSize: [size, size]
|
||||
});
|
||||
}
|
||||
449
src/data/mock.js
449
src/data/mock.js
@@ -1,7 +1,14 @@
|
||||
// ==============================|| DOORMILE - MOCK DATA ||============================== //
|
||||
// Static demo data powering every screen.
|
||||
|
||||
export const locations = ['Bengaluru', 'Mumbai', 'Delhi NCR', 'Hyderabad', 'Chennai', 'Pune'];
|
||||
export const locations = ['Coimbatore', 'Bengaluru', 'Hyderabad'];
|
||||
|
||||
// Real operating geography — the three live metros and their coordinates.
|
||||
export const cityCenters = {
|
||||
Coimbatore: { lat: 11.0168, lng: 76.9558 },
|
||||
Bengaluru: { lat: 12.9716, lng: 77.5946 },
|
||||
Hyderabad: { lat: 17.385, lng: 78.4867 }
|
||||
};
|
||||
|
||||
export const tenantsList = [
|
||||
'Freshly Foods',
|
||||
@@ -15,12 +22,12 @@ export const tenantsList = [
|
||||
export const orders = [
|
||||
{ id: 'DM-10241', tenant: 'Freshly Foods', location: 'Bengaluru', pickup: 'Koramangala Hub', drop: 'HSR Layout, Sec 2', customer: 'Riya Sharma', qty: 3, cod: 0, kms: 4.2, charges: 86, notes: 'Leave at door', status: 'delivered', date: '2026-06-04', payment: 'prepaid' },
|
||||
{ id: 'DM-10242', tenant: 'UrbanCart', location: 'Bengaluru', pickup: 'Indiranagar Store', drop: 'Whitefield, Phase 1', customer: 'Karthik Rao', qty: 1, cod: 1299, kms: 12.8, charges: 142, notes: 'Call on arrival', status: 'active', date: '2026-06-05', payment: 'cod' },
|
||||
{ id: 'DM-10243', tenant: 'MediQuick Pharma', location: 'Mumbai', pickup: 'Andheri West', drop: 'Bandra East', customer: 'Neha Kulkarni', qty: 2, cod: 0, kms: 6.5, charges: 98, notes: '', status: 'picked', date: '2026-06-05', payment: 'prepaid' },
|
||||
{ id: 'DM-10244', tenant: 'BloomBox Florists', location: 'Delhi NCR', pickup: 'Connaught Place', drop: 'Saket, Block C', customer: 'Arjun Mehta', qty: 1, cod: 450, kms: 9.1, charges: 110, notes: 'Fragile', status: 'pending', date: '2026-06-05', payment: 'cod' },
|
||||
{ id: 'DM-10243', tenant: 'MediQuick Pharma', location: 'Hyderabad', pickup: 'Hitech City Hub', drop: 'Banjara Hills', customer: 'Neha Kulkarni', qty: 2, cod: 0, kms: 6.5, charges: 98, notes: '', status: 'picked', date: '2026-06-05', payment: 'prepaid' },
|
||||
{ id: 'DM-10244', tenant: 'BloomBox Florists', location: 'Coimbatore', pickup: 'Gandhipuram', drop: 'R.S. Puram', customer: 'Arjun Mehta', qty: 1, cod: 450, kms: 9.1, charges: 110, notes: 'Fragile', status: 'pending', date: '2026-06-05', payment: 'cod' },
|
||||
{ id: 'DM-10245', tenant: 'Freshly Foods', location: 'Bengaluru', pickup: 'Koramangala Hub', drop: 'BTM Layout', customer: 'Sneha Iyer', qty: 4, cod: 0, kms: 3.4, charges: 74, notes: '', status: 'cancelled', date: '2026-06-03', payment: 'prepaid' },
|
||||
{ id: 'DM-10246', tenant: 'TechNova Retail', location: 'Hyderabad', pickup: 'Hitech City', drop: 'Gachibowli', customer: 'Vikram Reddy', qty: 1, cod: 4999, kms: 5.6, charges: 120, notes: 'ID required', status: 'created', date: '2026-06-05', payment: 'cod' },
|
||||
{ id: 'DM-10247', tenant: 'GreenLeaf Organics', location: 'Pune', pickup: 'Koregaon Park', drop: 'Viman Nagar', customer: 'Pooja Desai', qty: 2, cod: 0, kms: 7.2, charges: 102, notes: '', status: 'delivered', date: '2026-06-04', payment: 'prepaid' },
|
||||
{ id: 'DM-10248', tenant: 'UrbanCart', location: 'Chennai', pickup: 'T. Nagar', drop: 'Adyar', customer: 'Suresh Kumar', qty: 1, cod: 799, kms: 8.3, charges: 108, notes: 'Weekend slot', status: 'pending', date: '2026-06-05', payment: 'cod' }
|
||||
{ id: 'DM-10247', tenant: 'GreenLeaf Organics', location: 'Coimbatore', pickup: 'Peelamedu Hub', drop: 'Saravanampatti', customer: 'Pooja Desai', qty: 2, cod: 0, kms: 7.2, charges: 102, notes: '', status: 'delivered', date: '2026-06-04', payment: 'prepaid' },
|
||||
{ id: 'DM-10248', tenant: 'UrbanCart', location: 'Bengaluru', pickup: 'Jayanagar', drop: 'JP Nagar', customer: 'Suresh Kumar', qty: 1, cod: 799, kms: 8.3, charges: 108, notes: 'Weekend slot', status: 'pending', date: '2026-06-05', payment: 'cod' }
|
||||
];
|
||||
|
||||
export const orderTimeline = [
|
||||
@@ -41,10 +48,10 @@ export const deliveries = orders.map((o, i) => ({
|
||||
|
||||
export const riders = [
|
||||
{ id: 'RDR-001', userId: 'U1043', name: 'Mohan Das', phone: '+91 98450 11223', address: 'Koramangala, Bengaluru', vehicle: 'Honda Activa', vehicleNo: 'KA-05-HJ-4521', shift: 'Morning', start: '08:00', end: '16:00', fare: 320, fuel: 110, status: 'online', deliveries: 14, rating: 4.8 },
|
||||
{ id: 'RDR-002', userId: 'U1044', name: 'Imran Sheikh', phone: '+91 98860 44781', address: 'Andheri, Mumbai', vehicle: 'TVS Jupiter', vehicleNo: 'MH-02-CD-9087', shift: 'Evening', start: '14:00', end: '22:00', fare: 280, fuel: 95, status: 'on-delivery', deliveries: 11, rating: 4.6 },
|
||||
{ id: 'RDR-002', userId: 'U1044', name: 'Imran Sheikh', phone: '+91 98860 44781', address: 'Madhapur, Hyderabad', vehicle: 'TVS Jupiter', vehicleNo: 'TS-09-CD-9087', shift: 'Evening', start: '14:00', end: '22:00', fare: 280, fuel: 95, status: 'on-delivery', deliveries: 11, rating: 4.6 },
|
||||
{ id: 'RDR-003', userId: 'U1045', name: 'Ravi Teja', phone: '+91 99000 22119', address: 'Gachibowli, Hyderabad', vehicle: 'Hero Splendor', vehicleNo: 'TS-09-AB-3344', shift: 'Morning', start: '08:00', end: '16:00', fare: 300, fuel: 120, status: 'online', deliveries: 9, rating: 4.9 },
|
||||
{ id: 'RDR-004', userId: 'U1046', name: 'Sandeep Roy', phone: '+91 90080 55672', address: 'Saket, Delhi', vehicle: 'Bajaj Pulsar', vehicleNo: 'DL-3C-XY-1290', shift: 'Night', start: '22:00', end: '06:00', fare: 350, fuel: 130, status: 'offline', deliveries: 0, rating: 4.4 },
|
||||
{ id: 'RDR-005', userId: 'U1047', name: 'Faisal Khan', phone: '+91 97410 88123', address: 'Adyar, Chennai', vehicle: 'Honda Dio', vehicleNo: 'TN-07-PQ-6655', shift: 'Evening', start: '14:00', end: '22:00', fare: 290, fuel: 100, status: 'on-delivery', deliveries: 7, rating: 4.7 }
|
||||
{ id: 'RDR-004', userId: 'U1046', name: 'Sandeep Roy', phone: '+91 90080 55672', address: 'Gandhipuram, Coimbatore', vehicle: 'Bajaj Pulsar', vehicleNo: 'TN-37-XY-1290', shift: 'Night', start: '22:00', end: '06:00', fare: 350, fuel: 130, status: 'offline', deliveries: 0, rating: 4.4 },
|
||||
{ id: 'RDR-005', userId: 'U1047', name: 'Faisal Khan', phone: '+91 97410 88123', address: 'Peelamedu, Coimbatore', vehicle: 'Honda Dio', vehicleNo: 'TN-37-PQ-6655', shift: 'Evening', start: '14:00', end: '22:00', fare: 290, fuel: 100, status: 'on-delivery', deliveries: 7, rating: 4.7 }
|
||||
];
|
||||
|
||||
export const riderLogs = [
|
||||
@@ -56,16 +63,16 @@ export const riderLogs = [
|
||||
export const customers = [
|
||||
{ id: 1, name: 'Riya Sharma', phone: '+91 98111 22334', email: 'riya.sharma@mail.com', address: '24, 1st Cross, HSR Layout', location: 'Bengaluru', city: 'Bengaluru', state: 'Karnataka', postcode: '560102', totalOrders: 18, joined: '2025-03-12' },
|
||||
{ id: 2, name: 'Karthik Rao', phone: '+91 98222 33445', email: 'karthik.rao@mail.com', address: '8, Palm Meadows, Whitefield', location: 'Bengaluru', city: 'Bengaluru', state: 'Karnataka', postcode: '560066', totalOrders: 7, joined: '2025-07-21' },
|
||||
{ id: 3, name: 'Neha Kulkarni', phone: '+91 98333 44556', email: 'neha.k@mail.com', address: '102, Sea View, Bandra East', location: 'Mumbai', city: 'Mumbai', state: 'Maharashtra', postcode: '400051', totalOrders: 31, joined: '2024-11-02' },
|
||||
{ id: 4, name: 'Arjun Mehta', phone: '+91 98444 55667', email: 'arjun.m@mail.com', address: 'C-14, Saket', location: 'Delhi NCR', city: 'New Delhi', state: 'Delhi', postcode: '110017', totalOrders: 5, joined: '2025-09-18' },
|
||||
{ id: 5, name: 'Pooja Desai', phone: '+91 98555 66778', email: 'pooja.d@mail.com', address: '4, Lane 5, Koregaon Park', location: 'Pune', city: 'Pune', state: 'Maharashtra', postcode: '411001', totalOrders: 12, joined: '2025-01-30' }
|
||||
{ id: 3, name: 'Neha Kulkarni', phone: '+91 98333 44556', email: 'neha.k@mail.com', address: '102, Lake View, Banjara Hills', location: 'Hyderabad', city: 'Hyderabad', state: 'Telangana', postcode: '500034', totalOrders: 31, joined: '2024-11-02' },
|
||||
{ id: 4, name: 'Arjun Mehta', phone: '+91 98444 55667', email: 'arjun.m@mail.com', address: '14, R.S. Puram', location: 'Coimbatore', city: 'Coimbatore', state: 'Tamil Nadu', postcode: '641002', totalOrders: 5, joined: '2025-09-18' },
|
||||
{ id: 5, name: 'Pooja Desai', phone: '+91 98555 66778', email: 'pooja.d@mail.com', address: '4, Lane 5, Saravanampatti', location: 'Coimbatore', city: 'Coimbatore', state: 'Tamil Nadu', postcode: '641035', totalOrders: 12, joined: '2025-01-30' }
|
||||
];
|
||||
|
||||
export const tenants = [
|
||||
{ id: 1, name: 'Freshly Foods', contact: 'Anil Gupta', phone: '+91 80451 22000', email: 'ops@freshlyfoods.in', address: 'Koramangala 4th Block', city: 'Bengaluru', postcode: '560034', lat: '12.9352', lng: '77.6245', status: 'active', volume: 1240 },
|
||||
{ id: 2, name: 'UrbanCart', contact: 'Meera Nair', phone: '+91 22480 11233', email: 'logistics@urbancart.com', address: 'Indiranagar 100ft Rd', city: 'Bengaluru', postcode: '560038', lat: '12.9719', lng: '77.6412', status: 'active', volume: 980 },
|
||||
{ id: 3, name: 'MediQuick Pharma', contact: 'Rohit Sen', phone: '+91 22556 99001', email: 'dispatch@mediquick.in', address: 'Andheri West', city: 'Mumbai', postcode: '400058', lat: '19.1351', lng: '72.8290', status: 'pending', volume: 410 },
|
||||
{ id: 4, name: 'BloomBox Florists', contact: 'Tina Roy', phone: '+91 11409 33221', email: 'hello@bloombox.in', address: 'Connaught Place', city: 'New Delhi', postcode: '110001', lat: '28.6315', lng: '77.2167', status: 'inactive', volume: 120 },
|
||||
{ id: 3, name: 'MediQuick Pharma', contact: 'Rohit Sen', phone: '+91 40556 99001', email: 'dispatch@mediquick.in', address: 'Hitech City', city: 'Hyderabad', postcode: '500081', lat: '17.4435', lng: '78.3772', status: 'pending', volume: 410 },
|
||||
{ id: 4, name: 'BloomBox Florists', contact: 'Tina Roy', phone: '+91 42240 33221', email: 'hello@bloombox.in', address: 'Gandhipuram', city: 'Coimbatore', postcode: '641012', lat: '11.0168', lng: '76.9558', status: 'inactive', volume: 120 },
|
||||
{ id: 5, name: 'TechNova Retail', contact: 'Sameer Joshi', phone: '+91 40229 77654', email: 'ops@technova.in', address: 'Hitech City', city: 'Hyderabad', postcode: '500081', lat: '17.4435', lng: '78.3772', status: 'active', volume: 760 }
|
||||
];
|
||||
|
||||
@@ -78,8 +85,8 @@ export const tenantPricing = [
|
||||
export const pricing = [
|
||||
{ id: 1, location: 'Bengaluru', pricingId: 'PR-001', name: 'Standard', slab: '0-5 km', basePrice: 40, minKm: 2, pricePerKm: 9, maxKm: 5, minOrders: 1 },
|
||||
{ id: 2, location: 'Bengaluru', pricingId: 'PR-002', name: 'Express', slab: '5-10 km', basePrice: 65, minKm: 5, pricePerKm: 8, maxKm: 10, minOrders: 1 },
|
||||
{ id: 3, location: 'Mumbai', pricingId: 'PR-003', name: 'Standard', slab: '0-5 km', basePrice: 45, minKm: 2, pricePerKm: 10, maxKm: 5, minOrders: 1 },
|
||||
{ id: 4, location: 'Delhi NCR', pricingId: 'PR-004', name: 'Bulk', slab: '10+ km', basePrice: 95, minKm: 10, pricePerKm: 7, maxKm: 25, minOrders: 5 }
|
||||
{ id: 3, location: 'Hyderabad', pricingId: 'PR-003', name: 'Standard', slab: '0-5 km', basePrice: 45, minKm: 2, pricePerKm: 10, maxKm: 5, minOrders: 1 },
|
||||
{ id: 4, location: 'Coimbatore', pricingId: 'PR-004', name: 'Bulk', slab: '10+ km', basePrice: 95, minKm: 10, pricePerKm: 7, maxKm: 25, minOrders: 5 }
|
||||
];
|
||||
|
||||
export const invoices = [
|
||||
@@ -178,53 +185,122 @@ export const statusBreakdown = [
|
||||
// Mirrors doormile.com's primary narrative: Origin → Hub → Hub → Doorstep.
|
||||
export const threeMile = [
|
||||
{
|
||||
key: 'first', title: 'First Mile', subtitle: 'Origin to Hub', color: '#EA580C',
|
||||
metric: 184, metricLabel: 'pickups today', onTime: 98.1,
|
||||
key: 'first', title: 'First Mile', subtitle: 'Origin → Hub', color: '#EA580C',
|
||||
metric: 184, metricLabel: 'active pickups', throughput: 412, onTime: 98.1, exceptions: 2,
|
||||
utilization: 78, bottleneck: 'Peelamedu dock queue', aiRec: 'Add 2 pickup riders in Coimbatore',
|
||||
features: ['AI-scheduled pickups', 'Dynamic load consolidation', 'Yard & dock management', 'Pickup quality checks']
|
||||
},
|
||||
{
|
||||
key: 'mid', title: 'Mid Mile', subtitle: 'Hub to Hub Transit', color: '#0E7C7B',
|
||||
metric: 4, metricLabel: 'line-hauls live', onTime: 97.4,
|
||||
key: 'mid', title: 'Mid Mile', subtitle: 'Hub → Hub Transit', color: '#0E7C7B',
|
||||
metric: 4, metricLabel: 'line-hauls live', throughput: 38, onTime: 97.4, exceptions: 3,
|
||||
utilization: 71, bottleneck: 'CBE → BLR corridor +18m', aiRec: 'Pre-stage backup at Hyderabad hub',
|
||||
features: ['Optimised line-haul routing', 'Cross-docking & sortation', 'Live SLA monitoring', 'EV-first corridors']
|
||||
},
|
||||
{
|
||||
key: 'last', title: 'Last Mile', subtitle: 'Hub to Doorstep', color: '#1D4ED8',
|
||||
metric: 96, metricLabel: 'out for delivery', onTime: 98.9,
|
||||
key: 'last', title: 'Last Mile', subtitle: 'Hub → Doorstep', color: '#1D4ED8',
|
||||
metric: 96, metricLabel: 'out for delivery', throughput: 1330, onTime: 98.9, exceptions: 1,
|
||||
utilization: 84, bottleneck: null, aiRec: 'Consolidate 6 Whitefield drops into 1 run',
|
||||
features: ['Multi-stop route optimization', 'Precise delivery windows', 'Digital proof of delivery', 'Real-time customer updates']
|
||||
}
|
||||
];
|
||||
|
||||
// ==============================|| END-TO-END SHIPMENT JOURNEY (hop-by-hop) ||============================== //
|
||||
// Follows ONE parcel Chennai → Bengaluru through every node: agent → nearest hub → origin main hub
|
||||
// → line-haul → destination main hub → sub hub → delivery agent → customer. With live monitoring & reroute.
|
||||
export const shipmentJourney = {
|
||||
id: 'DM-CHN-BLR-7741',
|
||||
product: 'Documents & electronics · 3.2 kg',
|
||||
from: { city: 'Chennai', area: 'T. Nagar', name: 'Suresh Kumar' },
|
||||
to: { city: 'Bengaluru', area: 'Koramangala', name: 'Riya Sharma' },
|
||||
client: 'TechNova Retail',
|
||||
distance: 346,
|
||||
mode: 'Standard · EV-first',
|
||||
placed: '08 Jun, 09:12 AM',
|
||||
eta: '09 Jun, 02:30 PM',
|
||||
// ==============================|| AI COMMAND CENTER — pipeline + impact ||============================== //
|
||||
// The end-to-end flow Orders → Delivered, each stage carrying a live count for the command centre.
|
||||
export const commandPipeline = [
|
||||
{ key: 'orders', label: 'Orders Received', count: '1,402', sub: 'today', icon: 'order', pct: 100, status: 'done' },
|
||||
{ key: 'trust', label: 'Trust Validation', count: '99.2%', sub: 'cleared', icon: 'trust', pct: 99, status: 'done' },
|
||||
{ key: 'hub', label: 'Hub Allocation', count: '6', sub: 'hubs', icon: 'hub', pct: 100, status: 'done' },
|
||||
{ key: 'fleet', label: 'Fleet Assignment', count: '124', sub: 'vehicles', icon: 'fleet', pct: 96, status: 'done' },
|
||||
{ key: 'dispatch', label: 'Dispatch AI', count: '41', sub: 'optimized', icon: 'ai', pct: 88, status: 'active' },
|
||||
{ key: 'rider', label: 'Rider Allocation', count: '48', sub: 'active', icon: 'rider', pct: 74, status: 'active' },
|
||||
{ key: 'tracking', label: 'Live Tracking', count: '96', sub: 'in transit', icon: 'track', pct: 52, status: 'active' },
|
||||
{ key: 'delivered', label: 'Delivered', count: '1,330', sub: 'today', icon: 'done', pct: 95, status: 'active' }
|
||||
];
|
||||
|
||||
// ==============================|| HUB OPERATIONS CENTER (no map) ||============================== //
|
||||
// Per-city rollups for the Hub Operations cards + comparison + activity.
|
||||
export const hubCityStats = [
|
||||
{ city: 'Coimbatore', code: 'CBE', capacity: 10400, utilization: 70, processed: 5840, riders: 14, sla: 97.2, health: 'healthy' },
|
||||
{ city: 'Bengaluru', code: 'BLR', capacity: 13200, utilization: 64, processed: 8490, riders: 22, sla: 98.6, health: 'healthy' },
|
||||
{ city: 'Hyderabad', code: 'HYD', capacity: 9300, utilization: 50, processed: 4600, riders: 12, sla: 96.4, health: 'watch' }
|
||||
];
|
||||
|
||||
export const hubThroughput = [
|
||||
{ m: 'Mon', cbe: 720, blr: 1180, hyd: 560 },
|
||||
{ m: 'Tue', cbe: 810, blr: 1240, hyd: 620 },
|
||||
{ m: 'Wed', cbe: 760, blr: 1310, hyd: 680 },
|
||||
{ m: 'Thu', cbe: 880, blr: 1290, hyd: 640 },
|
||||
{ m: 'Fri', cbe: 940, blr: 1420, hyd: 710 },
|
||||
{ m: 'Sat', cbe: 1020, blr: 1510, hyd: 760 }
|
||||
];
|
||||
|
||||
export const hubActivity = [
|
||||
{ time: '10:42 AM', hub: 'Hoskote Regional Hub', text: 'Inbound line-haul from Coimbatore scanned in · 320 parcels', type: 'inbound' },
|
||||
{ time: '10:31 AM', hub: 'Coimbatore Regional Hub', text: 'Sortation wave 3 completed · 0 misroutes', type: 'sort' },
|
||||
{ time: '10:18 AM', hub: 'Hyderabad Regional Hub', text: 'Outbound EV truck dispatched to Bengaluru', type: 'outbound' },
|
||||
{ time: '09:58 AM', hub: 'Koramangala Micro Hub', text: 'Last-mile batch released to 8 riders', type: 'dispatch' },
|
||||
{ time: '09:40 AM', hub: 'Peelamedu Micro Hub', text: 'Dock 3 backlog cleared', type: 'sort' }
|
||||
];
|
||||
|
||||
export const maintenanceNotices = [
|
||||
{ hub: 'Peelamedu Micro Hub', item: 'Backup generator test', due: 'Overdue 1 day', severity: 'high' },
|
||||
{ hub: 'Hoskote Regional Hub', item: 'Conveyor belt B service', due: 'Due in 2 days', severity: 'medium' },
|
||||
{ hub: 'Hyderabad Regional Hub', item: 'Dock leveler inspection', due: 'Scheduled tonight', severity: 'low' }
|
||||
];
|
||||
|
||||
// AI contribution metrics for the AI Impact section.
|
||||
export const aiImpact = {
|
||||
routeSavings: 34, delaysPrevented: 23, optimizationActions: 41,
|
||||
fuelSavedL: 1240, co2ReducedKg: 12840, networkEfficiency: 92
|
||||
};
|
||||
|
||||
// ==============================|| TRIP JOURNEY (vehicle trip + manifest) ||============================== //
|
||||
// Primary entity = ONE line-haul TRIP: a single vehicle + driver moving a sealed manifest of many
|
||||
// shipments along the Coimbatore → Bengaluru corridor. Milestones are TRIP-level only; each
|
||||
// shipment's own lifecycle lives on Shipment 360 (/orders/:id), linked from the manifest.
|
||||
export const trip = {
|
||||
id: 'TRIP-CBE-BLR-0463',
|
||||
corridor: 'CBE → BLR',
|
||||
status: 'In Transit',
|
||||
mode: 'EV-first line-haul',
|
||||
vehicle: { reg: 'TN-37-LH-0463', type: 'EV Truck 4W', capacityKg: 1200, battery: 62 },
|
||||
driver: { name: 'Imran Sheikh', phone: '+91 98860 44781' },
|
||||
from: { city: 'Coimbatore', hub: 'Coimbatore Regional Hub' },
|
||||
to: { city: 'Bengaluru', hub: 'Hoskote Regional Hub' },
|
||||
distance: 365,
|
||||
departed: '08 Jun, 12:05 PM',
|
||||
eta: '09 Jun, 06:30 AM',
|
||||
progress: 52,
|
||||
currentStage: 'Line-Haul · Chennai → Bengaluru',
|
||||
hops: [
|
||||
{ key: 'booked', mile: 'First Mile', title: 'Shipment Booked', node: 'Chennai · T. Nagar', handler: 'Customer · Suresh Kumar', icon: 'order', time: '08 Jun, 09:12 AM', status: 'done', detail: 'Shipment ID generated · digital docs & e-waybill created' },
|
||||
{ key: 'pickup', mile: 'First Mile', title: 'Agent Pickup', node: 'Chennai · T. Nagar', handler: 'Rider · Faisal Khan (EV 2W)', icon: 'agent', time: '08 Jun, 10:05 AM', status: 'done', detail: 'OTP verified · photo proof captured · tamper seal applied' },
|
||||
{ key: 'nearhub', mile: 'First Mile', title: 'Nearest Hub — Check-in', node: 'Nungambakkam Micro Hub', handler: 'Hub operations', icon: 'hub', time: '08 Jun, 10:48 AM', status: 'done', detail: 'Scanned in · sorted for origin main hub' },
|
||||
{ key: 'mainhub-out', mile: 'Mid Mile', title: 'Origin Main Hub — Dispatched', node: 'Guindy Regional Hub (Chennai)', handler: 'Hub operations', icon: 'hub', time: '08 Jun, 01:20 PM', status: 'done', detail: 'Consolidated & loaded onto line-haul EV truck' },
|
||||
{ key: 'linehaul', mile: 'Mid Mile', title: 'Line-Haul In Transit', node: 'NH48 · Chennai → Bengaluru', handler: 'Driver · Imran Sheikh (EV Truck 4W)', icon: 'truck', time: '08 Jun, 01:35 PM', status: 'active', detail: 'En route · live GPS · 178 km to destination hub' },
|
||||
{ key: 'desthub', mile: 'Mid Mile', title: 'Destination Main Hub — Arrival', node: 'Hoskote Regional Hub (Bengaluru)', handler: 'Hub operations', icon: 'hub', time: 'Est. 09 Jun, 06:30 AM', status: 'pending', detail: 'Inbound scan & sortation' },
|
||||
{ key: 'subhub', mile: 'Last Mile', title: 'Sub Hub — Last-Mile Sort', node: 'Koramangala Micro Hub', handler: 'Hub operations', icon: 'hub', time: 'Est. 09 Jun, 10:15 AM', status: 'pending', detail: 'Routed to delivery agent zone' },
|
||||
{ key: 'assigned', mile: 'Last Mile', title: 'Delivery Agent Assigned', node: 'Bengaluru · Koramangala', handler: 'MileTruth AI · auto-assign', icon: 'agent', time: 'Est. 09 Jun, 11:00 AM', status: 'pending', detail: 'Multi-stop route optimized for the agent' },
|
||||
{ key: 'ofd', mile: 'Last Mile', title: 'Out for Delivery', node: 'Bengaluru · Koramangala', handler: 'Rider · auto-assigned (EV 2W)', icon: 'agent', time: 'Est. 09 Jun, 01:40 PM', status: 'pending', detail: 'Live tracking link shared with customer' },
|
||||
{ key: 'delivered', mile: 'Last Mile', title: 'Delivered', node: 'Bengaluru · Koramangala', handler: 'Customer · Riya Sharma', icon: 'done', time: 'Est. 09 Jun, 02:30 PM', status: 'pending', detail: 'OTP + photo proof of delivery' }
|
||||
loadKg: 940,
|
||||
pieces: 312,
|
||||
// TRIP-level milestones (not per-shipment)
|
||||
milestones: [
|
||||
{ key: 'sealed', title: 'Manifest Sealed', node: 'Coimbatore Regional Hub', time: '08 Jun, 11:20 AM', status: 'done', detail: '12 shipments · 312 pieces · 940 kg consolidated' },
|
||||
{ key: 'departed', title: 'Loaded & Departed', node: 'Coimbatore Regional Hub', time: '08 Jun, 12:05 PM', status: 'done', detail: 'Bay 4 · e-waybill EWB-77410 generated' },
|
||||
{ key: 'transit', title: 'In Transit · Line-Haul', node: 'CBE → BLR corridor (NH-544)', time: '08 Jun, 12:05 PM', status: 'active', detail: 'Live GPS · 168 km to destination hub · ETA protected' },
|
||||
{ key: 'arrival', title: 'Arrival Scan', node: 'Hoskote Regional Hub', time: 'Est. 09 Jun, 06:30 AM', status: 'pending', detail: 'Inbound gate scan & trip reconciliation' },
|
||||
{ key: 'sorted', title: 'Unloaded & Sorted', node: 'Hoskote Regional Hub', time: 'Est. 09 Jun, 07:30 AM', status: 'pending', detail: 'Shipments broken to last-mile micro hubs' },
|
||||
{ key: 'handover', title: 'Handover to Last-Mile', node: 'Bengaluru micro hubs', time: 'Est. 09 Jun, 09:00 AM', status: 'pending', detail: 'Each shipment enters its own last-mile journey' }
|
||||
],
|
||||
// every shipment riding this trip — links into Shipment 360
|
||||
manifest: [
|
||||
{ id: 'DM-10242', client: 'UrbanCart', customer: 'Karthik Rao', dropArea: 'Whitefield', pieces: 1, weightKg: 12.8, status: 'On board' },
|
||||
{ id: 'DM-10241', client: 'Freshly Foods', customer: 'Riya Sharma', dropArea: 'HSR Layout', pieces: 3, weightKg: 9.4, status: 'On board' },
|
||||
{ id: 'DM-10245', client: 'Freshly Foods', customer: 'Sneha Iyer', dropArea: 'BTM Layout', pieces: 4, weightKg: 14.2, status: 'On board' },
|
||||
{ id: 'DM-10248', client: 'UrbanCart', customer: 'Suresh Kumar', dropArea: 'Jayanagar', pieces: 1, weightKg: 8.3, status: 'On board' },
|
||||
{ id: 'DM-10261', client: 'TechNova Retail', customer: 'Vikram Reddy', dropArea: 'Indiranagar', pieces: 2, weightKg: 6.1, status: 'On board' },
|
||||
{ id: 'DM-10263', client: 'GreenLeaf Organics', customer: 'Pooja Desai', dropArea: 'Koramangala', pieces: 5, weightKg: 22.0, status: 'On board' },
|
||||
{ id: 'DM-10266', client: 'BloomBox Florists', customer: 'Arjun Mehta', dropArea: 'JP Nagar', pieces: 1, weightKg: 3.2, status: 'On board' },
|
||||
{ id: 'DM-10270', client: 'UrbanCart', customer: 'Meera Nair', dropArea: 'Marathahalli', pieces: 2, weightKg: 5.5, status: 'On board' },
|
||||
{ id: 'DM-10274', client: 'Freshly Foods', customer: 'Anil Gupta', dropArea: 'Electronic City', pieces: 3, weightKg: 11.7, status: 'On board' },
|
||||
{ id: 'DM-10277', client: 'TechNova Retail', customer: 'Sameer Joshi', dropArea: 'Hebbal', pieces: 1, weightKg: 4.0, status: 'On board' },
|
||||
{ id: 'DM-10281', client: 'MediQuick Pharma', customer: 'Rohit Sen', dropArea: 'Whitefield', pieces: 2, weightKg: 7.6, status: 'On board' },
|
||||
{ id: 'DM-10285', client: 'GreenLeaf Organics', customer: 'Tina Roy', dropArea: 'Yelahanka', pieces: 6, weightKg: 26.4, status: 'On board' }
|
||||
],
|
||||
events: [
|
||||
{ time: '08 Jun, 03:10 PM', type: 'reroute', title: 'Traffic on NH48 near Krishnagiri', detail: 'MileTruth AI rerouted via Hosur bypass — ETA protected (+0 min)' },
|
||||
{ time: '08 Jun, 05:40 PM', type: 'monitor', title: 'Weather check · clear', detail: 'No disruption forecast on the corridor' },
|
||||
{ time: '08 Jun, 08:15 PM', type: 'monitor', title: 'On schedule', detail: 'Vehicle at 62% battery · charging stop planned at Hosur' }
|
||||
{ time: '08 Jun, 02:10 PM', type: 'reroute', title: 'Congestion near Salem bypass', detail: 'MileTruth AI rerouted via NH-544 — ETA protected (+0 min)' },
|
||||
{ time: '08 Jun, 05:40 PM', type: 'monitor', title: 'Weather check · clear', detail: 'No disruption forecast on the CBE → BLR corridor' },
|
||||
{ time: '08 Jun, 08:15 PM', type: 'monitor', title: 'On schedule', detail: 'Vehicle at 62% battery · charging stop planned at Krishnagiri' }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -249,12 +325,12 @@ export const verticalOf = (tenant) => tenantVertical[tenant] || 'Enterprise & B2
|
||||
// ==============================|| PHARMA COLD-CHAIN ||============================== //
|
||||
export const coldChainSummary = { monitored: 248, inRange: 241, atRisk: 5, breaches: 2, compliance: 99.2, avgTemp: 4.2 };
|
||||
export const coldChainShipments = [
|
||||
{ id: 'DM-CC-2041', product: 'Insulin vials', tenant: 'MediQuick Pharma', range: '2 – 8 °C', temp: 4.6, route: 'Andheri → Bandra', status: 'in-range', excursionMin: 0 },
|
||||
{ id: 'DM-CC-2042', product: 'mRNA vaccine', tenant: 'MediQuick Pharma', range: '-20 – -15 °C', temp: -17.2, route: 'Hub HYD → BLR', status: 'in-range', excursionMin: 0 },
|
||||
{ id: 'DM-CC-2043', product: 'Frozen seafood', tenant: 'Freshly Foods', range: '≤ -18 °C', temp: -12.4, route: 'Koramangala → HSR', status: 'breach', excursionMin: 14 },
|
||||
{ id: 'DM-CC-2044', product: 'Dairy & yogurt', tenant: 'GreenLeaf Organics', range: '2 – 6 °C', temp: 7.1, route: 'Koregaon → Viman Nagar', status: 'at-risk', excursionMin: 3 },
|
||||
{ id: 'DM-CC-2045', product: 'Blood samples', tenant: 'MediQuick Pharma', range: '2 – 8 °C', temp: 5.3, route: 'Connaught Pl → Saket', status: 'in-range', excursionMin: 0 },
|
||||
{ id: 'DM-CC-2046', product: 'Antibiotics', tenant: 'MediQuick Pharma', range: '15 – 25 °C', temp: 21.4, route: 'Hitech City → Gachibowli', status: 'in-range', excursionMin: 0 }
|
||||
{ id: 'DM-CC-2041', product: 'Insulin vials', tenant: 'MediQuick Pharma', range: '2 – 8 °C', temp: 4.6, route: 'Hyderabad MedTech Zone → Jubilee Hills Hospital', status: 'in-range', excursionMin: 0 },
|
||||
{ id: 'DM-CC-2042', product: 'mRNA vaccine', tenant: 'MediQuick Pharma', range: '-20 – -15 °C', temp: -17.2, route: 'Hub HYD → Hub BLR', status: 'in-range', excursionMin: 0 },
|
||||
{ id: 'DM-CC-2043', product: 'Blood plasma', tenant: 'MediQuick Pharma', range: '≤ -18 °C', temp: -12.4, route: 'Bengaluru Pharma Park → Whitefield Clinic', status: 'breach', excursionMin: 14 },
|
||||
{ id: 'DM-CC-2044', product: 'Dairy & yogurt', tenant: 'GreenLeaf Organics', range: '2 – 6 °C', temp: 7.1, route: 'Coimbatore Cold Store → Peelamedu', status: 'at-risk', excursionMin: 3 },
|
||||
{ id: 'DM-CC-2045', product: 'Blood samples', tenant: 'MediQuick Pharma', range: '2 – 8 °C', temp: 5.3, route: 'Coimbatore Medical Hub → Peelamedu Hospital', status: 'in-range', excursionMin: 0 },
|
||||
{ id: 'DM-CC-2046', product: 'Antibiotics', tenant: 'MediQuick Pharma', range: '15 – 25 °C', temp: 21.4, route: 'Hyderabad MedTech Zone → Gachibowli Clinic', status: 'in-range', excursionMin: 0 }
|
||||
];
|
||||
|
||||
// ==============================|| LOGISTICS OPERATING SYSTEM — LAYER DATA ||============================== //
|
||||
@@ -291,37 +367,37 @@ export const trustQueue = [
|
||||
];
|
||||
|
||||
// ---- Layer 3: Intelligent Hub Network ----
|
||||
// Live hub network across the three operating metros: Coimbatore · Bengaluru · Hyderabad.
|
||||
export const hubs = [
|
||||
{ id: 'HUB-BLR-01', name: 'Koramangala Micro Hub', type: 'Micro Hub', city: 'Bengaluru', lat: 12.9352, lng: 77.6245, capacity: 1200, load: 940, inbound: 180, outbound: 210, dock: 4, status: 'online' },
|
||||
{ id: 'HUB-BLR-02', name: 'Whitefield City Hub', type: 'City Hub', city: 'Bengaluru', lat: 12.9698, lng: 77.7499, capacity: 4000, load: 2860, inbound: 520, outbound: 610, dock: 10, status: 'online' },
|
||||
{ id: 'HUB-CBE-RG', name: 'Coimbatore Regional Hub', type: 'Regional Hub', city: 'Coimbatore', lat: 11.0168, lng: 76.9558, capacity: 9000, load: 6120, inbound: 1400, outbound: 1520, dock: 18, status: 'online' },
|
||||
{ id: 'HUB-CBE-01', name: 'Peelamedu Micro Hub', type: 'Micro Hub', city: 'Coimbatore', lat: 11.0290, lng: 76.9960, capacity: 1400, load: 1180, inbound: 240, outbound: 260, dock: 5, status: 'busy' },
|
||||
{ id: 'HUB-BLR-RG', name: 'Hoskote Regional Hub', type: 'Regional Hub', city: 'Bengaluru', lat: 13.0707, lng: 77.7980, capacity: 12000, load: 7400, inbound: 1900, outbound: 2100, dock: 24, status: 'online' },
|
||||
{ id: 'HUB-MUM-01', name: 'Andheri City Hub', type: 'City Hub', city: 'Mumbai', lat: 19.1197, lng: 72.8468, capacity: 4500, load: 3950, inbound: 700, outbound: 760, dock: 12, status: 'busy' },
|
||||
{ id: 'HUB-HYD-01', name: 'Hitech Cross Dock', type: 'Cross Dock', city: 'Hyderabad', lat: 17.4435, lng: 78.3772, capacity: 3000, load: 1100, inbound: 340, outbound: 360, dock: 8, status: 'online' },
|
||||
{ id: 'HUB-DEL-RG', name: 'Bilaspur Regional Hub', type: 'Regional Hub', city: 'Delhi NCR', lat: 28.4231, lng: 77.0490, capacity: 14000, load: 11200, inbound: 2400, outbound: 2600, dock: 28, status: 'busy' }
|
||||
{ id: 'HUB-BLR-01', name: 'Koramangala Micro Hub', type: 'Micro Hub', city: 'Bengaluru', lat: 12.9352, lng: 77.6245, capacity: 1200, load: 1090, inbound: 180, outbound: 210, dock: 4, status: 'busy' },
|
||||
{ id: 'HUB-HYD-RG', name: 'Hyderabad Regional Hub', type: 'Regional Hub', city: 'Hyderabad', lat: 17.4435, lng: 78.3772, capacity: 8000, load: 3960, inbound: 920, outbound: 980, dock: 16, status: 'online' },
|
||||
{ id: 'HUB-HYD-01', name: 'Gachibowli Micro Hub', type: 'Micro Hub', city: 'Hyderabad', lat: 17.4401, lng: 78.3489, capacity: 1300, load: 640, inbound: 150, outbound: 165, dock: 5, status: 'online' }
|
||||
];
|
||||
|
||||
export const hubNetworkTypes = [
|
||||
{ type: 'Micro Hubs (Urban)', count: 5, desc: 'Last-mile EV staging' },
|
||||
{ type: 'City Hubs', count: 4, desc: 'Sort & local distribution' },
|
||||
{ type: 'Regional Hubs', count: 2, desc: 'Aggregation & line-haul origin' },
|
||||
{ type: 'Cross Dock Points', count: 1, desc: 'No-store transfer' }
|
||||
{ type: 'Regional Hubs', count: 3, desc: 'Aggregation & line-haul origin' },
|
||||
{ type: 'Micro Hubs (Urban)', count: 3, desc: 'Last-mile EV staging' }
|
||||
];
|
||||
|
||||
// Inter-city line-haul corridors connecting the three regional hubs (the network triangle).
|
||||
export const lineHauls = [
|
||||
{ id: 'LH-001', from: 'Hoskote Regional Hub', to: 'Bilaspur Regional Hub', corridor: 'BLR → DEL', distance: 2150, vehicle: 'EV Truck 4W', load: 86, eta: '34h', status: 'in-transit' },
|
||||
{ id: 'LH-002', from: 'Andheri City Hub', to: 'Hoskote Regional Hub', corridor: 'MUM → BLR', distance: 980, vehicle: 'ICE Truck 6W', load: 72, eta: '18h', status: 'in-transit' },
|
||||
{ id: 'LH-003', from: 'Hitech Cross Dock', to: 'Hoskote Regional Hub', corridor: 'HYD → BLR', distance: 570, vehicle: 'EV Truck 4W', load: 64, eta: '11h', status: 'scheduled' },
|
||||
{ id: 'LH-004', from: 'Bilaspur Regional Hub', to: 'Andheri City Hub', corridor: 'DEL → MUM', distance: 1420, vehicle: 'ICE Truck 6W', load: 91, eta: '24h', status: 'loading' }
|
||||
{ id: 'LH-001', from: 'Coimbatore Regional Hub', to: 'Hoskote Regional Hub', corridor: 'CBE → BLR', distance: 365, vehicle: 'EV Truck 4W', load: 84, eta: '7h', status: 'in-transit' },
|
||||
{ id: 'LH-002', from: 'Hoskote Regional Hub', to: 'Hyderabad Regional Hub', corridor: 'BLR → HYD', distance: 570, vehicle: 'EV Truck 4W', load: 71, eta: '11h', status: 'in-transit' },
|
||||
{ id: 'LH-003', from: 'Hyderabad Regional Hub', to: 'Coimbatore Regional Hub', corridor: 'HYD → CBE', distance: 790, vehicle: 'ICE Truck 6W', load: 58, eta: '15h', status: 'scheduled' },
|
||||
{ id: 'LH-004', from: 'Hoskote Regional Hub', to: 'Coimbatore Regional Hub', corridor: 'BLR → CBE', distance: 365, vehicle: 'EV Truck 4W', load: 92, eta: '7h', status: 'loading' }
|
||||
];
|
||||
|
||||
// ---- Layer 4: Fleet & Rider Operating System ----
|
||||
export const fleet = [
|
||||
{ id: 'VH-EV-018', model: 'Tata Ace EV', type: 'EV 4W', powertrain: 'EV', battery: 78, range: 96, health: 94, capacityKg: 600, status: 'on-trip', rider: 'Mohan Das', hub: 'Koramangala Micro Hub', uptime: 98.2 },
|
||||
{ id: 'VH-EV-022', model: 'Euler HiLoad EV', type: 'EV 3W', powertrain: 'EV', battery: 41, range: 38, health: 89, capacityKg: 688, status: 'charging', rider: '—', hub: 'Whitefield City Hub', uptime: 96.5 },
|
||||
{ id: 'VH-2W-104', model: 'Ola S1 Pro', type: 'EV 2W', powertrain: 'EV', battery: 63, range: 71, health: 91, capacityKg: 20, status: 'on-trip', rider: 'Ravi Teja', hub: 'Hitech Cross Dock', uptime: 97.1 },
|
||||
{ id: 'VH-EV-022', model: 'Euler HiLoad EV', type: 'EV 3W', powertrain: 'EV', battery: 41, range: 38, health: 89, capacityKg: 688, status: 'charging', rider: '—', hub: 'Hoskote Regional Hub', uptime: 96.5 },
|
||||
{ id: 'VH-2W-104', model: 'Ola S1 Pro', type: 'EV 2W', powertrain: 'EV', battery: 63, range: 71, health: 91, capacityKg: 20, status: 'on-trip', rider: 'Ravi Teja', hub: 'Hyderabad Regional Hub', uptime: 97.1 },
|
||||
{ id: 'VH-IC-211', model: 'Mahindra Bolero', type: 'ICE 4W', powertrain: 'ICE', battery: null, range: 320, health: 82, capacityKg: 1500, status: 'idle', rider: '—', hub: 'Hoskote Regional Hub', uptime: 93.4 },
|
||||
{ id: 'VH-2W-118', model: 'Honda Activa', type: 'ICE 2W', powertrain: 'ICE', battery: null, range: 180, health: 76, capacityKg: 25, status: 'maintenance', rider: '—', hub: 'Andheri City Hub', uptime: 88.9 },
|
||||
{ id: 'VH-EV-031', model: 'Tata Ace EV', type: 'EV 4W', powertrain: 'EV', battery: 88, range: 108, health: 96, capacityKg: 600, status: 'idle', rider: '—', hub: 'Bilaspur Regional Hub', uptime: 99.0 }
|
||||
{ id: 'VH-2W-118', model: 'Honda Activa', type: 'ICE 2W', powertrain: 'ICE', battery: null, range: 180, health: 76, capacityKg: 25, status: 'maintenance', rider: '—', hub: 'Coimbatore Regional Hub', uptime: 88.9 },
|
||||
{ id: 'VH-EV-031', model: 'Tata Ace EV', type: 'EV 4W', powertrain: 'EV', battery: 88, range: 108, health: 96, capacityKg: 600, status: 'idle', rider: '—', hub: 'Hyderabad Regional Hub', uptime: 99.0 }
|
||||
];
|
||||
|
||||
export const fleetSummary = {
|
||||
@@ -332,8 +408,8 @@ export const fleetSummary = {
|
||||
// ---- Layer 5: MileTruth AI Engine — dispatch & optimization ----
|
||||
export const dispatchQueue = [
|
||||
{ id: 'DM-10246', pickup: 'Hitech City', drop: 'Gachibowli', priority: 'high', sla: '45 min', suggestedRider: 'Ravi Teja', confidence: 96, status: 'matched', etaMin: 22 },
|
||||
{ id: 'DM-10244', pickup: 'Connaught Place', drop: 'Saket, Block C', priority: 'standard', sla: '90 min', suggestedRider: 'Sandeep Roy', confidence: 88, status: 'optimizing', etaMin: 41 },
|
||||
{ id: 'DM-10248', pickup: 'T. Nagar', drop: 'Adyar', priority: 'standard', sla: '120 min', suggestedRider: 'Faisal Khan', confidence: 91, status: 'matched', etaMin: 33 },
|
||||
{ id: 'DM-10244', pickup: 'Gandhipuram', drop: 'R.S. Puram', priority: 'standard', sla: '90 min', suggestedRider: 'Sandeep Roy', confidence: 88, status: 'optimizing', etaMin: 41 },
|
||||
{ id: 'DM-10248', pickup: 'Jayanagar', drop: 'JP Nagar', priority: 'standard', sla: '120 min', suggestedRider: 'Faisal Khan', confidence: 91, status: 'matched', etaMin: 33 },
|
||||
{ id: 'DM-10242', pickup: 'Indiranagar Store', drop: 'Whitefield, Phase 1', priority: 'express', sla: '60 min', suggestedRider: 'Mohan Das', confidence: 94, status: 'dispatched', etaMin: 28 }
|
||||
];
|
||||
|
||||
@@ -359,13 +435,152 @@ export const aiMetrics = { routeSavings: 34, avgEtaAccuracy: 92, batchRate: 2.6,
|
||||
|
||||
// ---- Layer 6: Execution & Visibility ----
|
||||
export const executionFeed = [
|
||||
{ id: 'DM-10242', stage: 'In-Transit', rider: 'Mohan Das', loc: 'HSR Layout', detail: 'On route · 4.2 km to drop', time: '10:42 AM', proof: null },
|
||||
{ id: 'DM-10243', stage: 'Picked Up', rider: 'Imran Sheikh', loc: 'Andheri West', detail: 'OTP verified · photo proof logged', time: '10:38 AM', proof: 'pickup' },
|
||||
{ id: 'DM-10247', stage: 'Delivered', rider: 'Ravi Teja', loc: 'Viman Nagar', detail: 'eSign captured · POD uploaded', time: '10:30 AM', proof: 'delivery' },
|
||||
{ id: 'DM-10251', stage: 'Exception', rider: 'Faisal Khan', loc: 'Adyar', detail: 'Customer unavailable · reattempt scheduled', time: '10:21 AM', proof: null },
|
||||
{ id: 'DM-10246', stage: 'Dispatched', rider: 'Sandeep Roy', loc: 'Hitech City', detail: 'Rider en route to pickup', time: '10:18 AM', proof: null }
|
||||
{ id: 'DM-10242', stage: 'In-Transit', rider: 'Mohan Das', loc: 'Indiranagar → Whitefield', detail: 'On route · 4.2 km to drop', time: '10:42 AM', proof: null },
|
||||
{ id: 'DM-10243', stage: 'Picked Up', rider: 'Imran Sheikh', loc: 'Gandhipuram → Peelamedu', detail: 'OTP verified · photo proof logged', time: '10:38 AM', proof: 'pickup' },
|
||||
{ id: 'DM-10247', stage: 'Delivered', rider: 'Ravi Teja', loc: 'Banjara Hills → Madhapur', detail: 'eSign captured · POD uploaded', time: '10:30 AM', proof: 'delivery' },
|
||||
{ id: 'DM-10251', stage: 'Exception', rider: 'Imran Sheikh', loc: 'R.S. Puram → Saravanampatti', detail: 'Customer unavailable · reattempt scheduled', time: '10:21 AM', proof: null },
|
||||
{ id: 'DM-10246', stage: 'Dispatched', rider: 'Sandeep Roy', loc: 'Hitech City → Gachibowli', detail: 'Rider en route to pickup', time: '10:18 AM', proof: null }
|
||||
];
|
||||
|
||||
// Active Deliveries Board — the live shipments an ops manager is accountable for right now.
|
||||
// etaStatus drives row tone & ETA colour: on-time (green), at-risk (amber), delayed (red).
|
||||
// delayMin is the signed deviation vs SLA (negative = ahead, positive = late) for delayed/at-risk rows.
|
||||
// ---- Live Tracking Control Tower — geo-coded shipments with real coordinates & routes ----
|
||||
// Each delivery carries pickup/drop lat-lng plus a road-shaped polyline; the map interpolates
|
||||
// the vehicle's live position from `progress`. vehicle ∈ Bike | Auto | Truck | Van.
|
||||
export const activeDeliveries = [
|
||||
{
|
||||
id: 'DM-10242', rider: 'Mohan Das', vehicle: 'Bike', priority: 'express', city: 'Bengaluru',
|
||||
origin: 'Indiranagar Store', destination: 'Whitefield, Phase 1', eta: '11:10 AM', etaStatus: 'on-time', delayMin: 0, status: 'In-Transit', progress: 68,
|
||||
pickup: { lat: 12.9719, lng: 77.6412 }, drop: { lat: 12.9698, lng: 77.7499 },
|
||||
route: [[12.9719, 77.6412], [12.9716, 77.672], [12.975, 77.701], [12.9705, 77.73], [12.9698, 77.7499]]
|
||||
},
|
||||
{
|
||||
id: 'DM-10243', rider: 'Imran Sheikh', vehicle: 'Bike', priority: 'high', city: 'Coimbatore',
|
||||
origin: 'Gandhipuram', destination: 'Peelamedu', eta: '11:24 AM', etaStatus: 'delayed', delayMin: 18, status: 'In-Transit', progress: 42,
|
||||
pickup: { lat: 11.0177, lng: 76.9656 }, drop: { lat: 11.03, lng: 76.9963 },
|
||||
route: [[11.0177, 76.9656], [11.021, 76.976], [11.027, 76.988], [11.03, 76.9963]]
|
||||
},
|
||||
{
|
||||
id: 'DM-10246', rider: 'Sandeep Roy', vehicle: 'Van', priority: 'high', city: 'Hyderabad',
|
||||
origin: 'Hitech City', destination: 'Gachibowli', eta: '11:02 AM', etaStatus: 'on-time', delayMin: 0, status: 'Dispatched', progress: 12,
|
||||
pickup: { lat: 17.4435, lng: 78.3772 }, drop: { lat: 17.4401, lng: 78.3489 },
|
||||
route: [[17.4435, 78.3772], [17.443, 78.365], [17.441, 78.355], [17.4401, 78.3489]]
|
||||
},
|
||||
{
|
||||
id: 'DM-10248', rider: 'Faisal Khan', vehicle: 'Truck', priority: 'standard', city: 'Bengaluru',
|
||||
origin: 'BTM Layout Depot', destination: 'Jayanagar 4th Block', eta: '11:35 AM', etaStatus: 'at-risk', delayMin: 7, status: 'In-Transit', progress: 55,
|
||||
pickup: { lat: 12.9166, lng: 77.6101 }, drop: { lat: 12.925, lng: 77.5938 },
|
||||
route: [[12.9166, 77.6101], [12.92, 77.602], [12.925, 77.5938]]
|
||||
},
|
||||
{
|
||||
id: 'DM-10244', rider: 'Sandeep Roy', vehicle: 'Van', priority: 'standard', city: 'Bengaluru',
|
||||
origin: 'Koramangala Hub', destination: 'Electronic City', eta: '12:05 PM', etaStatus: 'at-risk', delayMin: 9, status: 'Picked Up', progress: 30,
|
||||
pickup: { lat: 12.9352, lng: 77.6245 }, drop: { lat: 12.8452, lng: 77.6602 },
|
||||
route: [[12.9352, 77.6245], [12.91, 77.635], [12.88, 77.648], [12.8452, 77.6602]]
|
||||
},
|
||||
{
|
||||
id: 'DM-10251', rider: 'Imran Sheikh', vehicle: 'Auto', priority: 'high', city: 'Coimbatore',
|
||||
origin: 'R.S. Puram', destination: 'Saravanampatti', eta: 'Reattempt', etaStatus: 'delayed', delayMin: 35, status: 'Exception', progress: 80,
|
||||
pickup: { lat: 11.005, lng: 76.95 }, drop: { lat: 11.079, lng: 77.009 },
|
||||
route: [[11.005, 76.95], [11.03, 76.97], [11.055, 76.99], [11.079, 77.009]]
|
||||
},
|
||||
{
|
||||
id: 'DM-10241', rider: 'Mohan Das', vehicle: 'Auto', priority: 'standard', city: 'Bengaluru',
|
||||
origin: 'Koramangala Hub', destination: 'HSR Layout, Sec 2', eta: '10:58 AM', etaStatus: 'on-time', delayMin: 0, status: 'In-Transit', progress: 88,
|
||||
pickup: { lat: 12.9352, lng: 77.6245 }, drop: { lat: 12.9081, lng: 77.6476 },
|
||||
route: [[12.9352, 77.6245], [12.922, 77.636], [12.9081, 77.6476]]
|
||||
},
|
||||
{
|
||||
id: 'DM-10247', rider: 'Ravi Teja', vehicle: 'Bike', priority: 'express', city: 'Hyderabad',
|
||||
origin: 'Banjara Hills', destination: 'Madhapur', eta: '10:46 AM', etaStatus: 'on-time', delayMin: 0, status: 'In-Transit', progress: 74,
|
||||
pickup: { lat: 17.4156, lng: 78.4347 }, drop: { lat: 17.4483, lng: 78.3915 },
|
||||
route: [[17.4156, 78.4347], [17.43, 78.41], [17.4483, 78.3915]]
|
||||
},
|
||||
{
|
||||
id: 'DM-10239', rider: 'Ravi Teja', vehicle: 'Bike', priority: 'standard', city: 'Bengaluru',
|
||||
origin: 'Koramangala Hub', destination: 'Koramangala 5th Block', eta: '10:18 AM', etaStatus: 'on-time', delayMin: 0, status: 'Delivered', progress: 100,
|
||||
pickup: { lat: 12.9352, lng: 77.6245 }, drop: { lat: 12.9352, lng: 77.6135 },
|
||||
route: [[12.9352, 77.6245], [12.9352, 77.619], [12.9352, 77.6135]]
|
||||
},
|
||||
{
|
||||
id: 'DM-10240', rider: 'Sandeep Roy', vehicle: 'Van', priority: 'standard', city: 'Hyderabad',
|
||||
origin: 'Hitech City', destination: 'Kondapur', eta: '10:05 AM', etaStatus: 'on-time', delayMin: 0, status: 'Delivered', progress: 100,
|
||||
pickup: { lat: 17.4435, lng: 78.3772 }, drop: { lat: 17.4615, lng: 78.3635 },
|
||||
route: [[17.4435, 78.3772], [17.452, 78.37], [17.4615, 78.3635]]
|
||||
}
|
||||
];
|
||||
|
||||
// Control-tower KPI strip — the live operational pulse across the whole fleet.
|
||||
export const executionOps = {
|
||||
activeDeliveries: 96,
|
||||
delayed: 4,
|
||||
onTime: 98.6,
|
||||
activeRiders: 12,
|
||||
reroutes: 7,
|
||||
criticalAlerts: 3
|
||||
};
|
||||
|
||||
// Vehicle-type design tokens — icon glyph + brand colour, shared by queue, markers & legend.
|
||||
export const vehicleTypes = {
|
||||
Bike: { glyph: '🛵', color: '#1D4ED8' },
|
||||
Auto: { glyph: '🛺', color: '#EA580C' },
|
||||
Truck: { glyph: '🚚', color: '#7C3AED' },
|
||||
Van: { glyph: '🚐', color: '#0E7C7B' }
|
||||
};
|
||||
|
||||
// Fleet totals for the map legend (whole fleet, not just tracked shipments).
|
||||
export const fleetTotals = { Bike: 68, Auto: 16, Truck: 8, Van: 6, delayed: 4 };
|
||||
|
||||
// Inter-city corridor load — active shipments moving between the three service metros.
|
||||
// Rendered as faint lanes on the map and summarised in the corridor strip.
|
||||
export const networkCorridors = [
|
||||
{ from: 'Coimbatore', to: 'Bengaluru', active: 12, onTime: 97 },
|
||||
{ from: 'Bengaluru', to: 'Hyderabad', active: 9, onTime: 98 },
|
||||
{ from: 'Coimbatore', to: 'Hyderabad', active: 6, onTime: 96 }
|
||||
];
|
||||
|
||||
// Ambient live fleet scattered around the three metros — gives the map a real control-tower
|
||||
// density and feeds marker clustering. Deterministically generated so positions stay stable.
|
||||
export const fleetVehicles = (() => {
|
||||
const centers = {
|
||||
Bengaluru: [12.9716, 77.5946],
|
||||
Coimbatore: [11.0168, 76.9558],
|
||||
Hyderabad: [17.385, 78.4867]
|
||||
};
|
||||
const cityKeys = Object.keys(centers);
|
||||
// small seeded PRNG (mulberry32) for stable scatter
|
||||
let s = 0x9e3779b9;
|
||||
const rnd = () => {
|
||||
s |= 0; s = (s + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
const counts = { Bike: 64, Auto: 14, Truck: 7, Van: 3 }; // tracked vehicles make up the legend remainder
|
||||
const riderNames = ['Mohan Das', 'Imran Sheikh', 'Ravi Teja', 'Sandeep Roy', 'Faisal Khan', 'Arjun Mehta', 'Vikram Reddy', 'Suresh Kumar'];
|
||||
const out = [];
|
||||
let n = 0;
|
||||
Object.entries(counts).forEach(([type, count]) => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const ck = cityKeys[Math.floor(rnd() * cityKeys.length)];
|
||||
const [clat, clng] = centers[ck];
|
||||
const spread = ck === 'Bengaluru' ? 0.09 : 0.06;
|
||||
out.push({
|
||||
id: `VH-${type.slice(0, 2).toUpperCase()}-${String(++n).padStart(3, '0')}`,
|
||||
type,
|
||||
city: ck,
|
||||
lat: clat + (rnd() - 0.5) * spread * 2,
|
||||
lng: clng + (rnd() - 0.5) * spread * 2,
|
||||
bearing: Math.floor(rnd() * 360),
|
||||
rider: riderNames[Math.floor(rnd() * riderNames.length)],
|
||||
status: rnd() > 0.25 ? 'on-trip' : 'idle'
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
})();
|
||||
|
||||
export const executionStages = [
|
||||
{ key: 'pickup', title: 'Pickup Execution', items: ['Rider reaches pickup', 'OTP / eSign', 'Photo proof', 'Proof of pickup'], count: 12 },
|
||||
{ key: 'transit', title: 'In-Transit Tracking', items: ['Live GPS', 'Route monitoring', 'ETA updates', 'Geofencing'], count: 96 },
|
||||
@@ -417,3 +632,79 @@ export const integrations = [
|
||||
{ name: 'Transport Partners', group: 'Partners & Ecosystem', desc: 'Line-haul carriers', status: 'connected', calls: '8 carriers', icon: 'partner' },
|
||||
{ name: 'Warehouse Partners', group: 'Partners & Ecosystem', desc: 'Fulfilment nodes', status: 'pending', calls: '—', icon: 'partner' }
|
||||
];
|
||||
|
||||
// ==============================|| MILETRUTH AI — INSIGHTS & RECOMMENDATIONS ||============================== //
|
||||
// Powers the dashboard "DoorMile AI Insights" command panel.
|
||||
// type drives the icon; severity drives the chip colour (high=error, medium=warning, low/info=info).
|
||||
export const aiInsights = [
|
||||
{
|
||||
id: 'AI-201', type: 'route', severity: 'low',
|
||||
title: '6 deliveries can be consolidated',
|
||||
detail: 'Six Whitefield drops can merge into one multi-stop run — saves 41 km today.',
|
||||
action: 'Apply optimized route'
|
||||
},
|
||||
{
|
||||
id: 'AI-202', type: 'delay', severity: 'medium',
|
||||
title: 'Route congestion detected near Silk Board',
|
||||
detail: '3 Bengaluru shipments trending +14 min; congestion clearing after 5 PM.',
|
||||
action: 'Reroute via Sarjapur Road'
|
||||
},
|
||||
{
|
||||
id: 'AI-203', type: 'capacity', severity: 'medium',
|
||||
title: 'Peelamedu hub utilization at 82%',
|
||||
detail: 'Coimbatore inbound surge this evening; load factor approaching threshold.',
|
||||
action: 'Divert overflow to Coimbatore Regional Hub'
|
||||
},
|
||||
{
|
||||
id: 'AI-204', type: 'risk', severity: 'high',
|
||||
title: 'Reassign rider suggested · DM-10251',
|
||||
detail: 'R.S. Puram → Saravanampatti at risk after a failed attempt — nearer rider available.',
|
||||
action: 'Reassign to Sandeep Roy'
|
||||
},
|
||||
{
|
||||
id: 'AI-205', type: 'route', severity: 'info',
|
||||
title: 'EV-first opportunity · 9 ICE trips swappable',
|
||||
detail: 'Charging windows align for 9 trips currently on ICE vehicles across BLR & HYD.',
|
||||
action: 'Schedule EV swap'
|
||||
},
|
||||
{
|
||||
id: 'AI-206', type: 'delay', severity: 'info',
|
||||
title: 'Line-haul delay risk · BLR → HYD',
|
||||
detail: 'Weather model predicts 22 min delay after 4 PM on the corridor.',
|
||||
action: 'Pre-stage backup at Hyderabad Regional Hub'
|
||||
}
|
||||
];
|
||||
|
||||
// Network capacity utilization (%) — drives the AI panel mini-block.
|
||||
export const capacityUtilization = { overall: 82, hubs: 76, fleet: 71, riders: 84 };
|
||||
|
||||
// ==============================|| EXCEPTION CENTER — LIVE OPERATIONAL ISSUES ||============================== //
|
||||
// The triage queue an ops manager works first. category drives grouping/filter chips;
|
||||
// severity → row tone (high=error, medium=warning, low=info); status → workflow state.
|
||||
export const exceptionQueue = [
|
||||
{ id: 'DM-10251', category: 'Customer Unavailable', severity: 'high', detail: 'R.S. Puram → Saravanampatti · customer not reachable', owner: 'Imran Sheikh', age: '9 min', status: 'open' },
|
||||
{ id: 'HUB-CBE-01', category: 'Hub Capacity Exceeded', severity: 'high', detail: 'Peelamedu Micro Hub at 84% · inbound surge', owner: 'Hub ops · CBE', age: '8 min', status: 'open' },
|
||||
{ id: 'DM-10243', category: 'Traffic Delay', severity: 'medium', detail: 'Gandhipuram → Peelamedu · +18 min on current traffic', owner: 'Imran Sheikh', age: '12 min', status: 'investigating' },
|
||||
{ id: 'DM-CC-2043', category: 'Weather Delay', severity: 'high', detail: 'Cold-chain breach risk · Bengaluru Pharma Park run', owner: 'MediQuick Pharma', age: '14 min', status: 'open' },
|
||||
{ id: 'VH-2W-118', category: 'Vehicle Breakdown', severity: 'medium', detail: 'Honda Activa down · Coimbatore Regional Hub', owner: 'Fleet ops · CBE', age: '21 min', status: 'open' },
|
||||
{ id: 'DM-10244', category: 'Incorrect Address', severity: 'medium', detail: 'Incomplete drop address · R.S. Puram', owner: 'Sandeep Roy', age: '46 min', status: 'assigned' },
|
||||
{ id: 'DM-10248', category: 'Traffic Delay', severity: 'low', detail: 'Rider off optimized path · Jayanagar loop', owner: 'Faisal Khan', age: '6 min', status: 'monitoring' }
|
||||
];
|
||||
|
||||
// ==============================|| ATTENTION REQUIRED — DECISION CARDS ||============================== //
|
||||
// Rolled-up counts of what needs a manager's attention now. Each card is a single click into
|
||||
// the module that owns the workflow. severity → accent (high=error, medium=warning, low=info).
|
||||
export const attentionItems = [
|
||||
{ key: 'delayed', label: 'Delayed Orders', count: 7, severity: 'high', icon: 'delay', to: '/orders' },
|
||||
{ key: 'critical', label: 'Critical Alerts', count: 3, severity: 'high', icon: 'alert', to: '/tracking' },
|
||||
{ key: 'hub', label: 'Hub Bottleneck', count: 1, severity: 'medium', icon: 'hub', to: '/hubs' },
|
||||
{ key: 'route', label: 'Route Deviations', count: 2, severity: 'medium', icon: 'route', to: '/dispatch' }
|
||||
];
|
||||
|
||||
// Compact tracking-alert feed for the Live Operations column.
|
||||
export const trackingAlerts = [
|
||||
{ id: 'DM-10242', type: 'geofence', detail: 'Approaching drop · 4.2 km · HSR Layout', time: '2 min ago', severity: 'low' },
|
||||
{ id: 'DM-10251', type: 'exception', detail: 'Customer unavailable · reattempt scheduled', time: '6 min ago', severity: 'high' },
|
||||
{ id: 'DM-10243', type: 'delay', detail: 'ETA slipping · +18 min vs SLA', time: '11 min ago', severity: 'medium' },
|
||||
{ id: 'DM-10246', type: 'dispatch', detail: 'Rider en route to pickup · Hitech City', time: '14 min ago', severity: 'low' }
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AppBar,
|
||||
@@ -17,15 +17,17 @@ import {
|
||||
Tooltip,
|
||||
Button,
|
||||
Stack,
|
||||
Select,
|
||||
alpha
|
||||
} from '@mui/material';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
|
||||
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
|
||||
import PersonOutlineIcon from '@mui/icons-material/PersonOutline';
|
||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
|
||||
@@ -33,33 +35,41 @@ import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined';
|
||||
import DoneAllIcon from '@mui/icons-material/DoneAll';
|
||||
|
||||
import Logo from '@/components/Logo';
|
||||
import { locations } from '@/data/mock';
|
||||
import { useFilters } from '@/store/Filters';
|
||||
|
||||
const RED = '#C01227'; // brand accent (avatars, dots)
|
||||
const BAR = '#8E1F2A'; // muted deep-brick top bar (toned down from vivid #C01227)
|
||||
const RED = '#C01227'; // brand accent (reserved for attention: avatar, unread dots)
|
||||
|
||||
const INITIAL_NOTIFICATIONS = [
|
||||
{ id: 1, icon: Inventory2OutlinedIcon, title: 'New order #ORD-10482 placed', time: '2 min ago', to: '/orders', read: false },
|
||||
{ id: 2, icon: TwoWheelerOutlinedIcon, title: 'Rider Imran went online', time: '18 min ago', to: '/riders', read: false },
|
||||
{ id: 2, icon: TwoWheelerOutlinedIcon, title: 'Rider Imran went online', time: '18 min ago', to: '/fleet', read: false },
|
||||
{ id: 3, icon: PaymentsOutlinedIcon, title: 'Invoice INV-2041 marked paid', time: '1 hr ago', to: '/invoice', read: false },
|
||||
{ id: 4, icon: AssignmentOutlinedIcon, title: 'MileTruth AI re-optimized 41 routes', time: '3 hrs ago', to: '/dispatch', read: true }
|
||||
];
|
||||
|
||||
const MESSAGES = [
|
||||
{ id: 1, name: 'Priya Nair', text: 'Can we reroute the MG Road batch?', time: '5 min ago', initials: 'PN' },
|
||||
{ id: 2, name: 'Imran Khan', text: 'Reached the warehouse, loading now.', time: '22 min ago', initials: 'IK' },
|
||||
{ id: 3, name: 'Acme Logistics', text: 'Please confirm the revised pricing.', time: '2 hrs ago', initials: 'AL' }
|
||||
];
|
||||
|
||||
export default function Header({ onToggle }) {
|
||||
const navigate = useNavigate();
|
||||
const [account, setAccount] = useState(null);
|
||||
const [notifAnchor, setNotifAnchor] = useState(null);
|
||||
const [msgAnchor, setMsgAnchor] = useState(null);
|
||||
const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS);
|
||||
const [search, setSearch] = useState('');
|
||||
const { location, setLocation } = useFilters(); // global location — single source of truth
|
||||
const searchRef = useRef(null);
|
||||
|
||||
const unread = notifications.filter((n) => !n.read).length;
|
||||
|
||||
// ⌘K / Ctrl+K focuses global search
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const openNotif = (e) => setNotifAnchor(e.currentTarget);
|
||||
const closeNotif = () => setNotifAnchor(null);
|
||||
const markAllRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
@@ -79,76 +89,124 @@ export default function Header({ onToggle }) {
|
||||
<AppBar
|
||||
position="fixed"
|
||||
elevation={0}
|
||||
sx={{ bgcolor: BAR, color: '#fff', zIndex: (t) => t.zIndex.drawer + 1, boxShadow: '0 1px 0 rgba(0,0,0,0.06)' }}
|
||||
sx={{
|
||||
bgcolor: '#FFFFFF',
|
||||
color: 'grey.800',
|
||||
zIndex: (t) => t.zIndex.drawer + 1,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'grey.200'
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ minHeight: 64, px: { xs: 1.5, sm: 2.5 }, gap: 1 }}>
|
||||
<IconButton color="inherit" edge="start" onClick={onToggle} sx={{ mr: 0.5 }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
||||
{/* Brand wordmark — left side */}
|
||||
<Box
|
||||
onClick={() => navigate('/dashboard')}
|
||||
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
>
|
||||
<Logo onDark height={22} />
|
||||
{/* LEFT — hamburger + brand (equal-flex zone) */}
|
||||
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton edge="start" onClick={onToggle} sx={{ color: 'grey.700' }}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Box
|
||||
onClick={() => navigate('/dashboard')}
|
||||
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer', flexShrink: 0 }}
|
||||
>
|
||||
<Logo height={24} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
{/* Search — moved to the right */}
|
||||
{/* CENTER — global search, the primary nav element (fixed width = stays truly centered) */}
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={submitSearch}
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'flex' },
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
bgcolor: alpha('#fff', 0.16),
|
||||
borderRadius: 2,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
width: { sm: 240, md: 320 },
|
||||
'&:hover': { bgcolor: alpha('#fff', 0.22) },
|
||||
'&:focus-within': { bgcolor: alpha('#fff', 0.26) }
|
||||
height: 46,
|
||||
px: 1.75,
|
||||
bgcolor: 'grey.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200',
|
||||
borderRadius: 2.5,
|
||||
width: { sm: 300, md: 460, lg: 560 },
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': { borderColor: 'grey.300' },
|
||||
'&:focus-within': { borderColor: 'primary.main', bgcolor: '#fff', boxShadow: '0 0 0 3px rgba(192,18,39,0.08)' }
|
||||
}}
|
||||
>
|
||||
<SearchIcon sx={{ fontSize: 20, mr: 1, opacity: 0.9 }} />
|
||||
<SearchIcon sx={{ fontSize: 20, mr: 1, color: 'grey.500' }} />
|
||||
<InputBase
|
||||
inputRef={searchRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search orders, riders, customers…"
|
||||
sx={{ color: '#fff', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: '#fff' } }}
|
||||
inputProps={{ style: { color: '#fff' }, 'aria-label': 'search' }}
|
||||
placeholder="Search orders, shipments, riders, customers…"
|
||||
sx={{ color: 'grey.800', fontSize: '0.875rem', flex: 1 }}
|
||||
inputProps={{ 'aria-label': 'search' }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: 'none', md: 'flex' },
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
px: 0.75,
|
||||
py: 0.4,
|
||||
ml: 1,
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.300',
|
||||
bgcolor: '#fff',
|
||||
color: 'grey.500',
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
⌘K
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Tooltip title="Messages">
|
||||
<IconButton color="inherit" onClick={(e) => setMsgAnchor(e.currentTarget)}>
|
||||
<Badge badgeContent={MESSAGES.length} color="warning">
|
||||
<ChatBubbleOutlineIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Notifications">
|
||||
<IconButton color="inherit" onClick={openNotif}>
|
||||
<Badge badgeContent={unread} color="warning">
|
||||
<NotificationsNoneIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/* RIGHT — location + notifications + profile (equal-flex zone, right-aligned) */}
|
||||
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 0.5 }}>
|
||||
<Select
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
size="small"
|
||||
IconComponent={KeyboardArrowDownIcon}
|
||||
startAdornment={<PlaceOutlinedIcon sx={{ fontSize: 18, color: 'grey.500', mr: 0.75 }} />}
|
||||
sx={{
|
||||
display: { xs: 'none', md: 'flex' },
|
||||
minWidth: 168,
|
||||
bgcolor: 'grey.50',
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: 'grey.200' },
|
||||
'& .MuiSelect-select': { display: 'flex', alignItems: 'center', fontSize: '0.8125rem', fontWeight: 600, color: 'grey.700' }
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => (
|
||||
<MenuItem key={l} value={l}>{l}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Box
|
||||
onClick={(e) => setAccount(e.currentTarget)}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 0.5, cursor: 'pointer', py: 0.5, px: 0.5, borderRadius: 2, '&:hover': { bgcolor: alpha('#fff', 0.14) } }}
|
||||
>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: '#fff', color: RED, fontWeight: 700 }}>AD</Avatar>
|
||||
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: '#fff', fontWeight: 600 }}>
|
||||
Aman Deshmukh
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: alpha('#fff', 0.8) }}>
|
||||
Operations Admin
|
||||
</Typography>
|
||||
<Tooltip title="Notifications">
|
||||
<IconButton onClick={openNotif} sx={{ color: 'grey.700' }}>
|
||||
<Badge badgeContent={unread} color="error">
|
||||
<NotificationsNoneIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Box
|
||||
onClick={(e) => setAccount(e.currentTarget)}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, cursor: 'pointer', py: 0.5, px: 0.5, borderRadius: 2, '&:hover': { bgcolor: 'grey.100' } }}
|
||||
>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: RED, color: '#fff', fontWeight: 700, fontSize: '0.8rem' }}>AD</Avatar>
|
||||
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: 'grey.800', fontWeight: 600 }}>
|
||||
Aman Deshmukh
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'grey.500' }}>
|
||||
Operations Admin
|
||||
</Typography>
|
||||
</Box>
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: 18, color: 'grey.500', display: { xs: 'none', md: 'block' } }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -180,7 +238,7 @@ export default function Header({ onToggle }) {
|
||||
return (
|
||||
<MenuItem key={n.id} onClick={() => onNotifClick(n)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
|
||||
<ListItemIcon sx={{ mt: 0.25 }}>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: n.read ? 'grey.200' : alpha(RED, 0.12), color: RED }}>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: n.read ? 'grey.100' : alpha(RED, 0.12), color: n.read ? 'grey.500' : RED }}>
|
||||
<Icon fontSize="small" />
|
||||
</Avatar>
|
||||
</ListItemIcon>
|
||||
@@ -200,39 +258,6 @@ export default function Header({ onToggle }) {
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Messages dropdown */}
|
||||
<Menu
|
||||
anchorEl={msgAnchor}
|
||||
open={Boolean(msgAnchor)}
|
||||
onClose={() => setMsgAnchor(null)}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
PaperProps={{ sx: { mt: 1, width: 340, maxWidth: '90vw' } }}
|
||||
>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, px: 2, py: 1.25 }}>
|
||||
Messages
|
||||
</Typography>
|
||||
<Divider />
|
||||
{MESSAGES.map((m) => (
|
||||
<MenuItem key={m.id} onClick={() => setMsgAnchor(null)} sx={{ py: 1.25, whiteSpace: 'normal', alignItems: 'flex-start' }}>
|
||||
<ListItemIcon sx={{ mt: 0.25 }}>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: alpha(RED, 0.12), color: RED, fontWeight: 700, fontSize: '0.8rem' }}>
|
||||
{m.initials}
|
||||
</Avatar>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={m.name}
|
||||
secondary={m.text}
|
||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: 700 }}
|
||||
secondaryTypographyProps={{ fontSize: '0.8rem' }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1, mt: 0.5, flexShrink: 0 }}>
|
||||
{m.time}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
{/* Account dropdown */}
|
||||
<Menu
|
||||
anchorEl={account}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
@@ -8,52 +7,62 @@ import {
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
Toolbar
|
||||
} from '@mui/material';
|
||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||
|
||||
import navItems from '@/menu/navItems';
|
||||
import Logo from '@/components/Logo';
|
||||
|
||||
export const DRAWER_WIDTH = 232;
|
||||
export const MINI_WIDTH = 76;
|
||||
export const DRAWER_WIDTH = 200;
|
||||
export const MINI_WIDTH = 68;
|
||||
|
||||
const NAV_BG = '#8E1F2A'; // muted deep-brick brand red (toned down from vivid #C01227)
|
||||
const BRAND = '#C01227';
|
||||
|
||||
function NavLeaf({ item, open, active, depth = 0, onClick }) {
|
||||
// Light, operational sidebar — white surface, tight grouping, a single strong red active state.
|
||||
function NavLeaf({ item, open, active, onClick }) {
|
||||
const Icon = item.icon;
|
||||
const button = (
|
||||
<ListItemButton
|
||||
selected={active}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
my: 0.25,
|
||||
mx: open ? 1 : 0.75,
|
||||
px: open ? 1.5 : 0,
|
||||
position: 'relative',
|
||||
minHeight: 40,
|
||||
my: 0.2,
|
||||
mx: open ? 0.75 : 0.5,
|
||||
px: open ? 1.25 : 0,
|
||||
justifyContent: open ? 'flex-start' : 'center',
|
||||
borderRadius: 2,
|
||||
color: 'rgba(255,255,255,0.78)',
|
||||
'& .MuiListItemIcon-root': { color: 'inherit' },
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.12)', color: '#fff' },
|
||||
borderRadius: 1.5,
|
||||
color: 'grey.800',
|
||||
transition: 'background-color .12s ease, color .12s ease',
|
||||
'& .MuiListItemIcon-root': { color: '#B0566A', transition: 'color .12s ease' },
|
||||
'&:hover': { bgcolor: 'rgba(192,18,39,0.07)', color: BRAND, '& .MuiListItemIcon-root': { color: BRAND } },
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'rgba(255,255,255,0.18)',
|
||||
color: '#fff',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.22)' }
|
||||
bgcolor: 'rgba(192,18,39,0.14)',
|
||||
color: BRAND,
|
||||
'& .MuiListItemIcon-root': { color: BRAND },
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 5,
|
||||
bottom: 5,
|
||||
width: 3,
|
||||
borderRadius: '0 3px 3px 0',
|
||||
bgcolor: BRAND
|
||||
},
|
||||
'&:hover': { bgcolor: 'rgba(192,18,39,0.18)' }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: open ? 34 : 'auto', justifyContent: 'center' }}>
|
||||
{depth > 0 && !Icon ? <FiberManualRecordIcon sx={{ fontSize: 8 }} /> : Icon ? <Icon fontSize="small" /> : null}
|
||||
<ListItemIcon sx={{ minWidth: open ? 30 : 'auto', justifyContent: 'center' }}>
|
||||
<Icon sx={{ fontSize: 20 }} />
|
||||
</ListItemIcon>
|
||||
{open && (
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: active ? 700 : 500 }}
|
||||
primaryTypographyProps={{ fontSize: '0.82rem', fontWeight: active ? 700 : 500, letterSpacing: '0.01em' }}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
@@ -68,122 +77,66 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||
const isActive = (url) => url && location.pathname.startsWith(url);
|
||||
const expanded = open || isMobile;
|
||||
|
||||
const initialOpen = navItems
|
||||
.flatMap((g) => g.items)
|
||||
.filter((i) => i.children && i.children.some((c) => isActive(c.url)))
|
||||
.map((i) => i.id);
|
||||
const [collapse, setCollapse] = useState(initialOpen);
|
||||
|
||||
const go = (url) => {
|
||||
navigate(url);
|
||||
if (isMobile) onMobileClose();
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Box sx={{ bgcolor: NAV_BG, height: '100%', color: '#fff', display: 'flex', flexDirection: 'column' }}>
|
||||
<Toolbar sx={{ px: expanded ? 2.5 : 0, justifyContent: expanded ? 'flex-start' : 'center', minHeight: 64 }}>
|
||||
<Logo onDark compact={!expanded} />
|
||||
<Box sx={{ bgcolor: '#FFF5F5', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* brand — restrained height, generous top alignment */}
|
||||
<Toolbar
|
||||
sx={{
|
||||
px: expanded ? 2.25 : 0,
|
||||
pt: 0.5,
|
||||
justifyContent: expanded ? 'flex-start' : 'center',
|
||||
minHeight: 60,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'rgba(192,18,39,0.10)'
|
||||
}}
|
||||
>
|
||||
<Logo compact={!expanded} height={16} />
|
||||
</Toolbar>
|
||||
|
||||
{/* navigation */}
|
||||
<Box
|
||||
sx={{
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
flexGrow: 1,
|
||||
pb: 2,
|
||||
// slim, subtle scrollbar tuned for the dark-red sidebar — only shows on hover
|
||||
py: 1,
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'transparent transparent',
|
||||
'&:hover': { scrollbarColor: 'rgba(255,255,255,0.3) transparent' },
|
||||
'&::-webkit-scrollbar': { width: 6 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 8,
|
||||
transition: 'background-color 0.2s ease'
|
||||
},
|
||||
'&:hover::-webkit-scrollbar-thumb': { backgroundColor: 'rgba(255,255,255,0.28)' },
|
||||
'&::-webkit-scrollbar-thumb:hover': { backgroundColor: 'rgba(255,255,255,0.45)' }
|
||||
'&::-webkit-scrollbar-thumb': { backgroundColor: 'transparent', borderRadius: 8 },
|
||||
'&:hover::-webkit-scrollbar-thumb': { backgroundColor: 'rgba(0,0,0,0.18)' }
|
||||
}}
|
||||
>
|
||||
{navItems.map((grp) => (
|
||||
<Box key={grp.group} sx={{ mt: 1 }}>
|
||||
{expanded && (
|
||||
<Box key={grp.group || grp.items[0].id} sx={{ mt: grp.group ? 1.25 : 0.25 }}>
|
||||
{expanded && grp.group && (
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{ px: 2.5, color: 'rgba(255,255,255,0.55)', fontSize: '0.6875rem', letterSpacing: '0.08em' }}
|
||||
sx={{
|
||||
display: 'block',
|
||||
px: 2,
|
||||
mb: 0.25,
|
||||
color: 'rgba(158,14,32,0.62)',
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 800,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.13em'
|
||||
}}
|
||||
>
|
||||
{grp.group}
|
||||
</Typography>
|
||||
)}
|
||||
<List disablePadding sx={{ mt: 0.5 }}>
|
||||
{grp.items.map((item) => {
|
||||
if (item.children) {
|
||||
const opened = collapse.includes(item.id);
|
||||
const childActive = item.children.some((c) => isActive(c.url));
|
||||
const Icon = item.icon;
|
||||
const head = (
|
||||
<ListItemButton
|
||||
onClick={() =>
|
||||
expanded
|
||||
? setCollapse((p) => (p.includes(item.id) ? p.filter((x) => x !== item.id) : [...p, item.id]))
|
||||
: go(item.children[0].url)
|
||||
}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
my: 0.25,
|
||||
mx: expanded ? 1 : 0.75,
|
||||
px: expanded ? 1.5 : 0,
|
||||
justifyContent: expanded ? 'flex-start' : 'center',
|
||||
borderRadius: 2,
|
||||
color: childActive ? '#fff' : 'rgba(255,255,255,0.78)',
|
||||
bgcolor: childActive && !opened ? 'rgba(255,255,255,0.12)' : 'transparent',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.12)', color: '#fff' }
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: expanded ? 34 : 'auto', justifyContent: 'center', color: 'inherit' }}>
|
||||
<Icon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{expanded && (
|
||||
<>
|
||||
<ListItemText primary={item.title} primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: 500 }} />
|
||||
{opened ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
|
||||
</>
|
||||
)}
|
||||
</ListItemButton>
|
||||
);
|
||||
return (
|
||||
<Box key={item.id}>
|
||||
{expanded ? head : <Tooltip title={item.title} placement="right">{head}</Tooltip>}
|
||||
{expanded && (
|
||||
<Collapse in={opened} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ pl: 1.5 }}>
|
||||
{item.children.map((c) => (
|
||||
<NavLeaf key={c.id} item={c} open depth={1} active={isActive(c.url)} onClick={() => go(c.url)} />
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NavLeaf key={item.id} item={item} open={expanded} active={isActive(item.url)} onClick={() => go(item.url)} />
|
||||
);
|
||||
})}
|
||||
<List disablePadding>
|
||||
{grp.items.map((item) => (
|
||||
<NavLeaf key={item.id} item={item} open={expanded} active={isActive(item.url)} onClick={() => go(item.url)} />
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{expanded && (
|
||||
<Box sx={{ p: 2, borderTop: '1px solid rgba(255,255,255,0.12)' }}>
|
||||
<Typography variant="caption" sx={{ color: '#fff', fontWeight: 600, display: 'block', lineHeight: 1.3 }}>
|
||||
Delivering Trust.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)' }}>
|
||||
Beyond Boundaries · v1.0
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -201,23 +154,24 @@ export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: an in-flow grid-column element (NOT a fixed Drawer paper) so it can never be clipped
|
||||
// or overlap the content. Width comes from the layout grid; this just fills its column and pins
|
||||
// to the viewport while content scrolls.
|
||||
return (
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
<Box
|
||||
component="aside"
|
||||
sx={{
|
||||
width: open ? DRAWER_WIDTH : MINI_WIDTH,
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
'& .MuiDrawer-paper': {
|
||||
width: open ? DRAWER_WIDTH : MINI_WIDTH,
|
||||
border: 'none',
|
||||
overflowX: 'hidden',
|
||||
transition: (t) => t.transitions.create('width', { duration: t.transitions.duration.standard })
|
||||
}
|
||||
gridColumn: 1,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
borderRight: '1px solid',
|
||||
borderColor: 'rgba(192,18,39,0.14)',
|
||||
boxShadow: '1px 0 4px rgba(192,18,39,0.05)'
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
{content}
|
||||
</Drawer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,16 @@ import { useTheme } from '@mui/material/styles';
|
||||
import Header from './Header';
|
||||
import Sidebar, { DRAWER_WIDTH, MINI_WIDTH } from './Sidebar';
|
||||
|
||||
// ==============================|| MAIN LAYOUT — CSS GRID SHELL ||============================== //
|
||||
// One deterministic grid: [ sidebar (fixed px) | content (flexible, minWidth 0) ].
|
||||
// The sidebar width is the single source of truth (exported from Sidebar.jsx). No width-calc
|
||||
// hacks, no negative margins, no fixed-paper overlap — the content column begins exactly where
|
||||
// the sidebar ends, the sidebar can never be clipped, and content can never overflow horizontally.
|
||||
|
||||
export default function MainLayout() {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
|
||||
// Sidebar stays a permanent column on md+ (and through browser zoom); overlay drawer below md.
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [open, setOpen] = useState(true);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
@@ -17,26 +24,27 @@ export default function MainLayout() {
|
||||
else setOpen((p) => !p);
|
||||
};
|
||||
|
||||
const sidebarW = open ? DRAWER_WIDTH : MINI_WIDTH;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', bgcolor: 'background.default', minHeight: '100vh' }}>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
bgcolor: 'background.default',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: `${sidebarW}px minmax(0, 1fr)` },
|
||||
transition: theme.transitions.create('grid-template-columns', { duration: theme.transitions.duration.standard })
|
||||
}}
|
||||
>
|
||||
<Header onToggle={toggle} />
|
||||
<Sidebar
|
||||
open={open}
|
||||
isMobile={isMobile}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={() => setMobileOpen(false)}
|
||||
/>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
width: { lg: `calc(100% - ${open ? DRAWER_WIDTH : MINI_WIDTH}px)` },
|
||||
minHeight: '100vh',
|
||||
transition: theme.transitions.create('width', { duration: theme.transitions.duration.standard })
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ minHeight: 64 }} />
|
||||
<Box sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
|
||||
{/* column 1 on md+ (in-flow); overlay drawer on mobile (taken out of flow) */}
|
||||
<Sidebar open={open} isMobile={isMobile} mobileOpen={mobileOpen} onMobileClose={() => setMobileOpen(false)} />
|
||||
|
||||
{/* column 2 — content; minWidth:0 + overflowX:clip guarantee no horizontal spill */}
|
||||
<Box component="main" sx={{ minWidth: 0, minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<Toolbar sx={{ minHeight: 64, flexShrink: 0 }} />
|
||||
<Box sx={{ p: { xs: 2, sm: 3 }, flexGrow: 1, minWidth: 0, overflowX: 'clip' }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
|
||||
import theme from '@/theme';
|
||||
import App from '@/App';
|
||||
import { OpsProvider } from '@/store/OpsStore';
|
||||
import { FilterProvider } from '@/store/Filters';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
@@ -14,7 +16,11 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<CssBaseline />
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<FilterProvider>
|
||||
<OpsProvider>
|
||||
<App />
|
||||
</OpsProvider>
|
||||
</FilterProvider>
|
||||
</BrowserRouter>
|
||||
</LocalizationProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,102 +1,50 @@
|
||||
import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import MopedOutlinedIcon from '@mui/icons-material/MopedOutlined';
|
||||
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
|
||||
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
|
||||
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import BarChartOutlinedIcon from '@mui/icons-material/BarChartOutlined';
|
||||
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined';
|
||||
import SummarizeOutlinedIcon from '@mui/icons-material/SummarizeOutlined';
|
||||
import FactCheckOutlinedIcon from '@mui/icons-material/FactCheckOutlined';
|
||||
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||
import SecurityOutlinedIcon from '@mui/icons-material/SecurityOutlined';
|
||||
import HubOutlinedIcon from '@mui/icons-material/HubOutlined';
|
||||
import ElectricRickshawOutlinedIcon from '@mui/icons-material/ElectricRickshawOutlined';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
import SpaceDashboardOutlinedIcon from '@mui/icons-material/SpaceDashboardOutlined';
|
||||
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined';
|
||||
import TrendingUpOutlinedIcon from '@mui/icons-material/TrendingUpOutlined';
|
||||
import ApiOutlinedIcon from '@mui/icons-material/ApiOutlined';
|
||||
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
|
||||
import AcUnitOutlinedIcon from '@mui/icons-material/AcUnitOutlined';
|
||||
import AltRouteOutlinedIcon from '@mui/icons-material/AltRouteOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import HubOutlinedIcon from '@mui/icons-material/HubOutlined';
|
||||
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
|
||||
import BarChartOutlinedIcon from '@mui/icons-material/BarChartOutlined';
|
||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||
|
||||
// ==============================|| DOORMILE - LOGISTICS OPERATING SYSTEM NAV ||============================== //
|
||||
// Groups mirror the 8-layer end-to-end flow of the Doormile operating system.
|
||||
// ==============================|| DOORMILE — LOGISTICS OS NAVIGATION ||============================== //
|
||||
// Consolidated operational IA. The first group has no label (single Control Center entry — no
|
||||
// redundant header). All icons are the Material *Outlined* family for one consistent visual weight.
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
group: 'Overview',
|
||||
group: '',
|
||||
items: [{ id: 'dashboard', title: 'Control Center', url: '/dashboard', icon: SpaceDashboardOutlinedIcon }]
|
||||
},
|
||||
{
|
||||
group: 'Operations',
|
||||
items: [
|
||||
{ id: 'dashboard', title: 'System Overview', url: '/dashboard', icon: DashboardOutlinedIcon },
|
||||
{ id: 'three-mile', title: 'Three-Mile Network', url: '/three-mile', icon: RouteOutlinedIcon }
|
||||
{ id: 'dispatch-tracking', title: 'Dispatch & Tracking', url: '/tracking', icon: MyLocationOutlinedIcon },
|
||||
{ id: 'shipments', title: 'Shipments', url: '/orders', icon: Inventory2OutlinedIcon }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: '1 · Book & Create',
|
||||
group: 'Network',
|
||||
items: [
|
||||
{ id: 'orders', title: 'Shipments', url: '/orders', icon: Inventory2OutlinedIcon },
|
||||
{ id: 'customers', title: 'Customers', url: '/customers', icon: GroupsOutlinedIcon }
|
||||
{ id: 'fleet', title: 'Fleet', url: '/fleet', icon: LocalShippingOutlinedIcon },
|
||||
{ id: 'hubs', title: 'Hubs', url: '/hubs', icon: HubOutlinedIcon }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: '2 · Trust & Identity',
|
||||
items: [{ id: 'trust', title: 'Trust & Compliance', url: '/trust', icon: SecurityOutlinedIcon }]
|
||||
},
|
||||
{
|
||||
group: '3 · Hub Network',
|
||||
items: [{ id: 'hubs', title: 'Hub Network', url: '/hubs', icon: HubOutlinedIcon }]
|
||||
},
|
||||
{
|
||||
group: '4 · Fleet & Riders',
|
||||
group: 'Business',
|
||||
items: [
|
||||
{ id: 'fleet', title: 'Fleet', url: '/fleet', icon: ElectricRickshawOutlinedIcon },
|
||||
{ id: 'riders', title: 'Riders', url: '/riders', icon: TwoWheelerOutlinedIcon }
|
||||
{ id: 'customers', title: 'Customers', url: '/customers', icon: GroupsOutlinedIcon },
|
||||
{ id: 'finance', title: 'Finance', url: '/invoice', icon: PaymentsOutlinedIcon }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: '5 · MileTruth AI',
|
||||
items: [{ id: 'dispatch', title: 'AI Dispatch', url: '/dispatch', icon: AutoAwesomeOutlinedIcon }]
|
||||
group: 'Insights',
|
||||
items: [{ id: 'reports', title: 'Reports', url: '/reports', icon: BarChartOutlinedIcon }]
|
||||
},
|
||||
{
|
||||
group: '6 · Execution',
|
||||
items: [
|
||||
{ id: 'tracking', title: 'Live Tracking', url: '/tracking', icon: MyLocationOutlinedIcon },
|
||||
{ id: 'journey', title: 'Shipment Journey', url: '/tracking/journey', icon: AltRouteOutlinedIcon },
|
||||
{ id: 'cold-chain', title: 'Cold Chain', url: '/cold-chain', icon: AcUnitOutlinedIcon },
|
||||
{ id: 'deliveries', title: 'Deliveries', url: '/deliveries', icon: MopedOutlinedIcon }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: '7 · Analytics',
|
||||
items: [
|
||||
{ id: 'analytics', title: 'Analytics', url: '/analytics', icon: TrendingUpOutlinedIcon },
|
||||
{
|
||||
id: 'reports',
|
||||
title: 'Reports',
|
||||
icon: BarChartOutlinedIcon,
|
||||
children: [
|
||||
{ id: 'orders-summary', title: 'Order Summary', url: '/reports/orders-summary', icon: SummarizeOutlinedIcon },
|
||||
{ id: 'orders-details', title: 'Order Details', url: '/reports/orders-details', icon: FactCheckOutlinedIcon },
|
||||
{ id: 'riders-summary', title: 'Riders Summary', url: '/reports/riders-summary', icon: TwoWheelerOutlinedIcon },
|
||||
{ id: 'riders-logs', title: 'Riders Logs', url: '/reports/riders-logs', icon: MapOutlinedIcon }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
group: '8 · Integrations',
|
||||
items: [
|
||||
{ id: 'integrations', title: 'Integrations', url: '/integrations', icon: ApiOutlinedIcon },
|
||||
{ id: 'tenants', title: 'Clients', url: '/tenants', icon: ApartmentOutlinedIcon }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Finance',
|
||||
items: [
|
||||
{ id: 'pricing', title: 'Pricing', url: '/pricing', icon: PaymentsOutlinedIcon },
|
||||
{ id: 'invoice', title: 'Invoice', url: '/invoice', icon: ReceiptLongOutlinedIcon }
|
||||
]
|
||||
group: 'System',
|
||||
items: [{ id: 'settings', title: 'Settings', url: '/settings', icon: SettingsOutlinedIcon }]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -1,225 +1,237 @@
|
||||
import { Grid, Stack, Typography, Box, Button, Divider, Table, TableBody, TableCell, TableHead, TableRow, MenuItem, TextField, Avatar, LinearProgress, Chip } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Grid, Stack, Typography, Box, Button, Divider, LinearProgress, Chip, Avatar } from '@mui/material';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import HubOutlinedIcon from '@mui/icons-material/HubOutlined';
|
||||
import CurrencyRupeeIcon from '@mui/icons-material/CurrencyRupee';
|
||||
import TaskAltOutlinedIcon from '@mui/icons-material/TaskAltOutlined';
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||
import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded';
|
||||
import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
import EnergySavingsLeafOutlinedIcon from '@mui/icons-material/EnergySavingsLeafOutlined';
|
||||
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
|
||||
import SpeedOutlinedIcon from '@mui/icons-material/SpeedOutlined';
|
||||
import ArrowForwardRoundedIcon from '@mui/icons-material/ArrowForwardRounded';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import KpiStrip from '@/components/KpiStrip';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import AreaChart from '@/components/charts/AreaChart';
|
||||
import DonutChart from '@/components/charts/DonutChart';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import SystemPipeline from '@/components/SystemPipeline';
|
||||
import ThreeMileStrip from '@/components/ThreeMileStrip';
|
||||
import ProcessTracker from '@/components/ProcessTracker';
|
||||
import AiImpactSummary from '@/components/AiImpactSummary';
|
||||
import Toast, { useToast } from '@/components/Toast';
|
||||
import { ordersTrend, statusBreakdown, orders, riders, aiMetrics, fleetSummary, verticals, verticalOf } from '@/data/mock';
|
||||
import { dispatchQueue, activeDeliveries, aiInsights, executionFeed, fleetSummary, lanePerformance, hubCityStats, ordersTrend, analyticsKpis } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
const VERTICAL_COLOR = Object.fromEntries(verticals.map((v) => [v.label, v.color]));
|
||||
const SEV_DOT = { high: '#F04134', medium: '#FFBF00', low: '#00A2AE', info: '#8C8C8C' };
|
||||
const hubUtil = Math.round(hubCityStats.reduce((s, h) => s + h.utilization, 0) / hubCityStats.length);
|
||||
|
||||
function SectionLabel({ children }) {
|
||||
return <Typography variant="overline" color="text.secondary" sx={{ letterSpacing: '0.08em', display: 'block', mb: 1.25 }}>{children}</Typography>;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [toast, showToast] = useToast();
|
||||
|
||||
const priority = activeDeliveries.filter((d) => (d.priority === 'high' || d.priority === 'express') && d.status !== 'Delivered').slice(0, 4);
|
||||
const delayed = activeDeliveries.filter((d) => d.etaStatus !== 'on-time' && d.status !== 'Delivered').slice(0, 4);
|
||||
const recs = aiInsights.slice(0, 4);
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Total Orders', value: '1,402', icon: Inventory2OutlinedIcon },
|
||||
{ label: 'Active Shipments', value: '96', color: '#1D4ED8', icon: LocalShippingOutlinedIcon },
|
||||
{ label: 'Riders Online', value: '48', color: '#00773B', icon: TwoWheelerOutlinedIcon },
|
||||
{ label: 'Hub Utilization', value: `${hubUtil}%`, color: hubUtil > 80 ? '#A82216' : '#8A6500', icon: HubOutlinedIcon },
|
||||
{ label: 'Revenue Today', value: inr(384200), color: '#00727B', icon: CurrencyRupeeIcon },
|
||||
{ label: 'SLA Performance', value: `${analyticsKpis.slaAchievement}%`, color: '#00773B', icon: TaskAltOutlinedIcon }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="System Overview"
|
||||
breadcrumbs={[{ label: 'Dashboard' }]}
|
||||
action={
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<TextField select size="small" defaultValue="all" sx={{ minWidth: 150 }}>
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
<MenuItem value="blr">Bengaluru</MenuItem>
|
||||
<MenuItem value="mum">Mumbai</MenuItem>
|
||||
</TextField>
|
||||
<Button variant="outlined" startIcon={<FileDownloadOutlinedIcon />} onClick={() => showToast('System overview exported as CSV')}>Export</Button>
|
||||
</Stack>
|
||||
}
|
||||
title="Operations Control Center"
|
||||
breadcrumbs={[{ label: 'Control Center' }]}
|
||||
action={<Button variant="outlined" startIcon={<FileDownloadOutlinedIcon />} onClick={() => showToast('Snapshot exported as CSV')}>Export</Button>}
|
||||
/>
|
||||
|
||||
{/* End-to-end operating-system pipeline */}
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ letterSpacing: '0.08em' }}>
|
||||
End-to-End Intelligent Logistics Flow
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<SystemPipeline />
|
||||
</Box>
|
||||
{/* Top row — 6 live KPIs */}
|
||||
<KpiStrip items={kpis} />
|
||||
|
||||
{/* Three-Mile model — First → Mid → Last */}
|
||||
<Box sx={{ mb: 0.5 }}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ letterSpacing: '0.08em' }}>
|
||||
Three-Mile Network · One Connected System
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<ThreeMileStrip compact />
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Total Shipments" value="1,402" icon={Inventory2OutlinedIcon} trend={8.4} caption="vs last month" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Delivered" value="1,330" icon={LocalShippingOutlinedIcon} color="success" trend={6.1} caption="98.6% on-time" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Active Riders" value="48" icon={TwoWheelerOutlinedIcon} color="info" trend={-2.3} caption="of 124 fleet" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Revenue" value={inr(384200)} icon={CurrencyRupeeIcon} color="warning" trend={11.7} caption="vs last month" /></Grid>
|
||||
|
||||
<Grid item xs={12} lg={8}>
|
||||
<MainCard
|
||||
title="Orders Overview"
|
||||
action={<Stack direction="row" spacing={2}><Legend color="#C01227" label="Orders" /><Legend color="#00A854" label="Delivered" /></Stack>}
|
||||
>
|
||||
<AreaChart
|
||||
labels={ordersTrend.map((d) => d.m)}
|
||||
series={[
|
||||
{ name: 'Orders', color: '#C01227', data: ordersTrend.map((d) => d.orders) },
|
||||
{ name: 'Delivered', color: '#00A854', data: ordersTrend.map((d) => d.delivered) }
|
||||
]}
|
||||
/>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} lg={4}>
|
||||
<MainCard title="Order Status">
|
||||
<Box sx={{ py: 2 }}>
|
||||
<DonutChart data={statusBreakdown} centerValue="1,402" centerLabel="Orders" />
|
||||
</Box>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
{/* MileTruth AI + Sustainability */}
|
||||
<Grid item xs={12} lg={5}>
|
||||
<MainCard
|
||||
title={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Avatar variant="rounded" sx={{ bgcolor: '#FFF1E6', color: '#EA580C', width: 32, height: 32 }}><AutoAwesomeOutlinedIcon fontSize="small" /></Avatar>
|
||||
<Typography variant="h5">MileTruth AI Engine</Typography>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}><AiStat icon={RouteOutlinedIcon} color="#C01227" value={`${aiMetrics.routeSavings}%`} label="Route savings" /></Grid>
|
||||
<Grid item xs={6}><AiStat icon={SpeedOutlinedIcon} color="#C01227" value={`${aiMetrics.avgEtaAccuracy}%`} label="ETA accuracy" /></Grid>
|
||||
<Grid item xs={6}><AiStat icon={AutoAwesomeOutlinedIcon} color="#C01227" value={aiMetrics.reoptToday} label="Re-optimizations today" /></Grid>
|
||||
<Grid item xs={6}><AiStat icon={LocalShippingOutlinedIcon} color="#C01227" value={`${aiMetrics.delaysAvoided}/${aiMetrics.delaysPredicted}`} label="Delays avoided" /></Grid>
|
||||
</Grid>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} lg={3}>
|
||||
<MainCard title="EV-First Operations">
|
||||
<Stack alignItems="center" spacing={1} sx={{ py: 1 }}>
|
||||
<Avatar variant="rounded" sx={{ bgcolor: 'success.lighter', color: 'success.main', width: 48, height: 48 }}><EnergySavingsLeafOutlinedIcon /></Avatar>
|
||||
<Typography variant="h2" sx={{ fontWeight: 800, color: 'success.main' }}>{fleetSummary.evShare}%</Typography>
|
||||
<Typography variant="caption" color="text.secondary">EV fleet share</Typography>
|
||||
<Box sx={{ width: '100%', mt: 1 }}>
|
||||
<LinearProgress variant="determinate" value={fleetSummary.evShare} color="success" sx={{ height: 8, borderRadius: 4 }} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>{(fleetSummary.co2SavedKg / 1000).toFixed(1)}t CO₂ saved this month</Typography>
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} lg={4}>
|
||||
<MainCard title="Top Riders Today">
|
||||
<Stack divider={<Divider />} spacing={0}>
|
||||
{riders.slice(0, 4).map((r, i) => (
|
||||
<Stack key={r.id} direction="row" spacing={2} alignItems="center" sx={{ py: 1.1 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ width: 18 }}>{i + 1}</Typography>
|
||||
<UserAvatar name={r.name} size={36} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="subtitle2">{r.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{r.vehicle} · ⭐ {r.rating}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="subtitle2">{r.deliveries}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">deliveries</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={4}>
|
||||
<MainCard title="By Industry Vertical">
|
||||
<Stack spacing={1.5}>
|
||||
{verticals.map((v) => (
|
||||
<Box key={v.key} sx={{ border: '1px solid', borderColor: 'grey.200', borderRadius: 2, p: 1.5 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '3px', bgcolor: v.color }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2">{v.label}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{v.desc}</Typography>
|
||||
</Box>
|
||||
{/* Second row — dispatch intelligence */}
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<SectionLabel>Dispatch & Exceptions</SectionLabel>
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} md={6} lg={3}>
|
||||
<MainCard title="Live Dispatch Queue" action={<Button size="small" onClick={() => navigate('/orders/assign')} sx={{ fontWeight: 600 }}>Open</Button>} noPadding>
|
||||
<Stack divider={<Divider />}>
|
||||
{dispatchQueue.map((d) => (
|
||||
<Box key={d.id} sx={{ px: 2, py: 1.25 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.main' }}>{d.id}</Typography>
|
||||
<Chip size="small" label={`${d.confidence}%`} sx={{ height: 18, bgcolor: 'grey.100', fontWeight: 700, '& .MuiChip-label': { px: 0.75, fontSize: 10 } }} />
|
||||
</Stack>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'grey.900' }}>{v.shipments}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{v.onTime}% on-time</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>{d.pickup} → {d.drop}</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'grey.500' }}>AI → {d.suggestedRider} · SLA {d.sla}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6} lg={3}>
|
||||
<MainCard title="Priority Deliveries" noPadding>
|
||||
<Stack divider={<Divider />}>
|
||||
{priority.map((d) => (
|
||||
<Stack key={d.id} direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 2, py: 1.25 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.main' }}>{d.id}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>{d.destination}</Typography>
|
||||
</Box>
|
||||
<Box component="span" sx={{ px: 0.75, py: 0.25, borderRadius: 1, bgcolor: '#FEEAE9', color: '#A82216', fontSize: 10, fontWeight: 700, textTransform: 'uppercase', flexShrink: 0 }}>{d.priority}</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6} lg={3}>
|
||||
<MainCard title="Delayed Shipments" noPadding>
|
||||
<Stack divider={<Divider />}>
|
||||
{delayed.map((d) => (
|
||||
<Stack key={d.id} direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 2, py: 1.25 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.main' }}>{d.id}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>{d.rider}</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.4} alignItems="center" sx={{ color: 'error.main', flexShrink: 0 }}>
|
||||
<ScheduleOutlinedIcon sx={{ fontSize: 14 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>+{d.delayMin}m</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
{delayed.length === 0 && <Typography variant="caption" color="text.secondary" sx={{ p: 2, textAlign: 'center' }}>No delays.</Typography>}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6} lg={3}>
|
||||
<MainCard title="AI Recommendations" noPadding>
|
||||
<Stack divider={<Divider />}>
|
||||
{recs.map((r) => (
|
||||
<Stack key={r.id} direction="row" spacing={1} alignItems="flex-start" sx={{ px: 2, py: 1.25 }}>
|
||||
<AutoAwesomeOutlinedIcon sx={{ fontSize: 15, color: '#EA580C', mt: 0.2, flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'grey.800', display: 'block', lineHeight: 1.3 }}>{r.title}</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'primary.main', fontWeight: 600 }}>{r.action}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Grid item xs={12} lg={8}>
|
||||
<MainCard title="Recent Shipments" noPadding>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Order ID</TableCell>
|
||||
<TableCell>Customer</TableCell>
|
||||
<TableCell>Vertical</TableCell>
|
||||
<TableCell>Route</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Amount</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{orders.slice(0, 6).map((o) => {
|
||||
const v = verticalOf(o.tenant);
|
||||
return (
|
||||
<TableRow key={o.id} hover>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'primary.main' }}>{o.id}</TableCell>
|
||||
<TableCell>{o.customer}</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="small" label={v} sx={{ bgcolor: hexA(VERTICAL_COLOR[v], 0.12), color: VERTICAL_COLOR[v], fontWeight: 600 }} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary">{o.pickup} → {o.drop}</Typography>
|
||||
</TableCell>
|
||||
<TableCell><StatusChip status={o.status} /></TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(o.charges)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</MainCard>
|
||||
{/* Third row — events / fleet / route efficiency */}
|
||||
<Box sx={{ mt: 3.5 }}>
|
||||
<SectionLabel>Operational Intelligence</SectionLabel>
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} lg={4}>
|
||||
<MainCard title="Recent Operational Events" noPadding>
|
||||
<Stack divider={<Divider />}>
|
||||
{executionFeed.map((e, i) => (
|
||||
<Stack key={`${e.id}-${i}`} direction="row" spacing={1.25} alignItems="flex-start" sx={{ px: 2, py: 1.25 }}>
|
||||
<Box sx={{ mt: 0.6, width: 8, height: 8, borderRadius: '50%', bgcolor: e.stage === 'Exception' ? 'error.main' : e.stage === 'Delivered' ? 'success.main' : 'info.main', flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||
<Stack direction="row" justifyContent="space-between" spacing={1}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{e.stage}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>{e.time}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }} noWrap>{e.id} · {e.loc}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6} lg={4}>
|
||||
<MainCard title="Fleet Status Overview">
|
||||
<Grid container spacing={1.5} sx={{ mb: 1.5 }}>
|
||||
{[['On trip', fleetSummary.onTrip, '#1D4ED8'], ['Charging', fleetSummary.charging, '#00A2AE'], ['Idle', fleetSummary.idle, '#8C8C8C'], ['Maintenance', fleetSummary.maintenance, '#F04134']].map(([l, v, c]) => (
|
||||
<Grid item xs={6} key={l}>
|
||||
<Box sx={{ border: '1px solid', borderColor: 'grey.200', borderRadius: 2, p: 1.25 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 800, color: c }}>{v}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{l}</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">EV fleet share</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>{fleetSummary.evShare}%</Typography>
|
||||
</Stack>
|
||||
<LinearProgress variant="determinate" value={fleetSummary.evShare} color="success" sx={{ height: 7, borderRadius: 4 }} />
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6} lg={4}>
|
||||
<MainCard title="Route Efficiency" noPadding>
|
||||
<Stack divider={<Divider />}>
|
||||
{lanePerformance.slice(0, 5).map((l) => (
|
||||
<Box key={l.lane} sx={{ px: 2, py: 1.1 }}>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.4 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'grey.800' }} noWrap>{l.lane}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{l.onTime}% · {inr(l.costPer)}</Typography>
|
||||
</Stack>
|
||||
<LinearProgress variant="determinate" value={l.onTime} color={l.onTime >= 98 ? 'success' : l.onTime >= 96 ? 'warning' : 'error'} sx={{ height: 5, borderRadius: 3 }} />
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* End-to-end flow + volume trend */}
|
||||
<Box sx={{ mt: 3.5 }}>
|
||||
<SectionLabel>Network Flow & Volume</SectionLabel>
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} lg={5}>
|
||||
<ProcessTracker />
|
||||
</Grid>
|
||||
<Grid item xs={12} lg={7}>
|
||||
<MainCard
|
||||
title="Order Volume Trends"
|
||||
action={<Stack direction="row" spacing={2}><Legend color="#C01227" label="Orders" /><Legend color="#00A854" label="Delivered" /></Stack>}
|
||||
sx={{ height: '100%' }}
|
||||
>
|
||||
<Box sx={{ py: 1 }}>
|
||||
<AreaChart
|
||||
height={320}
|
||||
labels={ordersTrend.map((d) => d.m)}
|
||||
series={[
|
||||
{ name: 'Orders', color: '#C01227', data: ordersTrend.map((d) => d.orders) },
|
||||
{ name: 'Delivered', color: '#00A854', data: ordersTrend.map((d) => d.delivered) }
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* AI impact */}
|
||||
<Box sx={{ mt: 3.5 }}>
|
||||
<AiImpactSummary />
|
||||
</Box>
|
||||
|
||||
<Toast {...toast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AiStat({ icon: Icon, color, value, label }) {
|
||||
return (
|
||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||
<Avatar variant="rounded" sx={{ bgcolor: hexA(color, 0.12), color, width: 38, height: 38 }}><Icon fontSize="small" /></Avatar>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>{value}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ color, label }) {
|
||||
return (
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
@@ -228,8 +240,3 @@ function Legend({ color, label }) {
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const hexA = (hex, a) => {
|
||||
const n = parseInt(hex.replace('#', ''), 16);
|
||||
return `rgba(${n >> 16}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
|
||||
};
|
||||
|
||||
@@ -22,8 +22,11 @@ import StatCard from '@/components/StatCard';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import TabLabelCount from '@/components/TabLabelCount';
|
||||
import { deliveries, locations, tenantsList, riders } from '@/data/mock';
|
||||
import { deliveries, tenantsList, riders } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
import DateRangeFilter from '@/components/DateRangeFilter';
|
||||
import FilterSummary from '@/components/FilterSummary';
|
||||
import { useFilters, inRange } from '@/store/Filters';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'assigned', label: 'Assigned' },
|
||||
@@ -140,12 +143,11 @@ function DeliveryRow({ row, index }) {
|
||||
}
|
||||
|
||||
export default function Deliveries() {
|
||||
const { location, range } = useFilters(); // global location + date range
|
||||
const [tab, setTab] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [tenant, setTenant] = useState('all');
|
||||
const [location, setLocation] = useState('all');
|
||||
const [rider, setRider] = useState('all');
|
||||
const [headerLocation, setHeaderLocation] = useState('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(5);
|
||||
|
||||
@@ -170,13 +172,14 @@ export default function Deliveries() {
|
||||
const matchTab = d.status === tabKey;
|
||||
const matchTenant = tenant === 'all' || d.tenant === tenant;
|
||||
const matchLocation = location === 'all' || d.location === location;
|
||||
const matchDate = d.date ? inRange(d.date, range) : true;
|
||||
const matchRider = rider === 'all' || d.rider === rider;
|
||||
const matchSearch =
|
||||
!search ||
|
||||
[d.id, d.tenant, d.pickup, d.drop, d.rider, d.location].join(' ').toLowerCase().includes(search.toLowerCase());
|
||||
return matchTab && matchTenant && matchLocation && matchRider && matchSearch;
|
||||
return matchTab && matchTenant && matchLocation && matchDate && matchRider && matchSearch;
|
||||
}),
|
||||
[tabKey, tenant, location, rider, search]
|
||||
[tabKey, tenant, location, range, rider, search]
|
||||
);
|
||||
|
||||
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
||||
@@ -186,15 +189,7 @@ export default function Deliveries() {
|
||||
<PageHeader
|
||||
title="Deliveries"
|
||||
breadcrumbs={[{ label: 'Deliveries' }]}
|
||||
action={
|
||||
<TextField
|
||||
select size="small" value={headerLocation} onChange={(e) => setHeaderLocation(e.target.value)}
|
||||
sx={{ minWidth: 180 }} label="Location"
|
||||
>
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
}
|
||||
action={<DateRangeFilter />}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2.5} sx={{ mb: 1 }}>
|
||||
@@ -206,17 +201,10 @@ export default function Deliveries() {
|
||||
|
||||
<Card sx={{ mt: 1.5 }}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }} flexWrap="wrap" useFlexGap>
|
||||
<Button variant="outlined" startIcon={<CalendarTodayOutlinedIcon />} sx={{ color: 'text.secondary', borderColor: 'grey.300' }}>
|
||||
Jun 01 – Jun 05
|
||||
</Button>
|
||||
<TextField select size="small" value={tenant} onChange={(e) => { setTenant(e.target.value); setPage(0); }} sx={{ minWidth: 160 }} label="Tenant">
|
||||
<MenuItem value="all">All Tenants</MenuItem>
|
||||
{tenantsList.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select size="small" value={location} onChange={(e) => { setLocation(e.target.value); setPage(0); }} sx={{ minWidth: 150 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select size="small" value={rider} onChange={(e) => { setRider(e.target.value); setPage(0); }} sx={{ minWidth: 150 }} label="Rider">
|
||||
<MenuItem value="all">All Riders</MenuItem>
|
||||
{riders.map((r) => <MenuItem key={r.id} value={r.name}>{r.name}</MenuItem>)}
|
||||
@@ -229,6 +217,10 @@ export default function Deliveries() {
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ px: 2, pb: 1.5 }}>
|
||||
<FilterSummary count={filtered.length} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); setPage(0); }} variant="scrollable" scrollButtons="auto">
|
||||
{TABS.map((t, i) => (
|
||||
|
||||
@@ -1,206 +1,259 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Grid,
|
||||
Tabs,
|
||||
Tab,
|
||||
Box,
|
||||
Stack,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Button,
|
||||
Typography,
|
||||
Divider,
|
||||
Snackbar,
|
||||
Alert
|
||||
Box, Stack, Typography, Card, Tabs, Tab, TextField, MenuItem, Switch, Button, Grid,
|
||||
Snackbar, Alert
|
||||
} from '@mui/material';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import RestartAltOutlinedIcon from '@mui/icons-material/RestartAltOutlined';
|
||||
import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
|
||||
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
|
||||
import AltRouteOutlinedIcon from '@mui/icons-material/AltRouteOutlined';
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import ApiOutlinedIcon from '@mui/icons-material/ApiOutlined';
|
||||
import GppGoodOutlinedIcon from '@mui/icons-material/GppGoodOutlined';
|
||||
// section / row icons
|
||||
import BusinessOutlinedIcon from '@mui/icons-material/BusinessOutlined';
|
||||
import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined';
|
||||
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
|
||||
import SupportAgentOutlinedIcon from '@mui/icons-material/SupportAgentOutlined';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined';
|
||||
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined';
|
||||
import VpnKeyOutlinedIcon from '@mui/icons-material/VpnKeyOutlined';
|
||||
import DevicesOutlinedIcon from '@mui/icons-material/DevicesOutlined';
|
||||
import GroupOutlinedIcon from '@mui/icons-material/GroupOutlined';
|
||||
import EmailOutlinedIcon from '@mui/icons-material/EmailOutlined';
|
||||
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
|
||||
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
|
||||
import WebhookIcon from '@mui/icons-material/Webhook';
|
||||
import HistoryToggleOffOutlinedIcon from '@mui/icons-material/HistoryToggleOffOutlined';
|
||||
import PolicyOutlinedIcon from '@mui/icons-material/PolicyOutlined';
|
||||
import CloudDownloadOutlinedIcon from '@mui/icons-material/CloudDownloadOutlined';
|
||||
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
|
||||
const TIMEZONES = ['Asia/Kolkata (IST)', 'Asia/Dubai (GST)', 'UTC', 'America/New_York (EST)'];
|
||||
const LANGUAGES = ['English', 'हिन्दी (Hindi)', 'العربية (Arabic)'];
|
||||
const RETENTION = ['90 days', '180 days', '1 year', '3 years', 'Indefinite'];
|
||||
|
||||
function TabPanel({ value, index, children }) {
|
||||
if (value !== index) return null;
|
||||
return <Box sx={{ pt: 1 }}>{children}</Box>;
|
||||
}
|
||||
const TABS = [
|
||||
{ key: 'general', label: 'General', icon: TuneOutlinedIcon },
|
||||
{ key: 'operations', label: 'Operations', icon: AltRouteOutlinedIcon },
|
||||
{ key: 'security', label: 'Security', icon: LockOutlinedIcon },
|
||||
{ key: 'integrations', label: 'Integrations', icon: ApiOutlinedIcon },
|
||||
{ key: 'compliance', label: 'Compliance', icon: GppGoodOutlinedIcon }
|
||||
];
|
||||
|
||||
export default function Settings() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [toast, setToast] = useState(false);
|
||||
|
||||
// General
|
||||
const [general, setGeneral] = useState({
|
||||
orgName: 'Doormile Logistics Pvt. Ltd.',
|
||||
supportEmail: 'support@doormile.in',
|
||||
contact: '+91 98450 11223',
|
||||
timezone: TIMEZONES[0],
|
||||
language: LANGUAGES[0]
|
||||
});
|
||||
|
||||
// Notifications
|
||||
const [notify, setNotify] = useState({
|
||||
newOrders: true,
|
||||
riderStatus: true,
|
||||
invoicePaid: true,
|
||||
weeklyDigest: false,
|
||||
emailAlerts: true,
|
||||
smsAlerts: false
|
||||
});
|
||||
|
||||
// Security
|
||||
const [security, setSecurity] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
twoFactor: false
|
||||
});
|
||||
|
||||
const setG = (k) => (e) => setGeneral((p) => ({ ...p, [k]: e.target.value }));
|
||||
const setN = (k) => (e) => setNotify((p) => ({ ...p, [k]: e.target.checked }));
|
||||
const setS = (k) => (e) => setSecurity((p) => ({ ...p, [k]: e.target.value ?? e.target.checked }));
|
||||
|
||||
const save = () => setToast(true);
|
||||
const INITIAL = {
|
||||
general: { orgName: 'Doormile Logistics Pvt. Ltd.', supportEmail: 'support@doormile.in', contact: '+91 98450 11223' },
|
||||
notif: { customer: true, rider: true, delay: true, admin: true },
|
||||
security: { twoFactor: true, sessionTimeout: true }
|
||||
};
|
||||
|
||||
// ---- building blocks ----
|
||||
function SectionCard({ title, description, icon: Icon, action, children, sx }) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
breadcrumbs={[{ label: 'Settings' }]}
|
||||
action={
|
||||
<Button variant="contained" startIcon={<SaveOutlinedIcon />} onClick={save}>
|
||||
Save Changes
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<MainCard noPadding>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
value={tab}
|
||||
onChange={(_, v) => setTab(v)}
|
||||
sx={{
|
||||
'& .MuiTab-root': { alignItems: 'flex-start', textTransform: 'none', minHeight: 52, fontWeight: 600 }
|
||||
}}
|
||||
>
|
||||
<Tab icon={<TuneOutlinedIcon fontSize="small" />} iconPosition="start" label="General" />
|
||||
<Tab icon={<NotificationsNoneIcon fontSize="small" />} iconPosition="start" label="Notifications" />
|
||||
<Tab icon={<LockOutlinedIcon fontSize="small" />} iconPosition="start" label="Security" />
|
||||
</Tabs>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={9}>
|
||||
{/* General */}
|
||||
<TabPanel value={tab} index={0}>
|
||||
<MainCard title="Organisation">
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth label="Organisation Name" value={general.orgName} onChange={setG('orgName')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth label="Support Email" value={general.supportEmail} onChange={setG('supportEmail')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth label="Contact Number" value={general.contact} onChange={setG('contact')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth label="Timezone" value={general.timezone} onChange={setG('timezone')}>
|
||||
{TIMEZONES.map((t) => (
|
||||
<MenuItem key={t} value={t}>{t}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth label="Language" value={general.language} onChange={setG('language')}>
|
||||
{LANGUAGES.map((l) => (
|
||||
<MenuItem key={l} value={l}>{l}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</MainCard>
|
||||
</TabPanel>
|
||||
|
||||
{/* Notifications */}
|
||||
<TabPanel value={tab} index={1}>
|
||||
<MainCard title="Notification Preferences">
|
||||
<Stack divider={<Divider flexItem />} spacing={0}>
|
||||
{[
|
||||
{ k: 'newOrders', t: 'New orders', d: 'Notify when a new order is placed' },
|
||||
{ k: 'riderStatus', t: 'Rider status', d: 'When a rider goes online or offline' },
|
||||
{ k: 'invoicePaid', t: 'Invoice paid', d: 'When a client settles an invoice' },
|
||||
{ k: 'weeklyDigest', t: 'Weekly digest', d: 'A summary of operations every Monday' }
|
||||
].map((row) => (
|
||||
<Stack key={row.k} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>{row.t}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{row.d}</Typography>
|
||||
</Box>
|
||||
<Switch checked={notify[row.k]} onChange={setN(row.k)} />
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>Channels</Typography>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<FormControlLabel control={<Switch checked={notify.emailAlerts} onChange={setN('emailAlerts')} />} label="Email alerts" />
|
||||
<FormControlLabel control={<Switch checked={notify.smsAlerts} onChange={setN('smsAlerts')} />} label="SMS alerts" />
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</TabPanel>
|
||||
|
||||
{/* Security */}
|
||||
<TabPanel value={tab} index={2}>
|
||||
<Stack spacing={2.5}>
|
||||
<MainCard title="Change Password">
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth type="password" label="Current Password" value={security.currentPassword} onChange={setS('currentPassword')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} />
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth type="password" label="New Password" value={security.newPassword} onChange={setS('newPassword')} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth type="password" label="Confirm New Password" value={security.confirmPassword} onChange={setS('confirmPassword')} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</MainCard>
|
||||
<MainCard title="Two-Factor Authentication">
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>Authenticator app</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Require a one-time code at sign-in for extra security.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch checked={security.twoFactor} onChange={(e) => setSecurity((p) => ({ ...p, twoFactor: e.target.checked }))} />
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Stack>
|
||||
</TabPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Snackbar
|
||||
open={toast}
|
||||
autoHideDuration={2500}
|
||||
onClose={() => setToast(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="success" variant="filled" onClose={() => setToast(false)} sx={{ width: '100%' }}>
|
||||
Settings saved successfully.
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</>
|
||||
<Card variant="outlined" sx={{ borderColor: 'grey.200', boxShadow: '0 1px 2px rgba(16,24,40,0.04)', height: '100%', ...sx }}>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1.5} sx={{ mb: children ? 2.25 : 0 }}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ minWidth: 0 }}>
|
||||
{Icon && (
|
||||
<Box sx={{ width: 38, height: 38, borderRadius: 1.5, bgcolor: 'grey.100', color: 'grey.700', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon sx={{ fontSize: 20 }} />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.25 }}>{title}</Typography>
|
||||
{description && <Typography variant="caption" color="text.secondary">{description}</Typography>}
|
||||
</Box>
|
||||
</Stack>
|
||||
{action && <Box sx={{ flexShrink: 0 }}>{action}</Box>}
|
||||
</Stack>
|
||||
{children}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({ icon: Icon, title, desc, checked, onChange, divider }) {
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={2} sx={{ py: 1.75, borderTop: divider ? '1px solid' : 'none', borderColor: 'grey.100' }}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ minWidth: 0 }}>
|
||||
{Icon && (
|
||||
<Box sx={{ width: 36, height: 36, borderRadius: 1.5, bgcolor: 'grey.100', color: 'grey.700', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon sx={{ fontSize: 19 }} />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>{title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{desc}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Switch checked={checked} onChange={onChange} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const INTEGRATIONS = [
|
||||
{ key: 'emailjs', name: 'EmailJS', desc: 'Transactional email delivery', icon: EmailOutlinedIcon, status: 'connected', sync: '2 min ago' },
|
||||
{ key: 'whatsapp', name: 'WhatsApp', desc: 'Customer tracking updates', icon: WhatsAppIcon, status: 'connected', sync: '5 min ago' },
|
||||
{ key: 'gmaps', name: 'Google Maps', desc: 'Geocoding & route tiles', icon: MapOutlinedIcon, status: 'connected', sync: '1 min ago' },
|
||||
{ key: 'razorpay', name: 'Razorpay', desc: 'Payments & COD settlement', icon: PaymentsOutlinedIcon, status: 'degraded', sync: '38 min ago' },
|
||||
{ key: 'webhooks', name: 'Webhooks', desc: 'Event push to partner systems', icon: WebhookIcon, status: 'pending', sync: 'Never' }
|
||||
];
|
||||
|
||||
export default function Settings() {
|
||||
const [tab, setTab] = useState('general');
|
||||
const [toast, setToast] = useState('');
|
||||
const [general, setGeneral] = useState(INITIAL.general);
|
||||
const [notif, setNotif] = useState(INITIAL.notif);
|
||||
const [security, setSecurity] = useState(INITIAL.security);
|
||||
|
||||
const setG = (k) => (e) => setGeneral((p) => ({ ...p, [k]: e.target.value }));
|
||||
const setN = (k) => (e) => setNotif((p) => ({ ...p, [k]: e.target.checked }));
|
||||
const setS = (k) => (e) => setSecurity((p) => ({ ...p, [k]: e.target.checked }));
|
||||
|
||||
const save = () => setToast('Settings saved successfully.');
|
||||
const reset = () => {
|
||||
setGeneral(INITIAL.general); setNotif(INITIAL.notif); setSecurity(INITIAL.security);
|
||||
setToast('Changes reset to last saved state.');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* header */}
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" alignItems={{ xs: 'flex-start', md: 'center' }} spacing={2} sx={{ mb: 2.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.900', lineHeight: 1.1 }}>Settings</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, maxWidth: 560 }}>
|
||||
Manage organization preferences, notifications, integrations, and security settings.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1.5} sx={{ flexShrink: 0 }}>
|
||||
<Button variant="outlined" color="inherit" startIcon={<RestartAltOutlinedIcon />} onClick={reset} sx={{ borderColor: 'grey.300', color: 'grey.700' }}>Reset Changes</Button>
|
||||
<Button variant="contained" startIcon={<SaveOutlinedIcon />} onClick={save}>Save Changes</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* sticky tabs */}
|
||||
<Box sx={{ position: 'sticky', top: 64, zIndex: 5, bgcolor: 'background.default', borderBottom: '1px solid', borderColor: 'grey.200', mb: 3 }}>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto" sx={{ minHeight: 48, '& .MuiTab-root': { minHeight: 48, textTransform: 'none', fontWeight: 600, fontSize: '0.9rem' } }}>
|
||||
{TABS.map((t) => (
|
||||
<Tab key={t.key} value={t.key} icon={<t.icon sx={{ fontSize: 18 }} />} iconPosition="start" label={t.label} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* ===================== GENERAL ===================== */}
|
||||
{tab === 'general' && (
|
||||
<Box sx={{ maxWidth: 1080 }}>
|
||||
<SectionCard title="Organization Profile" description="Your company's core identity and contact details" icon={BusinessOutlinedIcon}>
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Organization Name" value={general.orgName} onChange={setG('orgName')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Support Email" value={general.supportEmail} onChange={setG('supportEmail')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Contact Number" value={general.contact} onChange={setG('contact')} /></Grid>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ===================== OPERATIONS ===================== */}
|
||||
{tab === 'operations' && (
|
||||
<Grid container spacing={2.5} sx={{ maxWidth: 1080 }}>
|
||||
{/* Notification Controls */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<SectionCard title="Notification Controls" description="Who gets alerted across the operation" icon={NotificationsNoneIcon}>
|
||||
<Box>
|
||||
<ToggleRow icon={SupportAgentOutlinedIcon} title="Customer Notifications" desc="Order, tracking link & delivery updates to customers" checked={notif.customer} onChange={setN('customer')} />
|
||||
<ToggleRow icon={TwoWheelerOutlinedIcon} title="Rider Notifications" desc="Assignment, pickup & route changes to riders" checked={notif.rider} onChange={setN('rider')} divider />
|
||||
<ToggleRow icon={WarningAmberOutlinedIcon} title="Delay Alerts" desc="Proactive alerts when a shipment trends late" checked={notif.delay} onChange={setN('delay')} divider />
|
||||
<ToggleRow icon={CampaignOutlinedIcon} title="Admin Escalations" desc="Critical exceptions routed to ops managers" checked={notif.admin} onChange={setN('admin')} divider />
|
||||
</Box>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* ===================== SECURITY ===================== */}
|
||||
{tab === 'security' && (
|
||||
<Grid container spacing={2.5} sx={{ maxWidth: 1080 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<SectionCard title="Two-Factor Authentication" description="Require a one-time code at sign-in" icon={ShieldOutlinedIcon} action={<Switch checked={security.twoFactor} onChange={setS('twoFactor')} />}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{security.twoFactor ? 'Enabled for all admin accounts via authenticator app.' : 'Currently disabled — enable to add a second verification step.'}
|
||||
</Typography>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<SectionCard title="Password Policy" description="Strength and rotation rules" icon={VpnKeyOutlinedIcon} action={<Button size="small" variant="outlined" color="inherit" sx={{ borderColor: 'grey.300', color: 'grey.700' }}>Configure</Button>}>
|
||||
<Typography variant="body2" color="text.secondary">Minimum 12 characters · mixed case · expires every 90 days.</Typography>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<SectionCard title="Session Management" description="Active sessions and auto-logout" icon={DevicesOutlinedIcon} action={<Button size="small" variant="outlined" color="inherit" sx={{ borderColor: 'grey.300', color: 'grey.700' }}>View Sessions</Button>}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">Auto sign-out after 30 min idle</Typography>
|
||||
<Switch checked={security.sessionTimeout} onChange={setS('sessionTimeout')} />
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<SectionCard title="Role Permissions" description="Access control across the org" icon={GroupOutlinedIcon} action={<Button size="small" variant="outlined" color="inherit" sx={{ borderColor: 'grey.300', color: 'grey.700' }}>Manage Roles</Button>}>
|
||||
<Typography variant="body2" color="text.secondary">4 roles · 12 members · 3 pending invites.</Typography>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* ===================== INTEGRATIONS ===================== */}
|
||||
{tab === 'integrations' && (
|
||||
<Grid container spacing={2.5}>
|
||||
{INTEGRATIONS.map((it) => (
|
||||
<Grid item xs={12} sm={6} lg={4} key={it.key}>
|
||||
<SectionCard title={it.name} description={it.desc} icon={it.icon} action={<StatusChip status={it.status} />}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ pt: 0.5 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Last sync</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{it.sync}</Typography>
|
||||
</Box>
|
||||
<Button size="small" variant="outlined" color="inherit" sx={{ borderColor: 'grey.300', color: 'grey.700' }}>Configure</Button>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* ===================== COMPLIANCE ===================== */}
|
||||
{tab === 'compliance' && (
|
||||
<Grid container spacing={2.5} sx={{ maxWidth: 1080 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<SectionCard title="Data Retention" description="How long operational records are kept" icon={HistoryToggleOffOutlinedIcon}>
|
||||
<TextField select fullWidth size="small" defaultValue={RETENTION[2]} label="Retention period">
|
||||
{RETENTION.map((r) => <MenuItem key={r} value={r}>{r}</MenuItem>)}
|
||||
</TextField>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<SectionCard title="Audit Logs" description="Immutable record of every admin action" icon={ReceiptLongOutlinedIcon} action={<Button size="small" variant="outlined" color="inherit" sx={{ borderColor: 'grey.300', color: 'grey.700' }}>View Logs</Button>}>
|
||||
<Typography variant="body2" color="text.secondary">1,284 events recorded in the last 30 days.</Typography>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<SectionCard title="Privacy Controls" description="Consent, masking and PII handling" icon={PolicyOutlinedIcon} action={<Button size="small" variant="outlined" color="inherit" sx={{ borderColor: 'grey.300', color: 'grey.700' }}>Manage</Button>}>
|
||||
<Typography variant="body2" color="text.secondary">Customer PII masked in exports · GDPR & DPDP aligned.</Typography>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<SectionCard title="Export Data" description="Download a full copy of your workspace data" icon={CloudDownloadOutlinedIcon} action={<Button size="small" variant="contained" disableElevation startIcon={<CloudDownloadOutlinedIcon sx={{ fontSize: 16 }} />}>Export</Button>}>
|
||||
<Typography variant="body2" color="text.secondary">Orders, riders, invoices and logs as CSV / JSON.</Typography>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Snackbar open={Boolean(toast)} autoHideDuration={2500} onClose={() => setToast('')} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
|
||||
<Alert severity="success" variant="filled" onClose={() => setToast('')} sx={{ width: '100%' }}>{toast}</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
17
src/pages/business/CustomersHub.jsx
Normal file
17
src/pages/business/CustomersHub.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import TabbedWorkspace from '@/components/TabbedWorkspace';
|
||||
import Customers from '@/pages/customers/Customers';
|
||||
import Tenants from '@/pages/tenants/Tenants';
|
||||
|
||||
// ==============================|| CUSTOMERS — directory workspace ||============================== //
|
||||
// Merges the consumer Customers directory and the Business Clients (tenants) directory.
|
||||
|
||||
export default function CustomersHub() {
|
||||
return (
|
||||
<TabbedWorkspace
|
||||
tabs={[
|
||||
{ key: 'customers', label: 'Customers', element: <Customers /> },
|
||||
{ key: 'clients', label: 'Business Clients', element: <Tenants /> }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
67
src/pages/business/FinanceHub.jsx
Normal file
67
src/pages/business/FinanceHub.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Card, Table, TableBody, TableCell, TableHead, TableRow, Typography, Box } from '@mui/material';
|
||||
|
||||
import TabbedWorkspace from '@/components/TabbedWorkspace';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import Invoices from '@/pages/invoice/Invoices';
|
||||
import Pricing from '@/pages/Pricing';
|
||||
import { invoices } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
// ==============================|| FINANCE — billing workspace ||============================== //
|
||||
// Merges Invoices, Pricing and a Payments ledger (settled invoices) into one screen.
|
||||
|
||||
const METHODS = ['NEFT', 'UPI', 'RTGS', 'Corporate Card', 'Cheque'];
|
||||
|
||||
function Payments() {
|
||||
const paid = invoices.filter((i) => i.status === 'paid');
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Payments" breadcrumbs={[{ label: 'Finance' }, { label: 'Payments' }]} />
|
||||
<Card>
|
||||
<Table size="small" sx={{ '& .MuiTableRow-root:hover': { backgroundColor: 'grey.50' } }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Invoice</TableCell>
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Period</TableCell>
|
||||
<TableCell>Method</TableCell>
|
||||
<TableCell>Received</TableCell>
|
||||
<TableCell align="right">Amount</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paid.map((p, i) => (
|
||||
<TableRow key={p.id} hover>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'primary.main' }}>{p.invoiceId}</TableCell>
|
||||
<TableCell>{p.client}</TableCell>
|
||||
<TableCell><Typography variant="caption" color="text.secondary">{p.period}</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" color="text.secondary">{METHODS[i % METHODS.length]}</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" color="text.secondary">{p.dueDate}</Typography></TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(p.amount)}</TableCell>
|
||||
<TableCell><StatusChip status="paid" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{paid.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} sx={{ textAlign: 'center', py: 4, color: 'text.secondary' }}>No payments recorded.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
<Box sx={{ height: 8 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FinanceHub() {
|
||||
return (
|
||||
<TabbedWorkspace
|
||||
tabs={[
|
||||
{ key: 'invoices', label: 'Invoices', element: <Invoices /> },
|
||||
{ key: 'pricing', label: 'Pricing', element: <Pricing /> },
|
||||
{ key: 'payments', label: 'Payments', element: <Payments /> }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import MainCard from '@/components/MainCard';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { customers, locations } from '@/data/mock';
|
||||
import { useFilters } from '@/store/Filters';
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '', phone: '', address: '', location: '', city: '', state: '',
|
||||
@@ -26,7 +27,7 @@ const EMPTY_FORM = {
|
||||
export default function Customers() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [location, setLocation] = useState('all');
|
||||
const { location } = useFilters(); // global location — single source of truth
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(5);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -74,13 +75,6 @@ export default function Customers() {
|
||||
breadcrumbs={[{ label: 'Customers' }]}
|
||||
action={
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ sm: 'center' }}>
|
||||
<TextField
|
||||
select size="small" value={location} onChange={(e) => { setLocation(e.target.value); setPage(0); }}
|
||||
sx={{ minWidth: 160 }} label="Location"
|
||||
>
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small" placeholder="Search customers…" value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }} sx={{ minWidth: 220 }}
|
||||
|
||||
256
src/pages/dispatch/DispatchBoard.jsx
Normal file
256
src/pages/dispatch/DispatchBoard.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Grid, Card, Stack, Box, Typography, Button, Divider, Chip, MenuItem, Select, IconButton, Tooltip
|
||||
} from '@mui/material';
|
||||
import BoltOutlinedIcon from '@mui/icons-material/BoltOutlined';
|
||||
import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
import ArrowRightAltRoundedIcon from '@mui/icons-material/ArrowRightAltRounded';
|
||||
import PersonAddAlt1OutlinedIcon from '@mui/icons-material/PersonAddAlt1Outlined';
|
||||
import UndoOutlinedIcon from '@mui/icons-material/UndoOutlined';
|
||||
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import Toast, { useToast } from '@/components/Toast';
|
||||
import { dispatchQueue, orders, riders } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
import { useOps } from '@/store/OpsStore';
|
||||
|
||||
const PRIORITY = { high: { fg: '#A82216', bg: '#FEEAE9' }, express: { fg: '#8A6500', bg: '#FFF7E0' }, standard: { fg: '#595959', bg: '#F0F0F0' } };
|
||||
const confColor = (c) => (c >= 90 ? '#00773B' : c >= 80 ? '#8A6500' : '#595959');
|
||||
const orderById = (id) => orders.find((o) => o.id === id);
|
||||
const riderByName = (name) => riders.find((r) => r.name === name);
|
||||
const availableRiders = riders.filter((r) => r.status !== 'offline');
|
||||
|
||||
function KpiCard({ label, value, subtitle, color }) {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
minHeight: 116,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
px: 2.5,
|
||||
py: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200',
|
||||
boxShadow: '0 1px 3px rgba(16,24,40,0.06)',
|
||||
transition: 'box-shadow .15s ease, transform .15s ease',
|
||||
'&:hover': { boxShadow: '0 6px 20px rgba(16,24,40,0.10)', transform: 'translateY(-2px)' }
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '1rem', fontWeight: 600, color: 'grey.700', lineHeight: 1.2 }}>{label}</Typography>
|
||||
<Typography sx={{ fontSize: { xs: '2.25rem', lg: '2.75rem' }, fontWeight: 800, lineHeight: 1.05, mt: 0.25, color: color || 'grey.900' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>{subtitle}</Typography>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DispatchBoard() {
|
||||
const navigate = useNavigate();
|
||||
const [toast, showToast] = useToast();
|
||||
const { assignments, assignOrder, unassignOrder, riderLoad, assignedCount } = useOps();
|
||||
|
||||
// per-card chosen rider (defaults to AI suggestion)
|
||||
const [choice, setChoice] = useState({});
|
||||
|
||||
const queue = useMemo(() => dispatchQueue.filter((q) => !assignments[q.id]), [assignments]);
|
||||
const assignedList = useMemo(() => Object.entries(assignments).map(([id, a]) => ({ id, ...a })).sort((x, y) => y.at - x.at), [assignments]);
|
||||
|
||||
const onlineCount = availableRiders.length;
|
||||
const avgConfidence = queue.length ? Math.round(queue.reduce((s, q) => s + q.confidence, 0) / queue.length) : 0;
|
||||
|
||||
const doAssign = (q, riderName) => {
|
||||
const rider = riderByName(riderName) || availableRiders[0];
|
||||
if (!rider) return showToast('No available rider', 'warning');
|
||||
assignOrder(q.id, rider);
|
||||
showToast(`${q.id} assigned to ${rider.name}`);
|
||||
};
|
||||
|
||||
const autoAssignAll = () => {
|
||||
if (!queue.length) return showToast('Queue is already clear', 'info');
|
||||
queue.forEach((q) => {
|
||||
const rider = riderByName(q.suggestedRider) || availableRiders[0];
|
||||
if (rider) assignOrder(q.id, rider);
|
||||
});
|
||||
showToast(`${queue.length} orders auto-assigned by MileTruth AI`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Dispatch Board"
|
||||
breadcrumbs={[{ label: 'Operations' }, { label: 'Dispatch' }]}
|
||||
action={
|
||||
<Button variant="contained" startIcon={<AutoAwesomeOutlinedIcon />} onClick={autoAssignAll} disabled={!queue.length}>
|
||||
Auto-assign all
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* primary KPI block — large dashboard cards */}
|
||||
<Grid container spacing={2.5} sx={{ mb: 3 }}>
|
||||
<Grid item xs={6} lg={3}>
|
||||
<KpiCard label="Awaiting Dispatch" value={queue.length} color={queue.length ? '#A82216' : '#00773B'} subtitle="Orders waiting for assignment" />
|
||||
</Grid>
|
||||
<Grid item xs={6} lg={3}>
|
||||
<KpiCard label="Assigned (Session)" value={assignedCount} color="#00773B" subtitle="Assigned in current session" />
|
||||
</Grid>
|
||||
<Grid item xs={6} lg={3}>
|
||||
<KpiCard label="Riders Available" value={onlineCount} subtitle="Ready for dispatch" />
|
||||
</Grid>
|
||||
<Grid item xs={6} lg={3}>
|
||||
<KpiCard label="Avg AI Confidence" value={queue.length ? `${avgConfidence}%` : '—'} color="#00773B" subtitle="Assignment confidence score" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2.5}>
|
||||
{/* LEFT · unassigned queue */}
|
||||
<Grid item xs={12} lg={8}>
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ p: 2, pb: 1 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Awaiting Dispatch</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{queue.length} orders</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack spacing={1.5} sx={{ p: 2 }}>
|
||||
{queue.map((q) => {
|
||||
const o = orderById(q.id) || {};
|
||||
const pr = PRIORITY[q.priority] || PRIORITY.standard;
|
||||
const selected = choice[q.id] ?? q.suggestedRider;
|
||||
return (
|
||||
<Box key={q.id} sx={{ p: 1.75, border: '1px solid', borderColor: 'grey.200', borderLeft: '4px solid', borderLeftColor: pr.fg, borderRadius: 2 }}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" spacing={1.5}>
|
||||
{/* order facts */}
|
||||
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.main', cursor: 'pointer' }} onClick={() => navigate(`/orders/${q.id}`)}>{q.id}</Typography>
|
||||
<Box component="span" sx={{ px: 0.75, py: 0.25, borderRadius: 1, bgcolor: pr.bg, color: pr.fg, fontSize: 10, fontWeight: 700, textTransform: 'uppercase' }}>{q.priority}</Box>
|
||||
{o.tenant && <Typography variant="caption" color="text.secondary">{o.tenant}</Typography>}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ minWidth: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>{q.pickup}</Typography>
|
||||
<ArrowRightAltRoundedIcon sx={{ fontSize: 16, color: 'grey.400' }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }} noWrap>{q.drop}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mt: 0.75 }}>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<ScheduleOutlinedIcon sx={{ fontSize: 14, color: 'grey.500' }} />
|
||||
<Typography variant="caption" color="text.secondary">SLA {q.sla} · ETA {q.etaMin}m</Typography>
|
||||
</Stack>
|
||||
{o.charges != null && <Typography variant="caption" sx={{ fontWeight: 600 }}>{inr(o.charges)}</Typography>}
|
||||
{o.kms != null && <Typography variant="caption" color="text.secondary">{o.kms} km</Typography>}
|
||||
</Stack>
|
||||
{/* AI suggestion */}
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.75 }}>
|
||||
<AutoAwesomeOutlinedIcon sx={{ fontSize: 14, color: '#EA580C' }} />
|
||||
<Typography variant="caption" color="text.secondary">AI suggests</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>{q.suggestedRider}</Typography>
|
||||
<Chip size="small" label={`${q.confidence}%`} sx={{ height: 18, bgcolor: 'grey.100', color: confColor(q.confidence), fontWeight: 700, '& .MuiChip-label': { px: 0.75, fontSize: 10 } }} />
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* assign controls */}
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexShrink: 0 }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={selected}
|
||||
onChange={(e) => setChoice((c) => ({ ...c, [q.id]: e.target.value }))}
|
||||
sx={{ minWidth: 150, '& .MuiSelect-select': { py: 0.75, fontSize: '0.8125rem' } }}
|
||||
>
|
||||
{availableRiders.map((r) => (
|
||||
<MenuItem key={r.id} value={r.name}>
|
||||
{r.name} {riderLoad(r.id) > 0 ? `· ${riderLoad(r.id)} load` : ''}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Button size="small" variant="contained" startIcon={<PersonAddAlt1OutlinedIcon />} onClick={() => doAssign(q, selected)}>
|
||||
Assign
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{queue.length === 0 && (
|
||||
<Stack alignItems="center" spacing={1} sx={{ py: 6, color: 'text.secondary' }}>
|
||||
<CheckCircleRoundedIcon sx={{ fontSize: 40, color: 'success.main' }} />
|
||||
<Typography variant="subtitle2">Queue clear — every order is dispatched.</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* RIGHT · riders + assigned-this-session */}
|
||||
<Grid item xs={12} lg={4}>
|
||||
<Stack spacing={2.5}>
|
||||
<Card>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ p: 2, pb: 1 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Riders</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{onlineCount} available</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack divider={<Divider />}>
|
||||
{riders.map((r) => {
|
||||
const load = riderLoad(r.id);
|
||||
return (
|
||||
<Stack key={r.id} direction="row" spacing={1.25} alignItems="center" sx={{ px: 2, py: 1.25 }}>
|
||||
<UserAvatar name={r.name} size={32} />
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }} noWrap>{r.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>{r.vehicle} · {r.address.split(',').pop().trim()}</Typography>
|
||||
</Box>
|
||||
<Stack alignItems="flex-end" spacing={0.25} sx={{ flexShrink: 0 }}>
|
||||
<StatusChip status={r.status} />
|
||||
<Typography variant="caption" color="text.secondary">{r.deliveries + load} today{load > 0 ? ` (+${load})` : ''}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ p: 2, pb: 1 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Assigned this session</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{assignedList.length}</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
{assignedList.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ p: 2, textAlign: 'center' }}>
|
||||
Nothing assigned yet. Use Assign or Auto-assign.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack divider={<Divider />}>
|
||||
{assignedList.map((a) => (
|
||||
<Stack key={a.id} direction="row" spacing={1} alignItems="center" sx={{ px: 2, py: 1.25 }}>
|
||||
<CheckCircleRoundedIcon sx={{ fontSize: 18, color: 'success.main', flexShrink: 0 }} />
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.main' }}>{a.id}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>→ {a.riderName} · {a.vehicle}</Typography>
|
||||
</Box>
|
||||
<Tooltip title="Unassign">
|
||||
<IconButton size="small" onClick={() => { unassignOrder(a.id); showToast(`${a.id} returned to queue`, 'info'); }}>
|
||||
<UndoOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Toast {...toast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Grid, Card, CardContent, Stack, Typography, Box, Avatar, LinearProgress, Table, TableBody, TableCell, TableHead, TableRow, Button, Tooltip, TextField, MenuItem } from '@mui/material';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import ElectricRickshawOutlinedIcon from '@mui/icons-material/ElectricRickshawOutlined';
|
||||
import BatteryChargingFullOutlinedIcon from '@mui/icons-material/BatteryChargingFullOutlined';
|
||||
import EnergySavingsLeafOutlinedIcon from '@mui/icons-material/EnergySavingsLeafOutlined';
|
||||
@@ -21,6 +23,7 @@ import { fleet, fleetSummary } from '@/data/mock';
|
||||
const BLANK = { id: '', model: '', type: 'EV 4W', powertrain: 'EV', capacityKg: '', hub: 'Koramangala Micro Hub' };
|
||||
|
||||
export default function Fleet() {
|
||||
const navigate = useNavigate();
|
||||
const [rows, setRows] = useState(fleet);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState(BLANK);
|
||||
@@ -56,7 +59,12 @@ export default function Fleet() {
|
||||
<PageHeader
|
||||
title="Fleet & Rider Operating System"
|
||||
breadcrumbs={[{ label: 'Fleet' }]}
|
||||
action={<Button variant="contained" startIcon={<AddOutlinedIcon />} onClick={() => setOpen(true)}>Add Vehicle</Button>}
|
||||
action={
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||
<Button variant="outlined" startIcon={<TwoWheelerOutlinedIcon />} onClick={() => navigate('/riders')}>Riders</Button>
|
||||
<Button variant="contained" startIcon={<AddOutlinedIcon />} onClick={() => setOpen(true)}>Add Vehicle</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<LayerBanner
|
||||
@@ -176,7 +184,7 @@ export default function Fleet() {
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" type="number" label="Capacity (kg)" value={form.capacityKg} onChange={set('capacityKg')} /></Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth size="small" label="Home Hub" value={form.hub} onChange={set('hub')}>
|
||||
{['Koramangala Micro Hub', 'Whitefield City Hub', 'Hoskote Regional Hub', 'Andheri City Hub', 'Hitech Cross Dock', 'Bilaspur Regional Hub'].map((h) => <MenuItem key={h} value={h}>{h}</MenuItem>)}
|
||||
{['Coimbatore Regional Hub', 'Peelamedu Micro Hub', 'Hoskote Regional Hub', 'Koramangala Micro Hub', 'Hyderabad Regional Hub', 'Gachibowli Micro Hub'].map((h) => <MenuItem key={h} value={h}>{h}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -1,199 +1,192 @@
|
||||
import { useState } from 'react';
|
||||
import { Grid, Card, CardContent, Stack, Typography, Box, Avatar, LinearProgress, Table, TableBody, TableCell, TableHead, TableRow, Button, Divider, TextField, MenuItem } from '@mui/material';
|
||||
import { Grid, Card, Stack, Typography, Box, LinearProgress, Button, Divider } from '@mui/material';
|
||||
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined';
|
||||
import HubOutlinedIcon from '@mui/icons-material/HubOutlined';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import RouteOutlinedIcon from '@mui/icons-material/RouteOutlined';
|
||||
import AddLocationAltOutlinedIcon from '@mui/icons-material/AddLocationAltOutlined';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import TaskAltOutlinedIcon from '@mui/icons-material/TaskAltOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||
import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import LayerBanner from '@/components/LayerBanner';
|
||||
import MapPlaceholder from '@/components/MapPlaceholder';
|
||||
import FormDialog from '@/components/FormDialog';
|
||||
import AreaChart from '@/components/charts/AreaChart';
|
||||
import Toast, { useToast } from '@/components/Toast';
|
||||
import { hubs, hubNetworkTypes, lineHauls } from '@/data/mock';
|
||||
import { hubCityStats, hubThroughput, hubActivity, maintenanceNotices, exceptionQueue } from '@/data/mock';
|
||||
|
||||
const BLANK = { name: '', type: 'Micro Hub', city: 'Bengaluru', capacity: '', dock: '' };
|
||||
const HEALTH = { healthy: { bg: '#E3F6EC', fg: '#00773B', label: 'Healthy' }, watch: { bg: '#FFF7E0', fg: '#8A6500', label: 'Watch' }, critical: { bg: '#FEEAE9', fg: '#A82216', label: 'Critical' } };
|
||||
const SEV = { high: '#F04134', medium: '#FFBF00', low: '#00A2AE' };
|
||||
|
||||
function Stat({ icon: Icon, label, value }) {
|
||||
return (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Icon sx={{ fontSize: 16, color: 'grey.500' }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.1 }}>{value}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function HubCard({ h }) {
|
||||
const health = HEALTH[h.health] || HEALTH.healthy;
|
||||
const utilColor = h.utilization > 80 ? 'error' : h.utilization > 70 ? 'warning' : 'success';
|
||||
return (
|
||||
<Card sx={{ p: 2.5, height: '100%', borderTop: '3px solid', borderTopColor: 'primary.main' }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>{h.city}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{h.code} · {h.processed.toLocaleString('en-IN')} processed today</Typography>
|
||||
</Box>
|
||||
<Box component="span" sx={{ px: 1, py: 0.25, borderRadius: 1, bgcolor: health.bg, color: health.fg, fontSize: 11, fontWeight: 700 }}>{health.label}</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="baseline" sx={{ mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">Capacity utilization</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{h.utilization}%</Typography>
|
||||
</Stack>
|
||||
<LinearProgress variant="determinate" value={h.utilization} color={utilColor} sx={{ height: 8, borderRadius: 4, mb: 2 }} />
|
||||
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid item xs={6}><Stat icon={WarehouseOutlinedIcon} label="Capacity" value={h.capacity.toLocaleString('en-IN')} /></Grid>
|
||||
<Grid item xs={6}><Stat icon={Inventory2OutlinedIcon} label="Processed" value={h.processed.toLocaleString('en-IN')} /></Grid>
|
||||
<Grid item xs={6}><Stat icon={TwoWheelerOutlinedIcon} label="Active Riders" value={h.riders} /></Grid>
|
||||
<Grid item xs={6}><Stat icon={TaskAltOutlinedIcon} label="SLA" value={`${h.sla}%`} /></Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HubNetwork() {
|
||||
const [rows, setRows] = useState(hubs);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState(BLANK);
|
||||
const [toast, showToast] = useToast();
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const addHub = () => {
|
||||
if (!form.name.trim()) return showToast('Enter a hub name', 'warning');
|
||||
const cityCode = (form.city.slice(0, 3) || 'HUB').toUpperCase();
|
||||
setRows((r) => [
|
||||
...r,
|
||||
{ id: `HUB-${cityCode}-${String(10 + r.length).slice(-2)}`, name: form.name, type: form.type, city: form.city, lat: 0, lng: 0, capacity: Number(form.capacity) || 1000, load: 0, inbound: 0, outbound: 0, dock: Number(form.dock) || 4, status: 'online' }
|
||||
]);
|
||||
setForm(BLANK);
|
||||
setOpen(false);
|
||||
showToast(`${form.name} added to network`);
|
||||
};
|
||||
|
||||
const totalCap = rows.reduce((s, h) => s + h.capacity, 0);
|
||||
const totalLoad = rows.reduce((s, h) => s + h.load, 0);
|
||||
const util = Math.round((totalLoad / totalCap) * 100);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Hub & Network Orchestration"
|
||||
breadcrumbs={[{ label: 'Hub Network' }]}
|
||||
action={<Button variant="contained" startIcon={<AddLocationAltOutlinedIcon />} onClick={() => setOpen(true)}>Add Hub</Button>}
|
||||
/>
|
||||
|
||||
<LayerBanner
|
||||
no={3}
|
||||
icon={HubOutlinedIcon}
|
||||
color="#0E7C7B"
|
||||
title="Intelligent Hub Network"
|
||||
subtitle="MileTruth AI selects the optimal origin hub, then sorts, line-hauls and routes to destination."
|
||||
steps={['Nearest Hub', 'Pickup Assignment', 'Hub Operations', 'Line-Haul Transfer', 'Destination Hub']}
|
||||
title="Hub Operations Center"
|
||||
breadcrumbs={[{ label: 'Network' }, { label: 'Hubs' }]}
|
||||
action={<Button variant="outlined" startIcon={<FileDownloadOutlinedIcon />} onClick={() => showToast('Hub report exported')}>Export</Button>}
|
||||
/>
|
||||
|
||||
{/* Top — hub summary cards */}
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Active Hubs" value={hubs.length} icon={WarehouseOutlinedIcon} color="info" caption="across 4 cities" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Network Utilisation" value={`${util}%`} icon={HubOutlinedIcon} color="warning" trend={3.1} caption="vs yesterday" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Line-Hauls Running" value={lineHauls.filter((l) => l.status === 'in-transit').length} icon={LocalShippingOutlinedIcon} color="primary" caption="inter-city" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Throughput Today" value={totalLoad.toLocaleString('en-IN')} icon={RouteOutlinedIcon} color="success" trend={6.4} caption="parcels sorted" /></Grid>
|
||||
|
||||
<Grid item xs={12} lg={8}>
|
||||
<MainCard title="Hub Load & Capacity" noPadding>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Hub</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>City</TableCell>
|
||||
<TableCell>Load / Capacity</TableCell>
|
||||
<TableCell align="center">Docks</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((h) => {
|
||||
const pct = Math.round((h.load / h.capacity) * 100);
|
||||
return (
|
||||
<TableRow key={h.id} hover>
|
||||
<TableCell>
|
||||
<Typography variant="subtitle2">{h.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{h.id}</Typography>
|
||||
</TableCell>
|
||||
<TableCell><Typography variant="caption">{h.type}</Typography></TableCell>
|
||||
<TableCell>{h.city}</TableCell>
|
||||
<TableCell sx={{ minWidth: 160 }}>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="caption" color="text.secondary">{h.load.toLocaleString('en-IN')} / {h.capacity.toLocaleString('en-IN')}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>{pct}%</Typography>
|
||||
</Stack>
|
||||
<LinearProgress variant="determinate" value={pct} color={pct > 90 ? 'error' : pct > 75 ? 'warning' : 'success'} sx={{ height: 6, borderRadius: 3, mt: 0.5 }} />
|
||||
</TableCell>
|
||||
<TableCell align="center">{h.dock}</TableCell>
|
||||
<TableCell><StatusChip status={h.status} /></TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={4}>
|
||||
<MainCard title="Network Types">
|
||||
<Stack divider={<Divider />} spacing={0}>
|
||||
{hubNetworkTypes.map((t) => (
|
||||
<Stack key={t.type} direction="row" spacing={2} alignItems="center" sx={{ py: 1.4 }}>
|
||||
<Avatar variant="rounded" sx={{ bgcolor: 'info.lighter', color: 'info.main', width: 40, height: 40 }}>
|
||||
<WarehouseOutlinedIcon fontSize="small" />
|
||||
</Avatar>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="subtitle2">{t.type}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t.desc}</Typography>
|
||||
</Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'grey.800' }}>{t.count}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={7}>
|
||||
<MainCard title="Line-Haul Corridors" noPadding>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Corridor</TableCell>
|
||||
<TableCell>Vehicle</TableCell>
|
||||
<TableCell align="right">Distance</TableCell>
|
||||
<TableCell>Load</TableCell>
|
||||
<TableCell>ETA</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{lineHauls.map((l) => (
|
||||
<TableRow key={l.id} hover>
|
||||
<TableCell>
|
||||
<Typography variant="subtitle2">{l.corridor}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{l.from} → {l.to}</Typography>
|
||||
</TableCell>
|
||||
<TableCell><Typography variant="caption">{l.vehicle}</Typography></TableCell>
|
||||
<TableCell align="right">{l.distance.toLocaleString('en-IN')} km</TableCell>
|
||||
<TableCell sx={{ minWidth: 90 }}>
|
||||
<LinearProgress variant="determinate" value={l.load} sx={{ height: 6, borderRadius: 3 }} />
|
||||
<Typography variant="caption" color="text.secondary">{l.load}%</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{l.eta}</TableCell>
|
||||
<TableCell><StatusChip status={l.status} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={5}>
|
||||
<MainCard title="Network Map">
|
||||
<MapPlaceholder
|
||||
height={320}
|
||||
label="Hub Network"
|
||||
showRoute={false}
|
||||
pins={[
|
||||
{ x: '24%', y: '60%', label: 'BLR', color: '#0E7C7B' },
|
||||
{ x: '14%', y: '38%', label: 'MUM', color: '#1D4ED8' },
|
||||
{ x: '40%', y: '34%', label: 'HYD', color: '#EA580C' },
|
||||
{ x: '46%', y: '14%', label: 'DEL', color: '#C01227' }
|
||||
]}
|
||||
/>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
{hubCityStats.map((h) => (
|
||||
<Grid key={h.city} item xs={12} md={4}><HubCard h={h} /></Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<FormDialog open={open} onClose={() => setOpen(false)} title="Add Hub" onSubmit={addHub} submitLabel="Add Hub">
|
||||
<Grid container spacing={2} sx={{ mt: 0 }}>
|
||||
<Grid item xs={12}><TextField fullWidth size="small" label="Hub Name" value={form.name} onChange={set('name')} placeholder="e.g. Electronic City Micro Hub" /></Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth size="small" label="Type" value={form.type} onChange={set('type')}>
|
||||
{['Micro Hub', 'City Hub', 'Regional Hub', 'Cross Dock'].map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
{/* Middle — throughput trends + capacity comparison */}
|
||||
<Box sx={{ mt: 3.5 }}>
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} lg={8}>
|
||||
<MainCard
|
||||
title="Throughput Trends"
|
||||
action={
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<Legend color="#EA580C" label="Coimbatore" />
|
||||
<Legend color="#1D4ED8" label="Bengaluru" />
|
||||
<Legend color="#0E7C7B" label="Hyderabad" />
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Box sx={{ py: 1 }}>
|
||||
<AreaChart
|
||||
height={300}
|
||||
labels={hubThroughput.map((d) => d.m)}
|
||||
series={[
|
||||
{ name: 'Coimbatore', color: '#EA580C', data: hubThroughput.map((d) => d.cbe) },
|
||||
{ name: 'Bengaluru', color: '#1D4ED8', data: hubThroughput.map((d) => d.blr) },
|
||||
{ name: 'Hyderabad', color: '#0E7C7B', data: hubThroughput.map((d) => d.hyd) }
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth size="small" label="City" value={form.city} onChange={set('city')}>
|
||||
{['Bengaluru', 'Mumbai', 'Delhi NCR', 'Hyderabad', 'Chennai', 'Pune'].map((c) => <MenuItem key={c} value={c}>{c}</MenuItem>)}
|
||||
</TextField>
|
||||
<Grid item xs={12} lg={4}>
|
||||
<MainCard title="Hub Performance Comparison" sx={{ height: '100%' }}>
|
||||
<Stack spacing={2.25} sx={{ py: 0.5 }}>
|
||||
{hubCityStats.map((h) => (
|
||||
<Box key={h.city}>
|
||||
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.4 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{h.city}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{h.utilization}% · SLA {h.sla}%</Typography>
|
||||
</Stack>
|
||||
<LinearProgress variant="determinate" value={h.utilization} color={h.utilization > 80 ? 'error' : h.utilization > 70 ? 'warning' : 'success'} sx={{ height: 7, borderRadius: 4 }} />
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" type="number" label="Capacity (parcels)" value={form.capacity} onChange={set('capacity')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" type="number" label="Docks" value={form.dock} onChange={set('dock')} /></Grid>
|
||||
</Grid>
|
||||
</FormDialog>
|
||||
</Box>
|
||||
|
||||
{/* Bottom — activity / alerts / maintenance */}
|
||||
<Box sx={{ mt: 3.5 }}>
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} lg={4}>
|
||||
<MainCard title="Recent Hub Activity" noPadding>
|
||||
<Stack divider={<Divider />}>
|
||||
{hubActivity.map((a, i) => (
|
||||
<Stack key={i} direction="row" spacing={1.25} alignItems="flex-start" sx={{ px: 2, py: 1.25 }}>
|
||||
<Box sx={{ mt: 0.6, width: 8, height: 8, borderRadius: '50%', bgcolor: 'info.main', flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||
<Stack direction="row" justifyContent="space-between" spacing={1}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }} noWrap>{a.hub}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>{a.time}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{a.text}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6} lg={4}>
|
||||
<MainCard title="Operational Alerts" noPadding>
|
||||
<Stack divider={<Divider />}>
|
||||
{exceptionQueue.slice(0, 5).map((e, i) => (
|
||||
<Stack key={`${e.id}-${i}`} direction="row" spacing={1.25} alignItems="flex-start" sx={{ px: 2, py: 1.25 }}>
|
||||
<Box sx={{ mt: 0.6, width: 8, height: 8, borderRadius: '50%', bgcolor: SEV[e.severity] || '#8C8C8C', flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: SEV[e.severity] === '#F04134' ? 'error.dark' : 'grey.800' }}>{e.category}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }} noWrap>{e.detail}</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>{e.age}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6} lg={4}>
|
||||
<MainCard title="Maintenance Notices" noPadding>
|
||||
<Stack divider={<Divider />}>
|
||||
{maintenanceNotices.map((m, i) => (
|
||||
<Stack key={i} direction="row" spacing={1.25} alignItems="center" sx={{ px: 2, py: 1.4 }}>
|
||||
<BuildOutlinedIcon sx={{ fontSize: 18, color: SEV[m.severity] || 'grey.500', flexShrink: 0 }} />
|
||||
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }} noWrap>{m.item}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>{m.hub}</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: m.severity === 'high' ? 'error.main' : 'grey.600', flexShrink: 0 }}>{m.due}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Toast {...toast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ color, label }) {
|
||||
return (
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Box sx={{ width: 9, height: 9, borderRadius: '3px', bgcolor: color }} />
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
22
src/pages/operations/DispatchTracking.jsx
Normal file
22
src/pages/operations/DispatchTracking.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import TabbedWorkspace from '@/components/TabbedWorkspace';
|
||||
import LiveTracking from '@/pages/tracking/LiveTracking';
|
||||
import DispatchBoard from '@/pages/dispatch/DispatchBoard';
|
||||
import ShipmentJourney from '@/pages/tracking/ShipmentJourney';
|
||||
import ColdChain from '@/pages/coldchain/ColdChain';
|
||||
|
||||
// ==============================|| DISPATCH & TRACKING — operations workspace ||============================== //
|
||||
// Merges Live Tracking (flagship map), Dispatch board (with AI suggestions), Shipment Journey
|
||||
// and Cold Chain into one screen. AI recommendations live inside the Dispatch tab.
|
||||
|
||||
export default function DispatchTracking() {
|
||||
return (
|
||||
<TabbedWorkspace
|
||||
tabs={[
|
||||
{ key: 'live', label: 'Live Tracking', element: <LiveTracking /> },
|
||||
{ key: 'dispatch', label: 'Dispatch', element: <DispatchBoard /> },
|
||||
{ key: 'journey', label: 'Journey', element: <ShipmentJourney /> },
|
||||
{ key: 'cold-chain', label: 'Cold Chain', element: <ColdChain /> }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +1,138 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Grid, Card, Stack, Button, TextField, MenuItem, InputAdornment, Box, Tabs, Tab,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Checkbox, IconButton,
|
||||
Tooltip, TablePagination, Typography, SpeedDial, SpeedDialAction, Chip
|
||||
Card, Stack, Button, Box, Tabs, Tab, Table, TableBody, TableCell, TableContainer, TableHead,
|
||||
TableRow, Checkbox, IconButton, Tooltip, TablePagination, Typography
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import PostAddOutlinedIcon from '@mui/icons-material/PostAddOutlined';
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
import PersonAddAltOutlinedIcon from '@mui/icons-material/PersonAddAltOutlined';
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import TabLabelCount from '@/components/TabLabelCount';
|
||||
import { orders } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import HourglassEmptyOutlinedIcon from '@mui/icons-material/HourglassEmptyOutlined';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import CancelOutlinedIcon from '@mui/icons-material/CancelOutlined';
|
||||
import CurrencyRupeeIcon from '@mui/icons-material/CurrencyRupee';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import KpiStrip from '@/components/KpiStrip';
|
||||
import PageToolbar from '@/components/PageToolbar';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import TabLabelCount from '@/components/TabLabelCount';
|
||||
import { orders } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
import { useOps } from '@/store/OpsStore';
|
||||
import { useFilters } from '@/store/Filters';
|
||||
|
||||
const ACTIVE = ['created', 'pending', 'picked', 'active'];
|
||||
const TABS = [
|
||||
{ key: 'created', label: 'Created' },
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'active', label: 'Active' },
|
||||
{ key: 'delivered', label: 'Delivered' },
|
||||
{ key: 'cancelled', label: 'Cancelled' }
|
||||
{ key: 'delayed', label: 'Delayed' },
|
||||
{ key: 'exceptions', label: 'Exceptions' }
|
||||
];
|
||||
|
||||
const STICKY_HEAD = {
|
||||
'& .MuiTableCell-head': { position: 'sticky', top: 0, zIndex: 2, bgcolor: 'grey.50' },
|
||||
'& .MuiTableRow-root:hover': { backgroundColor: 'grey.50' }
|
||||
};
|
||||
|
||||
export default function OrdersList() {
|
||||
const navigate = useNavigate();
|
||||
const { exceptions } = useOps();
|
||||
const { location } = useFilters(); // global location — single source of truth
|
||||
const [searchParams] = useSearchParams();
|
||||
const [tab, setTab] = useState(0);
|
||||
const [search, setSearch] = useState(searchParams.get('q') || '');
|
||||
const [tenant, setTenant] = useState('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(5);
|
||||
const [rpp, setRpp] = useState(10);
|
||||
const [selected, setSelected] = useState([]);
|
||||
|
||||
const tabKey = TABS[tab].key;
|
||||
const inTab = (o, key) => {
|
||||
if (key === 'all') return true;
|
||||
if (key === 'active') return ACTIVE.includes(o.status);
|
||||
if (key === 'delivered') return o.status === 'delivered';
|
||||
if (key === 'delayed') return o.status === 'pending'; // at-risk / awaiting action
|
||||
if (key === 'exceptions') return Boolean(exceptions[o.id]);
|
||||
return true;
|
||||
};
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
orders.filter((o) => {
|
||||
const matchTab = tabKey === 'created' ? true : o.status === tabKey;
|
||||
const matchTenant = tenant === 'all' || o.tenant === tenant;
|
||||
const matchLoc = location === 'all' || o.location === location;
|
||||
const matchSearch =
|
||||
!search ||
|
||||
[o.id, o.customer, o.pickup, o.drop, o.tenant].join(' ').toLowerCase().includes(search.toLowerCase());
|
||||
return matchTab && matchTenant && matchSearch;
|
||||
!search || [o.id, o.customer, o.pickup, o.drop, o.tenant].join(' ').toLowerCase().includes(search.toLowerCase());
|
||||
return inTab(o, tabKey) && matchTenant && matchLoc && matchSearch;
|
||||
}),
|
||||
[tabKey, tenant, search]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tabKey, tenant, location, search, exceptions]
|
||||
);
|
||||
|
||||
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
||||
const toggle = (id) => setSelected((p) => (p.includes(id) ? p.filter((x) => x !== id) : [...p, id]));
|
||||
const counts = {
|
||||
created: orders.length,
|
||||
pending: orders.filter((o) => o.status === 'pending').length,
|
||||
delivered: orders.filter((o) => o.status === 'delivered').length,
|
||||
cancelled: orders.filter((o) => o.status === 'cancelled').length
|
||||
};
|
||||
const count = (k) => orders.filter((o) => inTab(o, k)).length;
|
||||
|
||||
const codTotal = orders.reduce((s, o) => s + (o.cod || 0), 0);
|
||||
const kpis = [
|
||||
{ label: 'Total Orders', value: orders.length, icon: Inventory2OutlinedIcon },
|
||||
{ label: 'Active', value: count('active'), color: '#1D4ED8', icon: HourglassEmptyOutlinedIcon },
|
||||
{ label: 'Delivered', value: count('delivered'), color: '#00773B', icon: CheckCircleOutlineIcon },
|
||||
{ label: 'Exceptions', value: count('exceptions'), color: '#A82216', icon: CancelOutlinedIcon },
|
||||
{ label: 'COD to Collect', value: inr(codTotal), color: '#00727B', icon: CurrencyRupeeIcon }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Orders"
|
||||
title="Shipments"
|
||||
breadcrumbs={[{ label: 'Orders' }]}
|
||||
action={
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||
<Button variant="outlined" startIcon={<PostAddOutlinedIcon />} onClick={() => navigate('/orders/create-multiple')}>
|
||||
Create Multiple
|
||||
</Button>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/orders/create')}>
|
||||
Create Order
|
||||
</Button>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.25}>
|
||||
<Button variant="text" startIcon={<AutoAwesomeOutlinedIcon />} onClick={() => navigate('/orders/assign')}>Assign</Button>
|
||||
<Button variant="outlined" startIcon={<PostAddOutlinedIcon />} onClick={() => navigate('/orders/create-multiple')}>Bulk</Button>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/orders/create')}>Create Order</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2.5} sx={{ mb: 1 }}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Created Orders" value={counts.created} icon={Inventory2OutlinedIcon} caption="100%" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Pending Orders" value={counts.pending} icon={HourglassEmptyOutlinedIcon} color="warning" caption="25%" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Delivered Orders" value={counts.delivered} icon={CheckCircleOutlineIcon} color="success" caption="25%" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Cancelled Orders" value={counts.cancelled} icon={CancelOutlinedIcon} color="error" caption="12.5%" /></Grid>
|
||||
</Grid>
|
||||
<KpiStrip items={kpis} />
|
||||
|
||||
<Card sx={{ mt: 1.5 }}>
|
||||
{/* filter toolbar */}
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
|
||||
<TextField
|
||||
size="small" placeholder="Search orders…" value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ minWidth: 240 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button variant="outlined" size="medium" startIcon={<CalendarTodayOutlinedIcon />} sx={{ color: 'text.secondary', borderColor: 'grey.300' }}>
|
||||
Jun 01 – Jun 05
|
||||
</Button>
|
||||
<TextField select size="small" value={tenant} onChange={(e) => setTenant(e.target.value)} sx={{ minWidth: 170 }} label="Tenant">
|
||||
<MenuItem value="all">All Tenants</MenuItem>
|
||||
{[...new Set(orders.map((o) => o.tenant))].map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select size="small" defaultValue="all" sx={{ minWidth: 150 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{[...new Set(orders.map((o) => o.location))].map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); setPage(0); }}>
|
||||
<Card>
|
||||
<Box sx={{ px: 1.5, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); setPage(0); }} variant="scrollable" scrollButtons="auto">
|
||||
{TABS.map((t, i) => (
|
||||
<Tab key={t.key} label={<TabLabelCount label={t.label} count={counts[t.key]} active={tab === i} />} />
|
||||
<Tab key={t.key} label={<TabLabelCount label={t.label} count={count(t.key)} active={tab === i} />} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<Stack direction="row" alignItems="center" spacing={2} sx={{ px: 2, py: 1, bgcolor: 'primary.lighter' }}>
|
||||
<Typography variant="subtitle2" color="primary.dark">{selected.length} selected</Typography>
|
||||
<Button size="small" color="error" startIcon={<DeleteOutlineIcon />}>Delete</Button>
|
||||
</Stack>
|
||||
)}
|
||||
<PageToolbar
|
||||
search={search}
|
||||
onSearch={(v) => { setSearch(v); setPage(0); }}
|
||||
searchPlaceholder="Search ID, customer, pickup, drop…"
|
||||
filters={[
|
||||
{
|
||||
label: 'Client', value: tenant, onChange: (v) => { setTenant(v); setPage(0); }, width: 170,
|
||||
options: [{ value: 'all', label: 'All Clients' }, ...[...new Set(orders.map((o) => o.tenant))].map((t) => ({ value: t, label: t }))]
|
||||
}
|
||||
]}
|
||||
actions={
|
||||
selected.length > 0 && (
|
||||
<Button size="small" color="error" variant="outlined" startIcon={<DeleteOutlineIcon />}>
|
||||
Delete ({selected.length})
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableContainer sx={{ maxHeight: { md: 'calc(100vh - 360px)' } }}>
|
||||
<Table stickyHeader size="small" sx={STICKY_HEAD}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
@@ -140,56 +142,56 @@ export default function OrdersList() {
|
||||
onChange={(e) => setSelected(e.target.checked ? paged.map((o) => o.id) : [])}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>Tenant</TableCell>
|
||||
<TableCell>Order ID</TableCell>
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
<TableCell>Pickup</TableCell>
|
||||
<TableCell>Drop</TableCell>
|
||||
<TableCell align="center">QTY</TableCell>
|
||||
<TableCell>Route</TableCell>
|
||||
<TableCell align="center">Qty</TableCell>
|
||||
<TableCell align="right">COD</TableCell>
|
||||
<TableCell align="right">KMS</TableCell>
|
||||
<TableCell align="right">KM</TableCell>
|
||||
<TableCell align="right">Charges</TableCell>
|
||||
<TableCell>Notes</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="center">Actions</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paged.map((o) => (
|
||||
<TableRow key={o.id} hover selected={selected.includes(o.id)}>
|
||||
<TableCell padding="checkbox"><Checkbox checked={selected.includes(o.id)} onChange={() => toggle(o.id)} /></TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'primary.main', cursor: 'pointer' }} onClick={() => navigate(`/orders/${o.id}`)}>{o.id}</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700, color: 'primary.main', cursor: 'pointer' }} onClick={() => navigate(`/orders/${o.id}`)}>{o.id}</TableCell>
|
||||
<TableCell>{o.tenant}</TableCell>
|
||||
<TableCell>{o.location}</TableCell>
|
||||
<TableCell>{o.pickup}</TableCell>
|
||||
<TableCell>{o.drop}</TableCell>
|
||||
<TableCell><Typography variant="caption" color="text.secondary">{o.location}</Typography></TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>{o.pickup} → {o.drop}</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">{o.qty}</TableCell>
|
||||
<TableCell align="right">{o.cod ? inr(o.cod) : '—'}</TableCell>
|
||||
<TableCell align="right">{o.kms}</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(o.charges)}</TableCell>
|
||||
<TableCell><Typography variant="caption" color="text.secondary" noWrap>{o.notes || '—'}</Typography></TableCell>
|
||||
<TableCell><StatusChip status={o.status} /></TableCell>
|
||||
<TableCell align="center">
|
||||
<TableCell align="right">
|
||||
<Tooltip title="View"><IconButton size="small" onClick={() => navigate(`/orders/${o.id}`)}><VisibilityOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Edit"><IconButton size="small"><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Delete"><IconButton size="small" color="error"><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{paged.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} sx={{ textAlign: 'center', py: 5, color: 'text.secondary' }}>
|
||||
No orders match these filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div" count={filtered.length} page={page} onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25]}
|
||||
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[10, 25, 50]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<SpeedDial ariaLabel="Order actions" icon={<TuneIcon />} sx={{ position: 'fixed', bottom: 28, right: 28 }} FabProps={{ color: 'primary' }}>
|
||||
<SpeedDialAction icon={<AutoAwesomeOutlinedIcon />} tooltipTitle="AI Optimisation" onClick={() => navigate('/orders/assign')} />
|
||||
<SpeedDialAction icon={<PersonAddAltOutlinedIcon />} tooltipTitle="Manual Assign" onClick={() => navigate('/orders/assign')} />
|
||||
<SpeedDialAction icon={<DeleteOutlineIcon />} tooltipTitle="Delete" />
|
||||
</SpeedDial>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,15 +13,19 @@ import CloseIcon from '@mui/icons-material/Close';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import MapPlaceholder from '@/components/MapPlaceholder';
|
||||
import { ordersDetailReport, locations, tenantsList } from '@/data/mock';
|
||||
import DateRangeFilter from '@/components/DateRangeFilter';
|
||||
import FilterSummary from '@/components/FilterSummary';
|
||||
import { ordersDetailReport, orders, tenantsList } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
import { useFilters, inRange } from '@/store/Filters';
|
||||
|
||||
const STATUSES = ['all', 'created', 'pending', 'picked', 'active', 'delivered', 'cancelled'];
|
||||
// join the report to raw orders for the date + location each row actually belongs to
|
||||
const ORDER_META = Object.fromEntries(orders.map((o) => [o.id, { date: o.date, location: o.location }]));
|
||||
|
||||
export default function OrdersDetails() {
|
||||
const [location, setLocation] = useState('all');
|
||||
const { location, range } = useFilters(); // global location + date range
|
||||
const [tenant, setTenant] = useState('all');
|
||||
const [loc2, setLoc2] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -32,16 +36,21 @@ export default function OrdersDetails() {
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
ordersDetailReport.filter((o) => {
|
||||
const meta = ORDER_META[o.id] || {};
|
||||
const matchStatus = status === 'all' || o.status === status;
|
||||
const matchTenant = tenant === 'all' || o.client === tenant;
|
||||
const matchLoc = location === 'all' || meta.location === location;
|
||||
const matchDate = meta.date ? inRange(meta.date, range) : true;
|
||||
const matchSearch =
|
||||
!search ||
|
||||
[o.id, o.client, o.pickup, o.drop].join(' ').toLowerCase().includes(search.toLowerCase());
|
||||
return matchStatus && matchTenant && matchSearch;
|
||||
return matchStatus && matchTenant && matchLoc && matchDate && matchSearch;
|
||||
}),
|
||||
[status, tenant, search]
|
||||
[status, tenant, search, location, range]
|
||||
);
|
||||
|
||||
// reset to first page whenever the result set changes
|
||||
useMemo(() => setPage(0), [status, tenant, search, location, range]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
||||
|
||||
return (
|
||||
@@ -49,12 +58,7 @@ export default function OrdersDetails() {
|
||||
<PageHeader
|
||||
title="Orders Details"
|
||||
breadcrumbs={[{ label: 'Reports', to: '/reports' }, { label: 'Orders Details' }]}
|
||||
action={
|
||||
<TextField select size="small" value={location} onChange={(e) => setLocation(e.target.value)} sx={{ minWidth: 160 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
}
|
||||
action={<DateRangeFilter />}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
@@ -63,13 +67,6 @@ export default function OrdersDetails() {
|
||||
<MenuItem value="all">All Tenants</MenuItem>
|
||||
{tenantsList.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select size="small" value={loc2} onChange={(e) => setLoc2(e.target.value)} sx={{ minWidth: 160 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
<Button variant="outlined" startIcon={<CalendarTodayOutlinedIcon />} sx={{ color: 'text.secondary', borderColor: 'grey.300' }}>
|
||||
Jun 01 – Jun 05
|
||||
</Button>
|
||||
<TextField select size="small" value={status} onChange={(e) => { setStatus(e.target.value); setPage(0); }} sx={{ minWidth: 150 }} label="Status">
|
||||
{STATUSES.map((s) => <MenuItem key={s} value={s}>{s === 'all' ? 'All Status' : s[0].toUpperCase() + s.slice(1)}</MenuItem>)}
|
||||
</TextField>
|
||||
@@ -83,6 +80,10 @@ export default function OrdersDetails() {
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ px: 2, pb: 1.5 }}>
|
||||
<FilterSummary count={filtered.length} />
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
@@ -130,6 +131,13 @@ export default function OrdersDetails() {
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(o.charges)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={16} sx={{ textAlign: 'center', py: 5, color: 'text.secondary' }}>
|
||||
No orders in this date range / location. Try widening the range or clearing the location.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
@@ -163,11 +171,10 @@ export default function OrdersDetails() {
|
||||
The export will include {filtered.length} record(s) matching the current filters:
|
||||
</Typography>
|
||||
<Grid container spacing={1.5}>
|
||||
<Filter label="Date Range" value={range.label} />
|
||||
<Filter label="Location" value={location === 'all' ? 'All Locations' : location} />
|
||||
<Filter label="Tenant" value={tenant === 'all' ? 'All Tenants' : tenant} />
|
||||
<Filter label="Location (2)" value={loc2 === 'all' ? 'All Locations' : loc2} />
|
||||
<Filter label="Status" value={status === 'all' ? 'All Status' : status} />
|
||||
<Filter label="Date Range" value="Jun 01 – Jun 05" />
|
||||
<Filter label="Search" value={search || '—'} />
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
|
||||
@@ -9,8 +9,11 @@ import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { ordersSummary, locations, tenantsList } from '@/data/mock';
|
||||
import DateRangeFilter from '@/components/DateRangeFilter';
|
||||
import FilterSummary from '@/components/FilterSummary';
|
||||
import { ordersSummary, tenantsList } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
import { useFilters } from '@/store/Filters';
|
||||
|
||||
// Show 0 in red, anything else normally.
|
||||
function NumCell({ value, align = 'center', bold = false }) {
|
||||
@@ -23,14 +26,16 @@ function NumCell({ value, align = 'center', bold = false }) {
|
||||
}
|
||||
|
||||
export default function OrdersSummary() {
|
||||
const { location } = useFilters(); // global location — single source of truth
|
||||
const [open, setOpen] = useState({});
|
||||
const [location, setLocation] = useState('all');
|
||||
const [tenant, setTenant] = useState('all');
|
||||
const [loc2, setLoc2] = useState('all');
|
||||
|
||||
const toggle = (id) => setOpen((p) => ({ ...p, [id]: !p[id] }));
|
||||
|
||||
const totals = ordersSummary.reduce(
|
||||
// location is the global filter; tenant is this report's own (distinct purpose)
|
||||
const rows = ordersSummary.filter((r) => (location === 'all' || r.location === location) && (tenant === 'all' || r.tenant === tenant));
|
||||
|
||||
const totals = rows.reduce(
|
||||
(a, r) => ({
|
||||
oPending: a.oPending + r.orders.pending,
|
||||
oCancelled: a.oCancelled + r.orders.cancelled,
|
||||
@@ -46,35 +51,24 @@ export default function OrdersSummary() {
|
||||
{ oPending: 0, oCancelled: 0, oCompleted: 0, dPending: 0, dCancelled: 0, dCompleted: 0, collection: 0, kms: 0, actualKms: 0, amount: 0 }
|
||||
);
|
||||
|
||||
const headSx = { bgcolor: 'primary.lighter', fontWeight: 700, color: 'primary.dark', whiteSpace: 'nowrap' };
|
||||
const headSx = { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', whiteSpace: 'nowrap', borderBottom: '2px solid', borderColor: 'grey.200' };
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Orders Summary"
|
||||
breadcrumbs={[{ label: 'Reports', to: '/reports' }, { label: 'Orders Summary' }]}
|
||||
action={
|
||||
<TextField select size="small" value={location} onChange={(e) => setLocation(e.target.value)} sx={{ minWidth: 160 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
}
|
||||
action={<DateRangeFilter />}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
|
||||
<Button variant="outlined" startIcon={<CalendarTodayOutlinedIcon />} sx={{ color: 'text.secondary', borderColor: 'grey.300' }}>
|
||||
Jun 01 – Jun 05
|
||||
</Button>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<TextField select size="small" value={tenant} onChange={(e) => setTenant(e.target.value)} sx={{ minWidth: 170 }} label="Tenant">
|
||||
<MenuItem value="all">All Tenants</MenuItem>
|
||||
{tenantsList.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField select size="small" value={loc2} onChange={(e) => setLoc2(e.target.value)} sx={{ minWidth: 160 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<FilterSummary count={rows.length} />
|
||||
</Stack>
|
||||
|
||||
<TableContainer>
|
||||
@@ -101,7 +95,10 @@ export default function OrdersSummary() {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ordersSummary.map((r, idx) => (
|
||||
{rows.length === 0 && (
|
||||
<TableRow><TableCell colSpan={13} sx={{ textAlign: 'center', py: 5, color: 'text.secondary' }}>No tenants match this location.</TableCell></TableRow>
|
||||
)}
|
||||
{rows.map((r, idx) => (
|
||||
<Fragment key={r.id}>
|
||||
<TableRow hover>
|
||||
<TableCell padding="checkbox">
|
||||
@@ -172,7 +169,7 @@ export default function OrdersSummary() {
|
||||
))}
|
||||
|
||||
{/* totals */}
|
||||
<TableRow sx={{ '& td': { bgcolor: 'primary.lighter', borderTop: 2, borderColor: 'primary.light' } }}>
|
||||
<TableRow sx={{ '& td': { bgcolor: 'grey.100', borderTop: 2, borderColor: 'grey.300' } }}>
|
||||
<TableCell />
|
||||
<TableCell colSpan={2} sx={{ fontWeight: 700 }}>Totals</TableCell>
|
||||
<NumCell value={totals.oPending} bold />
|
||||
|
||||
21
src/pages/reports/ReportsHub.jsx
Normal file
21
src/pages/reports/ReportsHub.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import TabbedWorkspace from '@/components/TabbedWorkspace';
|
||||
import OrdersSummary from '@/pages/reports/OrdersSummary';
|
||||
import RidersSummary from '@/pages/reports/RidersSummary';
|
||||
import Analytics from '@/pages/analytics/Analytics';
|
||||
import OrdersDetails from '@/pages/reports/OrdersDetails';
|
||||
|
||||
// ==============================|| REPORTS — reporting center ||============================== //
|
||||
// Merges the report surfaces into one tabbed center: Orders, Riders, Revenue, Performance.
|
||||
|
||||
export default function ReportsHub() {
|
||||
return (
|
||||
<TabbedWorkspace
|
||||
tabs={[
|
||||
{ key: 'orders', label: 'Orders', element: <OrdersSummary /> },
|
||||
{ key: 'riders', label: 'Riders', element: <RidersSummary /> },
|
||||
{ key: 'revenue', label: 'Revenue', element: <Analytics /> },
|
||||
{ key: 'performance', label: 'Performance', element: <OrdersDetails /> }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import CloseIcon from '@mui/icons-material/Close';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import MapPlaceholder from '@/components/MapPlaceholder';
|
||||
import { ridersSummary, locations } from '@/data/mock';
|
||||
import DateRangeFilter from '@/components/DateRangeFilter';
|
||||
import FilterSummary from '@/components/FilterSummary';
|
||||
import { ridersSummary } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
function NumCell({ value, align = 'center' }) {
|
||||
@@ -32,32 +34,24 @@ function KmsChips({ kms, actual }) {
|
||||
|
||||
export default function RidersSummary() {
|
||||
const [open, setOpen] = useState({});
|
||||
const [location, setLocation] = useState('all');
|
||||
const [mapRider, setMapRider] = useState(null);
|
||||
|
||||
const toggle = (id) => setOpen((p) => ({ ...p, [id]: !p[id] }));
|
||||
const totalAmount = ridersSummary.reduce((a, r) => a + r.amount, 0);
|
||||
|
||||
const headSx = { bgcolor: 'primary.lighter', fontWeight: 700, color: 'primary.dark', whiteSpace: 'nowrap' };
|
||||
const headSx = { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', whiteSpace: 'nowrap', borderBottom: '2px solid', borderColor: 'grey.200' };
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Riders Summary"
|
||||
breadcrumbs={[{ label: 'Reports', to: '/reports' }, { label: 'Riders Summary' }]}
|
||||
action={
|
||||
<TextField select size="small" value={location} onChange={(e) => setLocation(e.target.value)} sx={{ minWidth: 160 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
}
|
||||
action={<DateRangeFilter />}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
|
||||
<Button variant="outlined" startIcon={<CalendarTodayOutlinedIcon />} sx={{ color: 'text.secondary', borderColor: 'grey.300' }}>
|
||||
Jun 01 – Jun 05
|
||||
</Button>
|
||||
<FilterSummary count={ridersSummary.length} />
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ import StatCard from '@/components/StatCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import TabLabelCount from '@/components/TabLabelCount';
|
||||
import { riders, riderLogs, locations } from '@/data/mock';
|
||||
import { riders, riderLogs } from '@/data/mock';
|
||||
import { useFilters } from '@/store/Filters';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
const TABS = [
|
||||
@@ -32,7 +33,7 @@ export default function Riders() {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [location, setLocation] = useState('all');
|
||||
const { location } = useFilters(); // global location — single source of truth
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(5);
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
@@ -72,10 +73,6 @@ export default function Riders() {
|
||||
breadcrumbs={[{ label: 'Riders' }]}
|
||||
action={
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ sm: 'center' }}>
|
||||
<TextField select size="small" value={location} onChange={(e) => setLocation(e.target.value)} sx={{ minWidth: 170 }} label="Location">
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
{locations.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/riders/create')}>
|
||||
Add Rider
|
||||
</Button>
|
||||
|
||||
@@ -1,186 +1,224 @@
|
||||
import { useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Grid, Stack, Typography, Box, Avatar, Table, TableBody, TableCell, TableHead, TableRow, Button, Chip, TextField, InputAdornment, Divider } from '@mui/material';
|
||||
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined';
|
||||
import PhotoCameraOutlinedIcon from '@mui/icons-material/PhotoCameraOutlined';
|
||||
import { Box, Stack, Typography, Button, TextField, InputAdornment } from '@mui/material';
|
||||
import AltRouteOutlinedIcon from '@mui/icons-material/AltRouteOutlined';
|
||||
import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined';
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
import AcUnitOutlinedIcon from '@mui/icons-material/AcUnitOutlined';
|
||||
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
|
||||
import ContentCopyOutlinedIcon from '@mui/icons-material/ContentCopyOutlined';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import LayerBanner from '@/components/LayerBanner';
|
||||
import MapPlaceholder from '@/components/MapPlaceholder';
|
||||
import TrackingControlBar from '@/components/tracking/TrackingControlBar';
|
||||
import ActiveRidersList from '@/components/tracking/ActiveRidersList';
|
||||
import FleetMap from '@/components/tracking/FleetMap';
|
||||
import RiderTimeline from '@/components/tracking/RiderTimeline';
|
||||
import FormDialog from '@/components/FormDialog';
|
||||
import Toast, { useToast } from '@/components/Toast';
|
||||
import { executionStages, executionFeed, ridersLive, orderTimeline } from '@/data/mock';
|
||||
import { activeDeliveries, fleetVehicles, riders } from '@/data/mock';
|
||||
import { snapRoutes } from '@/utils/osrm';
|
||||
import { useOps } from '@/store/OpsStore';
|
||||
|
||||
const now = () => new Date().toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true });
|
||||
|
||||
// Heavy children only re-render when their own props change (list runs at ~1fps, map at ~30fps).
|
||||
const MemoFleetMap = memo(FleetMap);
|
||||
const MemoActiveRiders = memo(ActiveRidersList);
|
||||
|
||||
const smoothstep = (x) => { const t = Math.max(0, Math.min(1, x)); return t * t * (3 - 2 * t); };
|
||||
const STOP_WINDOW = 0.08; // how far ahead a vehicle starts braking for a stop (route fraction)
|
||||
|
||||
// Speed envelope (0..1): eases away from the pickup, into the drop, and dips toward each
|
||||
// in-route stop (signals / waypoints) so motion reads as real traffic rather than a constant glide.
|
||||
function speedEnvelope(p, stops) {
|
||||
let e = 0.22 + 0.78 * smoothstep(p / 0.12) * smoothstep((1 - p) / 0.12);
|
||||
for (let i = 0; i < stops.length; i += 1) {
|
||||
e *= 0.12 + 0.88 * smoothstep(Math.abs(p - stops[i]) / STOP_WINDOW);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
// Per-vehicle motion character, derived deterministically from the shipment id so every
|
||||
// vehicle has its own cruising speed, traffic rhythm, stops and dwell — no two move identically.
|
||||
const MOTION = activeDeliveries.reduce((acc, d) => {
|
||||
let h = 0;
|
||||
for (let i = 0; i < d.id.length; i += 1) h = (h * 31 + d.id.charCodeAt(i)) >>> 0;
|
||||
const r = (h % 1000) / 1000;
|
||||
const r2 = ((h >>> 10) % 1000) / 1000;
|
||||
const r3 = ((h >>> 20) % 1000) / 1000;
|
||||
acc[d.id] = {
|
||||
speed: 0.5 + r * 0.65, // base cruising %/s
|
||||
freq: 0.14 + r2 * 0.16, // traffic-rhythm frequency
|
||||
phase: r * Math.PI * 2,
|
||||
stops: [0.28 + r2 * 0.1, 0.62 + r3 * 0.12], // in-route halts (signals / drops)
|
||||
dwell: [1.2 + r * 1.8, 1.0 + r3 * 1.8] // seconds paused at each halt
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Build the live delivery view-model from a progress map (shared by the map & queue lanes).
|
||||
function buildDeliveries(progress, assignments, exceptions, snapped) {
|
||||
return activeDeliveries.map((d) => {
|
||||
const a = assignments[d.id];
|
||||
const ex = exceptions[d.id];
|
||||
const pe = progress[d.id] ?? d.progress;
|
||||
return {
|
||||
...d,
|
||||
route: snapped[d.id] || d.route,
|
||||
progress: Math.round(pe),
|
||||
progressExact: pe,
|
||||
rider: a ? a.riderName : d.rider,
|
||||
status: ex ? 'Exception' : d.status,
|
||||
etaStatus: ex ? 'delayed' : d.etaStatus,
|
||||
flagged: Boolean(ex)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function LiveTracking() {
|
||||
const navigate = useNavigate();
|
||||
const [track, setTrack] = useState(null);
|
||||
const [share, setShare] = useState(false);
|
||||
const [toast, showToast] = useToast();
|
||||
const trackLink = track ? `https://track.doormile.com/${track.id}` : 'https://track.doormile.com';
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
|
||||
const { assignments, exceptions, assignOrder, rerouteOrder, raiseException } = useOps();
|
||||
const availableRiders = useMemo(() => riders.filter((r) => r.status !== 'offline'), []);
|
||||
|
||||
// snap every shipment's route to real streets via OSRM (falls back to the drawn path on failure)
|
||||
const [snapped, setSnapped] = useState({});
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
snapRoutes(activeDeliveries, { signal: ctrl.signal }).then(setSnapped).catch(() => {});
|
||||
return () => ctrl.abort();
|
||||
}, []);
|
||||
|
||||
// live simulation — advance in-flight shipments along their routes with natural, eased motion.
|
||||
// Two cadences: `progress` drives the map at ~30fps; `queueProgress` refreshes the list ~1×/s.
|
||||
const [progress, setProgress] = useState(() => Object.fromEntries(activeDeliveries.map((d) => [d.id, d.progress])));
|
||||
const [queueProgress, setQueueProgress] = useState(progress);
|
||||
const [updated, setUpdated] = useState(now);
|
||||
const baseProgress = useRef(Object.fromEntries(activeDeliveries.map((d) => [d.id, d.progress])));
|
||||
|
||||
useEffect(() => {
|
||||
const work = { ...baseProgress.current }; // local accumulator advanced every frame
|
||||
const runtime = {}; // per-vehicle dwell state (stop timer + next-stop pointer)
|
||||
let raf;
|
||||
let last = performance.now();
|
||||
let elapsed = 0;
|
||||
let sinceMap = 0;
|
||||
let sinceQueue = 0;
|
||||
let sinceClock = 0;
|
||||
|
||||
const step = (t) => {
|
||||
const dt = Math.min((t - last) / 1000, 0.05);
|
||||
last = t;
|
||||
elapsed += dt;
|
||||
sinceMap += dt;
|
||||
sinceQueue += dt;
|
||||
sinceClock += dt;
|
||||
|
||||
activeDeliveries.forEach((d) => {
|
||||
if (d.status === 'Delivered') return;
|
||||
const m = MOTION[d.id];
|
||||
const rt = runtime[d.id] || (runtime[d.id] = { dwell: 0, stopIdx: 0 });
|
||||
const cur = work[d.id] ?? d.progress;
|
||||
|
||||
if (rt.dwell > 0) { rt.dwell -= dt; return; } // paused at a halt — hold position
|
||||
|
||||
const p = cur / 100;
|
||||
const traffic = 0.82 + 0.18 * Math.sin(elapsed * m.freq + m.phase); // gentle ±18% breathing
|
||||
const v = m.speed * traffic * speedEnvelope(p, m.stops); // %/s with accel/decel + stop braking
|
||||
let next = cur + v * dt;
|
||||
|
||||
// arrive at the next scheduled halt → clamp and start its dwell
|
||||
if (rt.stopIdx < m.stops.length && next / 100 >= m.stops[rt.stopIdx]) {
|
||||
next = m.stops[rt.stopIdx] * 100;
|
||||
rt.dwell = m.dwell[rt.stopIdx];
|
||||
rt.stopIdx += 1;
|
||||
}
|
||||
|
||||
work[d.id] = d.status === 'Exception'
|
||||
? Math.min(next, Math.min(baseProgress.current[d.id] + 6, 92))
|
||||
: Math.min(next, 100); // arrive and hold — vehicles never teleport back
|
||||
});
|
||||
|
||||
if (sinceMap >= 0.033) { sinceMap = 0; setProgress({ ...work }); }
|
||||
if (sinceQueue >= 1) { sinceQueue = 0; setQueueProgress({ ...work }); }
|
||||
if (sinceClock >= 1) { sinceClock = 0; setUpdated(now()); }
|
||||
raf = requestAnimationFrame(step);
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
// map lane (fast) and queue lane (slow) view-models — each recomputed only when its inputs change
|
||||
const mapDeliveries = useMemo(() => buildDeliveries(progress, assignments, exceptions, snapped), [progress, assignments, exceptions, snapped]);
|
||||
const queueDeliveries = useMemo(() => buildDeliveries(queueProgress, assignments, exceptions, snapped), [queueProgress, assignments, exceptions, snapped]);
|
||||
|
||||
// operator actions — every one commits to OpsStore
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
reassign: (id, rider) => { assignOrder(id, rider); showToast(`${id} reassigned to ${rider.name}`); },
|
||||
reroute: (id) => { rerouteOrder(id); showToast(`${id} rerouted · ETA protected`); },
|
||||
call: (d) => showToast(`Calling ${d.rider}…`),
|
||||
message: (d) => showToast(`Message sent to ${d.rider}`),
|
||||
flag: (id) => { raiseException(id, 'Flagged from control tower'); showToast(`${id} flagged as exception`, 'warning'); },
|
||||
open: (id) => navigate(`/orders/${id}`)
|
||||
}),
|
||||
[assignOrder, rerouteOrder, raiseException, showToast, navigate]
|
||||
);
|
||||
|
||||
const toggleSelect = useCallback((id) => setSelectedId((cur) => (cur === id ? null : id)), []);
|
||||
const selectedDelivery = useMemo(() => queueDeliveries.find((d) => d.id === selectedId) || null, [queueDeliveries, selectedId]);
|
||||
const tracking = Boolean(selectedId); // Selected Tracking Mode
|
||||
|
||||
const trackLink = selectedId ? `https://track.doormile.com/${selectedId}` : 'https://track.doormile.com';
|
||||
const copyLink = () => {
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(trackLink).catch(() => {});
|
||||
showToast('Tracking link copied');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Execution & Visibility Layer"
|
||||
breadcrumbs={[{ label: 'Live Tracking' }]}
|
||||
action={<Button variant="outlined" startIcon={<ShareOutlinedIcon />} onClick={() => setShare(true)}>Share Tracking</Button>}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: { md: 'calc(100vh - 112px)' } }}>
|
||||
{/* compact header */}
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} justifyContent="space-between" alignItems={{ xs: 'flex-start', sm: 'center' }} spacing={1} sx={{ mb: 1.5, flexShrink: 0 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.1 }}>Live Tracking</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Fleet control tower · real-time operations</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button size="small" variant="text" startIcon={<AltRouteOutlinedIcon />} onClick={() => navigate('/tracking/journey')}>Trip Journey</Button>
|
||||
<Button size="small" variant="text" startIcon={<AcUnitOutlinedIcon />} onClick={() => navigate('/cold-chain')}>Cold Chain</Button>
|
||||
<Button size="small" variant="outlined" startIcon={<ShareOutlinedIcon />} onClick={() => setShare(true)}>Share</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<LayerBanner
|
||||
no={6}
|
||||
icon={MyLocationOutlinedIcon}
|
||||
color="#1D4ED8"
|
||||
title="Execution & Visibility"
|
||||
subtitle="Live GPS, photo proof of pickup & delivery, dynamic re-routing and exception handling."
|
||||
steps={['Pickup Execution', 'In-Transit Tracking', 'Dynamic Re-routing', 'Delivery Execution', 'Exception Handling']}
|
||||
/>
|
||||
<TrackingControlBar />
|
||||
|
||||
<Grid container spacing={2.5}>
|
||||
{executionStages.map((s) => (
|
||||
<Grid item xs={6} md={2.4} key={s.key}>
|
||||
<Box sx={{ bgcolor: 'background.paper', border: '1px solid', borderColor: 'grey.200', borderRadius: 2, p: 2, height: '100%' }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.800' }}>{s.count.toLocaleString('en-IN')}</Typography>
|
||||
<Typography variant="subtitle2" sx={{ mt: 0.25 }}>{s.title}</Typography>
|
||||
<Stack spacing={0.25} sx={{ mt: 1 }}>
|
||||
{s.items.slice(0, 3).map((it) => (
|
||||
<Typography key={it} variant="caption" color="text.secondary">• {it}</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
{/* control tower → Selected Tracking Mode: queue collapses, map expands, feed → rider timeline */}
|
||||
<Box sx={{ flexGrow: 1, minHeight: 0, display: 'flex', flexDirection: { xs: 'column', md: 'row' }, gap: 2 }}>
|
||||
{/* map — the primary surface; every rider marker stays visible & clickable */}
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0, minHeight: { xs: 460, md: 0 } }}>
|
||||
<MemoFleetMap
|
||||
vehicles={fleetVehicles}
|
||||
deliveries={mapDeliveries}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
lastUpdated={updated}
|
||||
actions={actions}
|
||||
riders={availableRiders}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* right rail — Active Riders roster (top) + Selected Rider timeline (bottom) */}
|
||||
<Box sx={{ width: { xs: '100%', md: 340, lg: 376 }, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 2, minHeight: { xs: 'auto', md: 0 } }}>
|
||||
<Box sx={{ flex: tracking ? '1 1 42%' : 1, minHeight: { xs: 340, md: 0 } }}>
|
||||
<MemoActiveRiders deliveries={queueDeliveries} selectedId={selectedId} onSelect={setSelectedId} />
|
||||
</Box>
|
||||
{tracking && (
|
||||
<Box sx={{ flex: '1 1 58%', minHeight: { xs: 380, md: 0 } }}>
|
||||
<RiderTimeline delivery={selectedDelivery} onClose={() => setSelectedId(null)} />
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
<Grid item xs={12} lg={7}>
|
||||
<MainCard title="Live Fleet Map">
|
||||
<MapPlaceholder
|
||||
height={380}
|
||||
label="Live Tracking"
|
||||
riders={ridersLive.slice(0, 4).map((r, i) => ({
|
||||
x: ['28%', '52%', '70%', '40%'][i],
|
||||
y: ['44%', '30%', '58%', '66%'][i],
|
||||
active: r.active
|
||||
}))}
|
||||
/>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={5}>
|
||||
<MainCard title="Live Execution Feed" contentSx={{ p: 0 }}>
|
||||
<Stack>
|
||||
{executionFeed.map((e, i) => (
|
||||
<Box key={e.id} sx={{ p: 2, borderTop: i === 0 ? 'none' : '1px solid', borderColor: 'grey.100' }}>
|
||||
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
||||
<Avatar variant="rounded" sx={{ width: 38, height: 38, ...stageStyle(e.stage) }}>
|
||||
{stageIcon(e.stage)}
|
||||
</Avatar>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="subtitle2" sx={{ color: 'primary.main' }}>{e.id}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{e.time}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ my: 0.25 }}>
|
||||
<StatusChip status={mapStage(e.stage)} label={e.stage} />
|
||||
{e.proof && <Chip size="small" icon={<PhotoCameraOutlinedIcon sx={{ fontSize: 14 }} />} label="Proof" sx={{ bgcolor: 'grey.100' }} />}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">{e.rider} · {e.loc}</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>{e.detail}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<MainCard title="Customer & Business Visibility" noPadding>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Shipment</TableCell>
|
||||
<TableCell>Rider</TableCell>
|
||||
<TableCell>Current Stage</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
<TableCell>Update</TableCell>
|
||||
<TableCell align="right">Tracking</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{executionFeed.map((e) => (
|
||||
<TableRow key={e.id} hover>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'primary.main' }}>{e.id}</TableCell>
|
||||
<TableCell>{e.rider}</TableCell>
|
||||
<TableCell><StatusChip status={mapStage(e.stage)} label={e.stage} /></TableCell>
|
||||
<TableCell>{e.loc}</TableCell>
|
||||
<TableCell><Typography variant="caption" color="text.secondary">{e.detail}</Typography></TableCell>
|
||||
<TableCell align="right">
|
||||
<Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => setTrack(e)}>Track</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Tracking detail */}
|
||||
<FormDialog
|
||||
open={Boolean(track)}
|
||||
onClose={() => setTrack(null)}
|
||||
title={track ? `Tracking · ${track.id}` : 'Tracking'}
|
||||
hideActions
|
||||
>
|
||||
{track && (
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<StatusChip status={mapStage(track.stage)} label={track.stage} />
|
||||
<Typography variant="caption" color="text.secondary">Updated {track.time}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={3}>
|
||||
<Box><Typography variant="caption" color="text.secondary">Rider</Typography><Typography variant="subtitle2">{track.rider}</Typography></Box>
|
||||
<Box><Typography variant="caption" color="text.secondary">Location</Typography><Typography variant="subtitle2">{track.loc}</Typography></Box>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary">{track.detail}</Typography>
|
||||
|
||||
<MapPlaceholder height={180} label={track.id} />
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Journey</Typography>
|
||||
<Stack spacing={0}>
|
||||
{orderTimeline.map((t, i) => (
|
||||
<Stack key={t.label} direction="row" spacing={1.5} alignItems="center" sx={{ py: 0.75 }}>
|
||||
{t.done ? <CheckCircleIcon sx={{ color: 'success.main', fontSize: 20 }} /> : <RadioButtonUncheckedIcon sx={{ color: 'grey.400', fontSize: 20 }} />}
|
||||
<Typography variant="body2" sx={{ flexGrow: 1, fontWeight: t.done ? 600 : 400, color: t.done ? 'text.primary' : 'text.secondary' }}>{t.label}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t.time}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
<Stack direction="row" spacing={1.5} justifyContent="flex-end">
|
||||
<Button startIcon={<ShareOutlinedIcon />} onClick={() => { setShare(true); }}>Share</Button>
|
||||
<Button variant="contained" startIcon={<AltRouteOutlinedIcon />} onClick={() => navigate('/tracking/journey')}>View full journey</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</FormDialog>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Share tracking */}
|
||||
<FormDialog open={share} onClose={() => setShare(false)} title="Share Live Tracking" hideActions>
|
||||
@@ -207,28 +245,6 @@ export default function LiveTracking() {
|
||||
</FormDialog>
|
||||
|
||||
<Toast {...toast} />
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStage = (stage) => {
|
||||
const k = { 'In-Transit': 'in-transit', 'Picked Up': 'picked-up', Delivered: 'delivered', Exception: 'exception', Dispatched: 'dispatched' };
|
||||
return k[stage] || 'active';
|
||||
};
|
||||
|
||||
function stageStyle(stage) {
|
||||
const m = {
|
||||
'In-Transit': { bgcolor: 'info.lighter', color: 'info.main' },
|
||||
'Picked Up': { bgcolor: 'primary.lighter', color: 'primary.main' },
|
||||
Delivered: { bgcolor: 'success.lighter', color: 'success.main' },
|
||||
Exception: { bgcolor: 'error.lighter', color: 'error.main' },
|
||||
Dispatched: { bgcolor: 'grey.100', color: 'grey.700' }
|
||||
};
|
||||
return m[stage] || m.Dispatched;
|
||||
}
|
||||
|
||||
function stageIcon(stage) {
|
||||
if (stage === 'Exception') return <WarningAmberOutlinedIcon fontSize="small" />;
|
||||
if (stage === 'In-Transit' || stage === 'Dispatched') return <AltRouteOutlinedIcon fontSize="small" />;
|
||||
return <PhotoCameraOutlinedIcon fontSize="small" />;
|
||||
}
|
||||
|
||||
@@ -1,136 +1,179 @@
|
||||
import { Grid, Stack, Typography, Box, Avatar, Button, Chip, LinearProgress, Divider } from '@mui/material';
|
||||
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Grid, Stack, Typography, Box, Avatar, Button, Chip, LinearProgress, Divider,
|
||||
Table, TableBody, TableCell, TableHead, TableRow
|
||||
} from '@mui/material';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import AltRouteOutlinedIcon from '@mui/icons-material/AltRouteOutlined';
|
||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
|
||||
import ShareOutlinedIcon from '@mui/icons-material/ShareOutlined';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import PersonOutlineRoundedIcon from '@mui/icons-material/PersonOutlineRounded';
|
||||
import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import MapPlaceholder from '@/components/MapPlaceholder';
|
||||
import MiniMap from '@/components/tracking/MiniMap';
|
||||
import Toast, { useToast } from '@/components/Toast';
|
||||
import { shipmentJourney as j } from '@/data/mock';
|
||||
import { trip, cityCenters } from '@/data/mock';
|
||||
|
||||
const HOP_ICONS = { order: ReceiptLongOutlinedIcon, agent: TwoWheelerOutlinedIcon, hub: WarehouseOutlinedIcon, truck: LocalShippingOutlinedIcon, done: HomeOutlinedIcon };
|
||||
const FROM = cityCenters[trip.from.city] || cityCenters.Coimbatore;
|
||||
const TO = cityCenters[trip.to.city] || cityCenters.Bengaluru;
|
||||
const ROUTE = [[FROM.lat, FROM.lng], [TO.lat, TO.lng]];
|
||||
|
||||
function Meta({ label, value, sub }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'grey.900', lineHeight: 1.25 }}>{value}</Typography>
|
||||
{sub && <Typography variant="caption" color="text.secondary">{sub}</Typography>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShipmentJourney() {
|
||||
const navigate = useNavigate();
|
||||
const [toast, showToast] = useToast();
|
||||
const doneCount = j.hops.filter((h) => h.status === 'done').length;
|
||||
const doneCount = trip.milestones.filter((m) => m.status === 'done').length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Shipment Journey"
|
||||
breadcrumbs={[{ label: 'Live Tracking', to: '/tracking' }, { label: j.id }]}
|
||||
title="Trip Journey"
|
||||
breadcrumbs={[{ label: 'Live Tracking', to: '/tracking' }, { label: trip.id }]}
|
||||
action={
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<Button variant="outlined" startIcon={<AltRouteOutlinedIcon />} onClick={() => showToast('MileTruth AI re-evaluated the route — ETA protected')}>Re-optimize</Button>
|
||||
<Button variant="contained" startIcon={<ShareOutlinedIcon />} onClick={() => showToast('Tracking link shared with customer')}>Share</Button>
|
||||
<Button variant="outlined" startIcon={<AltRouteOutlinedIcon />} onClick={() => showToast('MileTruth AI re-evaluated the corridor — ETA protected')}>Re-optimize</Button>
|
||||
<Button variant="contained" startIcon={<ShareOutlinedIcon />} onClick={() => showToast('Trip tracking link shared')}>Share</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Summary */}
|
||||
{/* Trip summary — one vehicle, one corridor, one manifest */}
|
||||
<Box sx={{ borderRadius: 2, border: '1px solid', borderColor: 'grey.200', bgcolor: 'background.paper', p: { xs: 2, md: 2.5 }, mb: 2.5 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={5}>
|
||||
<Typography variant="overline" color="text.secondary">{j.id} · {j.client}</Typography>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
||||
<Typography variant="overline" color="text.secondary">{trip.id}</Typography>
|
||||
<Chip size="small" label={trip.status} sx={{ height: 20, bgcolor: 'info.lighter', color: 'info.dark', fontWeight: 700 }} />
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.900' }}>{j.from.city}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{j.from.area}</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.900' }}>{trip.from.city}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{trip.from.hub}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1, height: 2, bgcolor: 'grey.200', position: 'relative', mx: 1, maxWidth: 90 }}>
|
||||
<LocalShippingOutlinedIcon sx={{ fontSize: 18, color: 'primary.main', position: 'absolute', top: -9, left: `${j.progress}%`, transform: 'translateX(-50%)' }} />
|
||||
<LocalShippingOutlinedIcon sx={{ fontSize: 18, color: 'primary.main', position: 'absolute', top: -9, left: `${trip.progress}%`, transform: 'translateX(-50%)' }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.900' }}>{j.to.city}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{j.to.area}</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.900' }}>{trip.to.city}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{trip.to.hub}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>{j.product} · {j.mode}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={2}><Summary label="Current stage" value={j.currentStage} small /></Grid>
|
||||
<Grid item xs={6} md={2}><Summary label="ETA" value={j.eta} /></Grid>
|
||||
<Grid item xs={6} md={1.5}><Summary label="Distance" value={`${j.distance} km`} /></Grid>
|
||||
<Grid item xs={6} md={1.5}>
|
||||
<Grid item xs={6} md={2}><Meta label="Vehicle" value={trip.vehicle.reg} sub={`${trip.vehicle.type} · ${trip.vehicle.battery}%`} /></Grid>
|
||||
<Grid item xs={6} md={2}><Meta label="Driver" value={trip.driver.name} sub={trip.driver.phone} /></Grid>
|
||||
<Grid item xs={4} md={1.3}><Meta label="Load" value={`${trip.loadKg} kg`} sub={`${trip.pieces} pcs`} /></Grid>
|
||||
<Grid item xs={4} md={1.2}><Meta label="ETA" value={trip.eta.split(', ')[1]} sub={trip.eta.split(', ')[0]} /></Grid>
|
||||
<Grid item xs={4} md={1.3}>
|
||||
<Typography variant="caption" color="text.secondary">Progress</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.900' }}>{j.progress}%</Typography>
|
||||
<LinearProgress variant="determinate" value={j.progress} color="primary" sx={{ height: 5, borderRadius: 3, mt: 0.5 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.900' }}>{trip.progress}%</Typography>
|
||||
<LinearProgress variant="determinate" value={trip.progress} color="primary" sx={{ height: 5, borderRadius: 3, mt: 0.5 }} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2.5}>
|
||||
{/* Hop-by-hop timeline */}
|
||||
{/* Manifest — every shipment on this trip (the scalable centerpiece) */}
|
||||
<Grid item xs={12} lg={7}>
|
||||
<MainCard title={`Journey · ${doneCount}/${j.hops.length} stages complete`}>
|
||||
<Box>
|
||||
{j.hops.map((h, i) => {
|
||||
const Icon = HOP_ICONS[h.icon] || WarehouseOutlinedIcon;
|
||||
const last = i === j.hops.length - 1;
|
||||
const newMile = i === 0 || j.hops[i - 1].mile !== h.mile;
|
||||
return (
|
||||
<Box key={h.key}>
|
||||
{newMile && (
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block', mt: i === 0 ? 0 : 1.5, mb: 0.5, letterSpacing: '0.1em' }}>
|
||||
{h.mile}
|
||||
</Typography>
|
||||
)}
|
||||
<Stack direction="row" spacing={1.75}>
|
||||
{/* rail */}
|
||||
<Stack alignItems="center" sx={{ width: 36 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
bgcolor: h.status === 'done' ? 'success.main' : h.status === 'active' ? 'primary.main' : 'grey.100',
|
||||
color: h.status === 'pending' ? 'grey.500' : '#fff',
|
||||
border: h.status === 'pending' ? '1px solid' : 'none',
|
||||
borderColor: 'grey.300'
|
||||
}}
|
||||
>
|
||||
{h.status === 'done' ? <CheckIcon sx={{ fontSize: 18 }} /> : <Icon sx={{ fontSize: 18 }} />}
|
||||
</Avatar>
|
||||
{!last && <Box sx={{ flexGrow: 1, width: 2, minHeight: 26, bgcolor: h.status === 'done' ? 'success.light' : 'grey.200', my: 0.25 }} />}
|
||||
</Stack>
|
||||
{/* content */}
|
||||
<Box sx={{ pb: last ? 0 : 2, flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: h.status === 'pending' ? 'text.secondary' : 'grey.900' }}>{h.title}</Typography>
|
||||
{h.status === 'active' && <Chip size="small" label="In progress" sx={{ bgcolor: 'primary.lighter', color: 'primary.dark', fontWeight: 600 }} />}
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'grey.800', fontWeight: 600 }}>{h.node}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{h.handler}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>{h.detail}</Typography>
|
||||
<Typography variant="caption" sx={{ color: h.status === 'pending' ? 'grey.400' : 'text.secondary', fontStyle: h.status === 'pending' ? 'italic' : 'normal' }}>{h.time}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<MainCard
|
||||
title={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Inventory2OutlinedIcon fontSize="small" sx={{ color: 'grey.600' }} />
|
||||
<Typography variant="h5">Manifest</Typography>
|
||||
<Chip size="small" label={`${trip.manifest.length} shipments`} sx={{ height: 20, bgcolor: 'grey.100', fontWeight: 700 }} />
|
||||
</Stack>
|
||||
}
|
||||
action={<Typography variant="caption" color="text.secondary">{trip.pieces} pieces · {trip.loadKg} kg</Typography>}
|
||||
noPadding
|
||||
>
|
||||
<Box sx={{ maxHeight: 460, overflowY: 'auto' }}>
|
||||
<Table size="small" stickyHeader sx={{ '& .MuiTableRow-root:hover': { backgroundColor: 'grey.50' }, '& .MuiTableCell-head': { bgcolor: 'grey.50' } }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Shipment</TableCell>
|
||||
<TableCell>Customer</TableCell>
|
||||
<TableCell>Drop Area</TableCell>
|
||||
<TableCell align="right">Pcs</TableCell>
|
||||
<TableCell align="right">Weight</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{trip.manifest.map((s) => (
|
||||
<TableRow key={s.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/orders/${s.id}`)}>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'primary.main' }}>{s.id}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>{s.customer}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{s.client}</Typography>
|
||||
</TableCell>
|
||||
<TableCell><Typography variant="caption" color="text.secondary">{s.dropArea}</Typography></TableCell>
|
||||
<TableCell align="right">{s.pieces}</TableCell>
|
||||
<TableCell align="right"><Typography variant="caption">{s.weightKg} kg</Typography></TableCell>
|
||||
<TableCell><Chip size="small" label={s.status} sx={{ height: 20, bgcolor: 'info.lighter', color: 'info.dark', fontWeight: 600 }} /></TableCell>
|
||||
<TableCell align="right"><OpenInNewRoundedIcon sx={{ fontSize: 15, color: 'grey.400' }} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
|
||||
{/* Map + live monitoring */}
|
||||
{/* Map + trip milestones + monitoring — all describe the TRIP */}
|
||||
<Grid item xs={12} lg={5}>
|
||||
<Stack spacing={2.5}>
|
||||
<MainCard title="Live Route">
|
||||
<MapPlaceholder
|
||||
height={240}
|
||||
label={`${j.from.city} → ${j.to.city}`}
|
||||
pins={[
|
||||
{ x: '16%', y: '74%', label: 'Chennai', color: '#00A854' },
|
||||
{ x: '48%', y: '50%', label: 'Line-haul', color: '#C01227' },
|
||||
{ x: '80%', y: '24%', label: 'Bengaluru', color: '#595959' }
|
||||
]}
|
||||
/>
|
||||
<MainCard title="Trip Route" noPadding>
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
<MiniMap route={ROUTE} progress={trip.progress} vehicle="Truck" pickup={FROM} drop={TO} height={220} />
|
||||
</Box>
|
||||
</MainCard>
|
||||
|
||||
<MainCard title={`Trip Milestones · ${doneCount}/${trip.milestones.length}`}>
|
||||
<Box>
|
||||
{trip.milestones.map((m, i) => {
|
||||
const last = i === trip.milestones.length - 1;
|
||||
return (
|
||||
<Stack key={m.key} direction="row" spacing={1.75}>
|
||||
<Stack alignItems="center" sx={{ width: 32 }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 30, height: 30,
|
||||
bgcolor: m.status === 'done' ? 'success.main' : m.status === 'active' ? 'primary.main' : 'grey.100',
|
||||
color: m.status === 'pending' ? 'grey.500' : '#fff',
|
||||
border: m.status === 'pending' ? '1px solid' : 'none', borderColor: 'grey.300'
|
||||
}}
|
||||
>
|
||||
{m.status === 'done' ? <CheckIcon sx={{ fontSize: 16 }} /> : <LocalShippingOutlinedIcon sx={{ fontSize: 16 }} />}
|
||||
</Avatar>
|
||||
{!last && <Box sx={{ flexGrow: 1, width: 2, minHeight: 22, bgcolor: m.status === 'done' ? 'success.light' : 'grey.200', my: 0.25 }} />}
|
||||
</Stack>
|
||||
<Box sx={{ pb: last ? 0 : 1.75, flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: m.status === 'pending' ? 'text.secondary' : 'grey.900' }}>{m.title}</Typography>
|
||||
{m.status === 'active' && <Chip size="small" label="In progress" sx={{ bgcolor: 'primary.lighter', color: 'primary.dark', fontWeight: 600 }} />}
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'grey.800', fontWeight: 600 }}>{m.node}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{m.detail}</Typography>
|
||||
<Typography variant="caption" sx={{ color: m.status === 'pending' ? 'grey.400' : 'text.secondary', fontStyle: m.status === 'pending' ? 'italic' : 'normal' }}>{m.time}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</MainCard>
|
||||
|
||||
<MainCard
|
||||
@@ -142,7 +185,7 @@ export default function ShipmentJourney() {
|
||||
}
|
||||
>
|
||||
<Stack divider={<Divider />} spacing={0}>
|
||||
{j.events.map((e, i) => (
|
||||
{trip.events.map((e, i) => (
|
||||
<Stack key={i} direction="row" spacing={1.5} alignItems="flex-start" sx={{ py: 1.25 }}>
|
||||
<Avatar variant="rounded" sx={{ width: 32, height: 32, bgcolor: e.type === 'reroute' ? 'warning.lighter' : 'grey.100', color: e.type === 'reroute' ? 'warning.dark' : 'grey.600' }}>
|
||||
{e.type === 'reroute' ? <AltRouteOutlinedIcon sx={{ fontSize: 18 }} /> : <VisibilityOutlinedIcon sx={{ fontSize: 18 }} />}
|
||||
@@ -163,12 +206,3 @@ export default function ShipmentJourney() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Summary({ label, value, small }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Typography variant={small ? 'subtitle2' : 'h5'} sx={{ fontWeight: 700, color: 'grey.900', lineHeight: 1.2 }}>{value}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
75
src/store/Filters.jsx
Normal file
75
src/store/Filters.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createContext, useContext, useState, useMemo, useEffect } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// ==============================|| GLOBAL FILTERS — single source of truth ||============================== //
|
||||
// One place for the application-wide Location and Date Range. The header Location selector and every
|
||||
// report read/write these, so there is exactly one source of truth (no duplicate, conflicting filters).
|
||||
// Persisted to localStorage so the selection survives refresh, tab switches and navigation.
|
||||
//
|
||||
// Presets are anchored to the dataset's reference day (not the wall clock) so they filter the demo
|
||||
// data sensibly — e.g. Last 7 Days ≠ Last 30 Days, Today ≠ This Month.
|
||||
|
||||
export const DATA_TODAY = dayjs('2026-06-05');
|
||||
const fmt = (d) => d.format('MMM DD');
|
||||
|
||||
export function buildRange(key) {
|
||||
const t = DATA_TODAY;
|
||||
switch (key) {
|
||||
case 'today': return { key, label: 'Today', start: t.startOf('day'), end: t.endOf('day') };
|
||||
case 'yesterday': { const y = t.subtract(1, 'day'); return { key, label: 'Yesterday', start: y.startOf('day'), end: y.endOf('day') }; }
|
||||
case 'last7': return { key, label: 'Last 7 Days', start: t.subtract(6, 'day').startOf('day'), end: t.endOf('day') };
|
||||
case 'last30': return { key, label: 'Last 30 Days', start: t.subtract(29, 'day').startOf('day'), end: t.endOf('day') };
|
||||
case 'thisMonth': return { key, label: 'This Month', start: t.startOf('month'), end: t.endOf('month') };
|
||||
case 'prevMonth': { const p = t.subtract(1, 'month'); return { key, label: 'Previous Month', start: p.startOf('month'), end: p.endOf('month') }; }
|
||||
default: return { key: 'last30', label: 'Last 30 Days', start: t.subtract(29, 'day').startOf('day'), end: t.endOf('day') };
|
||||
}
|
||||
}
|
||||
|
||||
export function customRange(start, end) {
|
||||
return { key: 'custom', label: `${fmt(start)} – ${fmt(end)}`, start: start.startOf('day'), end: end.endOf('day') };
|
||||
}
|
||||
|
||||
// inclusive, day-granular date-in-range test
|
||||
export function inRange(dateStr, range) {
|
||||
if (!range?.start || !range?.end) return true;
|
||||
const d = dayjs(dateStr).startOf('day');
|
||||
return !d.isBefore(range.start.startOf('day')) && !d.isAfter(range.end.startOf('day'));
|
||||
}
|
||||
|
||||
const FilterContext = createContext(null);
|
||||
const STORE_KEY = 'dm.filters';
|
||||
|
||||
export function FilterProvider({ children }) {
|
||||
const [location, setLocation] = useState('all');
|
||||
const [range, setRange] = useState(() => buildRange('last30'));
|
||||
|
||||
// hydrate once
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORE_KEY);
|
||||
if (!raw) return;
|
||||
const s = JSON.parse(raw);
|
||||
if (s.location) setLocation(s.location);
|
||||
if (s.rangeKey === 'custom' && s.start && s.end) setRange(customRange(dayjs(s.start), dayjs(s.end)));
|
||||
else if (s.rangeKey) setRange(buildRange(s.rangeKey));
|
||||
} catch { /* ignore corrupt storage */ }
|
||||
}, []);
|
||||
|
||||
// persist on change
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORE_KEY, JSON.stringify({
|
||||
location, rangeKey: range.key, start: range.start?.toISOString(), end: range.end?.toISOString()
|
||||
}));
|
||||
} catch { /* storage unavailable */ }
|
||||
}, [location, range]);
|
||||
|
||||
const value = useMemo(() => ({ location, setLocation, range, setRange }), [location, range]);
|
||||
return <FilterContext.Provider value={value}>{children}</FilterContext.Provider>;
|
||||
}
|
||||
|
||||
export function useFilters() {
|
||||
const ctx = useContext(FilterContext);
|
||||
if (!ctx) throw new Error('useFilters must be used within a FilterProvider');
|
||||
return ctx;
|
||||
}
|
||||
75
src/store/OpsStore.jsx
Normal file
75
src/store/OpsStore.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
|
||||
// ==============================|| OPS STORE — SESSION PERSISTENCE ||============================== //
|
||||
// A lightweight in-memory store so operational actions actually COMMIT during a session (no backend).
|
||||
// Holds: dispatch assignments (order → rider), reroutes, and exceptions. Future workflows (status
|
||||
// advance, payments) hang off the same provider. State resets on full reload — intentional for a demo.
|
||||
|
||||
const OpsContext = createContext(null);
|
||||
|
||||
export function OpsProvider({ children }) {
|
||||
// assignments: { [orderId]: { riderId, riderName, vehicle, at } }
|
||||
const [assignments, setAssignments] = useState({});
|
||||
// reroutes: { [orderId]: at }
|
||||
const [reroutes, setReroutes] = useState({});
|
||||
// exceptions: { [orderId]: { reason, at } }
|
||||
const [exceptions, setExceptions] = useState({});
|
||||
|
||||
const assignOrder = useCallback((orderId, rider) => {
|
||||
setAssignments((prev) => ({
|
||||
...prev,
|
||||
[orderId]: { riderId: rider.id, riderName: rider.name, vehicle: rider.vehicle, at: Date.now() }
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const unassignOrder = useCallback((orderId) => {
|
||||
setAssignments((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[orderId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const rerouteOrder = useCallback((orderId) => {
|
||||
setReroutes((prev) => ({ ...prev, [orderId]: Date.now() }));
|
||||
}, []);
|
||||
|
||||
const raiseException = useCallback((orderId, reason) => {
|
||||
setExceptions((prev) => ({ ...prev, [orderId]: { reason, at: Date.now() } }));
|
||||
}, []);
|
||||
|
||||
const clearException = useCallback((orderId) => {
|
||||
setExceptions((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[orderId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const assignmentOf = useCallback((orderId) => assignments[orderId] || null, [assignments]);
|
||||
const exceptionOf = useCallback((orderId) => exceptions[orderId] || null, [exceptions]);
|
||||
const isRerouted = useCallback((orderId) => Boolean(reroutes[orderId]), [reroutes]);
|
||||
const riderLoad = useCallback(
|
||||
(riderId) => Object.values(assignments).filter((a) => a.riderId === riderId).length,
|
||||
[assignments]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
assignments, reroutes, exceptions,
|
||||
assignOrder, unassignOrder, rerouteOrder, raiseException, clearException,
|
||||
assignmentOf, exceptionOf, isRerouted, riderLoad,
|
||||
assignedCount: Object.keys(assignments).length,
|
||||
exceptionCount: Object.keys(exceptions).length
|
||||
}),
|
||||
[assignments, reroutes, exceptions, assignOrder, unassignOrder, rerouteOrder, raiseException, clearException, assignmentOf, exceptionOf, isRerouted, riderLoad]
|
||||
);
|
||||
|
||||
return <OpsContext.Provider value={value}>{children}</OpsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useOps() {
|
||||
const ctx = useContext(OpsContext);
|
||||
if (!ctx) throw new Error('useOps must be used within an OpsProvider');
|
||||
return ctx;
|
||||
}
|
||||
49
src/utils/geo.js
Normal file
49
src/utils/geo.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// ==============================|| GEO HELPERS — route interpolation & bearing ||============================== //
|
||||
// Lightweight planar math (good enough for city-scale visualisation) used to place a vehicle
|
||||
// along its route from a progress %, split a route into completed / remaining paths, and
|
||||
// compute heading so markers can rotate to face their direction of travel.
|
||||
|
||||
const seg = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
|
||||
export function bearing(a, b) {
|
||||
const dLng = ((b[1] - a[1]) * Math.PI) / 180;
|
||||
const lat1 = (a[0] * Math.PI) / 180;
|
||||
const lat2 = (b[0] * Math.PI) / 180;
|
||||
const y = Math.sin(dLng) * Math.cos(lat2);
|
||||
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
|
||||
return (((Math.atan2(y, x) * 180) / Math.PI) + 360) % 360;
|
||||
}
|
||||
|
||||
// Returns { point: [lat,lng], bearing, completed: [...], remaining: [...] } for a progress 0..100.
|
||||
export function pointAlongRoute(route, progress) {
|
||||
if (!route || route.length === 0) return null;
|
||||
if (route.length === 1) return { point: route[0], bearing: 0, completed: [route[0]], remaining: [route[0]] };
|
||||
|
||||
const lengths = [];
|
||||
let total = 0;
|
||||
for (let i = 0; i < route.length - 1; i++) {
|
||||
const l = seg(route[i], route[i + 1]);
|
||||
lengths.push(l);
|
||||
total += l;
|
||||
}
|
||||
|
||||
const target = Math.min(Math.max(progress, 0), 100) / 100 * total;
|
||||
let acc = 0;
|
||||
for (let i = 0; i < lengths.length; i++) {
|
||||
if (acc + lengths[i] >= target || i === lengths.length - 1) {
|
||||
const t = lengths[i] === 0 ? 0 : (target - acc) / lengths[i];
|
||||
const a = route[i];
|
||||
const b = route[i + 1];
|
||||
const point = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
return {
|
||||
point,
|
||||
bearing: bearing(a, b),
|
||||
completed: [...route.slice(0, i + 1), point],
|
||||
remaining: [point, ...route.slice(i + 1)]
|
||||
};
|
||||
}
|
||||
acc += lengths[i];
|
||||
}
|
||||
const last = route[route.length - 1];
|
||||
return { point: last, bearing: 0, completed: route, remaining: [last] };
|
||||
}
|
||||
43
src/utils/osrm.js
Normal file
43
src/utils/osrm.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// ==============================|| OSRM ROUTING — snap routes to real streets ||============================== //
|
||||
// Turns a list of [lat,lng] waypoints into road-following geometry via the OSRM route service.
|
||||
// Results are cached in-memory; any failure (offline / rate-limit) returns null so callers can
|
||||
// fall back to their hand-drawn polyline. Uses the public demo server — swap OSRM_HOST for a
|
||||
// self-hosted instance in production (the demo server is not for production traffic).
|
||||
|
||||
const OSRM_HOST = 'https://router.project-osrm.org';
|
||||
const cache = new Map();
|
||||
|
||||
const key = (waypoints) => waypoints.map((p) => p.join(',')).join(';');
|
||||
|
||||
export async function snapRoute(waypoints, { signal } = {}) {
|
||||
if (!waypoints || waypoints.length < 2) return null;
|
||||
const k = key(waypoints);
|
||||
if (cache.has(k)) return cache.get(k);
|
||||
|
||||
const coords = waypoints.map(([lat, lng]) => `${lng},${lat}`).join(';');
|
||||
const url = `${OSRM_HOST}/route/v1/driving/${coords}?overview=full&geometries=geojson`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) throw new Error(`OSRM ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (data.code !== 'Ok' || !data.routes?.length) throw new Error(`OSRM ${data.code}`);
|
||||
const line = data.routes[0].geometry.coordinates.map(([lng, lat]) => [lat, lng]);
|
||||
cache.set(k, line);
|
||||
return line;
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') cache.set(k, null); // remember the failure, don't hammer
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Snap many routes at once. Returns { [id]: [[lat,lng], …] } only for the ones that succeeded.
|
||||
export async function snapRoutes(items, { signal } = {}) {
|
||||
const entries = await Promise.all(
|
||||
items.map(async (it) => {
|
||||
const line = await snapRoute(it.route, { signal });
|
||||
return line ? [it.id, line] : null;
|
||||
})
|
||||
);
|
||||
return Object.fromEntries(entries.filter(Boolean));
|
||||
}
|
||||
Reference in New Issue
Block a user