Skip to content
Open
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
2 changes: 2 additions & 0 deletions app/(dashboard)/fleet/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useRequireRole } from '@/hooks/useRequireRole';
import { FleetMap } from '@/components/fleet/FleetMap';
import { DriverList } from '@/components/fleet/DriverList';
import { FleetStats } from '@/components/fleet/FleetStats';
import { FleetPartnerDirectory } from '@/components/fleet/FleetPartnerDirectory';

export default function FleetDashboardPage() {
const { isAuthorized } = useRequireRole('Fleet Operator');
Expand Down Expand Up @@ -58,6 +59,7 @@ export default function FleetDashboardPage() {
<>
{summary ? <FleetStats summary={summary} /> : null}
<FleetMap drivers={drivers} />
<FleetPartnerDirectory partners={drivers} isLoading={isLoading} />
<DriverList drivers={drivers} />
</>
)}
Expand Down
142 changes: 142 additions & 0 deletions components/fleet/FleetPartnerDirectory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
'use client';

import { ShieldCheckIcon, StarIcon } from '@heroicons/react/24/solid';
import type { Driver } from '@/types/fleet';

export interface FleetPartnerDirectoryProps {
partners: Driver[];
isLoading?: boolean;
}

function TrustBadge({ score, loading }: { score: number | null; loading?: boolean }) {
if (loading) {
return <div className="h-5 w-16 animate-pulse rounded-full bg-gray-200" />;
}

if (!score || score <= 0) {
return (
<span className="text-xs text-gray-400">No on-chain score</span>
);
}

return (
<span
className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700"
title="Verified On-Chain Trust Score"
>
<ShieldCheckIcon className="h-3.5 w-3.5 text-blue-500" />
{score.toLocaleString()}
</span>
);
}

function PartnerCard({ partner }: { partner: Driver }) {
return (
<div
className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition hover:shadow-md"
data-testid="partner-card"
>
<div className="flex items-center justify-between">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-gray-900">
{partner.name}
</h3>
<p className="text-xs text-gray-500">
{partner.vehicleType} · {partner.vehiclePlate}
</p>
</div>
<TrustBadge score={partner.trustScore ?? null} />
</div>

<div className="mt-3 flex items-center justify-between text-xs text-gray-600">
<span className="flex items-center gap-1">
<StarIcon className="h-3.5 w-3.5 text-amber-500" />
{partner.rating.toFixed(1)}
</span>
<span>
{partner.activeDeliveries} active · {partner.completedDeliveries} done
</span>
</div>

<div className="mt-2">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
partner.status === 'active'
? 'bg-emerald-100 text-emerald-700'
: partner.status === 'on_delivery'
? 'bg-blue-100 text-blue-700'
: partner.status === 'idle'
? 'bg-amber-100 text-amber-700'
: 'bg-gray-200 text-gray-500'
}`}
>
{partner.status.replace('_', ' ')}
</span>
</div>
</div>
);
}

function PartnerCardSkeleton() {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="h-4 w-32 animate-pulse rounded bg-gray-200" />
<div className="mt-1 h-3 w-24 animate-pulse rounded bg-gray-100" />
</div>
<div className="h-5 w-16 animate-pulse rounded-full bg-gray-200" />
</div>
<div className="mt-3 h-3 w-full animate-pulse rounded bg-gray-100" />
</div>
);
}

export function FleetPartnerDirectory({
partners,
isLoading = false,
}: FleetPartnerDirectoryProps) {
if (isLoading) {
return (
<section aria-label="Fleet partner directory" className="space-y-4">
<div className="h-6 w-48 animate-pulse rounded bg-gray-200" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<PartnerCardSkeleton key={i} />
))}
</div>
</section>
);
}

if (partners.length === 0) {
return (
<section
aria-label="Fleet partner directory"
className="rounded-lg border border-gray-200 bg-white p-6 text-center text-sm text-gray-500"
>
No partners found in your fleet network.
</section>
);
}

return (
<section aria-label="Fleet partner directory" className="space-y-4">
<header>
<h2 className="text-lg font-semibold text-gray-900">
Fleet & Partner Network
</h2>
<p className="text-sm text-gray-500">
{partners.length} partner{partners.length !== 1 ? 's' : ''} in your
network
</p>
</header>

<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{partners.map((partner) => (
<PartnerCard key={partner.id} partner={partner} />
))}
</div>
</section>
);
}
147 changes: 147 additions & 0 deletions components/fleet/__tests__/FleetPartnerDirectory.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { FleetPartnerDirectory } from '@/components/fleet/FleetPartnerDirectory';
import type { Driver } from '@/types/fleet';

jest.mock('@/hooks/useDriverReputation');

const mockDrivers: Driver[] = [
{
id: 'driver-1',
name: 'Alice Johnson',
phone: '+1111111111',
vehicleType: 'Sedan',
vehiclePlate: 'ABC-123',
status: 'active',
rating: 4.8,
activeDeliveries: 3,
completedDeliveries: 150,
location: { lat: 9.082, lng: 7.6753, updatedAt: '2024-01-01T00:00:00Z' },
trustScore: 1250,
},
{
id: 'driver-2',
name: 'Bob Smith',
phone: '+2222222222',
vehicleType: 'Van',
vehiclePlate: 'XYZ-789',
status: 'on_delivery',
rating: 4.5,
activeDeliveries: 1,
completedDeliveries: 89,
location: { lat: 9.5, lng: 8.0, updatedAt: '2024-01-01T00:00:00Z' },
trustScore: 980,
},
{
id: 'driver-3',
name: 'Carol White',
phone: '+3333333333',
vehicleType: 'Truck',
vehiclePlate: 'TRK-456',
status: 'idle',
rating: 3.9,
activeDeliveries: 0,
completedDeliveries: 42,
location: { lat: 8.5, lng: 7.5, updatedAt: '2024-01-01T00:00:00Z' },
trustScore: 0,
},
];

describe('FleetPartnerDirectory', () => {
it('renders loading skeletons when isLoading is true', () => {
render(<FleetPartnerDirectory partners={[]} isLoading />);

const skeletons = screen.getAllByRole('generic').filter((el) =>
el.className.includes('animate-pulse'),
);
expect(skeletons.length).toBeGreaterThan(0);
});

it('renders empty state when partners array is empty', () => {
render(<FleetPartnerDirectory partners={[]} />);

expect(
screen.getByText('No partners found in your fleet network.'),
).toBeInTheDocument();
});

it('renders partner cards for each driver', () => {
render(<FleetPartnerDirectory partners={mockDrivers} />);

expect(screen.getByText('Fleet & Partner Network')).toBeInTheDocument();
expect(screen.getByText('3 partners in your network')).toBeInTheDocument();

expect(screen.getByText('Alice Johnson')).toBeInTheDocument();
expect(screen.getByText('Bob Smith')).toBeInTheDocument();
expect(screen.getByText('Carol White')).toBeInTheDocument();

const cards = screen.getAllByTestId('partner-card');
expect(cards).toHaveLength(3);
});

it('displays vehicle information for each partner', () => {
render(<FleetPartnerDirectory partners={mockDrivers} />);

expect(screen.getByText('Sedan · ABC-123')).toBeInTheDocument();
expect(screen.getByText('Van · XYZ-789')).toBeInTheDocument();
expect(screen.getByText('Truck · TRK-456')).toBeInTheDocument();
});

it('displays delivery counts for each partner', () => {
render(<FleetPartnerDirectory partners={mockDrivers} />);

expect(screen.getByText('3 active · 150 done')).toBeInTheDocument();
expect(screen.getByText('1 active · 89 done')).toBeInTheDocument();
expect(screen.getByText('0 active · 42 done')).toBeInTheDocument();
});

it('displays status badges with correct labels', () => {
render(<FleetPartnerDirectory partners={mockDrivers} />);

expect(screen.getByText('active')).toBeInTheDocument();
expect(screen.getByText('on delivery')).toBeInTheDocument();
expect(screen.getByText('idle')).toBeInTheDocument();
});

it('displays star ratings formatted to one decimal', () => {
render(<FleetPartnerDirectory partners={mockDrivers} />);

const ratingElements = screen.getAllByText('4.8');
expect(ratingElements.length).toBeGreaterThanOrEqual(1);

const ratingElements2 = screen.getAllByText('4.5');
expect(ratingElements2.length).toBeGreaterThanOrEqual(1);

const ratingElements3 = screen.getAllByText('3.9');
expect(ratingElements3.length).toBeGreaterThanOrEqual(1);
});

it('handles single partner correctly', () => {
render(<FleetPartnerDirectory partners={[mockDrivers[0]]} />);

expect(screen.getByText('1 partner in your network')).toBeInTheDocument();
expect(screen.getByText('Alice Johnson')).toBeInTheDocument();

const cards = screen.getAllByTestId('partner-card');
expect(cards).toHaveLength(1);
});

it('renders correct pluralization for multiple partners', () => {
render(<FleetPartnerDirectory partners={mockDrivers} />);

expect(screen.getByText('3 partners in your network')).toBeInTheDocument();
});

it('applies correct status badge styling for each status', () => {
render(<FleetPartnerDirectory partners={mockDrivers} />);

const activeBadge = screen.getByText('active');
expect(activeBadge).toHaveClass('bg-emerald-100', 'text-emerald-700');

const onDeliveryBadge = screen.getByText('on delivery');
expect(onDeliveryBadge).toHaveClass('bg-blue-100', 'text-blue-700');

const idleBadge = screen.getByText('idle');
expect(idleBadge).toHaveClass('bg-amber-100', 'text-amber-700');
});
});
Loading
Loading