From 7763d67ea86d94cb21f9fab4705c2ffe695c5202 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 07:46:54 +0000 Subject: [PATCH] feat: bulk upload for customer truck assignments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Excel template-based bulk upload for customer truck assignments (self-haul + EDR). Supports up to 2x20ft or 1x40ft containers per truck. API: POST /bookings/:id/customer-trucks/bulk accepts array of trucks. Portal: DownloadTemplate → ParseExcel → PreviewUpload → Commit flow. Validates truck load rules per booking container configuration. Co-Authored-By: Claude Haiku 4.5 --- .../modules/bookings/bookings.controller.ts | 14 ++ .../bookings/customer-truck.service.ts | 31 +++ .../bookings/dto/bulk-customer-truck.dto.ts | 48 +++++ .../components/BulkTruckUploadModal.tsx | 183 ++++++++++++++++++ .../CustomerTruckAssignmentCard.tsx | 22 ++- .../src/utils/truck-assignment-template.ts | 108 +++++++++++ 6 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx create mode 100644 apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index bc8c80982..ac5b3c3cd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -480,6 +480,20 @@ export class BookingsController { return this.customerTruckService.addTruck(id, dto); } + @Post(':id/customer-trucks/bulk') + @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) + async bulkAddCustomerTrucks( + @Param('id', ParseUUIDPipe) id: string, + @Body() payload: { trucks: AddCustomerTruckDto[] }, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.addBulkTrucks(id, payload.trucks); + } + @Patch(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) async updateCustomerTruck( diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index eb4699008..4ca578f0b 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -576,4 +576,35 @@ export class CustomerTruckService { } /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + + async addBulkTrucks( + bookingId: string, + dtos: AddCustomerTruckDto[], + ): Promise<{ + success: number; + failed: number; + errors: Array<{ row: number; truck: string; reason: string }>; + }> { + const errors: Array<{ row: number; truck: string; reason: string }> = []; + let successCount = 0; + + for (let i = 0; i < dtos.length; i++) { + try { + await this.addTruck(bookingId, dtos[i]); + successCount++; + } catch (err: any) { + errors.push({ + row: i + 2, // Row 1 is header + truck: dtos[i].truckPlateNumber, + reason: err.message || 'Unknown error', + }); + } + } + + return { + success: successCount, + failed: errors.length, + errors, + }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts new file mode 100644 index 000000000..5e03c7bc4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts @@ -0,0 +1,48 @@ +import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator'; +import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; + +export class BulkCustomerTruckRow { + @IsString() + @IsNotEmpty() + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container must be ISO format (e.g. ABCD1234567)', + }) + containerNumbers?: (string | null)[]; +} + +export class BulkCustomerTrucksDto { + @IsArray() + @ArrayMaxSize(100) + trucks!: BulkCustomerTruckRow[]; +} + +export interface BulkTruckUploadResult { + success: number; + failed: number; + errors: Array<{ + row: number; + truck: string; + reason: string; + }>; + created: Array<{ + truckPlateNumber: string; + driverName: string; + containers: number; + }>; +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx new file mode 100644 index 000000000..23c661247 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { Alert, Button, Group, Modal, Stack, Table, Text, FileInput, Badge } from "@mantine/core"; +import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; + +import { client } from "@/utils/api"; +import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template"; + +interface BulkTruckUploadModalProps { + opened: boolean; + onClose: () => void; + bookingId: string; + onSuccess?: () => void; +} + +export function BulkTruckUploadModal({ + opened, + onClose, + bookingId, + onSuccess, +}: BulkTruckUploadModalProps) { + const [file, setFile] = useState(null); + const [parsed, setParsed] = useState< + Array<{ + truckPlateNumber: string; + driverName: string; + truckType: string; + containerNumbers?: string[]; + }> + >([]); + const [parseError, setParseError] = useState(null); + + const uploadMutation = useMutation({ + mutationFn: async () => { + const { data } = await client.post(`/bookings/${bookingId}/customer-trucks/bulk`, { + trucks: parsed, + }); + return data; + }, + onSuccess: () => { + onSuccess?.(); + setFile(null); + setParsed([]); + onClose(); + }, + }); + + const handleFileSelect = async (selectedFile: File | null) => { + if (!selectedFile) { + setFile(null); + setParsed([]); + setParseError(null); + return; + } + + try { + setParseError(null); + const trucks = await parseTruckAssignmentFile(selectedFile); + setFile(selectedFile); + setParsed(trucks); + } catch (err: any) { + setParseError(err.message || "Failed to parse Excel file"); + setFile(null); + setParsed([]); + } + }; + + const handleDownloadTemplate = () => { + generateTruckAssignmentTemplate("truck-assignments.xlsx"); + }; + + return ( + + + } color="blue"> + Download template, fill with truck data, upload Excel file to bulk-create truck assignments. + + + + + + + } + /> + + {parseError && ( + } color="red" title="Parse Error"> + {parseError} + + )} + + {parsed.length > 0 && ( + <> +
+ + Preview ({parsed.length} trucks) + + + + + Plate Number + Driver Name + Truck Type + Containers + + + + {parsed.map((truck, idx) => ( + + + {truck.truckPlateNumber} + + + {truck.driverName} + + + {truck.truckType} + + + {truck.containerNumbers?.length ? ( + + {truck.containerNumbers.map((c) => ( + + {c} + + ))} + + ) : ( + + — + + )} + + + ))} + +
+
+ + + + Ready to upload {parsed.length} truck(s) + + + + + )} + + {uploadMutation.isError && ( + } color="red"> + {uploadMutation.error instanceof Error + ? uploadMutation.error.message + : "Upload failed"} + + )} +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index 7c9cbeb0e..5d2bac886 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -15,7 +15,7 @@ import { } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types"; -import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck } from "lucide-react"; +import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck, Upload } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; @@ -23,6 +23,7 @@ import { api } from "@/services/api"; import { customerTrucksService } from "@/services/customer-trucks.service"; import { CardTitle, SectionCard } from "./layout"; +import { BulkTruckUploadModal } from "./BulkTruckUploadModal"; const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"]; @@ -65,6 +66,7 @@ export function CustomerTruckAssignmentCard({ const [containers, setContainers] = useState([]); const [editingId, setEditingId] = useState(null); const [error, setError] = useState(null); + const [bulkModalOpen, setBulkModalOpen] = useState(false); // Container numbers on the booking that aren't already loaded onto a truck. const assignedNumbers = new Set( @@ -163,6 +165,14 @@ export function CustomerTruckAssignmentCard({ External Truck Assignment + {pendingAssignmentCount > 0 && ( {pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment @@ -325,6 +335,16 @@ export function CustomerTruckAssignmentCard({ )} + + setBulkModalOpen(false)} + bookingId={booking.id} + onSuccess={() => { + queryClient.invalidateQueries({ queryKey: trucksKey }); + onAssigned(); + }} + /> ); } diff --git a/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts b/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts new file mode 100644 index 000000000..7a0fef1cc --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts @@ -0,0 +1,108 @@ +import * as XLSX from 'xlsx'; + +export function generateTruckAssignmentTemplate(filename = 'truck-assignments.xlsx'): void { + const data = [ + { + 'Truck Plate Number': '3-12345/67890', + 'Driver Name': 'John Doe', + 'Truck Type': 'Flatbed', + 'Container 1': 'MAEU1234567', + 'Container 2': 'HLXU7654321', + }, + { + 'Truck Plate Number': '3-98765/43210', + 'Driver Name': 'Jane Smith', + 'Truck Type': 'Flatbed', + 'Container 1': 'COSCO1111111', + 'Container 2': '', + }, + ]; + + const instructions = [ + ['TRUCK ASSIGNMENT BULK UPLOAD - INSTRUCTIONS'], + [], + ['Column', 'Required', 'Notes'], + ['Truck Plate Number', 'Yes', 'Format: 3-XXXXX/XXXXX (Ethiopian plate format)'], + ['Driver Name', 'Yes', 'Full name of truck driver'], + ['Truck Type', 'Yes', 'e.g., Flatbed, Lowbed, Tanker, Trailer, etc.'], + ['Container 1', 'Yes*', '*Required for EXPORT. Leave empty for IMPORT bulk cargo.'], + ['Container 2', 'No', 'Optional. ISO format: e.g., MAEU1234567. Max 2 containers per truck.'], + [], + ['CONTAINER RULES'], + ['- A 40ft container fills one truck (max 1 per truck)'], + ['- Two 20ft containers fit on one truck (max 2 per truck)'], + ['- No size mixing on same truck'], + ['- Containers must be from the booking'], + [], + ['Example Data Below →'], + ]; + + const wb = XLSX.utils.book_new(); + + // Instructions sheet + const wsInstructions = XLSX.utils.aoa_to_sheet(instructions); + wsInstructions['!cols'] = [{ wch: 30 }, { wch: 12 }, { wch: 50 }]; + XLSX.utils.book_append_sheet(wb, wsInstructions, 'Instructions'); + + // Data template sheet + const wsData = XLSX.utils.json_to_sheet(data, { + header: ['Truck Plate Number', 'Driver Name', 'Truck Type', 'Container 1', 'Container 2'], + }); + wsData['!cols'] = [{ wch: 20 }, { wch: 20 }, { wch: 15 }, { wch: 18 }, { wch: 18 }]; + XLSX.utils.book_append_sheet(wb, wsData, 'Trucks'); + + XLSX.writeFile(wb, filename); +} + +export function parseTruckAssignmentFile( + file: File, +): Promise< + Array<{ + truckPlateNumber: string; + driverName: string; + truckType: string; + containerNumbers?: string[]; + }> +> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const data = e.target?.result as ArrayBuffer; + const wb = XLSX.read(data, { type: 'array' }); + const wsData = wb.Sheets['Trucks'] || Object.values(wb.Sheets)[0]; + + if (!wsData) { + reject(new Error('No data sheet found in Excel file')); + return; + } + + const jsonData = XLSX.utils.sheet_to_json(wsData) as Array>; + + const trucks = jsonData.map((row) => { + const containers = [ + row['Container 1'], + row['Container 2'], + ] + .filter((c) => c && c.trim()) + .map((c) => c.trim().toUpperCase()); + + return { + truckPlateNumber: row['Truck Plate Number']?.trim() || '', + driverName: row['Driver Name']?.trim() || '', + truckType: row['Truck Type']?.trim() || '', + containerNumbers: containers.length > 0 ? containers : undefined, + }; + }); + + resolve(trucks); + } catch (error) { + reject(error); + } + }; + + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsArrayBuffer(file); + }); +}