update ui admin
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user