From 80a652e1772d513f07481440ab4fb124e5467573 Mon Sep 17 00:00:00 2001 From: nelly439 Date: Sat, 29 Aug 2026 21:57:29 +0100 Subject: [PATCH 1/2] Test: Implement E2E: Bulk Shipment Import to Active Delivery List --- .../components/BulkShipmentImport.tsx | 410 +++++++++++++++++ .../__tests__/BulkShipmentImport.test.tsx | 414 ++++++++++++++++++ services/deliveries.service.ts | 5 + 3 files changed, 829 insertions(+) create mode 100644 features/deliveries/components/BulkShipmentImport.tsx create mode 100644 features/deliveries/components/__tests__/BulkShipmentImport.test.tsx diff --git a/features/deliveries/components/BulkShipmentImport.tsx b/features/deliveries/components/BulkShipmentImport.tsx new file mode 100644 index 0000000..62ef334 --- /dev/null +++ b/features/deliveries/components/BulkShipmentImport.tsx @@ -0,0 +1,410 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { Upload, X, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react'; +import { deliveriesService } from '@/services/deliveries.service'; +import { useQueryClient } from '@tanstack/react-query'; +import { useToast } from '@/hooks/useToast'; + +export interface CsvDeliveryRow { + pickupAddress: string; + destination: string; + packageSize: 'small' | 'medium' | 'large'; + description: string; + recipientName: string; + recipientPhone: string; + recipientEmail: string; +} + +export interface BulkImportResult { + row: number; + success: boolean; + trackingNumber?: string; + error?: string; +} + +export interface BulkShipmentImportProps { + onComplete?: (_results: BulkImportResult[]) => void; +} + +function parseCsvContent(content: string): CsvDeliveryRow[] { + const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0); + if (lines.length === 0) return []; + + const headers = lines[0].split(',').map((h) => h.trim().toLowerCase()); + const requiredHeaders = [ + 'pickupaddress', + 'destination', + 'packagesize', + 'description', + 'recipientname', + 'recipientphone', + 'recipientemail', + ]; + + const missingHeaders = requiredHeaders.filter((h) => !headers.includes(h)); + if (missingHeaders.length > 0) { + throw new Error(`Missing required CSV columns: ${missingHeaders.join(', ')}`); + } + + const rows: CsvDeliveryRow[] = []; + for (let i = 1; i < lines.length; i++) { + const values = lines[i].split(',').map((v) => v.trim()); + if (values.length !== headers.length) continue; + + const row: Record = {}; + headers.forEach((header, idx) => { + row[header] = values[idx]; + }); + + rows.push({ + pickupAddress: row['pickupaddress'], + destination: row['destination'], + packageSize: ['small', 'medium', 'large'].includes(row['packagesize'].toLowerCase()) + ? (row['packagesize'].toLowerCase() as 'small' | 'medium' | 'large') + : 'small', + description: row['description'], + recipientName: row['recipientname'], + recipientPhone: row['recipientphone'], + recipientEmail: row['recipientemail'], + }); + } + + return rows; +} + +export function BulkShipmentImport({ onComplete }: BulkShipmentImportProps) { + const [isDragging, setIsDragging] = useState(false); + const [fileName, setFileName] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState({ current: 0, total: 0 }); + const [results, setResults] = useState([]); + const [error, setError] = useState(null); + + const queryClient = useQueryClient(); + const { success: toastSuccess, error: toastError } = useToast(); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const processFile = useCallback( + async (file: File) => { + setError(null); + setResults([]); + setFileName(file.name); + setIsProcessing(true); + setProgress({ current: 0, total: 0 }); + + try { + const content = await file.text(); + const rows = parseCsvContent(content); + + if (rows.length === 0) { + throw new Error('CSV file is empty or has no valid data rows'); + } + + const importResults: BulkImportResult[] = []; + setProgress({ current: 0, total: rows.length }); + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + try { + const delivery = await deliveriesService.createDelivery(row); + importResults.push({ + row: i + 1, + success: true, + trackingNumber: delivery.trackingNumber, + }); + } catch (err) { + importResults.push({ + row: i + 1, + success: false, + error: err instanceof Error ? err.message : 'Unknown error', + }); + } + + setProgress({ current: i + 1, total: rows.length }); + } + + setResults(importResults); + + const successCount = importResults.filter((r) => r.success).length; + const failCount = importResults.filter((r) => !r.success).length; + + if (successCount > 0) { + queryClient.invalidateQueries({ queryKey: ['deliveries'] }); + toastSuccess( + `Bulk import complete`, + `${successCount} shipment${successCount !== 1 ? 's' : ''} created${failCount > 0 ? `, ${failCount} failed` : ''}` + ); + } + + if (failCount > 0 && successCount === 0) { + toastError('Bulk import failed', 'All shipments failed to create'); + } + + onComplete?.(importResults); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to process CSV file'; + setError(message); + toastError('Upload failed', message); + } finally { + setIsProcessing(false); + } + }, + [queryClient, toastSuccess, toastError, onComplete] + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const droppedFiles = Array.from(e.dataTransfer.files); + const csvFile = droppedFiles.find((f) => f.type === 'text/csv' || f.name.endsWith('.csv')); + + if (csvFile) { + void processFile(csvFile); + } else { + setError('Please upload a valid CSV file'); + } + }, + [processFile] + ); + + const handleFileInput = useCallback( + (e: React.ChangeEvent) => { + const selectedFiles = Array.from(e.target.files ?? []); + const csvFile = selectedFiles.find((f) => f.type === 'text/csv' || f.name.endsWith('.csv')); + + if (csvFile) { + void processFile(csvFile); + } else { + setError('Please upload a valid CSV file'); + } + + e.target.value = ''; + }, + [processFile] + ); + + const handleReset = useCallback(() => { + setFileName(null); + setResults([]); + setError(null); + setProgress({ current: 0, total: 0 }); + }, []); + + const successCount = results.filter((r) => r.success).length; + const failCount = results.filter((r) => !r.success).length; + + return ( +
+
+

+ Bulk Shipment Import +

+

+ Upload a CSV file to create multiple shipments at once. New shipments will appear on the Active Deliveries page. +

+ + {!fileName ? ( +
+ + + +
+ ) : ( +
+
+
+
+ +
+
+

+ {fileName} +

+

+ {isProcessing + ? `Processing ${progress.current} of ${progress.total}...` + : 'Processing complete'} +

+
+
+ + {!isProcessing && ( + + )} +
+ + {isProcessing && ( +
+
+
0 ? (progress.current / progress.total) * 100 : 0}%` }} + /> +
+

+ {progress.current} / {progress.total} shipments processed +

+
+ )} + + {error && ( +
+
+ +

{error}

+
+
+ )} + + {results.length > 0 && ( +
+
+ {successCount > 0 && ( + + + {successCount} succeeded + + )} + {failCount > 0 && ( + + + {failCount} failed + + )} +
+ +
+ {results.map((result, idx) => ( +
+
+ + Row {result.row}: + + {result.success ? ( + + Created {result.trackingNumber} + + ) : ( + + {result.error} + + )} +
+ {result.success ? ( + + ) : ( + + )} +
+ ))} +
+
+ )} + + {isProcessing && ( +
+ + Processing shipments... +
+ )} +
+ )} + + {results.length > 0 && !isProcessing && ( +
+
+ +
+

+ {successCount > 0 + ? `${successCount} shipment${successCount !== 1 ? 's' : ''} imported successfully` + : 'No shipments were imported'} +

+ {successCount > 0 && ( +

+ New shipments are now visible on the Active Deliveries page. +

+ )} +
+
+
+ )} +
+
+ ); +} diff --git a/features/deliveries/components/__tests__/BulkShipmentImport.test.tsx b/features/deliveries/components/__tests__/BulkShipmentImport.test.tsx new file mode 100644 index 0000000..6d8a76c --- /dev/null +++ b/features/deliveries/components/__tests__/BulkShipmentImport.test.tsx @@ -0,0 +1,414 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { BulkShipmentImport } from '@/features/deliveries/components/BulkShipmentImport'; +import { useToast } from '@/hooks/useToast'; +import { deliveriesService } from '@/services/deliveries.service'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +jest.mock('@/services/deliveries.service'); +jest.mock('@/hooks/useToast'); + +const mockDeliveriesService = deliveriesService as jest.Mocked; + +const mockUseToast = useToast as jest.MockedFunction; + +const mockToastSuccess = jest.fn(); +const mockToastError = jest.fn(); + +mockUseToast.mockReturnValue({ + success: mockToastSuccess, + error: mockToastError, + info: jest.fn(), + loading: jest.fn(), + toast: jest.fn(), + isLoading: false, + notifications: [], + fetchNotifications: jest.fn(), +} as any); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); + }; +}; + +const validCsvContent = `pickupAddress,destination,packageSize,description,recipientName,recipientPhone,recipientEmail +123 Main St,Lagos,small,Electronics package,John Doe,+2348012345678,john@example.com +456 Oak Ave,Abuja,medium,Documents shipment,Jane Smith,+2348098765432,jane@example.com`; + +const singleCsvContent = `pickupAddress,destination,packageSize,description,recipientName,recipientPhone,recipientEmail +123 Main St,Lagos,small,Electronics package,John Doe,+2348012345678,john@example.com`; + +const createMockDelivery = (trackingNumber: string) => ({ + id: `delivery-${trackingNumber}`, + trackingNumber, + senderId: 'sender-1', + status: 'PENDING' as const, + origin: 'Origin', + destination: 'Destination', + escrowStatus: 'NOT_LOCKED' as const, + amount: 100, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}); + +Object.defineProperty(window, 'File', { + writable: true, + value: class MockFile extends File { + constructor(bits: any[], name: string, options?: FilePropertyBag) { + super(bits, name, options); + (this as any).text = async () => { + if (typeof bits[0] === 'string') return bits[0]; + return bits[0].text ? await bits[0].text() : ''; + }; + } + }, +}); + +describe('BulkShipmentImport', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockToastSuccess.mockClear(); + mockToastError.mockClear(); + }); + + it('renders upload drop zone initially', () => { + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('Bulk Shipment Import')).toBeInTheDocument(); + expect(screen.getByText(/Click to upload or drag and drop/)).toBeInTheDocument(); + expect(screen.getByText(/CSV files only/)).toBeInTheDocument(); + }); + + it('processes valid CSV and creates deliveries', async () => { + const mockDelivery1 = createMockDelivery('TRACK-001'); + const mockDelivery2 = createMockDelivery('TRACK-002'); + + mockDeliveriesService.createDelivery + .mockResolvedValueOnce(mockDelivery1) + .mockResolvedValueOnce(mockDelivery2); + + render(, { wrapper: createWrapper() }); + + const input = document.getElementById('csv-upload-input') as HTMLInputElement; + const file = new File([validCsvContent], 'shipments.csv', { type: 'text/csv' }); + + Object.defineProperty(input, 'files', { + value: [file], + }); + + fireEvent.change(input); + + await waitFor(() => { + expect(screen.getByText(/shipments\.csv/)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(mockDeliveriesService.createDelivery).toHaveBeenCalledTimes(2); + }); + + await waitFor(() => { + expect(screen.getByText(/2 shipments imported successfully/)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText(/New shipments are now visible on the Active Deliveries page/)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('Row 1:')).toBeInTheDocument(); + expect(screen.getByText(/Created TRACK-001/)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('Row 2:')).toBeInTheDocument(); + expect(screen.getByText(/Created TRACK-002/)).toBeInTheDocument(); + }); + }); + + it('shows error for CSV with missing required columns', async () => { + const invalidCsv = `pickupAddress,destination\n123 Main St,Lagos`; + render(, { wrapper: createWrapper() }); + + const input = document.getElementById('csv-upload-input') as HTMLInputElement; + const file = new File([invalidCsv], 'bad.csv', { type: 'text/csv' }); + + Object.defineProperty(input, 'files', { + value: [file], + }); + + fireEvent.change(input); + + await waitFor(() => { + expect(screen.getByText(/Missing required CSV columns/)).toBeInTheDocument(); + }); + }); + + it('shows error for empty CSV file', async () => { + render(, { wrapper: createWrapper() }); + + const input = document.getElementById('csv-upload-input') as HTMLInputElement; + const file = new File([''], 'empty.csv', { type: 'text/csv' }); + + Object.defineProperty(input, 'files', { + value: [file], + }); + + fireEvent.change(input); + + await waitFor(() => { + expect(screen.getByText(/CSV file is empty or has no valid data rows/)).toBeInTheDocument(); + }); + }); + + it('handles partial API failures gracefully', async () => { + const mockDelivery1 = createMockDelivery('TRACK-001'); + + mockDeliveriesService.createDelivery + .mockResolvedValueOnce(mockDelivery1) + .mockRejectedValueOnce(new Error('Network error')); + + render(, { wrapper: createWrapper() }); + + const input = document.getElementById('csv-upload-input') as HTMLInputElement; + const file = new File([validCsvContent], 'partial.csv', { type: 'text/csv' }); + + Object.defineProperty(input, 'files', { + value: [file], + }); + + fireEvent.change(input); + + await waitFor(() => { + expect(mockDeliveriesService.createDelivery).toHaveBeenCalledTimes(2); + }); + + await waitFor(() => { + expect(screen.getByText(/1 succeeded/)).toBeInTheDocument(); + expect(screen.getByText(/1 failed/)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('Row 2:')).toBeInTheDocument(); + expect(screen.getByText(/Network error/)).toBeInTheDocument(); + }); + }); + + it('handles complete API failure', async () => { + mockDeliveriesService.createDelivery.mockRejectedValue(new Error('Server error')); + + render(, { wrapper: createWrapper() }); + + const input = document.getElementById('csv-upload-input') as HTMLInputElement; + const file = new File([singleCsvContent], 'fail.csv', { type: 'text/csv' }); + + Object.defineProperty(input, 'files', { + value: [file], + }); + + fireEvent.change(input); + + await waitFor(() => { + expect(mockDeliveriesService.createDelivery).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith( + 'Bulk import failed', + 'All shipments failed to create' + ); + }); + }); + + it('allows resetting after upload completes', async () => { + const mockDelivery = createMockDelivery('TRACK-001'); + mockDeliveriesService.createDelivery.mockResolvedValue(mockDelivery); + + render(, { wrapper: createWrapper() }); + + const input = document.getElementById('csv-upload-input') as HTMLInputElement; + const file = new File([singleCsvContent], 'reset.csv', { type: 'text/csv' }); + + Object.defineProperty(input, 'files', { + value: [file], + }); + + fireEvent.change(input); + + await waitFor(() => { + expect(screen.getByText(/reset\.csv/)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText(/1 shipment imported successfully/)).toBeInTheDocument(); + }); + + const resetButton = screen.getByLabelText('Remove file'); + fireEvent.click(resetButton); + + await waitFor(() => { + expect(screen.queryByText(/reset\.csv/)).not.toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText(/Click to upload or drag and drop/)).toBeInTheDocument(); + }); + }); + + it('handles drag and drop of CSV file', async () => { + const mockDelivery = createMockDelivery('TRACK-001'); + mockDeliveriesService.createDelivery.mockResolvedValue(mockDelivery); + + render(, { wrapper: createWrapper() }); + + const dropZone = document.querySelector('label[for="csv-upload-input"]') as HTMLElement; + expect(dropZone).toBeTruthy(); + + const file = new File([singleCsvContent], 'dropped.csv', { type: 'text/csv' }); + + const dataTransfer = { + files: [file], + }; + + fireEvent.dragEnter(dropZone, { dataTransfer }); + fireEvent.dragOver(dropZone, { dataTransfer }); + fireEvent.drop(dropZone, { dataTransfer }); + + await waitFor(() => { + expect(screen.getByText(/dropped\.csv/)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(mockDeliveriesService.createDelivery).toHaveBeenCalledTimes(1); + }); + }); + + it('calls onComplete callback with results', async () => { + const mockDelivery = createMockDelivery('TRACK-001'); + mockDeliveriesService.createDelivery.mockResolvedValue(mockDelivery); + + const onComplete = jest.fn(); + render(, { wrapper: createWrapper() }); + + const input = document.getElementById('csv-upload-input') as HTMLInputElement; + const file = new File([singleCsvContent], 'callback.csv', { type: 'text/csv' }); + + Object.defineProperty(input, 'files', { + value: [file], + }); + + fireEvent.change(input); + + await waitFor(() => { + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + const callbackResults = onComplete.mock.calls[0][0]; + expect(callbackResults).toHaveLength(1); + expect(callbackResults[0].success).toBe(true); + expect(callbackResults[0].trackingNumber).toBe('TRACK-001'); + }); + + it('invalidates deliveries query on successful import', async () => { + const mockDelivery = createMockDelivery('TRACK-001'); + mockDeliveriesService.createDelivery.mockResolvedValue(mockDelivery); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const invalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); + + const wrapper = function Wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); + }; + + render(, { wrapper }); + + const input = document.getElementById('csv-upload-input') as HTMLInputElement; + const file = new File([singleCsvContent], 'invalidate.csv', { type: 'text/csv' }); + + Object.defineProperty(input, 'files', { + value: [file], + }); + + fireEvent.change(input); + + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['deliveries'] }); + }); + + invalidateQueries.mockRestore(); + }); + + it('shows processing state during import', async () => { + let resolveCreate: (_value: ReturnType) => void; + const mockDeliveryPromise = new Promise>((resolve) => { + resolveCreate = resolve; + }); + + mockDeliveriesService.createDelivery.mockReturnValue(mockDeliveryPromise); + + render(, { wrapper: createWrapper() }); + + const input = document.getElementById('csv-upload-input') as HTMLInputElement; + const file = new File([singleCsvContent], 'progress.csv', { type: 'text/csv' }); + + Object.defineProperty(input, 'files', { + value: [file], + }); + + fireEvent.change(input); + + await waitFor(() => { + expect(screen.getByText(/Processing shipments/)).toBeInTheDocument(); + }); + + resolveCreate!(createMockDelivery('TRACK-001')); + await mockDeliveryPromise; + + await waitFor(() => { + expect(screen.getByText(/Processing complete/)).toBeInTheDocument(); + }); + }); + + it('handles CSV with extra whitespace in headers and values', async () => { + const csvWithSpaces = ` pickupAddress , destination , packageSize , description , recipientName , recipientPhone , recipientEmail +123 Main St,Lagos,small,Test,John Doe,+2348012345678,john@example.com`; + const mockDelivery = createMockDelivery('TRACK-001'); + mockDeliveriesService.createDelivery.mockResolvedValue(mockDelivery); + + render(, { wrapper: createWrapper() }); + + const input = document.getElementById('csv-upload-input') as HTMLInputElement; + const file = new File([csvWithSpaces], 'spaces.csv', { type: 'text/csv' }); + + Object.defineProperty(input, 'files', { + value: [file], + }); + + fireEvent.change(input); + + await waitFor(() => { + expect(mockDeliveriesService.createDelivery).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(screen.getByText(/1 shipment imported successfully/)).toBeInTheDocument(); + }); + }); +}); diff --git a/services/deliveries.service.ts b/services/deliveries.service.ts index 3af4878..7c24a93 100644 --- a/services/deliveries.service.ts +++ b/services/deliveries.service.ts @@ -35,5 +35,10 @@ export const deliveriesService = { getDeliveryById: async (id: string): Promise => { const { data } = await apiClient.get(`/deliveries/${id}`); return data; + }, + + createDelivery: async (payload: CreateDeliveryPayload): Promise => { + const { data } = await apiClient.post('/deliveries', payload); + return data; } }; From 153f6257988db0dd7def3ce217f6e24454545d3e Mon Sep 17 00:00:00 2001 From: nelly439 Date: Sat, 29 Aug 2026 22:33:47 +0100 Subject: [PATCH 2/2] Test: Implement Component: Fleet & Partner Network Directory Grid --- app/(dashboard)/fleet/page.tsx | 2 + components/fleet/FleetPartnerDirectory.tsx | 142 +++++++++++++++++ .../__tests__/FleetPartnerDirectory.test.tsx | 147 ++++++++++++++++++ types/fleet.ts | 1 + 4 files changed, 292 insertions(+) create mode 100644 components/fleet/FleetPartnerDirectory.tsx create mode 100644 components/fleet/__tests__/FleetPartnerDirectory.test.tsx diff --git a/app/(dashboard)/fleet/page.tsx b/app/(dashboard)/fleet/page.tsx index 776337d..377bddf 100644 --- a/app/(dashboard)/fleet/page.tsx +++ b/app/(dashboard)/fleet/page.tsx @@ -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'); @@ -58,6 +59,7 @@ export default function FleetDashboardPage() { <> {summary ? : null} + )} diff --git a/components/fleet/FleetPartnerDirectory.tsx b/components/fleet/FleetPartnerDirectory.tsx new file mode 100644 index 0000000..c530a47 --- /dev/null +++ b/components/fleet/FleetPartnerDirectory.tsx @@ -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
; + } + + if (!score || score <= 0) { + return ( + No on-chain score + ); + } + + return ( + + + {score.toLocaleString()} + + ); +} + +function PartnerCard({ partner }: { partner: Driver }) { + return ( +
+
+
+

+ {partner.name} +

+

+ {partner.vehicleType} · {partner.vehiclePlate} +

+
+ +
+ +
+ + + {partner.rating.toFixed(1)} + + + {partner.activeDeliveries} active · {partner.completedDeliveries} done + +
+ +
+ + {partner.status.replace('_', ' ')} + +
+
+ ); +} + +function PartnerCardSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+ ); +} + +export function FleetPartnerDirectory({ + partners, + isLoading = false, +}: FleetPartnerDirectoryProps) { + if (isLoading) { + return ( +
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); + } + + if (partners.length === 0) { + return ( +
+ No partners found in your fleet network. +
+ ); + } + + return ( +
+
+

+ Fleet & Partner Network +

+

+ {partners.length} partner{partners.length !== 1 ? 's' : ''} in your + network +

+
+ +
+ {partners.map((partner) => ( + + ))} +
+
+ ); +} diff --git a/components/fleet/__tests__/FleetPartnerDirectory.test.tsx b/components/fleet/__tests__/FleetPartnerDirectory.test.tsx new file mode 100644 index 0000000..95bea43 --- /dev/null +++ b/components/fleet/__tests__/FleetPartnerDirectory.test.tsx @@ -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(); + + 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(); + + expect( + screen.getByText('No partners found in your fleet network.'), + ).toBeInTheDocument(); + }); + + it('renders partner cards for each driver', () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByText('3 partners in your network')).toBeInTheDocument(); + }); + + it('applies correct status badge styling for each status', () => { + render(); + + 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'); + }); +}); diff --git a/types/fleet.ts b/types/fleet.ts index 135dd4d..ff9969c 100644 --- a/types/fleet.ts +++ b/types/fleet.ts @@ -17,6 +17,7 @@ export interface Driver { activeDeliveries: number; completedDeliveries: number; location: DriverLocation; + trustScore?: number; } export interface FleetSummary {