Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .eslintrc.json

This file was deleted.

2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:

strategy:
matrix:
node-version: [18.x, 20.x]
node-version: [20.x, 22.x]

steps:
- uses: actions/checkout@v4
Expand Down
66 changes: 66 additions & 0 deletions app/(dashboard)/fleet/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="p-6">
<p className="text-sm text-gray-500" role="status">
Verifying access…
</p>
</main>
);
}

return (
<main className="mx-auto flex max-w-7xl flex-col gap-6 p-4 sm:p-6">
<header className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-gray-900">
Fleet Dashboard
</h1>
<p className="text-sm text-gray-500">
Live view of drivers in your fleet.
</p>
</div>
<button
type="button"
onClick={() => void refetch()}
disabled={isLoading}
className="self-start rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
>
{isLoading ? 'Refreshing…' : 'Refresh'}
</button>
</header>

{error ? (
<div
role="alert"
className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700"
>
{error}
</div>
) : null}

{isLoading && drivers.length === 0 ? (
<div className="rounded-md border border-gray-200 bg-white p-6 text-sm text-gray-500">
Loading fleet…
</div>
) : (
<>
{summary ? <FleetStats summary={summary} /> : null}
<FleetMap drivers={drivers} />
<DriverList drivers={drivers} />
</>
)}
</main>
);
}
116 changes: 116 additions & 0 deletions app/api/fleet/drivers/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
28 changes: 28 additions & 0 deletions app/dev/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main style={{ padding: 24, fontFamily: 'system-ui' }}>
<p>Signing in as demo Fleet Operator…</p>
</main>
);
}
75 changes: 75 additions & 0 deletions components/fleet/DriverList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

import type { Driver, DriverStatus } from '@/types/fleet';

interface DriverListProps {
drivers: Driver[];
}

const STATUS_LABEL: Record<DriverStatus, string> = {
active: 'Active',
on_delivery: 'On Delivery',
idle: 'Idle',
offline: 'Offline',
};

const STATUS_BADGE: Record<DriverStatus, string> = {
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 (
<section
aria-label="Driver list"
className="rounded-md border border-gray-200 bg-white p-6 text-center text-sm text-gray-500"
>
No drivers in your fleet yet.
</section>
);
}

return (
<section
aria-label="Driver list"
className="rounded-md border border-gray-200 bg-white"
>
<header className="border-b border-gray-200 px-4 py-3">
<h2 className="text-base font-semibold text-gray-900">
Active Drivers
</h2>
</header>

<ul role="list" className="divide-y divide-gray-100">
{drivers.map((driver) => (
<li
key={driver.id}
className="flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex flex-col">
<span className="font-medium text-gray-900">{driver.name}</span>
<span className="text-xs text-gray-500">
{driver.vehicleType} · {driver.vehiclePlate}
</span>
</div>

<div className="flex items-center gap-3 text-xs">
<span className="text-gray-500">
{driver.activeDeliveries} active · {driver.completedDeliveries} done
</span>
<span className="text-gray-500">★ {driver.rating.toFixed(1)}</span>
<span
className={`rounded-full px-2 py-0.5 font-medium ${STATUS_BADGE[driver.status]}`}
>
{STATUS_LABEL[driver.status]}
</span>
</div>
</li>
))}
</ul>
</section>
);
}
30 changes: 30 additions & 0 deletions components/fleet/FleetMap.tsx
Original file line number Diff line number Diff line change
@@ -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: () => (
<div className="flex h-full w-full items-center justify-center text-sm text-gray-500">
Loading map…
</div>
),
});

export function FleetMap({ drivers }: FleetMapProps) {
return (
<section
aria-label="Fleet map"
className="h-[28rem] overflow-hidden rounded-md border border-gray-200 bg-white"
>
<FleetMapClient drivers={drivers} />
</section>
);
}
Loading