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 (
+
+
+
+
+ {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/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 && (
+
+ )}
+
+ {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;
}
};
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 {