diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 69483fa..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "next/core-web-vitals", - "rules": { - "no-console": "warn", - "no-unused-vars": "warn" - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f841fcb..a4dfd7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x] + node-version: [20.x, 22.x] steps: - uses: actions/checkout@v4 diff --git a/app/(dashboard)/fleet/page.tsx b/app/(dashboard)/fleet/page.tsx new file mode 100644 index 0000000..776337d --- /dev/null +++ b/app/(dashboard)/fleet/page.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useFleet } from '@/hooks/useFleet'; +import { useRequireRole } from '@/hooks/useRequireRole'; +import { FleetMap } from '@/components/fleet/FleetMap'; +import { DriverList } from '@/components/fleet/DriverList'; +import { FleetStats } from '@/components/fleet/FleetStats'; + +export default function FleetDashboardPage() { + const { isAuthorized } = useRequireRole('Fleet Operator'); + const { drivers, summary, isLoading, error, refetch } = useFleet(); + + if (!isAuthorized) { + return ( +
+

+ Verifying access… +

+
+ ); + } + + return ( +
+
+
+

+ Fleet Dashboard +

+

+ Live view of drivers in your fleet. +

+
+ +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {isLoading && drivers.length === 0 ? ( +
+ Loading fleet… +
+ ) : ( + <> + {summary ? : null} + + + + )} +
+ ); +} diff --git a/app/api/fleet/drivers/route.ts b/app/api/fleet/drivers/route.ts new file mode 100644 index 0000000..11d0855 --- /dev/null +++ b/app/api/fleet/drivers/route.ts @@ -0,0 +1,116 @@ +// DEV-ONLY DEMO ROUTE — delete before opening the PR. +// This stands in for the real backend so /fleet renders with data on localhost. +import { NextResponse } from 'next/server'; +import type { FleetResponse } from '@/types/fleet'; + +const fixture: FleetResponse = { + drivers: [ + { + id: 'd1', + name: 'Adaeze Okafor', + phone: '+2348011110001', + vehicleType: 'Cargo Van', + vehiclePlate: 'LAG-114-AB', + status: 'on_delivery', + rating: 4.9, + activeDeliveries: 2, + completedDeliveries: 312, + location: { lat: 6.5244, lng: 3.3792, updatedAt: new Date().toISOString() }, + }, + { + id: 'd2', + name: 'Tunde Bello', + phone: '+2348011110002', + vehicleType: 'Box Truck', + vehiclePlate: 'LAG-227-CD', + status: 'active', + rating: 4.7, + activeDeliveries: 1, + completedDeliveries: 218, + location: { lat: 6.6018, lng: 3.3515, updatedAt: new Date().toISOString() }, + }, + { + id: 'd3', + name: 'Ngozi Eze', + phone: '+2348011110003', + vehicleType: 'Bike', + vehiclePlate: 'LAG-339-EF', + status: 'idle', + rating: 4.6, + activeDeliveries: 0, + completedDeliveries: 84, + location: { lat: 6.4654, lng: 3.4064, updatedAt: new Date().toISOString() }, + }, + { + id: 'd4', + name: 'Ifeanyi Obi', + phone: '+2348011110004', + vehicleType: 'Cargo Van', + vehiclePlate: 'ABJ-441-GH', + status: 'on_delivery', + rating: 4.8, + activeDeliveries: 3, + completedDeliveries: 401, + location: { lat: 9.0579, lng: 7.4951, updatedAt: new Date().toISOString() }, + }, + { + id: 'd5', + name: 'Halima Yusuf', + phone: '+2348011110005', + vehicleType: 'Bike', + vehiclePlate: 'ABJ-553-IJ', + status: 'active', + rating: 4.5, + activeDeliveries: 1, + completedDeliveries: 152, + location: { lat: 9.0764, lng: 7.3986, updatedAt: new Date().toISOString() }, + }, + { + id: 'd6', + name: 'Chuka Mensah', + phone: '+2348011110006', + vehicleType: 'Cargo Van', + vehiclePlate: 'PHC-665-KL', + status: 'offline', + rating: 4.3, + activeDeliveries: 0, + completedDeliveries: 67, + location: { lat: 4.8156, lng: 7.0498, updatedAt: new Date().toISOString() }, + }, + { + id: 'd7', + name: 'Sade Williams', + phone: '+2348011110007', + vehicleType: 'Bike', + vehiclePlate: 'LAG-777-MN', + status: 'on_delivery', + rating: 4.9, + activeDeliveries: 1, + completedDeliveries: 290, + location: { lat: 6.5244, lng: 3.3892, updatedAt: new Date().toISOString() }, + }, + { + id: 'd8', + name: 'Olumide Akinwale', + phone: '+2348011110008', + vehicleType: 'Box Truck', + vehiclePlate: 'IBA-889-OP', + status: 'idle', + rating: 4.4, + activeDeliveries: 0, + completedDeliveries: 122, + location: { lat: 7.3775, lng: 3.947, updatedAt: new Date().toISOString() }, + }, + ], + summary: { + totalDrivers: 8, + activeDrivers: 2, + onDelivery: 3, + idle: 2, + offline: 1, + }, +}; + +export async function GET() { + return NextResponse.json(fixture); +} diff --git a/app/dev/login/page.tsx b/app/dev/login/page.tsx new file mode 100644 index 0000000..7226833 --- /dev/null +++ b/app/dev/login/page.tsx @@ -0,0 +1,28 @@ +// DEV-ONLY DEMO PAGE — delete before opening the PR. +// Primes the auth store with a Fleet Operator user so /fleet is viewable +// without a real auth backend. +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/store/authStore'; + +export default function DevLoginPage() { + const router = useRouter(); + const setUser = useAuthStore((s) => s.setUser); + + useEffect(() => { + setUser({ + id: 'demo-fleet-1', + email: 'fleet@example.com', + role: 'Fleet Operator', + }); + router.replace('/fleet'); + }, [router, setUser]); + + return ( +
+

Signing in as demo Fleet Operator…

+
+ ); +} diff --git a/components/fleet/DriverList.tsx b/components/fleet/DriverList.tsx new file mode 100644 index 0000000..6ea540d --- /dev/null +++ b/components/fleet/DriverList.tsx @@ -0,0 +1,75 @@ +'use client'; + +import type { Driver, DriverStatus } from '@/types/fleet'; + +interface DriverListProps { + drivers: Driver[]; +} + +const STATUS_LABEL: Record = { + active: 'Active', + on_delivery: 'On Delivery', + idle: 'Idle', + offline: 'Offline', +}; + +const STATUS_BADGE: Record = { + active: 'bg-emerald-100 text-emerald-700', + on_delivery: 'bg-blue-100 text-blue-700', + idle: 'bg-amber-100 text-amber-700', + offline: 'bg-gray-200 text-gray-700', +}; + +export function DriverList({ drivers }: DriverListProps) { + if (drivers.length === 0) { + return ( +
+ No drivers in your fleet yet. +
+ ); + } + + return ( +
+
+

+ Active Drivers +

+
+ +
    + {drivers.map((driver) => ( +
  • +
    + {driver.name} + + {driver.vehicleType} · {driver.vehiclePlate} + +
    + +
    + + {driver.activeDeliveries} active · {driver.completedDeliveries} done + + ★ {driver.rating.toFixed(1)} + + {STATUS_LABEL[driver.status]} + +
    +
  • + ))} +
+
+ ); +} diff --git a/components/fleet/FleetMap.tsx b/components/fleet/FleetMap.tsx new file mode 100644 index 0000000..73b49f5 --- /dev/null +++ b/components/fleet/FleetMap.tsx @@ -0,0 +1,30 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import type { Driver } from '@/types/fleet'; + +interface FleetMapProps { + drivers: Driver[]; +} + +// react-leaflet uses `window`/`document` at import time, so it must be +// loaded only on the client. This keeps the page itself SSR-friendly. +const FleetMapClient = dynamic(() => import('./FleetMapClient'), { + ssr: false, + loading: () => ( +
+ Loading map… +
+ ), +}); + +export function FleetMap({ drivers }: FleetMapProps) { + return ( +
+ +
+ ); +} diff --git a/components/fleet/FleetMapClient.tsx b/components/fleet/FleetMapClient.tsx new file mode 100644 index 0000000..1af2058 --- /dev/null +++ b/components/fleet/FleetMapClient.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { MapContainer, TileLayer, CircleMarker, Tooltip } from 'react-leaflet'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import { clusterDrivers } from './clustering'; +import type { Driver } from '@/types/fleet'; + +interface FleetMapClientProps { + drivers: Driver[]; +} + +const DEFAULT_CENTER: [number, number] = [9.082, 8.6753]; // Nigeria centroid + +function pickCenter(drivers: Driver[]): [number, number] { + if (drivers.length === 0) return DEFAULT_CENTER; + const lat = + drivers.reduce((s, d) => s + d.location.lat, 0) / drivers.length; + const lng = + drivers.reduce((s, d) => s + d.location.lng, 0) / drivers.length; + return [lat, lng]; +} + +function radiusFor(count: number): number { + if (count <= 1) return 8; + if (count < 5) return 12; + if (count < 15) return 18; + return 24; +} + +export default function FleetMapClient({ drivers }: FleetMapClientProps) { + const clusters = useMemo(() => clusterDrivers(drivers), [drivers]); + const center = useMemo(() => pickCenter(drivers), [drivers]); + + // Ensure Leaflet picks up the CSS-loaded marker icons in environments + // (Next.js bundles) where the default detection misfires. Without this + // the default markers can render broken even when CSS is imported. + useEffect(() => { + const proto = L.Icon.Default.prototype as { _getIconUrl?: unknown }; + if (proto._getIconUrl) delete proto._getIconUrl; + }, []); + + return ( + + + {clusters.map((cluster) => { + const count = cluster.drivers.length; + const isCluster = count > 1; + return ( + + + {isCluster ? ( + {count} drivers + ) : ( + {cluster.drivers[0].name} + )} + + + ); + })} + + ); +} diff --git a/components/fleet/FleetStats.tsx b/components/fleet/FleetStats.tsx new file mode 100644 index 0000000..33d607d --- /dev/null +++ b/components/fleet/FleetStats.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { FleetSummary } from '@/types/fleet'; + +interface FleetStatsProps { + summary: FleetSummary; +} + +const STATUS_COLORS: Record = { + Active: '#10b981', + 'On Delivery': '#3b82f6', + Idle: '#f59e0b', + Offline: '#6b7280', +}; + +export function FleetStats({ summary }: FleetStatsProps) { + const data = [ + { name: 'Active', value: summary.activeDrivers }, + { name: 'On Delivery', value: summary.onDelivery }, + { name: 'Idle', value: summary.idle }, + { name: 'Offline', value: summary.offline }, + ]; + + return ( +
+
+

+ Fleet Overview +

+ + {summary.totalDrivers} drivers + +
+ +
+ + + + + + + + + {data.map((entry) => ( + + ))} + + + +
+
+ ); +} diff --git a/components/fleet/__tests__/clustering.test.ts b/components/fleet/__tests__/clustering.test.ts new file mode 100644 index 0000000..3142901 --- /dev/null +++ b/components/fleet/__tests__/clustering.test.ts @@ -0,0 +1,59 @@ +import { clusterDrivers } from '@/components/fleet/clustering'; +import type { Driver } from '@/types/fleet'; + +function driver(id: string, lat: number, lng: number): Driver { + return { + id, + name: `Driver ${id}`, + phone: '+1', + vehicleType: 'Van', + vehiclePlate: id, + status: 'active', + rating: 5, + activeDeliveries: 0, + completedDeliveries: 0, + location: { lat, lng, updatedAt: '2026-04-25T00:00:00Z' }, + }; +} + +describe('clusterDrivers', () => { + it('returns an empty array for no drivers', () => { + expect(clusterDrivers([])).toEqual([]); + }); + + it('groups nearby drivers into a single cluster', () => { + // All three points sit inside the cell starting at lat=6.5 (6.5/0.05=130), + // lng=3.4 (3.4/0.05=68). Picking values from the cell interior avoids + // floor() boundary surprises. + const drivers = [ + driver('a', 6.51, 3.41), + driver('b', 6.52, 3.42), + driver('c', 6.53, 3.43), + ]; + const clusters = clusterDrivers(drivers, 0.05); + expect(clusters).toHaveLength(1); + expect(clusters[0].drivers).toHaveLength(3); + }); + + it('separates drivers in different cells', () => { + const drivers = [ + driver('a', 6.5, 3.4), + driver('b', 9.0, 7.4), + ]; + const clusters = clusterDrivers(drivers, 0.05); + expect(clusters).toHaveLength(2); + for (const c of clusters) { + expect(c.drivers).toHaveLength(1); + } + }); + + it('places cluster centroid at the mean of grouped driver positions', () => { + const drivers = [ + driver('a', 6.51, 3.41), + driver('b', 6.53, 3.43), + ]; + const [cluster] = clusterDrivers(drivers, 0.05); + expect(cluster.lat).toBeCloseTo(6.52, 3); + expect(cluster.lng).toBeCloseTo(3.42, 3); + }); +}); diff --git a/components/fleet/clustering.ts b/components/fleet/clustering.ts new file mode 100644 index 0000000..0eaaf34 --- /dev/null +++ b/components/fleet/clustering.ts @@ -0,0 +1,46 @@ +import type { Driver } from '@/types/fleet'; + +export interface MapCluster { + id: string; + lat: number; + lng: number; + drivers: Driver[]; +} + +/** + * Grid-based clustering for fleet map pins. + * + * Why: avoids adding an extra dependency (react-leaflet-cluster). For a fleet + * dashboard the driver count is bounded (~hundreds), so an O(N) bucketing + * pass at a coarse resolution is sufficient and predictable. Each cluster's + * representative position is the centroid of its drivers. + */ +export function clusterDrivers( + drivers: Driver[], + cellSize = 0.05, +): MapCluster[] { + const buckets: Record = {}; + + for (const d of drivers) { + const key = `${Math.floor(d.location.lat / cellSize)}:${Math.floor(d.location.lng / cellSize)}`; + const bucket = buckets[key]; + if (bucket) { + bucket.push(d); + } else { + buckets[key] = [d]; + } + } + + const clusters: MapCluster[] = []; + for (const key of Object.keys(buckets)) { + const group = buckets[key]; + const lat = + group.reduce((s: number, d: Driver) => s + d.location.lat, 0) / + group.length; + const lng = + group.reduce((s: number, d: Driver) => s + d.location.lng, 0) / + group.length; + clusters.push({ id: key, lat, lng, drivers: group }); + } + return clusters; +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..52b5c84 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,16 @@ +import coreWebVitals from 'eslint-config-next/core-web-vitals'; + +const config = [ + ...coreWebVitals, + { + rules: { + 'no-console': 'warn', + 'no-unused-vars': 'warn', + }, + }, + { + ignores: ['.next/**', 'node_modules/**', 'tsconfig.tsbuildinfo'], + }, +]; + +export default config; diff --git a/hooks/__tests__/useFleet.test.ts b/hooks/__tests__/useFleet.test.ts new file mode 100644 index 0000000..ad59006 --- /dev/null +++ b/hooks/__tests__/useFleet.test.ts @@ -0,0 +1,115 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import axios from 'axios'; +import { useFleet } from '@/hooks/useFleet'; +import { fleetService } from '@/services/fleetService'; +import type { FleetResponse } from '@/types/fleet'; + +jest.mock('@/services/fleetService', () => ({ + fleetService: { + getFleet: jest.fn(), + }, +})); + +const mockFleet: FleetResponse = { + drivers: [ + { + id: 'd1', + name: 'Alice', + phone: '+15555550001', + vehicleType: 'Van', + vehiclePlate: 'AB-123', + status: 'active', + rating: 4.8, + activeDeliveries: 1, + completedDeliveries: 42, + location: { lat: 6.5, lng: 3.4, updatedAt: '2026-04-25T10:00:00Z' }, + }, + { + id: 'd2', + name: 'Bob', + phone: '+15555550002', + vehicleType: 'Bike', + vehiclePlate: 'XY-987', + status: 'idle', + rating: 4.5, + activeDeliveries: 0, + completedDeliveries: 18, + location: { lat: 9.0, lng: 7.4, updatedAt: '2026-04-25T10:00:00Z' }, + }, + ], + summary: { + totalDrivers: 2, + activeDrivers: 1, + onDelivery: 0, + idle: 1, + offline: 0, + }, +}; + +describe('useFleet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('loads drivers and summary from the service', async () => { + (fleetService.getFleet as jest.Mock).mockResolvedValueOnce(mockFleet); + + const { result } = renderHook(() => useFleet()); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(fleetService.getFleet).toHaveBeenCalledTimes(1); + expect(result.current.drivers).toHaveLength(2); + expect(result.current.summary?.totalDrivers).toBe(2); + expect(result.current.error).toBeNull(); + }); + + it('exposes a friendly error message when the API fails', async () => { + (fleetService.getFleet as jest.Mock).mockRejectedValueOnce( + new Error('Network down'), + ); + + const { result } = renderHook(() => useFleet()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.error).toBe('Network down'); + expect(result.current.drivers).toEqual([]); + }); + + it('ignores cancelled requests and does not surface them as errors', async () => { + const cancelError = new axios.Cancel('aborted'); + (fleetService.getFleet as jest.Mock).mockRejectedValueOnce(cancelError); + + const { result } = renderHook(() => useFleet()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.error).toBeNull(); + }); + + it('refetch triggers a fresh service call', async () => { + (fleetService.getFleet as jest.Mock) + .mockResolvedValueOnce(mockFleet) + .mockResolvedValueOnce({ + ...mockFleet, + summary: { ...mockFleet.summary, activeDrivers: 5 }, + }); + + const { result } = renderHook(() => useFleet()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.refetch(); + }); + + await waitFor(() => + expect(fleetService.getFleet).toHaveBeenCalledTimes(2), + ); + await waitFor(() => + expect(result.current.summary?.activeDrivers).toBe(5), + ); + }); +}); diff --git a/hooks/__tests__/useRequireRole.test.ts b/hooks/__tests__/useRequireRole.test.ts new file mode 100644 index 0000000..54d5c69 --- /dev/null +++ b/hooks/__tests__/useRequireRole.test.ts @@ -0,0 +1,51 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useRequireRole } from '@/hooks/useRequireRole'; +import { useAuthStore } from '@/store/authStore'; + +const mockReplace = jest.fn(); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ replace: mockReplace }), +})); + +describe('useRequireRole', () => { + beforeEach(() => { + jest.clearAllMocks(); + act(() => { + useAuthStore.getState().clearUser(); + }); + }); + + it('redirects unauthenticated users to /login', async () => { + renderHook(() => useRequireRole('Fleet Operator')); + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/login')); + }); + + it('redirects authenticated users with a different role to /', async () => { + act(() => { + useAuthStore.getState().setUser({ + id: 'u1', + email: 'driver@example.com', + role: 'Driver', + }); + }); + + const { result } = renderHook(() => useRequireRole('Fleet Operator')); + await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/')); + expect(result.current.isAuthorized).toBe(false); + }); + + it('reports isAuthorized=true for users with the exact required role', () => { + act(() => { + useAuthStore.getState().setUser({ + id: 'u2', + email: 'fleet@example.com', + role: 'Fleet Operator', + }); + }); + + const { result } = renderHook(() => useRequireRole('Fleet Operator')); + expect(result.current.isAuthorized).toBe(true); + expect(mockReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/hooks/useFleet.ts b/hooks/useFleet.ts new file mode 100644 index 0000000..d94c390 --- /dev/null +++ b/hooks/useFleet.ts @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import axios from 'axios'; +import { fleetService } from '@/services/fleetService'; +import type { Driver, FleetSummary } from '@/types/fleet'; + +interface UseFleetResult { + drivers: Driver[]; + summary: FleetSummary | null; + isLoading: boolean; + error: string | null; + refetch: () => Promise; +} + +const EMPTY_SUMMARY: FleetSummary = { + totalDrivers: 0, + activeDrivers: 0, + onDelivery: 0, + idle: 0, + offline: 0, +}; + +/** + * useFleet — single source for fleet data (drivers + aggregate summary). + * + * Components consume this hook; they never call fleetService directly. + * Aborts in-flight requests on unmount or refetch to avoid setting state + * on an unmounted component. + */ +export function useFleet(): UseFleetResult { + const [drivers, setDrivers] = useState([]); + const [summary, setSummary] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadTick, setReloadTick] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + + fleetService + .getFleet(controller.signal) + .then((data) => { + if (cancelled) return; + setDrivers(data.drivers); + setSummary(data.summary); + setError(null); + }) + .catch((err: unknown) => { + if (cancelled) return; + if (axios.isCancel(err)) return; + const message = + err instanceof Error && err.message + ? err.message + : 'Failed to load fleet data'; + setError(message); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [reloadTick]); + + const refetch = useCallback(async (): Promise => { + setIsLoading(true); + setError(null); + setReloadTick((tick) => tick + 1); + }, []); + + const safeSummary = useMemo( + () => summary ?? (drivers.length === 0 && !isLoading ? EMPTY_SUMMARY : null), + [summary, drivers.length, isLoading], + ); + + return { + drivers, + summary: safeSummary, + isLoading, + error, + refetch, + }; +} diff --git a/hooks/useRequireRole.ts b/hooks/useRequireRole.ts new file mode 100644 index 0000000..cb17bcc --- /dev/null +++ b/hooks/useRequireRole.ts @@ -0,0 +1,44 @@ +import { useEffect, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuthStore, UserRole } from '@/store/authStore'; + +interface UseRequireRoleResult { + isAuthorized: boolean; + isAuthenticated: boolean; + role: UserRole | null; +} + +/** + * useRequireRole — gate a page strictly to a specific role. + * + * Behavior: + * - Unauthenticated visitors are redirected to /login. + * - Authenticated users with a different role are redirected to /. + * - Returns isAuthorized=true only when the role matches exactly. + */ +export function useRequireRole(required: UserRole): UseRequireRoleResult { + const router = useRouter(); + const user = useAuthStore((s) => s.user); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + const isAuthorized = useMemo( + () => isAuthenticated && user?.role === required, + [isAuthenticated, user?.role, required], + ); + + useEffect(() => { + if (!isAuthenticated) { + router.replace('/login'); + return; + } + if (user && user.role !== required) { + router.replace('/'); + } + }, [isAuthenticated, user, required, router]); + + return { + isAuthorized, + isAuthenticated, + role: user?.role ?? null, + }; +} diff --git a/package-lock.json b/package-lock.json index edd6ccc..809cae7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", + "@types/leaflet": "^1.9.8", "@types/node": "^20.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -537,7 +538,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3804,6 +3804,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3811,6 +3874,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3910,6 +3980,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", @@ -5542,6 +5622,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5641,6 +5842,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -5784,6 +5991,16 @@ "license": "MIT", "peer": true }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6537,6 +6754,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -6603,6 +6826,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -7472,6 +7704,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -9466,6 +9707,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -11812,6 +12059,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", diff --git a/package.json b/package.json index 49a8509..81cef15 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint .", "type-check": "tsc --noEmit", "test": "jest" }, @@ -39,6 +39,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", + "@types/leaflet": "^1.9.8", "@types/node": "^20.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af7d731..26dc07d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,9 @@ importers: '@types/jest': specifier: ^30.0.0 version: 30.0.0 + '@types/leaflet': + specifier: ^1.9.8 + version: 1.9.21 '@types/node': specifier: ^20.0.0 version: 20.19.39 @@ -1009,6 +1012,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -1030,6 +1036,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/leaflet@1.9.21': + resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + '@types/node@20.19.37': resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} @@ -6067,6 +6076,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -6649,6 +6660,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + damerau-levenshtein@1.0.8: {} data-urls@5.0.0: @@ -7143,6 +7192,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7409,6 +7460,8 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + internmap@2.0.3: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -8241,6 +8294,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash@4.18.1: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 diff --git a/services/__tests__/fleetService.test.ts b/services/__tests__/fleetService.test.ts new file mode 100644 index 0000000..830753a --- /dev/null +++ b/services/__tests__/fleetService.test.ts @@ -0,0 +1,85 @@ +import axios from 'axios'; +import { fleetService } from '@/services/fleetService'; +import type { FleetResponse } from '@/types/fleet'; + +jest.mock('axios'); + +const mockedAxios = axios as jest.Mocked; + +const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ''; + +describe('fleetService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('GETs /fleet/drivers and returns the response payload', async () => { + const fleet: FleetResponse = { + drivers: [], + summary: { + totalDrivers: 0, + activeDrivers: 0, + onDelivery: 0, + idle: 0, + offline: 0, + }, + }; + mockedAxios.get.mockResolvedValueOnce({ data: fleet }); + + const result = await fleetService.getFleet(); + + expect(mockedAxios.get).toHaveBeenCalledWith( + `${apiBase}/fleet/drivers`, + expect.any(Object), + ); + expect(result).toEqual(fleet); + }); + + it('GETs /fleet/drivers/:id for a single driver', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { + id: 'd1', + name: 'Alice', + phone: '+1', + vehicleType: 'Van', + vehiclePlate: 'AB-1', + status: 'active', + rating: 5, + activeDeliveries: 0, + completedDeliveries: 0, + location: { lat: 0, lng: 0, updatedAt: '2026-04-25T00:00:00Z' }, + }, + }); + + const driver = await fleetService.getDriver('d1'); + + expect(mockedAxios.get).toHaveBeenCalledWith( + `${apiBase}/fleet/drivers/d1`, + expect.any(Object), + ); + expect(driver.id).toBe('d1'); + }); + + it('forwards an AbortSignal so callers can cancel requests', async () => { + const controller = new AbortController(); + mockedAxios.get.mockResolvedValueOnce({ + data: { + drivers: [], + summary: { + totalDrivers: 0, + activeDrivers: 0, + onDelivery: 0, + idle: 0, + offline: 0, + }, + }, + }); + + await fleetService.getFleet(controller.signal); + + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ signal: controller.signal }), + ); + }); +}); diff --git a/services/fleetService.ts b/services/fleetService.ts new file mode 100644 index 0000000..7fe20fe --- /dev/null +++ b/services/fleetService.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; +import type { Driver, FleetResponse } from '@/types/fleet'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +/** + * fleetService — all fleet-related API communication. + * Hooks call this; components never call this directly. + */ +export const fleetService = { + async getFleet(signal?: AbortSignal): Promise { + const { data } = await axios.get( + `${API_BASE_URL}/fleet/drivers`, + { signal }, + ); + return data; + }, + + async getDriver(id: string, signal?: AbortSignal): Promise { + const { data } = await axios.get( + `${API_BASE_URL}/fleet/drivers/${id}`, + { signal }, + ); + return data; + }, +}; diff --git a/store/authStore.ts b/store/authStore.ts new file mode 100644 index 0000000..f60c8df --- /dev/null +++ b/store/authStore.ts @@ -0,0 +1,37 @@ +import { create } from 'zustand'; + +export type UserRole = + | 'Customer' + | 'Driver' + | 'Admin' + | 'Fleet Operator'; + +export interface AuthUser { + id: string; + email: string; + role: UserRole; +} + +interface AuthState { + user: AuthUser | null; + isAuthenticated: boolean; +} + +interface AuthActions { + setUser: (user: AuthUser) => void; + clearUser: () => void; +} + +type AuthStore = AuthState & AuthActions; + +const initialState: AuthState = { + user: null, + isAuthenticated: false, +}; + +export const useAuthStore = create((set) => ({ + ...initialState, + + setUser: (user: AuthUser) => set({ user, isAuthenticated: true }), + clearUser: () => set(initialState), +})); diff --git a/types/fleet.ts b/types/fleet.ts new file mode 100644 index 0000000..135dd4d --- /dev/null +++ b/types/fleet.ts @@ -0,0 +1,33 @@ +export type DriverStatus = 'active' | 'idle' | 'offline' | 'on_delivery'; + +export interface DriverLocation { + lat: number; + lng: number; + updatedAt: string; +} + +export interface Driver { + id: string; + name: string; + phone: string; + vehicleType: string; + vehiclePlate: string; + status: DriverStatus; + rating: number; + activeDeliveries: number; + completedDeliveries: number; + location: DriverLocation; +} + +export interface FleetSummary { + totalDrivers: number; + activeDrivers: number; + onDelivery: number; + idle: number; + offline: number; +} + +export interface FleetResponse { + drivers: Driver[]; + summary: FleetSummary; +}