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; } };