update api
This commit is contained in:
@@ -20,41 +20,64 @@ import AreaChart from '@/components/charts/AreaChart';
|
||||
import ProcessTracker from '@/components/ProcessTracker';
|
||||
import AiImpactSummary from '@/components/AiImpactSummary';
|
||||
import Toast, { useToast } from '@/components/Toast';
|
||||
import { dispatchQueue, activeDeliveries, aiInsights, executionFeed, fleetSummary, lanePerformance, hubCityStats, ordersTrend, analyticsKpis } from '@/data/mock';
|
||||
import AsyncBoundary from '@/components/AsyncBoundary';
|
||||
import HealthStatusWidget from '@/components/HealthStatusWidget';
|
||||
import useApi from '@/hooks/useApi';
|
||||
import { getDashboard } from '@/services/adminService';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
const SEV_DOT = { high: '#F04134', medium: '#FFBF00', low: '#00A2AE', info: '#8C8C8C' };
|
||||
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 }
|
||||
];
|
||||
// Live dashboard payload (GET /admin/dashboard), auto-refreshed every 30s.
|
||||
const { data, loading, error, refetch } = useApi(getDashboard, [], { refreshMs: 30000 });
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Operations Control Center"
|
||||
breadcrumbs={[{ label: 'Control Center' }]}
|
||||
action={<Button variant="outlined" startIcon={<FileDownloadOutlinedIcon />} onClick={() => showToast('Snapshot exported as CSV')}>Export</Button>}
|
||||
action={
|
||||
<Stack direction="row" spacing={2.5} alignItems="center">
|
||||
<HealthStatusWidget />
|
||||
<Button variant="outlined" startIcon={<FileDownloadOutlinedIcon />} onClick={() => showToast('Snapshot exported as CSV')}>Export</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<AsyncBoundary loading={loading} error={error} onRetry={refetch} skeletonHeight={96} skeletonCount={4}>
|
||||
{data && <DashboardContent data={data} showToast={showToast} />}
|
||||
</AsyncBoundary>
|
||||
|
||||
<Toast {...toast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardContent({ data }) {
|
||||
const navigate = useNavigate();
|
||||
const { kpis: k, dispatchQueue, activeDeliveries, aiInsights, executionFeed, fleetSummary, lanePerformance, ordersTrend } = data;
|
||||
|
||||
const priority = activeDeliveries.filter((d) => (d.priority === 'high' || d.priority === 'express') && d.status !== 'Delivered').slice(0, 4);
|
||||
const delayed = activeDeliveries.filter((d) => d.etaStatus !== 'on-time' && d.status !== 'Delivered').slice(0, 4);
|
||||
const recs = aiInsights.slice(0, 4);
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Total Orders', value: k.totalOrders.toLocaleString('en-IN'), icon: Inventory2OutlinedIcon },
|
||||
{ label: 'Active Shipments', value: String(k.activeShipments), color: '#1D4ED8', icon: LocalShippingOutlinedIcon },
|
||||
{ label: 'Riders Online', value: String(k.ridersOnline), color: '#00773B', icon: TwoWheelerOutlinedIcon },
|
||||
{ label: 'Hub Utilization', value: `${k.hubUtilization}%`, color: k.hubUtilization > 80 ? '#A82216' : '#8A6500', icon: HubOutlinedIcon },
|
||||
{ label: 'Revenue Today', value: inr(k.revenueToday), color: '#00727B', icon: CurrencyRupeeIcon },
|
||||
{ label: 'SLA Performance', value: `${k.slaPerformance}%`, color: '#00773B', icon: TaskAltOutlinedIcon }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top row — 6 live KPIs */}
|
||||
<KpiStrip items={kpis} />
|
||||
|
||||
@@ -226,8 +249,6 @@ export default function Dashboard() {
|
||||
<Box sx={{ mt: 3.5 }}>
|
||||
<AiImpactSummary />
|
||||
</Box>
|
||||
|
||||
<Toast {...toast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Link
|
||||
Link,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import Visibility from '@mui/icons-material/Visibility';
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||
@@ -21,12 +22,29 @@ import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined
|
||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||
|
||||
import Logo from '@/components/Logo';
|
||||
import { useAuth } from '@/store/AuthStore';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const [show, setShow] = useState(false);
|
||||
const [auth, setAuth] = useState('admin@doormile.in');
|
||||
const [pwd, setPwd] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSignIn = async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(auth, pwd);
|
||||
navigate('/dashboard', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Sign in failed. Please check your credentials.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container sx={{ minHeight: '100vh' }}>
|
||||
@@ -89,6 +107,7 @@ export default function Login() {
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2.5}>
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.75 }}>Auth Name</Typography>
|
||||
<TextField fullWidth placeholder="Enter your auth name" value={auth} onChange={(e) => setAuth(e.target.value)} />
|
||||
@@ -101,6 +120,7 @@ export default function Login() {
|
||||
placeholder="Enter your password"
|
||||
value={pwd}
|
||||
onChange={(e) => setPwd(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !submitting && handleSignIn()}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -116,8 +136,8 @@ export default function Login() {
|
||||
<FormControlLabel control={<Checkbox defaultChecked size="small" />} label={<Typography variant="body2">Remember me</Typography>} />
|
||||
<Link href="#" underline="hover" variant="body2" color="primary">Forgot password?</Link>
|
||||
</Stack>
|
||||
<Button fullWidth size="large" variant="contained" onClick={() => navigate('/dashboard')}>
|
||||
Sign In
|
||||
<Button fullWidth size="large" variant="contained" onClick={handleSignIn} disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Sign In'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -15,15 +15,14 @@ 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 AsyncBoundary from '@/components/AsyncBoundary';
|
||||
import useApi from '@/hooks/useApi';
|
||||
import { getDispatchBoard, assignMiler } from '@/services/adminService';
|
||||
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 (
|
||||
@@ -53,33 +52,67 @@ function KpiCard({ label, value, subtitle, color }) {
|
||||
}
|
||||
|
||||
export default function DispatchBoard() {
|
||||
const navigate = useNavigate();
|
||||
const [toast, showToast] = useToast();
|
||||
// Dispatch board payload: queue + bookings + milers (GET /admin/dispatch-queue, /bookings, /milers).
|
||||
const { data, loading, error, refetch } = useApi(getDispatchBoard, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AsyncBoundary loading={loading} error={error} onRetry={refetch} skeletonHeight={116} skeletonCount={3}>
|
||||
{data && <DispatchBoardContent board={data} showToast={showToast} />}
|
||||
</AsyncBoundary>
|
||||
<Toast {...toast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DispatchBoardContent({ board, showToast }) {
|
||||
const navigate = useNavigate();
|
||||
const { assignments, assignOrder, unassignOrder, riderLoad, assignedCount } = useOps();
|
||||
|
||||
const { queue: dispatchQueue, bookings, milers } = board;
|
||||
const orderById = (id) => bookings.find((o) => o.id === id);
|
||||
const riderByName = (name) => milers.find((r) => r.name === name);
|
||||
const availableRiders = useMemo(() => milers.filter((r) => r.status !== 'offline'), [milers]);
|
||||
|
||||
// per-card chosen rider (defaults to AI suggestion)
|
||||
const [choice, setChoice] = useState({});
|
||||
|
||||
const queue = useMemo(() => dispatchQueue.filter((q) => !assignments[q.id]), [assignments]);
|
||||
const queue = useMemo(() => dispatchQueue.filter((q) => !assignments[q.id]), [dispatchQueue, 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}`);
|
||||
// Persist the assignment to the backend (POST /admin/bookings/:id/assign-miler), then
|
||||
// reflect it in the session OpsStore so the board updates immediately.
|
||||
const commitAssign = async (orderId, rider) => {
|
||||
try {
|
||||
await assignMiler(orderId, rider.id);
|
||||
assignOrder(orderId, rider);
|
||||
return true;
|
||||
} catch (err) {
|
||||
showToast(err?.message || `Could not assign ${orderId}`, 'error');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const autoAssignAll = () => {
|
||||
const doAssign = async (q, riderName) => {
|
||||
const rider = riderByName(riderName) || availableRiders[0];
|
||||
if (!rider) return showToast('No available rider', 'warning');
|
||||
if (await commitAssign(q.id, rider)) showToast(`${q.id} assigned to ${rider.name}`);
|
||||
};
|
||||
|
||||
const autoAssignAll = async () => {
|
||||
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`);
|
||||
const count = queue.length;
|
||||
await Promise.all(
|
||||
queue.map((q) => {
|
||||
const rider = riderByName(q.suggestedRider) || availableRiders[0];
|
||||
return rider ? commitAssign(q.id, rider) : Promise.resolve();
|
||||
})
|
||||
);
|
||||
showToast(`${count} orders auto-assigned by MileTruth AI`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -198,7 +231,7 @@ export default function DispatchBoard() {
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack divider={<Divider />}>
|
||||
{riders.map((r) => {
|
||||
{milers.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 }}>
|
||||
@@ -249,8 +282,6 @@ export default function DispatchBoard() {
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Toast {...toast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ import MainCard from '@/components/MainCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import MapPlaceholder from '@/components/MapPlaceholder';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { orders, orderTimeline, deliveries } from '@/data/mock';
|
||||
import AsyncBoundary from '@/components/AsyncBoundary';
|
||||
import Toast, { useToast } from '@/components/Toast';
|
||||
import useApi from '@/hooks/useApi';
|
||||
import { getBooking, updateBookingStatus } from '@/services/adminService';
|
||||
import { inr } from '@/utils/format';
|
||||
|
||||
const RedConnector = styled(StepConnector)(({ theme }) => ({
|
||||
@@ -35,9 +38,30 @@ function Dot({ active }) {
|
||||
|
||||
export default function OrderDetails() {
|
||||
const { id } = useParams();
|
||||
// Booking detail (GET /admin/bookings/:id) -> { order, timeline, delivery }.
|
||||
const { data, loading, error, refetch } = useApi(() => getBooking(id), [id]);
|
||||
|
||||
return (
|
||||
<AsyncBoundary loading={loading} error={error} onRetry={refetch} skeletonHeight={140} skeletonCount={3}>
|
||||
{data && <OrderDetailsContent data={data} refetch={refetch} />}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderDetailsContent({ data, refetch }) {
|
||||
const navigate = useNavigate();
|
||||
const order = orders.find((o) => o.id === id) || orders[1];
|
||||
const delivery = deliveries.find((d) => d.id === order.id) || deliveries[1];
|
||||
const [toast, showToast] = useToast();
|
||||
const { order, timeline, delivery } = data;
|
||||
|
||||
const cancelOrder = async () => {
|
||||
try {
|
||||
await updateBookingStatus(order.id, 'cancelled');
|
||||
showToast(`${order.id} cancelled`);
|
||||
refetch();
|
||||
} catch (err) {
|
||||
showToast(err?.message || 'Could not cancel order', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -51,7 +75,7 @@ export default function OrderDetails() {
|
||||
breadcrumbs={[{ label: 'Orders', to: '/orders' }, { label: order.id }]}
|
||||
action={
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<Button variant="outlined" color="error">Cancel Order</Button>
|
||||
<Button variant="outlined" color="error" onClick={cancelOrder} disabled={order.status === 'cancelled'}>Cancel Order</Button>
|
||||
<Button variant="contained" startIcon={<EditOutlinedIcon />}>Edit Order</Button>
|
||||
</Stack>
|
||||
}
|
||||
@@ -97,7 +121,7 @@ export default function OrderDetails() {
|
||||
|
||||
<MainCard title="Delivery Timeline">
|
||||
<Stepper orientation="vertical" connector={<RedConnector />} sx={{ ml: 0.5 }}>
|
||||
{orderTimeline.map((s) => (
|
||||
{timeline.map((s) => (
|
||||
<Step key={s.label} active completed={s.done}>
|
||||
<StepLabel StepIconComponent={() => <Dot active={s.done} />}>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
@@ -141,6 +165,8 @@ export default function OrderDetails() {
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Toast {...toast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ 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 AsyncBoundary from '@/components/AsyncBoundary';
|
||||
import useApi from '@/hooks/useApi';
|
||||
import { getBookings } from '@/services/adminService';
|
||||
import { inr } from '@/utils/format';
|
||||
import { useOps } from '@/store/OpsStore';
|
||||
import { useFilters } from '@/store/Filters';
|
||||
@@ -41,6 +43,16 @@ const STICKY_HEAD = {
|
||||
};
|
||||
|
||||
export default function OrdersList() {
|
||||
// Bookings list (GET /admin/bookings).
|
||||
const { data: orders, loading, error, refetch } = useApi(getBookings, []);
|
||||
return (
|
||||
<AsyncBoundary loading={loading} error={error} onRetry={refetch} skeletonHeight={120} skeletonCount={5}>
|
||||
{orders && <OrdersListContent orders={orders} />}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function OrdersListContent({ orders }) {
|
||||
const navigate = useNavigate();
|
||||
const { exceptions } = useOps();
|
||||
const { location } = useFilters(); // global location — single source of truth
|
||||
|
||||
@@ -12,7 +12,10 @@ import FleetMap from '@/components/tracking/FleetMap';
|
||||
import RiderTimeline from '@/components/tracking/RiderTimeline';
|
||||
import FormDialog from '@/components/FormDialog';
|
||||
import Toast, { useToast } from '@/components/Toast';
|
||||
import { activeDeliveries, fleetVehicles, riders } from '@/data/mock';
|
||||
import AsyncBoundary from '@/components/AsyncBoundary';
|
||||
import useApi from '@/hooks/useApi';
|
||||
import { getTrackingBoard, getConsignments } from '@/services/adminService';
|
||||
import realtime from '@/services/realtime';
|
||||
import { snapRoutes } from '@/utils/osrm';
|
||||
import { useOps } from '@/store/OpsStore';
|
||||
|
||||
@@ -37,25 +40,30 @@ function speedEnvelope(p, stops) {
|
||||
|
||||
// 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;
|
||||
}, {});
|
||||
function deriveMotion(list) {
|
||||
return list.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) => {
|
||||
// `consignments` is the fetched base set; `serverById` overlays the latest status/eta/rider
|
||||
// pushed by the realtime channel (polling placeholder today).
|
||||
function buildDeliveries(consignments, progress, assignments, exceptions, snapped, serverById) {
|
||||
return consignments.map((d) => {
|
||||
const live = serverById[d.id] || d;
|
||||
const a = assignments[d.id];
|
||||
const ex = exceptions[d.id];
|
||||
const pe = progress[d.id] ?? d.progress;
|
||||
@@ -64,37 +72,69 @@ function buildDeliveries(progress, assignments, exceptions, snapped) {
|
||||
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,
|
||||
rider: a ? a.riderName : live.rider,
|
||||
status: ex ? 'Exception' : live.status,
|
||||
etaStatus: ex ? 'delayed' : live.etaStatus,
|
||||
eta: live.eta ?? d.eta,
|
||||
delayMin: live.delayMin ?? d.delayMin,
|
||||
flagged: Boolean(ex)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function LiveTracking() {
|
||||
// Tracking control-tower payload: consignments + ambient fleet + milers.
|
||||
const { data, loading, error, refetch } = useApi(getTrackingBoard, []);
|
||||
return (
|
||||
<AsyncBoundary loading={loading} error={error} onRetry={refetch} skeletonHeight={200} skeletonCount={2}>
|
||||
{data && <TrackingBoard board={data} />}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackingBoard({ board }) {
|
||||
const navigate = useNavigate();
|
||||
const [share, setShare] = useState(false);
|
||||
const [toast, showToast] = useToast();
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
|
||||
// Base consignment set + derived motion are pinned to first load so the animation is stable;
|
||||
// live status changes arrive via the realtime overlay below rather than remounting the sim.
|
||||
const consignmentsRef = useRef(board.consignments);
|
||||
const consignments = consignmentsRef.current;
|
||||
const fleetVehicles = board.fleetVehicles;
|
||||
const MOTION = useMemo(() => deriveMotion(consignments), [consignments]);
|
||||
|
||||
const { assignments, exceptions, assignOrder, rerouteOrder, raiseException } = useOps();
|
||||
const availableRiders = useMemo(() => riders.filter((r) => r.status !== 'offline'), []);
|
||||
const availableRiders = useMemo(() => board.milers.filter((r) => r.status !== 'offline'), [board.milers]);
|
||||
|
||||
// Realtime overlay — latest server-side status/eta/rider per consignment id. Today the
|
||||
// realtime service polls GET /admin/consignments; swapping it for a socket changes nothing here.
|
||||
const [serverById, setServerById] = useState(() => Object.fromEntries(consignments.map((d) => [d.id, d])));
|
||||
useEffect(() => {
|
||||
const unsubscribe = realtime.subscribe(
|
||||
getConsignments,
|
||||
(list) => setServerById(Object.fromEntries(list.map((d) => [d.id, d]))),
|
||||
{ intervalMs: 15000 }
|
||||
);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// 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(() => {});
|
||||
snapRoutes(consignments, { signal: ctrl.signal }).then(setSnapped).catch(() => {});
|
||||
return () => ctrl.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 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 [progress, setProgress] = useState(() => Object.fromEntries(consignments.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])));
|
||||
const baseProgress = useRef(Object.fromEntries(consignments.map((d) => [d.id, d.progress])));
|
||||
|
||||
useEffect(() => {
|
||||
const work = { ...baseProgress.current }; // local accumulator advanced every frame
|
||||
@@ -114,7 +154,7 @@ export default function LiveTracking() {
|
||||
sinceQueue += dt;
|
||||
sinceClock += dt;
|
||||
|
||||
activeDeliveries.forEach((d) => {
|
||||
consignments.forEach((d) => {
|
||||
if (d.status === 'Delivered') return;
|
||||
const m = MOTION[d.id];
|
||||
const rt = runtime[d.id] || (runtime[d.id] = { dwell: 0, stopIdx: 0 });
|
||||
@@ -149,8 +189,14 @@ export default function LiveTracking() {
|
||||
}, []);
|
||||
|
||||
// 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]);
|
||||
const mapDeliveries = useMemo(
|
||||
() => buildDeliveries(consignments, progress, assignments, exceptions, snapped, serverById),
|
||||
[consignments, progress, assignments, exceptions, snapped, serverById]
|
||||
);
|
||||
const queueDeliveries = useMemo(
|
||||
() => buildDeliveries(consignments, queueProgress, assignments, exceptions, snapped, serverById),
|
||||
[consignments, queueProgress, assignments, exceptions, snapped, serverById]
|
||||
);
|
||||
|
||||
// operator actions — every one commits to OpsStore
|
||||
const actions = useMemo(
|
||||
|
||||
Reference in New Issue
Block a user