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