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-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
index 13624691a..4a26236b0 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
@@ -2811,13 +2811,12 @@ export class TrainSchedulingService {
return [
`
${wagonCells}
- | EMPTY — no cargo allocated |
+ EMPTY — no cargo allocated |
`,
];
}
return allocations.map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
- const company = booking?.company as Record | null | undefined;
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
const containerItems = allocation.containerItems ?? [];
const firstContainer = containerItems[0];
@@ -2826,8 +2825,6 @@ export class TrainSchedulingService {
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
return `
${wagonCells}
- | ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} |
- ${esc(booking?.companyId)} |
${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} |
${esc(containerNumbers || firstContainer?.containerNumber)} |
${esc(chassisNumbers)} |
@@ -2909,8 +2906,6 @@ export class TrainSchedulingService {
Equated Length |
Tare Weight |
Load Capacity |
- Customer Name |
- Customer ID |
Cargo Type |
Container No |
Chassis No |
@@ -2918,7 +2913,7 @@ export class TrainSchedulingService {
- ${rows || '| No wagons on this train set. |
'}
+ ${rows || '| No wagons on this train set. |
'}
diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx
index 546bd0def..6591ffa7c 100644
--- a/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx
@@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
import { useMutation } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
+import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate';
/**
* Customer Pickup + Proof of Delivery capture for a LOADED cargo.
@@ -27,6 +28,10 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
toast({ title: 'Receiver name is required', variant: 'destructive' });
return;
}
+ if (isBackdated(pickupDate)) {
+ toast({ title: 'Pickup date cannot be in the past', variant: 'destructive' });
+ return;
+ }
try {
await deliver.mutateAsync({
id: cargoId,
@@ -75,6 +80,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
setPickupDate(e.target.value)}
/>
diff --git a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx
index 2088db32d..c9748d80a 100644
--- a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx
@@ -18,6 +18,7 @@ import { useEffect, useState } from 'react';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { useToast } from '@/hooks/use-toast';
+import { isBackdated } from '@/lib/no-backdate';
import { lastMileService, type LastMileRecord } from '@/services/last-mile.service';
interface TruckDetentionModalProps {
@@ -111,6 +112,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
description="Detention clock start"
value={arrived}
onChange={(v) => setArrived(v ? new Date(v) : null)}
+ minDate={new Date()}
clearable
/>
setDelivered(v ? new Date(v) : null)}
+ minDate={new Date()}
clearable
/>
-
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx
index 9e66d9861..681f12ad5 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
+import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate';
import { warehouseService } from '@/services/warehouse.service';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
@@ -440,6 +441,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
});
return;
}
+ // No backdating: gate times are recorded as they happen. The locked
+ // entrance (exit step) keeps its original past gate-in untouched.
+ if (!isEntranceLocked && isBackdated(gateInTime)) {
+ toast({ variant: 'destructive', title: 'Gate in time cannot be in the past' });
+ return;
+ }
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
toast({
variant: 'destructive',
@@ -447,6 +454,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
});
return;
}
+ if (isExitStep && isBackdated(gateOutTime)) {
+ toast({ variant: 'destructive', title: 'Gate out time cannot be in the past' });
+ return;
+ }
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
return;
@@ -646,7 +657,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
)}
- setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
+ setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
{hasContainerWeights && (
@@ -679,7 +690,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
Computed net: {computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}
- setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
+ setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
{weightMismatch && (
} color="red" variant="light">
diff --git a/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts b/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts
new file mode 100644
index 000000000..0ce0541ee
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts
@@ -0,0 +1,20 @@
+/**
+ * Backdating guard for operational time entries (gate in/out, mile truck
+ * times, delivery pickups): times must be recorded as they happen, never
+ * dated back. A one-hour grace covers real-world lag (weighbridge queue,
+ * operator finishing the form after the event).
+ */
+export const BACKDATE_GRACE_MS = 60 * 60 * 1000;
+
+/** Local-time "YYYY-MM-DDTHH:mm" for a datetime-local input's `min`. */
+export const nowLocalDateTimeInput = (): string =>
+ new Date(Date.now() - new Date().getTimezoneOffset() * 60_000)
+ .toISOString()
+ .slice(0, 16);
+
+/** True when the value is more than the grace period in the past. */
+export const isBackdated = (value: string | Date | null | undefined): boolean => {
+ if (!value) return false;
+ const t = value instanceof Date ? value.getTime() : new Date(value).getTime();
+ return Number.isFinite(t) && t < Date.now() - BACKDATE_GRACE_MS;
+};
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.
+
+
+
+ }
+ variant="light"
+ onClick={handleDownloadTemplate}
+ >
+ Download Template
+
+
+
+ }
+ />
+
+ {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.mutate()}
+ leftSection={}
+ >
+ Upload Trucks
+
+
+ >
+ )}
+
+ {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
+ }
+ onClick={() => setBulkModalOpen(true)}
+ >
+ Bulk Upload
+
{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);
+ });
+}