264 lines
14 KiB
JavaScript
264 lines
14 KiB
JavaScript
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 ArrowForwardRoundedIcon from '@mui/icons-material/ArrowForwardRounded';
|
|
|
|
import PageHeader from '@/components/PageHeader';
|
|
import KpiStrip from '@/components/KpiStrip';
|
|
import MainCard from '@/components/MainCard';
|
|
import StatusChip from '@/components/StatusChip';
|
|
import AreaChart from '@/components/charts/AreaChart';
|
|
import ProcessTracker from '@/components/ProcessTracker';
|
|
import AiImpactSummary from '@/components/AiImpactSummary';
|
|
import Toast, { useToast } from '@/components/Toast';
|
|
import AsyncBoundary from '@/components/AsyncBoundary';
|
|
import HealthStatusWidget from '@/components/HealthStatusWidget';
|
|
import useApi from '@/hooks/useApi';
|
|
import { getDashboard } from '@/services/adminService';
|
|
import { inr } from '@/utils/format';
|
|
|
|
const SEV_DOT = { high: '#F04134', medium: '#FFBF00', low: '#00A2AE', info: '#8C8C8C' };
|
|
|
|
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 [toast, showToast] = useToast();
|
|
// Live dashboard payload (GET /admin/dashboard), auto-refreshed every 30s.
|
|
const { data, loading, error, refetch } = useApi(getDashboard, [], { refreshMs: 30000 });
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="Operations Control Center"
|
|
breadcrumbs={[{ label: 'Control Center' }]}
|
|
action={
|
|
<Stack direction="row" spacing={2.5} alignItems="center">
|
|
<HealthStatusWidget />
|
|
<Button variant="outlined" startIcon={<FileDownloadOutlinedIcon />} onClick={() => showToast('Snapshot exported as CSV')}>Export</Button>
|
|
</Stack>
|
|
}
|
|
/>
|
|
|
|
<AsyncBoundary loading={loading} error={error} onRetry={refetch} skeletonHeight={96} skeletonCount={4}>
|
|
{data && <DashboardContent data={data} showToast={showToast} />}
|
|
</AsyncBoundary>
|
|
|
|
<Toast {...toast} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function DashboardContent({ data }) {
|
|
const navigate = useNavigate();
|
|
const { kpis: k, dispatchQueue, activeDeliveries, aiInsights, executionFeed, fleetSummary, lanePerformance, ordersTrend } = data;
|
|
|
|
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: k.totalOrders.toLocaleString('en-IN'), icon: Inventory2OutlinedIcon },
|
|
{ label: 'Active Shipments', value: String(k.activeShipments), color: '#1D4ED8', icon: LocalShippingOutlinedIcon },
|
|
{ label: 'Riders Online', value: String(k.ridersOnline), color: '#00773B', icon: TwoWheelerOutlinedIcon },
|
|
{ label: 'Hub Utilization', value: `${k.hubUtilization}%`, color: k.hubUtilization > 80 ? '#A82216' : '#8A6500', icon: HubOutlinedIcon },
|
|
{ label: 'Revenue Today', value: inr(k.revenueToday), color: '#00727B', icon: CurrencyRupeeIcon },
|
|
{ label: 'SLA Performance', value: `${k.slaPerformance}%`, color: '#00773B', icon: TaskAltOutlinedIcon }
|
|
];
|
|
|
|
return (
|
|
<>
|
|
{/* Top row — 6 live KPIs */}
|
|
<KpiStrip items={kpis} />
|
|
|
|
{/* 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>
|
|
<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>
|
|
))}
|
|
</Stack>
|
|
</MainCard>
|
|
</Grid>
|
|
</Grid>
|
|
</Box>
|
|
|
|
{/* 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>
|
|
</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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Legend({ color, label }) {
|
|
return (
|
|
<Stack direction="row" spacing={0.75} alignItems="center">
|
|
<Box sx={{ width: 10, height: 10, borderRadius: '3px', bgcolor: color }} />
|
|
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
|
</Stack>
|
|
);
|
|
}
|