mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user