update api

This commit is contained in:
2026-06-11 21:28:04 +05:30
parent 0736712464
commit 807d68c9b4
25 changed files with 1167 additions and 84 deletions

View File

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

View File

@@ -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>

View File

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

View File

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

View File

@@ -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

View File

@@ -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(