feat: bulk upload for customer truck assignments

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 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-22 07:46:54 +00:00
parent 5395d4f346
commit 7763d67ea8
6 changed files with 405 additions and 1 deletions

View File

@@ -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(

View File

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

View File

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

View File

@@ -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<File | null>(null);
const [parsed, setParsed] = useState<
Array<{
truckPlateNumber: string;
driverName: string;
truckType: string;
containerNumbers?: string[];
}>
>([]);
const [parseError, setParseError] = useState<string | null>(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 (
<Modal
opened={opened}
onClose={onClose}
title="Bulk Upload Truck Assignments"
size="lg"
centered
>
<Stack gap="lg">
<Alert icon={<AlertCircle size={16} />} color="blue">
Download template, fill with truck data, upload Excel file to bulk-create truck assignments.
</Alert>
<Group>
<Button
leftSection={<Download size={16} />}
variant="light"
onClick={handleDownloadTemplate}
>
Download Template
</Button>
</Group>
<FileInput
label="Select Excel File"
placeholder="Choose .xlsx file"
accept=".xlsx,.xls"
value={file}
onChange={handleFileSelect}
leftSection={<Upload size={14} />}
/>
{parseError && (
<Alert icon={<AlertTriangle size={16} />} color="red" title="Parse Error">
{parseError}
</Alert>
)}
{parsed.length > 0 && (
<>
<div>
<Text fw={600} mb="xs">
Preview ({parsed.length} trucks)
</Text>
<Table striped highlightOnHover size="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Plate Number</Table.Th>
<Table.Th>Driver Name</Table.Th>
<Table.Th>Truck Type</Table.Th>
<Table.Th>Containers</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{parsed.map((truck, idx) => (
<Table.Tr key={idx}>
<Table.Td>
<Text size="sm">{truck.truckPlateNumber}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{truck.driverName}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{truck.truckType}</Text>
</Table.Td>
<Table.Td>
{truck.containerNumbers?.length ? (
<Group gap="xs">
{truck.containerNumbers.map((c) => (
<Badge key={c} size="sm">
{c}
</Badge>
))}
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
<Group justify="space-between">
<Text size="sm" c="dimmed">
Ready to upload {parsed.length} truck(s)
</Text>
<Button
loading={uploadMutation.isPending}
onClick={() => uploadMutation.mutate()}
leftSection={<CheckCircle size={16} />}
>
Upload Trucks
</Button>
</Group>
</>
)}
{uploadMutation.isError && (
<Alert icon={<AlertTriangle size={16} />} color="red">
{uploadMutation.error instanceof Error
? uploadMutation.error.message
: "Upload failed"}
</Alert>
)}
</Stack>
</Modal>
);
}

View File

@@ -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<string[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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({
<CardTitle>External Truck Assignment</CardTitle>
</Group>
<Group gap={12}>
<Button
size="xs"
variant="light"
leftSection={<Upload size={14} />}
onClick={() => setBulkModalOpen(true)}
>
Bulk Upload
</Button>
{pendingAssignmentCount > 0 && (
<Text size="sm" fw={600} c="#b45309">
{pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment
@@ -325,6 +335,16 @@ export function CustomerTruckAssignmentCard({
</Group>
)}
</Stack>
<BulkTruckUploadModal
opened={bulkModalOpen}
onClose={() => setBulkModalOpen(false)}
bookingId={booking.id}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: trucksKey });
onAssigned();
}}
/>
</SectionCard>
);
}

View File

@@ -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<Record<string, any>>;
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);
});
}