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

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
# DoorMile Admin Console — environment
# Base URL for the admin backend API. All service calls are prefixed with this.
VITE_API_BASE_URL=http://localhost:8000/api/v1
# When 'true', services resolve mock adapter payloads instead of hitting the network.
# Flip to 'false' once the backend is reachable — no page code changes required.
VITE_USE_MOCK=true

View File

@@ -0,0 +1,64 @@
# Migration path: `useApi` → TanStack Query
The current data layer is intentionally dependency-free: a thin `useApi` hook over plain
service functions, with a mock-adapter flip in `http.js`. This is enough for the foundation,
but as the number of integrated screens grows we'll want request deduplication, caching,
background refetch, and mutation state. [TanStack Query](https://tanstack.com/query) is the
intended destination. This document records how to get there without a rewrite.
## What stays (the important part)
Nothing in `src/services/*` changes. The service functions (`getDashboard`, `getBookings`,
`assignMiler`, …) are already framework-agnostic `() => Promise<T>` calls that branch
mock/real inside `http.js`. TanStack Query wraps these — it does not replace them. The DTO
typedefs in `services/dto.js` remain the contract.
## What changes
`hooks/useApi.js` is the only abstraction that maps onto Query. Today:
```js
const { data, loading, error, refetch } = useApi(getDashboard, [], { refreshMs: 30000 });
```
Becomes:
```js
const { data, isLoading: loading, error, refetch } = useQuery({
queryKey: ['admin', 'dashboard'],
queryFn: getDashboard,
refetchInterval: 30000,
});
```
`AsyncBoundary` keeps working unchanged — it only consumes `{ loading, error, onRetry }`.
### Recommended sequencing
1. Add deps: `@tanstack/react-query` (+ devtools). Wrap the app in `<QueryClientProvider>` in
`main.jsx`, just inside `AuthProvider`.
2. **Shim, don't sweep.** Re-implement `useApi` internally with `useQuery`, deriving a stable
`queryKey` from the fetcher + deps. Every existing page keeps calling `useApi` and instantly
gains caching/dedup. This is the lowest-risk step and can ship on its own.
3. Migrate page-by-page from the `useApi` shim to direct `useQuery` calls with explicit
`queryKey`s (needed for cache invalidation and cross-screen sharing).
4. Convert writes (`assignMiler`, `assignVehicle`, `updateBookingStatus`) to `useMutation`
with `onSuccess: () => queryClient.invalidateQueries(...)`, replacing the current
OpsStore optimistic-overlay pattern where a real backend now persists the change.
5. Replace the `realtime.js` polling placeholder with a WebSocket/SSE source that calls
`queryClient.setQueryData(...)` on each message — Query becomes the single cache the socket
pushes into.
## Query key conventions (when we get there)
| Data | Key |
| --- | --- |
| Dashboard | `['admin','dashboard']` |
| Bookings list | `['admin','bookings', filters]` |
| Booking detail | `['admin','bookings', id]` |
| Milers | `['admin','milers']` |
| Consignments | `['admin','consignments']` |
| Health | `['health']` |
Keeping keys hierarchical lets a mutation invalidate `['admin','bookings']` and refresh both
the list and any open detail view.

View File

@@ -4,6 +4,18 @@ import { Box, CircularProgress } from '@mui/material';
import MainLayout from '@/layout/MainLayout';
import MinimalLayout from '@/layout/MinimalLayout';
import { useAuth } from '@/store/AuthStore';
// Gate the authenticated shell: bounce unauthenticated visitors to /login. Optional
// `roles`/`permission` add role-based access — an authenticated user lacking the role or
// capability is sent back to the dashboard rather than the login screen.
function ProtectedRoute({ children, roles, permission }) {
const { isAuthenticated, hasRole, can } = useAuth();
if (!isAuthenticated) return <Navigate to="/login" replace />;
if (roles && !hasRole(roles)) return <Navigate to="/dashboard" replace />;
if (permission && !can(permission)) return <Navigate to="/dashboard" replace />;
return children;
}
const load = (factory) => {
const C = lazy(factory);
@@ -23,8 +35,14 @@ const load = (factory) => {
export default function App() {
return (
<Routes>
{/* Shell pages */}
<Route element={<MainLayout />}>
{/* Shell pages — require an authenticated admin session */}
<Route
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
>
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
<Route path="/orders" element={load(() => import('@/pages/orders/OrdersList'))} />

View File

@@ -0,0 +1,38 @@
// ==============================|| AsyncBoundary ||============================== //
// Wraps a data-driven section: renders skeleton placeholders while loading and an
// error alert with Retry on failure, otherwise the children. Keeps loading/error
// handling consistent across every page that migrates off mock data.
import { Box, Skeleton, Alert, Button, Stack } from '@mui/material';
import RefreshOutlinedIcon from '@mui/icons-material/RefreshOutlined';
export default function AsyncBoundary({ loading, error, onRetry, children, skeletonHeight = 120, skeletonCount = 1 }) {
if (loading) {
return (
<Stack spacing={1.5}>
{Array.from({ length: skeletonCount }).map((_, i) => (
<Skeleton key={i} variant="rounded" height={skeletonHeight} animation="wave" />
))}
</Stack>
);
}
if (error) {
return (
<Alert
severity="error"
action={
onRetry && (
<Button color="inherit" size="small" startIcon={<RefreshOutlinedIcon fontSize="small" />} onClick={onRetry}>
Retry
</Button>
)
}
>
{error?.message || 'Something went wrong while loading this section.'}
</Alert>
);
}
return <Box>{children}</Box>;
}

View File

@@ -0,0 +1,43 @@
// ==============================|| HEALTH STATUS WIDGET ||============================== //
// Compact backend health strip — Backend / Database / Redis as coloured dots, polled from
// GET /health every 30s. 🟢 healthy · 🟡 degraded · 🔴 offline.
import { Stack, Box, Typography, Tooltip, Skeleton } from '@mui/material';
import useApi from '@/hooks/useApi';
import { getHealth } from '@/services/healthService';
const TONE = {
healthy: { color: '#00A854', label: 'Healthy' },
degraded: { color: '#FFBF00', label: 'Degraded' },
offline: { color: '#F04134', label: 'Offline' }
};
function Indicator({ label, state }) {
const tone = TONE[state] || TONE.offline;
return (
<Tooltip title={`${label}: ${tone.label}`}>
<Stack direction="row" spacing={0.6} alignItems="center">
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: tone.color, boxShadow: `0 0 0 3px ${tone.color}22` }} />
<Typography variant="caption" color="text.secondary">{label}</Typography>
</Stack>
</Tooltip>
);
}
export default function HealthStatusWidget() {
const { data, loading, error } = useApi(getHealth, [], { refreshMs: 30000 });
if (loading) return <Skeleton variant="rounded" width={260} height={24} />;
if (error || !data) {
return <Indicator label="Backend" state="offline" />;
}
const s = data.services || {};
return (
<Stack direction="row" spacing={2} alignItems="center" sx={{ flexWrap: 'wrap' }}>
<Indicator label="Backend" state={s.api || data.status} />
<Indicator label="Database" state={s.database} />
<Indicator label="Redis" state={s.redis} />
</Stack>
);
}

9
src/config/env.js Normal file
View File

@@ -0,0 +1,9 @@
// ==============================|| ENVIRONMENT CONFIG ||============================== //
// Single place that reads Vite env vars, so the rest of the app never touches
// import.meta.env directly. Defaults keep the app working if .env is missing.
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1').replace(/\/$/, '');
// Mock mode: services short-circuit to adapter payloads instead of real HTTP.
// Defaults to true so a fresh checkout (no backend) still renders.
export const USE_MOCK = String(import.meta.env.VITE_USE_MOCK ?? 'true') === 'true';

54
src/hooks/useApi.js Normal file
View File

@@ -0,0 +1,54 @@
// ==============================|| useApi — DATA FETCHING HOOK ||============================== //
// Standard { data, loading, error, refetch } contract over a service function, with
// optional polling for auto-refresh. Cancels in-flight updates on unmount/dep change so
// a slow response can't write into an unmounted component.
//
// const { data, loading, error, refetch } = useApi(getDashboard, [], { refreshMs: 30000 });
import { useState, useEffect, useRef, useCallback } from 'react';
export default function useApi(fetcher, deps = [], { refreshMs } = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Keep the latest fetcher without forcing it into the dep array.
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
const aliveRef = useRef(true);
const run = useCallback(async ({ silent = false } = {}) => {
if (!silent) setLoading(true);
setError(null);
try {
const result = await fetcherRef.current();
if (aliveRef.current) setData(result);
} catch (err) {
if (aliveRef.current) setError(err);
} finally {
if (aliveRef.current) setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
aliveRef.current = true;
run();
let timer;
if (refreshMs) {
timer = setInterval(() => run({ silent: true }), refreshMs);
}
return () => {
aliveRef.current = false;
if (timer) clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, refreshMs]);
const refetch = useCallback(() => run(), [run]);
return { data, loading, error, refetch };
}

View File

@@ -37,6 +37,7 @@ import DoneAllIcon from '@mui/icons-material/DoneAll';
import Logo from '@/components/Logo';
import { locations } from '@/data/mock';
import { useFilters } from '@/store/Filters';
import { useAuth } from '@/store/AuthStore';
const RED = '#C01227'; // brand accent (reserved for attention: avatar, unread dots)
@@ -49,6 +50,7 @@ const INITIAL_NOTIFICATIONS = [
export default function Header({ onToggle }) {
const navigate = useNavigate();
const { logout } = useAuth();
const [account, setAccount] = useState(null);
const [notifAnchor, setNotifAnchor] = useState(null);
const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS);
@@ -276,7 +278,7 @@ export default function Header({ onToggle }) {
Settings
</MenuItem>
<Divider />
<MenuItem onClick={() => { setAccount(null); navigate('/login'); }} sx={{ color: 'error.main' }}>
<MenuItem onClick={() => { setAccount(null); logout(); }} sx={{ color: 'error.main' }}>
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
Logout
</MenuItem>

View File

@@ -7,6 +7,7 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import theme from '@/theme';
import App from '@/App';
import { AuthProvider } from '@/store/AuthStore';
import { OpsProvider } from '@/store/OpsStore';
import { FilterProvider } from '@/store/Filters';
@@ -16,11 +17,13 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<CssBaseline />
<LocalizationProvider dateAdapter={AdapterDayjs}>
<BrowserRouter>
<AuthProvider>
<FilterProvider>
<OpsProvider>
<App />
</OpsProvider>
</FilterProvider>
</AuthProvider>
</BrowserRouter>
</LocalizationProvider>
</ThemeProvider>

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 count = queue.length;
await Promise.all(
queue.map((q) => {
const rider = riderByName(q.suggestedRider) || availableRiders[0];
if (rider) assignOrder(q.id, rider);
});
showToast(`${queue.length} orders auto-assigned by MileTruth AI`);
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,7 +40,8 @@ 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) => {
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;
@@ -52,10 +56,14 @@ const MOTION = activeDeliveries.reduce((acc, d) => {
};
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(

View File

@@ -0,0 +1,97 @@
// ==============================|| ADMIN SERVICE ||============================== //
// Read/write calls against the /api/v1/admin surface. Pages consume these via the useApi
// hook (loading/error/refetch for free). Composite *Board fetchers aggregate a few endpoints
// client-side so a screen can load in one useApi call; each underlying call still branches
// mock/real independently inside http.js.
import { get, post, put } from './http';
import {
mockDashboard,
mockBookings,
mockBooking,
mockMilers,
mockDispatchQueue,
mockConsignments,
mockTrackConsignment,
mockFleetPositions,
mockOk
} from './mock/admin';
// ---- Dashboard -----------------------------------------------------------
/** @returns {Promise<import('./dto').DashboardResponse>} */
export function getDashboard() {
return get('/admin/dashboard', { mock: mockDashboard });
}
// ---- Bookings / Shipments ------------------------------------------------
/** @returns {Promise<import('./dto').Booking[]>} */
export function getBookings() {
return get('/admin/bookings', { mock: mockBookings });
}
/** @returns {Promise<import('./dto').BookingDetail>} */
export function getBooking(id) {
return get(`/admin/bookings/${id}`, { mock: () => mockBooking(id) });
}
export function assignMiler(bookingId, milerId) {
return post(`/admin/bookings/${bookingId}/assign-miler`, { milerId }, { mock: () => mockOk({ bookingId, milerId }) });
}
export function assignVehicle(bookingId, vehicleId) {
return post(`/admin/bookings/${bookingId}/assign-vehicle`, { vehicleId }, { mock: () => mockOk({ bookingId, vehicleId }) });
}
export function updateBookingStatus(bookingId, status) {
return put(`/admin/bookings/${bookingId}/status`, { status }, { mock: () => mockOk({ bookingId, status }) });
}
// ---- Milers (riders) -----------------------------------------------------
/** @returns {Promise<import('./dto').Miler[]>} */
export function getMilers() {
return get('/admin/milers', { mock: mockMilers });
}
// ---- Dispatch ------------------------------------------------------------
/** @returns {Promise<import('./dto').DispatchQueueItem[]>} */
export function getDispatchQueue() {
return get('/admin/dispatch-queue', { mock: mockDispatchQueue });
}
/**
* Composite for the Dispatch Board: queue + bookings (for enrichment) + milers.
* @returns {Promise<import('./dto').DispatchBoardResponse>}
*/
export async function getDispatchBoard() {
const [queue, bookings, milers] = await Promise.all([getDispatchQueue(), getBookings(), getMilers()]);
return { queue, bookings, milers };
}
// ---- Tracking / Consignments ---------------------------------------------
/** @returns {Promise<import('./dto').Consignment[]>} */
export function getConsignments() {
return get('/admin/consignments', { mock: mockConsignments });
}
/** @returns {Promise<import('./dto').Consignment|null>} */
export function trackConsignment(trackingNo) {
return get(`/admin/consignments/track/${trackingNo}`, { mock: () => mockTrackConsignment(trackingNo) });
}
export function getFleetPositions() {
return get('/admin/fleet/positions', { mock: mockFleetPositions });
}
/**
* Composite for the Live Tracking control tower: consignments + ambient fleet + milers.
* @returns {Promise<import('./dto').TrackingBoardResponse>}
*/
export async function getTrackingBoard() {
const [consignments, fleetVehicles, milers] = await Promise.all([getConsignments(), getFleetPositions(), getMilers()]);
return { consignments, fleetVehicles, milers };
}

View File

@@ -0,0 +1,11 @@
// ==============================|| AUTH SERVICE — ADMIN ||============================== //
// Maps to the /api/v1/admin auth surface. In mock mode these resolve adapter
// payloads; flip VITE_USE_MOCK=false to hit the real endpoints unchanged.
import { post } from './http';
import { mockLogin } from './mock/admin';
// POST /admin/login -> { token, profile }
export function login(authName, password) {
return post('/admin/login', { authName, password }, { mock: () => mockLogin({ authName }) });
}

161
src/services/dto.js Normal file
View File

@@ -0,0 +1,161 @@
// ==============================|| API DTOs — response contracts ||============================== //
// This project is plain JS, so the "types" are JSDoc @typedef blocks. They document the
// exact shape each endpoint returns (and the mock adapter must mirror), and give editors
// autocompletion when a service is annotated with `@returns {Promise<import('./dto').X>}`.
// When the real backend lands, these typedefs are the contract to verify against.
// ---- Auth ----------------------------------------------------------------
/**
* @typedef {Object} AdminProfile
* @property {string} id
* @property {string} name
* @property {string} role // e.g. 'super_admin' | 'ops_admin' | 'dispatcher' | 'viewer'
* @property {string} email
* @property {string[]} [permissions] // fine-grained capability keys, e.g. ['bookings:assign']
*/
/**
* @typedef {Object} LoginResponse
* @property {string} token // JWT
* @property {AdminProfile} profile
*/
// ---- Health --------------------------------------------------------------
/** @typedef {'healthy'|'degraded'|'offline'} HealthState */
/**
* @typedef {Object} HealthResponse // GET /health
* @property {HealthState} status
* @property {{ database: HealthState, redis: HealthState, api: HealthState }} services
* @property {number} [uptimeSec]
*/
/**
* @typedef {Object} ReadyResponse // GET /ready
* @property {boolean} ready
*/
// ---- Dashboard -----------------------------------------------------------
/**
* @typedef {Object} DashboardKpis
* @property {number} totalOrders
* @property {number} activeShipments
* @property {number} ridersOnline
* @property {number} hubUtilization
* @property {number} revenueToday
* @property {number} slaPerformance
*/
/**
* @typedef {Object} DashboardResponse // GET /admin/dashboard
* @property {DashboardKpis} kpis
* @property {DispatchQueueItem[]} dispatchQueue
* @property {Consignment[]} activeDeliveries
* @property {Object[]} aiInsights
* @property {Object[]} executionFeed
* @property {Object} fleetSummary
* @property {Object[]} lanePerformance
* @property {Object[]} ordersTrend
*/
// ---- Bookings / Shipments ------------------------------------------------
/**
* @typedef {Object} Booking // GET /admin/bookings[]
* @property {string} id
* @property {string} tenant
* @property {string} location
* @property {string} pickup
* @property {string} drop
* @property {string} customer
* @property {number} qty
* @property {number} cod
* @property {number} kms
* @property {number} charges
* @property {string} status
* @property {string} date
* @property {string} payment
*/
/**
* @typedef {Object} TimelineStep
* @property {string} label
* @property {string} time
* @property {boolean} done
*/
/**
* @typedef {Object} BookingDetail // GET /admin/bookings/:id
* @property {Booking} order
* @property {TimelineStep[]} timeline
* @property {Object} delivery // rider + products line items
*/
// ---- Milers (riders) -----------------------------------------------------
/**
* @typedef {Object} Miler // GET /admin/milers[]
* @property {string} id
* @property {string} name
* @property {string} phone
* @property {string} vehicle
* @property {string} vehicleNo
* @property {string} status // 'online' | 'on-delivery' | 'offline'
* @property {number} deliveries
* @property {number} rating
*/
// ---- Dispatch ------------------------------------------------------------
/**
* @typedef {Object} DispatchQueueItem
* @property {string} id
* @property {string} pickup
* @property {string} drop
* @property {string} priority // 'high' | 'express' | 'standard'
* @property {string} sla
* @property {string} suggestedRider
* @property {number} confidence
* @property {number} etaMin
* @property {string} status
*/
/**
* @typedef {Object} DispatchBoardResponse
* @property {DispatchQueueItem[]} queue
* @property {Booking[]} bookings
* @property {Miler[]} milers
*/
// ---- Tracking / Consignments ---------------------------------------------
/**
* @typedef {Object} Consignment // GET /admin/consignments[]
* @property {string} id
* @property {string} rider
* @property {string} vehicle
* @property {string} priority
* @property {string} city
* @property {string} origin
* @property {string} destination
* @property {string} eta
* @property {string} etaStatus // 'on-time' | 'at-risk' | 'delayed'
* @property {number} delayMin
* @property {string} status
* @property {number} progress
* @property {{lat:number,lng:number}} pickup
* @property {{lat:number,lng:number}} drop
* @property {number[][]} route
*/
/**
* @typedef {Object} TrackingBoardResponse
* @property {Consignment[]} consignments
* @property {Object[]} fleetVehicles // ambient fleet scatter for the map
* @property {Miler[]} milers
*/
export {}; // module marker — this file only declares types

View File

@@ -0,0 +1,16 @@
// ==============================|| HEALTH SERVICE ||============================== //
// Backend liveness/readiness. Note: these endpoints live at the API root (/health, /ready),
// not under /admin — the path passed here is still relative to VITE_API_BASE_URL.
import { get } from './http';
import { mockHealth, mockReady } from './mock/health';
/** @returns {Promise<import('./dto').HealthResponse>} */
export function getHealth() {
return get('/health', { mock: mockHealth });
}
/** @returns {Promise<import('./dto').ReadyResponse>} */
export function getReady() {
return get('/ready', { mock: mockReady });
}

87
src/services/http.js Normal file
View File

@@ -0,0 +1,87 @@
// ==============================|| HTTP CLIENT ||============================== //
// Thin fetch wrapper shared by every service. Responsibilities:
// • prefix the configured API base URL
// • attach the JWT bearer token when present
// • parse JSON and throw a typed ApiError on non-2xx
// • on 401, invoke a registered handler (AuthStore wires this to logout+redirect)
// • in mock mode, short-circuit to an adapter resolver so the app runs with no backend
//
// Flipping VITE_USE_MOCK=false routes the same calls to the real network — callers
// (services/pages) never change.
import { API_BASE_URL, USE_MOCK } from '@/config/env';
import { getToken } from './token';
export class ApiError extends Error {
constructor(status, message, body) {
super(message || `Request failed (${status})`);
this.name = 'ApiError';
this.status = status;
this.body = body;
}
}
// AuthStore registers a handler here; called whenever the API returns 401.
let onUnauthorized = null;
export function setUnauthorizedHandler(fn) {
onUnauthorized = fn;
}
// Resolve a mock payload (value or factory) with a small delay so loading
// skeletons actually render in mock mode.
function resolveMock(mock) {
const value = typeof mock === 'function' ? mock() : mock;
return new Promise((resolve) => {
setTimeout(() => resolve(value), 300);
});
}
async function request(path, { method = 'GET', body, mock, signal } = {}) {
if (USE_MOCK && mock !== undefined) {
return resolveMock(mock);
}
const token = getToken();
const headers = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
let res;
try {
res = await fetch(`${API_BASE_URL}${path}`, {
method,
headers,
body: body != null ? JSON.stringify(body) : undefined,
signal
});
} catch (err) {
if (err?.name === 'AbortError') throw err;
throw new ApiError(0, 'Network error — could not reach the server.');
}
// Parse JSON when present; tolerate empty bodies (e.g. 204).
let payload = null;
const text = await res.text();
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = text;
}
}
if (res.status === 401 && onUnauthorized) onUnauthorized();
if (!res.ok) {
const message = (payload && (payload.message || payload.error)) || `Request failed (${res.status})`;
throw new ApiError(res.status, message, payload);
}
return payload;
}
export const get = (path, opts) => request(path, { ...opts, method: 'GET' });
export const post = (path, body, opts) => request(path, { ...opts, method: 'POST', body });
export const put = (path, body, opts) => request(path, { ...opts, method: 'PUT', body });
export const del = (path, opts) => request(path, { ...opts, method: 'DELETE' });
export default { get, post, put, del };

113
src/services/mock/admin.js Normal file
View File

@@ -0,0 +1,113 @@
// ==============================|| MOCK ADAPTER — ADMIN ||============================== //
// Assembles the documented backend response shapes from the existing demo data in
// src/data/mock.js. Defining these shapes IS the act of pinning the API contract:
// when the real backend lands it must return the same structure, and flipping
// VITE_USE_MOCK=false will send the page through to live HTTP unchanged.
import {
dispatchQueue,
activeDeliveries,
aiInsights,
executionFeed,
fleetSummary,
lanePerformance,
hubCityStats,
ordersTrend,
analyticsKpis,
orders,
orderTimeline,
deliveries,
riders,
fleetVehicles
} from '@/data/mock';
const hubUtilization = Math.round(hubCityStats.reduce((s, h) => s + h.utilization, 0) / hubCityStats.length);
// GET /admin/dashboard
export function mockDashboard() {
return {
// Headline KPI strip — previously hardcoded in the page.
kpis: {
totalOrders: 1402,
activeShipments: 96,
ridersOnline: 48,
hubUtilization,
revenueToday: 384200,
slaPerformance: analyticsKpis.slaAchievement
},
dispatchQueue,
activeDeliveries,
aiInsights,
executionFeed,
fleetSummary,
lanePerformance,
ordersTrend
};
}
// GET /admin/bookings -> Booking[]
export function mockBookings() {
return orders;
}
// GET /admin/bookings/:id -> BookingDetail
export function mockBooking(id) {
const order = orders.find((o) => o.id === id) || orders[0];
const delivery = deliveries.find((d) => d.id === order.id) || deliveries[0];
return { order, timeline: orderTimeline, delivery };
}
// GET /admin/milers -> Miler[]
export function mockMilers() {
return riders;
}
// GET /admin/dispatch-queue -> DispatchQueueItem[]
export function mockDispatchQueue() {
return dispatchQueue;
}
// GET /admin/consignments -> Consignment[]
export function mockConsignments() {
return activeDeliveries;
}
// GET /admin/consignments/track/:trackingno -> Consignment
export function mockTrackConsignment(trackingNo) {
return activeDeliveries.find((d) => d.id === trackingNo) || null;
}
// Ambient fleet scatter for the tracking map.
export function mockFleetPositions() {
return fleetVehicles;
}
// Write acknowledgements — the mock backend simply confirms the mutation.
export function mockOk(extra = {}) {
return { ok: true, ...extra };
}
// POST /admin/login — fake token + admin profile (incl. role + fine-grained permissions).
export function mockLogin({ authName } = {}) {
return {
token: 'mock.admin.jwt.token',
profile: {
id: 'ADM-001',
name: 'Aman Deshmukh',
role: 'ops_admin',
email: authName || 'admin@doormile.in',
permissions: [
'dashboard:view',
'bookings:view',
'bookings:assign',
'bookings:update',
'milers:view',
'consignments:view',
'fleet:view',
'hubs:view',
'reports:view',
'settings:view'
]
}
};
}

View File

@@ -0,0 +1,14 @@
// ==============================|| MOCK ADAPTER — HEALTH ||============================== //
// Shapes for GET /health and GET /ready (see HealthResponse / ReadyResponse in dto.js).
export function mockHealth() {
return {
status: 'healthy',
services: { api: 'healthy', database: 'healthy', redis: 'degraded' },
uptimeSec: 1843200
};
}
export function mockReady() {
return { ready: true };
}

55
src/services/realtime.js Normal file
View File

@@ -0,0 +1,55 @@
// ==============================|| REALTIME SERVICE — placeholder ||============================== //
// Future tracking updates (rider GPS, consignment status, ETA changes) should arrive over a
// push channel. Until that backend exists, this module is a *polling-backed placeholder* that
// exposes the same subscribe()/unsubscribe contract a WebSocket layer would, so consumers
// (e.g. LiveTracking) can be written against the final API today.
//
// Migration path → swap the internals of `subscribe` to open a socket and forward messages;
// callers don't change. See connectSocket() below for the intended shape.
const DEFAULT_INTERVAL_MS = 15000;
/**
* Subscribe to a stream of updates. Today this polls `fetcher` on an interval and forwards
* each result to `onMessage`. Returns an unsubscribe function.
*
* @param {() => Promise<any>} fetcher How to pull the latest snapshot (e.g. getConsignments)
* @param {(data:any) => void} onMessage Called with each snapshot
* @param {{ intervalMs?: number }} [opts]
* @returns {() => void} unsubscribe
*/
export function subscribe(fetcher, onMessage, { intervalMs = DEFAULT_INTERVAL_MS } = {}) {
let stopped = false;
let timer = null;
const tick = async () => {
if (stopped) return;
try {
const data = await fetcher();
if (!stopped) onMessage(data);
} catch {
/* transient — keep polling */
}
if (!stopped) timer = setTimeout(tick, intervalMs);
};
timer = setTimeout(tick, intervalMs);
return () => {
stopped = true;
if (timer) clearTimeout(timer);
};
}
/**
* Intended future entry point — open a WebSocket and dispatch messages to subscribers.
* Left unimplemented on purpose; wiring this up is how the placeholder becomes real.
*
* @param {string} _url e.g. `${WS_BASE_URL}/admin/consignments/stream`
*/
export function connectSocket(_url) {
// TODO: const ws = new WebSocket(_url); ws.onmessage = (e) => dispatch(JSON.parse(e.data));
throw new Error('connectSocket() not implemented — realtime is polling-backed for now.');
}
export default { subscribe, connectSocket };

51
src/services/token.js Normal file
View File

@@ -0,0 +1,51 @@
// ==============================|| AUTH TOKEN HOLDER ||============================== //
// Plain (non-React) module that owns the JWT. Kept in memory for speed and mirrored
// to localStorage so the session survives refresh. http.js reads from here without
// taking a dependency on React context, and AuthStore drives it from the UI side.
const TOKEN_KEY = 'dm.token';
const PROFILE_KEY = 'dm.profile';
let token = null;
try {
token = localStorage.getItem(TOKEN_KEY);
} catch {
/* storage unavailable */
}
export function getToken() {
return token;
}
export function setToken(next) {
token = next || null;
try {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
} catch {
/* storage unavailable */
}
}
export function getProfile() {
try {
const raw = localStorage.getItem(PROFILE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export function setProfile(profile) {
try {
if (profile) localStorage.setItem(PROFILE_KEY, JSON.stringify(profile));
else localStorage.removeItem(PROFILE_KEY);
} catch {
/* storage unavailable */
}
}
export function clearAuth() {
setToken(null);
setProfile(null);
}

84
src/store/AuthStore.jsx Normal file
View File

@@ -0,0 +1,84 @@
import { createContext, useContext, useState, useMemo, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import * as authService from '@/services/authService';
import { setUnauthorizedHandler } from '@/services/http';
import { getToken, setToken, getProfile, setProfile, clearAuth } from '@/services/token';
// ==============================|| AUTH STORE — admin session ||============================== //
// Owns the authenticated admin session. The JWT itself lives in services/token.js (a plain
// module http.js reads from); this provider mirrors it into React state, drives login/logout,
// and wires the 401 handler so an expired/invalid token bounces the user to /login.
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const navigate = useNavigate();
const [token, setTokenState] = useState(() => getToken());
const [profile, setProfileState] = useState(() => getProfile());
const logout = useCallback(() => {
clearAuth();
setTokenState(null);
setProfileState(null);
navigate('/login', { replace: true });
}, [navigate]);
// Any 401 from the API clears the session and redirects.
useEffect(() => {
setUnauthorizedHandler(() => logout());
return () => setUnauthorizedHandler(null);
}, [logout]);
const login = useCallback(async (authName, password) => {
const { token: nextToken, profile: nextProfile } = await authService.login(authName, password);
setToken(nextToken);
setProfile(nextProfile);
setTokenState(nextToken);
setProfileState(nextProfile);
return nextProfile;
}, []);
// ---- Role-based access ----
// hasRole accepts a role or list of roles; can() checks a fine-grained permission key.
const hasRole = useCallback(
(roles) => {
if (!profile?.role) return false;
const list = Array.isArray(roles) ? roles : [roles];
return list.includes(profile.role);
},
[profile]
);
const can = useCallback(
(permission) => {
const perms = profile?.permissions;
if (!perms) return false;
return perms.includes(permission);
},
[profile]
);
const value = useMemo(
() => ({
token,
profile,
role: profile?.role || null,
permissions: profile?.permissions || [],
isAuthenticated: Boolean(token),
login,
logout,
hasRole,
can
}),
[token, profile, login, logout, hasRole, can]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within an AuthProvider');
return ctx;
}