update api
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal 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
|
||||||
64
docs/migration-tanstack-query.md
Normal file
64
docs/migration-tanstack-query.md
Normal 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.
|
||||||
22
src/App.jsx
22
src/App.jsx
@@ -4,6 +4,18 @@ import { Box, CircularProgress } from '@mui/material';
|
|||||||
|
|
||||||
import MainLayout from '@/layout/MainLayout';
|
import MainLayout from '@/layout/MainLayout';
|
||||||
import MinimalLayout from '@/layout/MinimalLayout';
|
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 load = (factory) => {
|
||||||
const C = lazy(factory);
|
const C = lazy(factory);
|
||||||
@@ -23,8 +35,14 @@ const load = (factory) => {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Shell pages */}
|
{/* Shell pages — require an authenticated admin session */}
|
||||||
<Route element={<MainLayout />}>
|
<Route
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MainLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
|
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
|
||||||
|
|
||||||
<Route path="/orders" element={load(() => import('@/pages/orders/OrdersList'))} />
|
<Route path="/orders" element={load(() => import('@/pages/orders/OrdersList'))} />
|
||||||
|
|||||||
38
src/components/AsyncBoundary.jsx
Normal file
38
src/components/AsyncBoundary.jsx
Normal 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>;
|
||||||
|
}
|
||||||
43
src/components/HealthStatusWidget.jsx
Normal file
43
src/components/HealthStatusWidget.jsx
Normal 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
9
src/config/env.js
Normal 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
54
src/hooks/useApi.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import DoneAllIcon from '@mui/icons-material/DoneAll';
|
|||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
import { locations } from '@/data/mock';
|
import { locations } from '@/data/mock';
|
||||||
import { useFilters } from '@/store/Filters';
|
import { useFilters } from '@/store/Filters';
|
||||||
|
import { useAuth } from '@/store/AuthStore';
|
||||||
|
|
||||||
const RED = '#C01227'; // brand accent (reserved for attention: avatar, unread dots)
|
const RED = '#C01227'; // brand accent (reserved for attention: avatar, unread dots)
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ const INITIAL_NOTIFICATIONS = [
|
|||||||
|
|
||||||
export default function Header({ onToggle }) {
|
export default function Header({ onToggle }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { logout } = useAuth();
|
||||||
const [account, setAccount] = useState(null);
|
const [account, setAccount] = useState(null);
|
||||||
const [notifAnchor, setNotifAnchor] = useState(null);
|
const [notifAnchor, setNotifAnchor] = useState(null);
|
||||||
const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS);
|
const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS);
|
||||||
@@ -276,7 +278,7 @@ export default function Header({ onToggle }) {
|
|||||||
Settings
|
Settings
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Divider />
|
<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>
|
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
|
||||||
Logout
|
Logout
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
|||||||
|
|
||||||
import theme from '@/theme';
|
import theme from '@/theme';
|
||||||
import App from '@/App';
|
import App from '@/App';
|
||||||
|
import { AuthProvider } from '@/store/AuthStore';
|
||||||
import { OpsProvider } from '@/store/OpsStore';
|
import { OpsProvider } from '@/store/OpsStore';
|
||||||
import { FilterProvider } from '@/store/Filters';
|
import { FilterProvider } from '@/store/Filters';
|
||||||
|
|
||||||
@@ -16,11 +17,13 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
|||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
<FilterProvider>
|
<FilterProvider>
|
||||||
<OpsProvider>
|
<OpsProvider>
|
||||||
<App />
|
<App />
|
||||||
</OpsProvider>
|
</OpsProvider>
|
||||||
</FilterProvider>
|
</FilterProvider>
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -20,41 +20,64 @@ import AreaChart from '@/components/charts/AreaChart';
|
|||||||
import ProcessTracker from '@/components/ProcessTracker';
|
import ProcessTracker from '@/components/ProcessTracker';
|
||||||
import AiImpactSummary from '@/components/AiImpactSummary';
|
import AiImpactSummary from '@/components/AiImpactSummary';
|
||||||
import Toast, { useToast } from '@/components/Toast';
|
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';
|
import { inr } from '@/utils/format';
|
||||||
|
|
||||||
const SEV_DOT = { high: '#F04134', medium: '#FFBF00', low: '#00A2AE', info: '#8C8C8C' };
|
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 }) {
|
function SectionLabel({ children }) {
|
||||||
return <Typography variant="overline" color="text.secondary" sx={{ letterSpacing: '0.08em', display: 'block', mb: 1.25 }}>{children}</Typography>;
|
return <Typography variant="overline" color="text.secondary" sx={{ letterSpacing: '0.08em', display: 'block', mb: 1.25 }}>{children}</Typography>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [toast, showToast] = useToast();
|
const [toast, showToast] = useToast();
|
||||||
|
// Live dashboard payload (GET /admin/dashboard), auto-refreshed every 30s.
|
||||||
const priority = activeDeliveries.filter((d) => (d.priority === 'high' || d.priority === 'express') && d.status !== 'Delivered').slice(0, 4);
|
const { data, loading, error, refetch } = useApi(getDashboard, [], { refreshMs: 30000 });
|
||||||
const delayed = activeDeliveries.filter((d) => d.etaStatus !== 'on-time' && d.status !== 'Delivered').slice(0, 4);
|
|
||||||
const recs = aiInsights.slice(0, 4);
|
|
||||||
|
|
||||||
const kpis = [
|
|
||||||
{ label: 'Total Orders', value: '1,402', icon: Inventory2OutlinedIcon },
|
|
||||||
{ label: 'Active Shipments', value: '96', color: '#1D4ED8', icon: LocalShippingOutlinedIcon },
|
|
||||||
{ label: 'Riders Online', value: '48', color: '#00773B', icon: TwoWheelerOutlinedIcon },
|
|
||||||
{ label: 'Hub Utilization', value: `${hubUtil}%`, color: hubUtil > 80 ? '#A82216' : '#8A6500', icon: HubOutlinedIcon },
|
|
||||||
{ label: 'Revenue Today', value: inr(384200), color: '#00727B', icon: CurrencyRupeeIcon },
|
|
||||||
{ label: 'SLA Performance', value: `${analyticsKpis.slaAchievement}%`, color: '#00773B', icon: TaskAltOutlinedIcon }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Operations Control Center"
|
title="Operations Control Center"
|
||||||
breadcrumbs={[{ label: '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 */}
|
{/* Top row — 6 live KPIs */}
|
||||||
<KpiStrip items={kpis} />
|
<KpiStrip items={kpis} />
|
||||||
|
|
||||||
@@ -226,8 +249,6 @@ export default function Dashboard() {
|
|||||||
<Box sx={{ mt: 3.5 }}>
|
<Box sx={{ mt: 3.5 }}>
|
||||||
<AiImpactSummary />
|
<AiImpactSummary />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Toast {...toast} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Link
|
Link,
|
||||||
|
Alert
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import Visibility from '@mui/icons-material/Visibility';
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
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 VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||||
|
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
|
import { useAuth } from '@/store/AuthStore';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { login } = useAuth();
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const [auth, setAuth] = useState('admin@doormile.in');
|
const [auth, setAuth] = useState('admin@doormile.in');
|
||||||
const [pwd, setPwd] = useState('');
|
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 (
|
return (
|
||||||
<Grid container sx={{ minHeight: '100vh' }}>
|
<Grid container sx={{ minHeight: '100vh' }}>
|
||||||
@@ -89,6 +107,7 @@ export default function Login() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Stack spacing={2.5}>
|
<Stack spacing={2.5}>
|
||||||
|
{error && <Alert severity="error">{error}</Alert>}
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 0.75 }}>Auth Name</Typography>
|
<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)} />
|
<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"
|
placeholder="Enter your password"
|
||||||
value={pwd}
|
value={pwd}
|
||||||
onChange={(e) => setPwd(e.target.value)}
|
onChange={(e) => setPwd(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && !submitting && handleSignIn()}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
@@ -116,8 +136,8 @@ export default function Login() {
|
|||||||
<FormControlLabel control={<Checkbox defaultChecked size="small" />} label={<Typography variant="body2">Remember me</Typography>} />
|
<FormControlLabel control={<Checkbox defaultChecked size="small" />} label={<Typography variant="body2">Remember me</Typography>} />
|
||||||
<Link href="#" underline="hover" variant="body2" color="primary">Forgot password?</Link>
|
<Link href="#" underline="hover" variant="body2" color="primary">Forgot password?</Link>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button fullWidth size="large" variant="contained" onClick={() => navigate('/dashboard')}>
|
<Button fullWidth size="large" variant="contained" onClick={handleSignIn} disabled={submitting}>
|
||||||
Sign In
|
{submitting ? 'Signing in…' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -15,15 +15,14 @@ import PageHeader from '@/components/PageHeader';
|
|||||||
import StatusChip from '@/components/StatusChip';
|
import StatusChip from '@/components/StatusChip';
|
||||||
import UserAvatar from '@/components/UserAvatar';
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
import Toast, { useToast } from '@/components/Toast';
|
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 { inr } from '@/utils/format';
|
||||||
import { useOps } from '@/store/OpsStore';
|
import { useOps } from '@/store/OpsStore';
|
||||||
|
|
||||||
const PRIORITY = { high: { fg: '#A82216', bg: '#FEEAE9' }, express: { fg: '#8A6500', bg: '#FFF7E0' }, standard: { fg: '#595959', bg: '#F0F0F0' } };
|
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 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 }) {
|
function KpiCard({ label, value, subtitle, color }) {
|
||||||
return (
|
return (
|
||||||
@@ -53,33 +52,67 @@ function KpiCard({ label, value, subtitle, color }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DispatchBoard() {
|
export default function DispatchBoard() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [toast, showToast] = useToast();
|
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 { 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)
|
// per-card chosen rider (defaults to AI suggestion)
|
||||||
const [choice, setChoice] = useState({});
|
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 assignedList = useMemo(() => Object.entries(assignments).map(([id, a]) => ({ id, ...a })).sort((x, y) => y.at - x.at), [assignments]);
|
||||||
|
|
||||||
const onlineCount = availableRiders.length;
|
const onlineCount = availableRiders.length;
|
||||||
const avgConfidence = queue.length ? Math.round(queue.reduce((s, q) => s + q.confidence, 0) / queue.length) : 0;
|
const avgConfidence = queue.length ? Math.round(queue.reduce((s, q) => s + q.confidence, 0) / queue.length) : 0;
|
||||||
|
|
||||||
const doAssign = (q, riderName) => {
|
// Persist the assignment to the backend (POST /admin/bookings/:id/assign-miler), then
|
||||||
const rider = riderByName(riderName) || availableRiders[0];
|
// reflect it in the session OpsStore so the board updates immediately.
|
||||||
if (!rider) return showToast('No available rider', 'warning');
|
const commitAssign = async (orderId, rider) => {
|
||||||
assignOrder(q.id, rider);
|
try {
|
||||||
showToast(`${q.id} assigned to ${rider.name}`);
|
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');
|
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];
|
const rider = riderByName(q.suggestedRider) || availableRiders[0];
|
||||||
if (rider) assignOrder(q.id, rider);
|
return rider ? commitAssign(q.id, rider) : Promise.resolve();
|
||||||
});
|
})
|
||||||
showToast(`${queue.length} orders auto-assigned by MileTruth AI`);
|
);
|
||||||
|
showToast(`${count} orders auto-assigned by MileTruth AI`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -198,7 +231,7 @@ export default function DispatchBoard() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Stack divider={<Divider />}>
|
<Stack divider={<Divider />}>
|
||||||
{riders.map((r) => {
|
{milers.map((r) => {
|
||||||
const load = riderLoad(r.id);
|
const load = riderLoad(r.id);
|
||||||
return (
|
return (
|
||||||
<Stack key={r.id} direction="row" spacing={1.25} alignItems="center" sx={{ px: 2, py: 1.25 }}>
|
<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>
|
</Stack>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Toast {...toast} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import MainCard from '@/components/MainCard';
|
|||||||
import StatusChip from '@/components/StatusChip';
|
import StatusChip from '@/components/StatusChip';
|
||||||
import MapPlaceholder from '@/components/MapPlaceholder';
|
import MapPlaceholder from '@/components/MapPlaceholder';
|
||||||
import UserAvatar from '@/components/UserAvatar';
|
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';
|
import { inr } from '@/utils/format';
|
||||||
|
|
||||||
const RedConnector = styled(StepConnector)(({ theme }) => ({
|
const RedConnector = styled(StepConnector)(({ theme }) => ({
|
||||||
@@ -35,9 +38,30 @@ function Dot({ active }) {
|
|||||||
|
|
||||||
export default function OrderDetails() {
|
export default function OrderDetails() {
|
||||||
const { id } = useParams();
|
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 navigate = useNavigate();
|
||||||
const order = orders.find((o) => o.id === id) || orders[1];
|
const [toast, showToast] = useToast();
|
||||||
const delivery = deliveries.find((d) => d.id === order.id) || deliveries[1];
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -51,7 +75,7 @@ export default function OrderDetails() {
|
|||||||
breadcrumbs={[{ label: 'Orders', to: '/orders' }, { label: order.id }]}
|
breadcrumbs={[{ label: 'Orders', to: '/orders' }, { label: order.id }]}
|
||||||
action={
|
action={
|
||||||
<Stack direction="row" spacing={1.5}>
|
<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>
|
<Button variant="contained" startIcon={<EditOutlinedIcon />}>Edit Order</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
}
|
}
|
||||||
@@ -97,7 +121,7 @@ export default function OrderDetails() {
|
|||||||
|
|
||||||
<MainCard title="Delivery Timeline">
|
<MainCard title="Delivery Timeline">
|
||||||
<Stepper orientation="vertical" connector={<RedConnector />} sx={{ ml: 0.5 }}>
|
<Stepper orientation="vertical" connector={<RedConnector />} sx={{ ml: 0.5 }}>
|
||||||
{orderTimeline.map((s) => (
|
{timeline.map((s) => (
|
||||||
<Step key={s.label} active completed={s.done}>
|
<Step key={s.label} active completed={s.done}>
|
||||||
<StepLabel StepIconComponent={() => <Dot active={s.done} />}>
|
<StepLabel StepIconComponent={() => <Dot active={s.done} />}>
|
||||||
<Stack direction="row" justifyContent="space-between">
|
<Stack direction="row" justifyContent="space-between">
|
||||||
@@ -141,6 +165,8 @@ export default function OrderDetails() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<Toast {...toast} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import KpiStrip from '@/components/KpiStrip';
|
|||||||
import PageToolbar from '@/components/PageToolbar';
|
import PageToolbar from '@/components/PageToolbar';
|
||||||
import StatusChip from '@/components/StatusChip';
|
import StatusChip from '@/components/StatusChip';
|
||||||
import TabLabelCount from '@/components/TabLabelCount';
|
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 { inr } from '@/utils/format';
|
||||||
import { useOps } from '@/store/OpsStore';
|
import { useOps } from '@/store/OpsStore';
|
||||||
import { useFilters } from '@/store/Filters';
|
import { useFilters } from '@/store/Filters';
|
||||||
@@ -41,6 +43,16 @@ const STICKY_HEAD = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function OrdersList() {
|
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 navigate = useNavigate();
|
||||||
const { exceptions } = useOps();
|
const { exceptions } = useOps();
|
||||||
const { location } = useFilters(); // global location — single source of truth
|
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 RiderTimeline from '@/components/tracking/RiderTimeline';
|
||||||
import FormDialog from '@/components/FormDialog';
|
import FormDialog from '@/components/FormDialog';
|
||||||
import Toast, { useToast } from '@/components/Toast';
|
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 { snapRoutes } from '@/utils/osrm';
|
||||||
import { useOps } from '@/store/OpsStore';
|
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
|
// 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.
|
// 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;
|
let h = 0;
|
||||||
for (let i = 0; i < d.id.length; i += 1) h = (h * 31 + d.id.charCodeAt(i)) >>> 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 r = (h % 1000) / 1000;
|
||||||
@@ -52,10 +56,14 @@ const MOTION = activeDeliveries.reduce((acc, d) => {
|
|||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
// Build the live delivery view-model from a progress map (shared by the map & queue lanes).
|
// Build the live delivery view-model from a progress map (shared by the map & queue lanes).
|
||||||
function buildDeliveries(progress, assignments, exceptions, snapped) {
|
// `consignments` is the fetched base set; `serverById` overlays the latest status/eta/rider
|
||||||
return activeDeliveries.map((d) => {
|
// 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 a = assignments[d.id];
|
||||||
const ex = exceptions[d.id];
|
const ex = exceptions[d.id];
|
||||||
const pe = progress[d.id] ?? d.progress;
|
const pe = progress[d.id] ?? d.progress;
|
||||||
@@ -64,37 +72,69 @@ function buildDeliveries(progress, assignments, exceptions, snapped) {
|
|||||||
route: snapped[d.id] || d.route,
|
route: snapped[d.id] || d.route,
|
||||||
progress: Math.round(pe),
|
progress: Math.round(pe),
|
||||||
progressExact: pe,
|
progressExact: pe,
|
||||||
rider: a ? a.riderName : d.rider,
|
rider: a ? a.riderName : live.rider,
|
||||||
status: ex ? 'Exception' : d.status,
|
status: ex ? 'Exception' : live.status,
|
||||||
etaStatus: ex ? 'delayed' : d.etaStatus,
|
etaStatus: ex ? 'delayed' : live.etaStatus,
|
||||||
|
eta: live.eta ?? d.eta,
|
||||||
|
delayMin: live.delayMin ?? d.delayMin,
|
||||||
flagged: Boolean(ex)
|
flagged: Boolean(ex)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LiveTracking() {
|
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 navigate = useNavigate();
|
||||||
const [share, setShare] = useState(false);
|
const [share, setShare] = useState(false);
|
||||||
const [toast, showToast] = useToast();
|
const [toast, showToast] = useToast();
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
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 { 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)
|
// snap every shipment's route to real streets via OSRM (falls back to the drawn path on failure)
|
||||||
const [snapped, setSnapped] = useState({});
|
const [snapped, setSnapped] = useState({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
snapRoutes(activeDeliveries, { signal: ctrl.signal }).then(setSnapped).catch(() => {});
|
snapRoutes(consignments, { signal: ctrl.signal }).then(setSnapped).catch(() => {});
|
||||||
return () => ctrl.abort();
|
return () => ctrl.abort();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// live simulation — advance in-flight shipments along their routes with natural, eased motion.
|
// 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.
|
// 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 [queueProgress, setQueueProgress] = useState(progress);
|
||||||
const [updated, setUpdated] = useState(now);
|
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(() => {
|
useEffect(() => {
|
||||||
const work = { ...baseProgress.current }; // local accumulator advanced every frame
|
const work = { ...baseProgress.current }; // local accumulator advanced every frame
|
||||||
@@ -114,7 +154,7 @@ export default function LiveTracking() {
|
|||||||
sinceQueue += dt;
|
sinceQueue += dt;
|
||||||
sinceClock += dt;
|
sinceClock += dt;
|
||||||
|
|
||||||
activeDeliveries.forEach((d) => {
|
consignments.forEach((d) => {
|
||||||
if (d.status === 'Delivered') return;
|
if (d.status === 'Delivered') return;
|
||||||
const m = MOTION[d.id];
|
const m = MOTION[d.id];
|
||||||
const rt = runtime[d.id] || (runtime[d.id] = { dwell: 0, stopIdx: 0 });
|
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
|
// 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 mapDeliveries = useMemo(
|
||||||
const queueDeliveries = useMemo(() => buildDeliveries(queueProgress, assignments, exceptions, snapped), [queueProgress, assignments, exceptions, snapped]);
|
() => 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
|
// operator actions — every one commits to OpsStore
|
||||||
const actions = useMemo(
|
const actions = useMemo(
|
||||||
|
|||||||
97
src/services/adminService.js
Normal file
97
src/services/adminService.js
Normal 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 };
|
||||||
|
}
|
||||||
11
src/services/authService.js
Normal file
11
src/services/authService.js
Normal 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
161
src/services/dto.js
Normal 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
|
||||||
16
src/services/healthService.js
Normal file
16
src/services/healthService.js
Normal 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
87
src/services/http.js
Normal 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
113
src/services/mock/admin.js
Normal 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'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
14
src/services/mock/health.js
Normal file
14
src/services/mock/health.js
Normal 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
55
src/services/realtime.js
Normal 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
51
src/services/token.js
Normal 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
84
src/store/AuthStore.jsx
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user