From ecd85d9d7cd9760e2e9edd8d58086a90089be6b0 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 07:41:48 +0000 Subject: [PATCH 1/4] Customer Name + Customer ID columns removed from the Export Marshalling Document --- .../train-scheduling.service.ts | 9 ++------- .../components/cargoes/DeliverCargoDialog.tsx | 6 ++++++ .../operations/TruckDetentionModal.tsx | 19 +++++++++++++++++- .../warehouses/ReleaseOrderModal.tsx | 15 ++++++++++++-- .../backoffice/src/lib/no-backdate.ts | 20 +++++++++++++++++++ 5 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/lib/no-backdate.ts 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; +}; From ba6fd0f41b9659ae770c8db618d35664341e3606 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 07:46:54 +0000 Subject: [PATCH 2/4] 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); + }); +} From 5395d4f346eee63b6058689cf14f89af61ef7ca4 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 07:41:48 +0000 Subject: [PATCH 3/4] Customer Name + Customer ID columns removed from the Export Marshalling Document --- .../train-scheduling.service.ts | 9 ++------- .../components/cargoes/DeliverCargoDialog.tsx | 6 ++++++ .../operations/TruckDetentionModal.tsx | 19 +++++++++++++++++- .../warehouses/ReleaseOrderModal.tsx | 15 ++++++++++++-- .../backoffice/src/lib/no-backdate.ts | 20 +++++++++++++++++++ 5 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/lib/no-backdate.ts 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; +}; From 7763d67ea86d94cb21f9fab4705c2ffe695c5202 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 07:46:54 +0000 Subject: [PATCH 4/4] 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); + }); +}