diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0a8d3b7 --- /dev/null +++ b/.env.example @@ -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 diff --git a/docs/migration-tanstack-query.md b/docs/migration-tanstack-query.md new file mode 100644 index 0000000..f4e79bf --- /dev/null +++ b/docs/migration-tanstack-query.md @@ -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` 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 `` 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. diff --git a/src/App.jsx b/src/App.jsx index eda0fcf..377bcf8 100644 --- a/src/App.jsx +++ b/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 ; + if (roles && !hasRole(roles)) return ; + if (permission && !can(permission)) return ; + return children; +} const load = (factory) => { const C = lazy(factory); @@ -23,8 +35,14 @@ const load = (factory) => { export default function App() { return ( - {/* Shell pages */} - }> + {/* Shell pages — require an authenticated admin session */} + + + + } + > import('@/pages/Dashboard'))} /> import('@/pages/orders/OrdersList'))} /> diff --git a/src/components/AsyncBoundary.jsx b/src/components/AsyncBoundary.jsx new file mode 100644 index 0000000..d0b4e97 --- /dev/null +++ b/src/components/AsyncBoundary.jsx @@ -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 ( + + {Array.from({ length: skeletonCount }).map((_, i) => ( + + ))} + + ); + } + + if (error) { + return ( + } onClick={onRetry}> + Retry + + ) + } + > + {error?.message || 'Something went wrong while loading this section.'} + + ); + } + + return {children}; +} diff --git a/src/components/HealthStatusWidget.jsx b/src/components/HealthStatusWidget.jsx new file mode 100644 index 0000000..9c3ea75 --- /dev/null +++ b/src/components/HealthStatusWidget.jsx @@ -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 ( + + + + {label} + + + ); +} + +export default function HealthStatusWidget() { + const { data, loading, error } = useApi(getHealth, [], { refreshMs: 30000 }); + + if (loading) return ; + if (error || !data) { + return ; + } + + const s = data.services || {}; + return ( + + + + + + ); +} diff --git a/src/config/env.js b/src/config/env.js new file mode 100644 index 0000000..b9c9988 --- /dev/null +++ b/src/config/env.js @@ -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'; diff --git a/src/hooks/useApi.js b/src/hooks/useApi.js new file mode 100644 index 0000000..30ffd99 --- /dev/null +++ b/src/hooks/useApi.js @@ -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 }; +} diff --git a/src/layout/MainLayout/Header.jsx b/src/layout/MainLayout/Header.jsx index 3dd445e..88f52c5 100644 --- a/src/layout/MainLayout/Header.jsx +++ b/src/layout/MainLayout/Header.jsx @@ -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 - { setAccount(null); navigate('/login'); }} sx={{ color: 'error.main' }}> + { setAccount(null); logout(); }} sx={{ color: 'error.main' }}> Logout diff --git a/src/main.jsx b/src/main.jsx index a6f620b..99af72e 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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( - - - - - + + + + + + + diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 20a9ff3..b24eb8f 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -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 {children}; } 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 ( <> } onClick={() => showToast('Snapshot exported as CSV')}>Export} + action={ + + + + + } /> + + {data && } + + + + + ); +} + +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 */} @@ -226,8 +249,6 @@ export default function Dashboard() { - - ); } diff --git a/src/pages/auth/Login.jsx b/src/pages/auth/Login.jsx index 9080ee3..fe17010 100644 --- a/src/pages/auth/Login.jsx +++ b/src/pages/auth/Login.jsx @@ -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 ( @@ -89,6 +107,7 @@ export default function Login() { + {error && {error}} Auth Name 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: ( @@ -116,8 +136,8 @@ export default function Login() { } label={Remember me} /> Forgot password? - diff --git a/src/pages/dispatch/DispatchBoard.jsx b/src/pages/dispatch/DispatchBoard.jsx index 3c153e7..91d91c9 100644 --- a/src/pages/dispatch/DispatchBoard.jsx +++ b/src/pages/dispatch/DispatchBoard.jsx @@ -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 ( + <> + + {data && } + + + + ); +} + +function DispatchBoardContent({ board, showToast }) { + const navigate = useNavigate(); const { assignments, assignOrder, unassignOrder, riderLoad, assignedCount } = useOps(); + const { queue: dispatchQueue, bookings, milers } = board; + const orderById = (id) => bookings.find((o) => o.id === id); + const riderByName = (name) => milers.find((r) => r.name === name); + const availableRiders = useMemo(() => milers.filter((r) => r.status !== 'offline'), [milers]); + // per-card chosen rider (defaults to AI suggestion) const [choice, setChoice] = useState({}); - const queue = useMemo(() => dispatchQueue.filter((q) => !assignments[q.id]), [assignments]); + const queue = useMemo(() => dispatchQueue.filter((q) => !assignments[q.id]), [dispatchQueue, assignments]); const assignedList = useMemo(() => Object.entries(assignments).map(([id, a]) => ({ id, ...a })).sort((x, y) => y.at - x.at), [assignments]); const onlineCount = availableRiders.length; const avgConfidence = queue.length ? Math.round(queue.reduce((s, q) => s + q.confidence, 0) / queue.length) : 0; - const doAssign = (q, riderName) => { - const rider = riderByName(riderName) || availableRiders[0]; - if (!rider) return showToast('No available rider', 'warning'); - assignOrder(q.id, rider); - showToast(`${q.id} assigned to ${rider.name}`); + // Persist the assignment to the backend (POST /admin/bookings/:id/assign-miler), then + // reflect it in the session OpsStore so the board updates immediately. + const commitAssign = async (orderId, rider) => { + try { + await assignMiler(orderId, rider.id); + assignOrder(orderId, rider); + return true; + } catch (err) { + showToast(err?.message || `Could not assign ${orderId}`, 'error'); + return false; + } }; - const autoAssignAll = () => { + const doAssign = async (q, riderName) => { + const rider = riderByName(riderName) || availableRiders[0]; + if (!rider) return showToast('No available rider', 'warning'); + if (await commitAssign(q.id, rider)) showToast(`${q.id} assigned to ${rider.name}`); + }; + + const autoAssignAll = async () => { if (!queue.length) return showToast('Queue is already clear', 'info'); - queue.forEach((q) => { - const rider = riderByName(q.suggestedRider) || availableRiders[0]; - if (rider) assignOrder(q.id, rider); - }); - showToast(`${queue.length} orders auto-assigned by MileTruth AI`); + const count = queue.length; + await Promise.all( + queue.map((q) => { + const rider = riderByName(q.suggestedRider) || availableRiders[0]; + return rider ? commitAssign(q.id, rider) : Promise.resolve(); + }) + ); + showToast(`${count} orders auto-assigned by MileTruth AI`); }; return ( @@ -198,7 +231,7 @@ export default function DispatchBoard() { }> - {riders.map((r) => { + {milers.map((r) => { const load = riderLoad(r.id); return ( @@ -249,8 +282,6 @@ export default function DispatchBoard() { - - ); } diff --git a/src/pages/orders/OrderDetails.jsx b/src/pages/orders/OrderDetails.jsx index cab61d6..32c026d 100644 --- a/src/pages/orders/OrderDetails.jsx +++ b/src/pages/orders/OrderDetails.jsx @@ -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 ( + + {data && } + + ); +} + +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={ - + } @@ -97,7 +121,7 @@ export default function OrderDetails() { } sx={{ ml: 0.5 }}> - {orderTimeline.map((s) => ( + {timeline.map((s) => ( }> @@ -141,6 +165,8 @@ export default function OrderDetails() { + + ); } diff --git a/src/pages/orders/OrdersList.jsx b/src/pages/orders/OrdersList.jsx index 1911483..0a98997 100644 --- a/src/pages/orders/OrdersList.jsx +++ b/src/pages/orders/OrdersList.jsx @@ -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 ( + + {orders && } + + ); +} + +function OrdersListContent({ orders }) { const navigate = useNavigate(); const { exceptions } = useOps(); const { location } = useFilters(); // global location — single source of truth diff --git a/src/pages/tracking/LiveTracking.jsx b/src/pages/tracking/LiveTracking.jsx index 6b27609..570f1bb 100644 --- a/src/pages/tracking/LiveTracking.jsx +++ b/src/pages/tracking/LiveTracking.jsx @@ -12,7 +12,10 @@ import FleetMap from '@/components/tracking/FleetMap'; import RiderTimeline from '@/components/tracking/RiderTimeline'; import FormDialog from '@/components/FormDialog'; import Toast, { useToast } from '@/components/Toast'; -import { activeDeliveries, fleetVehicles, riders } from '@/data/mock'; +import AsyncBoundary from '@/components/AsyncBoundary'; +import useApi from '@/hooks/useApi'; +import { getTrackingBoard, getConsignments } from '@/services/adminService'; +import realtime from '@/services/realtime'; import { snapRoutes } from '@/utils/osrm'; import { useOps } from '@/store/OpsStore'; @@ -37,25 +40,30 @@ function speedEnvelope(p, stops) { // Per-vehicle motion character, derived deterministically from the shipment id so every // vehicle has its own cruising speed, traffic rhythm, stops and dwell — no two move identically. -const MOTION = activeDeliveries.reduce((acc, d) => { - let h = 0; - for (let i = 0; i < d.id.length; i += 1) h = (h * 31 + d.id.charCodeAt(i)) >>> 0; - const r = (h % 1000) / 1000; - const r2 = ((h >>> 10) % 1000) / 1000; - const r3 = ((h >>> 20) % 1000) / 1000; - acc[d.id] = { - speed: 0.5 + r * 0.65, // base cruising %/s - freq: 0.14 + r2 * 0.16, // traffic-rhythm frequency - phase: r * Math.PI * 2, - stops: [0.28 + r2 * 0.1, 0.62 + r3 * 0.12], // in-route halts (signals / drops) - dwell: [1.2 + r * 1.8, 1.0 + r3 * 1.8] // seconds paused at each halt - }; - return acc; -}, {}); +function deriveMotion(list) { + return list.reduce((acc, d) => { + let h = 0; + for (let i = 0; i < d.id.length; i += 1) h = (h * 31 + d.id.charCodeAt(i)) >>> 0; + const r = (h % 1000) / 1000; + const r2 = ((h >>> 10) % 1000) / 1000; + const r3 = ((h >>> 20) % 1000) / 1000; + acc[d.id] = { + speed: 0.5 + r * 0.65, // base cruising %/s + freq: 0.14 + r2 * 0.16, // traffic-rhythm frequency + phase: r * Math.PI * 2, + stops: [0.28 + r2 * 0.1, 0.62 + r3 * 0.12], // in-route halts (signals / drops) + dwell: [1.2 + r * 1.8, 1.0 + r3 * 1.8] // seconds paused at each halt + }; + return acc; + }, {}); +} // Build the live delivery view-model from a progress map (shared by the map & queue lanes). -function buildDeliveries(progress, assignments, exceptions, snapped) { - return activeDeliveries.map((d) => { +// `consignments` is the fetched base set; `serverById` overlays the latest status/eta/rider +// pushed by the realtime channel (polling placeholder today). +function buildDeliveries(consignments, progress, assignments, exceptions, snapped, serverById) { + return consignments.map((d) => { + const live = serverById[d.id] || d; const a = assignments[d.id]; const ex = exceptions[d.id]; const pe = progress[d.id] ?? d.progress; @@ -64,37 +72,69 @@ function buildDeliveries(progress, assignments, exceptions, snapped) { route: snapped[d.id] || d.route, progress: Math.round(pe), progressExact: pe, - rider: a ? a.riderName : d.rider, - status: ex ? 'Exception' : d.status, - etaStatus: ex ? 'delayed' : d.etaStatus, + rider: a ? a.riderName : live.rider, + status: ex ? 'Exception' : live.status, + etaStatus: ex ? 'delayed' : live.etaStatus, + eta: live.eta ?? d.eta, + delayMin: live.delayMin ?? d.delayMin, flagged: Boolean(ex) }; }); } export default function LiveTracking() { + // Tracking control-tower payload: consignments + ambient fleet + milers. + const { data, loading, error, refetch } = useApi(getTrackingBoard, []); + return ( + + {data && } + + ); +} + +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( diff --git a/src/services/adminService.js b/src/services/adminService.js new file mode 100644 index 0000000..e436682 --- /dev/null +++ b/src/services/adminService.js @@ -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} */ +export function getDashboard() { + return get('/admin/dashboard', { mock: mockDashboard }); +} + +// ---- Bookings / Shipments ------------------------------------------------ + +/** @returns {Promise} */ +export function getBookings() { + return get('/admin/bookings', { mock: mockBookings }); +} + +/** @returns {Promise} */ +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} */ +export function getMilers() { + return get('/admin/milers', { mock: mockMilers }); +} + +// ---- Dispatch ------------------------------------------------------------ + +/** @returns {Promise} */ +export function getDispatchQueue() { + return get('/admin/dispatch-queue', { mock: mockDispatchQueue }); +} + +/** + * Composite for the Dispatch Board: queue + bookings (for enrichment) + milers. + * @returns {Promise} + */ +export async function getDispatchBoard() { + const [queue, bookings, milers] = await Promise.all([getDispatchQueue(), getBookings(), getMilers()]); + return { queue, bookings, milers }; +} + +// ---- Tracking / Consignments --------------------------------------------- + +/** @returns {Promise} */ +export function getConsignments() { + return get('/admin/consignments', { mock: mockConsignments }); +} + +/** @returns {Promise} */ +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} + */ +export async function getTrackingBoard() { + const [consignments, fleetVehicles, milers] = await Promise.all([getConsignments(), getFleetPositions(), getMilers()]); + return { consignments, fleetVehicles, milers }; +} diff --git a/src/services/authService.js b/src/services/authService.js new file mode 100644 index 0000000..4e99ad7 --- /dev/null +++ b/src/services/authService.js @@ -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 }) }); +} diff --git a/src/services/dto.js b/src/services/dto.js new file mode 100644 index 0000000..df5275e --- /dev/null +++ b/src/services/dto.js @@ -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}`. +// 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 diff --git a/src/services/healthService.js b/src/services/healthService.js new file mode 100644 index 0000000..357caf6 --- /dev/null +++ b/src/services/healthService.js @@ -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} */ +export function getHealth() { + return get('/health', { mock: mockHealth }); +} + +/** @returns {Promise} */ +export function getReady() { + return get('/ready', { mock: mockReady }); +} diff --git a/src/services/http.js b/src/services/http.js new file mode 100644 index 0000000..6e85fd0 --- /dev/null +++ b/src/services/http.js @@ -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 }; diff --git a/src/services/mock/admin.js b/src/services/mock/admin.js new file mode 100644 index 0000000..f7b7383 --- /dev/null +++ b/src/services/mock/admin.js @@ -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' + ] + } + }; +} diff --git a/src/services/mock/health.js b/src/services/mock/health.js new file mode 100644 index 0000000..294871f --- /dev/null +++ b/src/services/mock/health.js @@ -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 }; +} diff --git a/src/services/realtime.js b/src/services/realtime.js new file mode 100644 index 0000000..4fc6b16 --- /dev/null +++ b/src/services/realtime.js @@ -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} 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 }; diff --git a/src/services/token.js b/src/services/token.js new file mode 100644 index 0000000..fc8d6bc --- /dev/null +++ b/src/services/token.js @@ -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); +} diff --git a/src/store/AuthStore.jsx b/src/store/AuthStore.jsx new file mode 100644 index 0000000..852b417 --- /dev/null +++ b/src/store/AuthStore.jsx @@ -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 {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within an AuthProvider'); + return ctx; +}