Merge pull request #1478 from Tria-plc/Emty-container-return

Emty container return
This commit is contained in:
Hagernesh Tadesse
2026-09-02 23:49:17 +03:00
committed by GitHub
19 changed files with 1199 additions and 332 deletions

View File

@@ -1,4 +1,4 @@
import { usesEdrMileService } from './mile-haulage.util';
import { edrHaulsThisBooking, usesEdrMileService } from './mile-haulage.util';
/**
* The road legs are chosen on the contract and copied onto the booking. EDR
@@ -26,27 +26,76 @@ describe('usesEdrMileService', () => {
});
it('an export that chose collection uses EDR haulage', () => {
expect(
usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' })),
).toBe(true);
expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe(
true,
);
});
it('ignores the delivery address on an export — delivery is the import leg', () => {
expect(
usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' })),
).toBe(false);
expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' }))).toBe(
false,
);
});
it('a domestic booking counts either leg', () => {
expect(
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' })),
).toBe(true);
expect(
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })),
).toBe(true);
expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe(
true,
);
expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' }))).toBe(
true,
);
});
it('treats a whitespace-only address as no choice', () => {
expect(usesEdrMileService(booking({ lastMile: ' ' }))).toBe(false);
});
});
/**
* Self-haul is closed only once EDR has committed to the leg. Delivery chosen
* on the contract is a request the chief still has to approve; collection has
* no approval step.
*/
describe('edrHaulsThisBooking', () => {
const booking = (over: Partial<Parameters<typeof edrHaulsThisBooking>[0]> = {}) => ({
tradeDirection: 'IMPORT',
firstMile: null,
lastMile: null,
lastMileCommitted: false,
...over,
});
it('an import whose last-mile request is not yet approved may still self-haul', () => {
expect(edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa' }))).toBe(false);
});
it('an import whose last-mile request was approved is hauled by EDR', () => {
expect(
edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa', lastMileCommitted: true })),
).toBe(true);
});
it('an import that chose no delivery self-hauls, whatever the leg tables say', () => {
expect(edrHaulsThisBooking(booking({ lastMileCommitted: true }))).toBe(false);
});
it('an export that chose collection is hauled by EDR — no approval step on that leg', () => {
expect(edrHaulsThisBooking(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe(
true,
);
});
it('a domestic booking is blocked by collection, or by an approved delivery', () => {
expect(edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe(
true,
);
expect(
edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })),
).toBe(false);
expect(
edrHaulsThisBooking(
booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa', lastMileCommitted: true }),
),
).toBe(true);
});
});

View File

@@ -39,7 +39,60 @@ export const SELF_HAUL_CONFLICT_MESSAGE =
'This booking is delivered by the customers own truck — an EDR mile leg cannot also be assigned.';
export const EDR_HAULAGE_CONFLICT_MESSAGE =
'Customer truck assignment is only allowed when first/last mile delivery is not selected';
'Customer truck assignment is only allowed when first/last mile delivery is not selected, or when the EDR last-mile request has not been approved';
/** The booking fields that decide whether the customer may still bring their own truck. */
export interface MileCommitmentRow extends MileHaulageRow {
/**
* EDR has actually committed to the delivery leg: the booking's last-mile
* request was approved, or a `freight.last_mile` leg row exists for it.
* Selecting delivery on the contract is only a request — see
* `edrHaulsThisBooking`.
*/
lastMileCommitted: boolean;
}
/**
* SQL for `MileCommitmentRow.lastMileCommitted`, to be selected alongside the
* booking row aliased `b`. Both services that gate self-haul read the same
* fragment so the rule cannot drift between them.
*/
export const LAST_MILE_COMMITTED_SQL = `(
EXISTS (SELECT 1
FROM freight.last_mile lm
WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL)
OR EXISTS (SELECT 1
FROM freight.last_mile_requests lmr
WHERE lmr.booking_id = b.id
AND lmr.deleted_at IS NULL
AND lmr.status = 'APPROVED')
)`;
/**
* Whether EDR is hauling this booking's road leg, such that the customer may
* NOT assign their own truck. Stricter than `usesEdrMileService` on the
* delivery side: choosing last-mile delivery on the contract opens a request
* that the Truck & Machinery chief still has to approve, and until that
* approval the customer is free to self-haul instead. Collection (the export
* leg) has no approval step, so the contract choice alone decides it.
*
* `usesEdrMileService` keeps answering the other question — whether the booking
* belongs in the EDR mile queues at all — and the queue side still refuses a
* booking that already carries a customer truck, so the two paths remain
* mutually exclusive whichever acts first.
*/
export function edrHaulsThisBooking(booking: MileCommitmentRow): boolean {
const hasFirstMile = Boolean(booking.firstMile?.trim());
const lastMileApproved = Boolean(booking.lastMile?.trim()) && booking.lastMileCommitted;
switch (booking.tradeDirection) {
case 'IMPORT':
return lastMileApproved;
case 'EXPORT':
return hasFirstMile;
default:
return hasFirstMile || lastMileApproved;
}
}
/**
* The road legs are chosen on the contract. A booking whose contract bought

View File

@@ -86,6 +86,7 @@ import {
import { ContractViewDto } from "./dto/contract-view.dto";
import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto";
import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto";
import { BulkCustomerTrucksDto } from "./dto/bulk-customer-truck.dto";
import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto";
import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto";
import { CustomerTruckService } from "./customer-truck.service";
@@ -848,7 +849,7 @@ export class BookingsController {
})
async bulkAddCustomerTrucks(
@Param("id", ParseUUIDPipe) id: string,
@Body() payload: { trucks: AddCustomerTruckDto[] },
@Body() payload: BulkCustomerTrucksDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);

View File

@@ -29,6 +29,11 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import {
EDR_HAULAGE_CONFLICT_MESSAGE,
LAST_MILE_COMMITTED_SQL,
edrHaulsThisBooking,
} from '../../common/mile-haulage.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -174,18 +179,23 @@ export class BookingsService {
dto: CustomerTruckAssignmentDto,
): Promise<Booking> {
const booking = await this.findById(bookingId);
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
const usesMileService =
booking.tradeDirection === 'IMPORT'
? hasLastMile
: booking.tradeDirection === 'EXPORT'
? hasFirstMile
: hasFirstMile || hasLastMile;
if (usesMileService) {
throw new BadRequestException(
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
);
// Same rule as CustomerTruckService.assertSelfHaulPaid: an EDR delivery leg
// closes self-haul only once it has been approved.
const [commitment]: Array<{ lastMileCommitted: boolean }> = await this.dataSource.query(
`SELECT ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted"
FROM freight.bookings b
WHERE b.id = $1`,
[bookingId],
);
if (
edrHaulsThisBooking({
tradeDirection: booking.tradeDirection ?? null,
firstMile: booking.firstMilePickupAddress ?? null,
lastMile: booking.lastMileDeliveryAddress ?? null,
lastMileCommitted: Boolean(commitment?.lastMileCommitted),
})
) {
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
}
if (booking.customerTruckAssignedAt) {
throw new ConflictException('Customer truck assignment is already submitted and locked');

View File

@@ -9,12 +9,17 @@ import { DataSource, EntityManager, IsNull } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import type {
BulkTruckUploadError,
BulkTruckUploadResult,
} from './dto/bulk-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import {
EDR_HAULAGE_CONFLICT_MESSAGE,
usesEdrMileService,
LAST_MILE_COMMITTED_SQL,
edrHaulsThisBooking,
} from '../../common/mile-haulage.util';
import {
assertBulkTonnageRemains,
@@ -36,6 +41,8 @@ interface BookingGuardRow {
paymentStatus: string | null;
status: string | null;
trainScheduleStatus: string | null;
/** See `MileCommitmentRow` — an approved EDR last-mile leg closes self-haul. */
lastMileCommitted: boolean;
}
/**
@@ -547,7 +554,8 @@ export class CustomerTruckService {
ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
WHERE tsb.booking_id = b.id AND tsb.deleted_at IS NULL
ORDER BY ts.updated_at DESC
LIMIT 1) AS "trainScheduleStatus"
LIMIT 1) AS "trainScheduleStatus",
${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted"
FROM freight.bookings b
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
@@ -557,10 +565,12 @@ export class CustomerTruckService {
}
private assertSelfHaulPaid(booking: BookingGuardRow): void {
// Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two
// halves of this rule cannot drift apart — they did, and a booking ended up
// with a customer truck and an EDR leg at once.
if (usesEdrMileService(booking)) {
// Mirrors the EDR side (LastMileService.assertEdrHaulsThisBooking) so the
// two halves of this rule cannot drift apart — they did, and a booking ended
// up with a customer truck and an EDR leg at once. A last-mile leg only
// blocks self-haul once it is approved; until then the customer may still
// bring their own truck, and doing so makes the pending request unapprovable.
if (edrHaulsThisBooking(booking)) {
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
}
if (booking.paymentStatus !== 'PAID') {
@@ -631,26 +641,29 @@ export class CustomerTruckService {
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
/**
* Add trucks one at a time, keeping the good ones. Partial success is the
* right shape here: one mistyped plate in a twenty-row spreadsheet should not
* discard the other nineteen trucks. Every row still goes through `addTruck`,
* so no guard is skipped.
*/
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 }> = [];
): Promise<BulkTruckUploadResult> {
const errors: BulkTruckUploadError[] = [];
let successCount = 0;
for (let i = 0; i < dtos.length; i++) {
try {
await this.addTruck(bookingId, dtos[i]);
successCount++;
} catch (err: any) {
} catch (err) {
errors.push({
row: i + 2, // Row 1 is header
index: i,
row: i + 2, // Row 1 is the header
truck: dtos[i].truckPlateNumber,
reason: err.message || 'Unknown error',
reason: err instanceof Error ? err.message : 'Unknown error',
});
}
}

View File

@@ -12,7 +12,7 @@ import {
Min,
} from 'class-validator';
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types';
/**
* Add one external customer truck to a booking.
@@ -41,7 +41,7 @@ export class AddCustomerTruckDto {
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
@Matches(ISO_CONTAINER_NUMBER, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})

View File

@@ -1,48 +1,41 @@
import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator';
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
import { ArrayMaxSize, ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
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)[];
}
import { AddCustomerTruckDto } from './add-customer-truck.dto';
/**
* Bulk self-haul truck assignment, parsed from the customer's Excel upload in
* the browser and posted as JSON (the house pattern — the API never receives an
* .xlsx for import).
*
* Rows reuse `AddCustomerTruckDto` verbatim rather than redeclaring the fields:
* the earlier copy drifted, missing `plannedTons` / `plannedQuantity`, so bulk
* cargo could not be uploaded at all.
*/
export class BulkCustomerTrucksDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(100)
trucks!: BulkCustomerTruckRow[];
@ValidateNested({ each: true })
@Type(() => AddCustomerTruckDto)
trucks!: AddCustomerTruckDto[];
}
export interface BulkTruckUploadError {
/**
* Position in the submitted array. The client knows which spreadsheet line it
* read each entry from, so it maps this back to the row number the customer
* actually sees.
*/
index: number;
/** 1-based row assuming a single header line — a fallback for non-Excel callers. */
row: number;
truck: string;
reason: string;
}
export interface BulkTruckUploadResult {
success: number;
failed: number;
errors: Array<{
row: number;
truck: string;
reason: string;
}>;
created: Array<{
truckPlateNumber: string;
driverName: string;
containers: number;
}>;
errors: BulkTruckUploadError[];
}

View File

@@ -1,12 +1,12 @@
import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types';
export const CUSTOMER_TRUCK_TYPES = [
'Flatbed',
'Container Chassis',
'Lowboy',
'Box Truck',
'Tipper',
] as const;
/**
* Re-exported for the DTOs that already import it from here. The list itself
* lives in `@edr/types` so the portal's dropdown and its Excel template read the
* same values this validator enforces.
*/
export { CUSTOMER_TRUCK_TYPES };
export class CustomerTruckAssignmentDto {
@IsString()
@@ -27,7 +27,7 @@ export class CustomerTruckAssignmentDto {
@IsString()
@IsNotEmpty()
@MaxLength(16)
@Matches(/^[A-Z]{4}\d{7}$/, {
@Matches(ISO_CONTAINER_NUMBER, {
message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567',
})
containerNumberToLoad!: string;

View File

@@ -8,6 +8,7 @@ import {
Matches,
Min,
} from 'class-validator';
import { ISO_CONTAINER_NUMBER } from '@edr/types';
/**
* Register an import self-haul truck leaving the port: the containers it actually
@@ -20,7 +21,7 @@ export class DepartCustomerTruckDto {
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
@Matches(ISO_CONTAINER_NUMBER, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})

View File

@@ -1,4 +1,5 @@
import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
import { ISO_CONTAINER_NUMBER } from '@edr/types';
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
export class LoadCustomerTruckDto {
@@ -7,7 +8,7 @@ export class LoadCustomerTruckDto {
// A truck carries at most 2 containers (two 20ft, or one 40ft).
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
@Matches(ISO_CONTAINER_NUMBER, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})

View File

@@ -16,6 +16,17 @@ import {
import { EmptyReturnRequestsService } from './empty-return-requests.service';
import type { EmptyReturnRequestStatus } from './entities/empty-return-request.entity';
/**
* Reading the queue is OR'd with the warehouse-inventory key the rest of the
* Imports menu uses, so the staff who already run container returns can open
* it while the dedicated key is still being handed out. Approving and
* rejecting stay on the review key alone — that one is a commercial decision.
*/
const CAN_VIEW = [
FREIGHT_PERMS.emptyReturnRequests.view,
FREIGHT_PERMS.warehouseInventory.view,
];
@ApiTags('empty-return-requests')
@ApiBearerAuth()
@Controller('empty-return-requests')
@@ -23,7 +34,7 @@ export class EmptyReturnRequestsController {
constructor(private readonly service: EmptyReturnRequestsService) {}
@Get()
@BookingStaff(FREIGHT_PERMS.emptyReturnRequests.view)
@BookingStaff(CAN_VIEW)
@ApiOperation({ summary: 'Empty container return requests queue' })
findAll(@Query('status') status?: string, @Query('bookingId') bookingId?: string) {
return this.service.findAll({
@@ -42,7 +53,7 @@ export class EmptyReturnRequestsController {
}
@Get('eligibility/:bookingId')
@MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view)
@MixedAudience(CAN_VIEW)
@ApiOperation({
summary:
'Whether a booking may request an empty return, its free containers, and the price per container',
@@ -55,14 +66,14 @@ export class EmptyReturnRequestsController {
}
@Get('by-booking/:bookingId')
@MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view)
@MixedAudience(CAN_VIEW)
@ApiOperation({ summary: "A booking's empty return requests, newest first" })
findForBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.service.findForBooking(bookingId);
}
@Get(':id')
@MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view)
@MixedAudience(CAN_VIEW)
@ApiOperation({ summary: 'Get an empty return request by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
return this.service.findById(id, this.portalUserId(user));

View File

@@ -714,7 +714,10 @@ const App = () => {
path="empty-return-requests"
element={
<RequirePermission
permission={FREIGHT_PERMS.emptyReturnRequests.view}
permission={[
FREIGHT_PERMS.emptyReturnRequests.view,
FREIGHT_PERMS.warehouseInventory.view,
]}
>
<EmptyReturnRequestsPage />
</RequirePermission>

View File

@@ -372,7 +372,13 @@ export const buildSidebarSections = (
label: "Empty Return Requests",
href: "/dashboard/empty-return-requests",
icon: <Undo2 />,
permission: FREIGHT_PERMS.emptyReturnRequests.view,
// Visible to whoever already runs container returns, OR'd with
// the queue's own key — so the dedicated permission can be handed
// out per position later without the menu disappearing now.
permission: [
FREIGHT_PERMS.emptyReturnRequests.view,
FREIGHT_PERMS.warehouseInventory.view,
],
},
{
label: "Register Full Containers",

View File

@@ -18,6 +18,8 @@ import {
} from "@mantine/core";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
import { extractErrorMessage } from "@/components/warehouses/options";
@@ -55,6 +57,11 @@ const money = (amount: number | null | undefined, currency: string | null | unde
export default function EmptyReturnRequestsPage() {
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
// Anyone who runs container returns can watch the queue; pricing and
// approving is its own permission, so show the buttons disabled rather than
// letting them fire into a 403.
const canReview = hasPermission(user, FREIGHT_PERMS.emptyReturnRequests.review);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [approving, setApproving] = useState<EmptyReturnRequest | null>(null);
const [rejecting, setRejecting] = useState<EmptyReturnRequest | null>(null);
@@ -204,10 +211,23 @@ export default function EmptyReturnRequestsPage() {
cell: ({ row }) =>
row.original.status === "SUBMITTED" ? (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button size="xs" variant="subtle" color="red" onClick={() => setRejecting(row.original)}>
<Button
size="xs"
variant="subtle"
color="red"
disabled={!canReview}
title={canReview ? undefined : "You do not have permission to review these requests"}
onClick={() => setRejecting(row.original)}
>
Reject
</Button>
<Button size="xs" variant="light" onClick={() => setApproving(row.original)}>
<Button
size="xs"
variant="light"
disabled={!canReview}
title={canReview ? undefined : "You do not have permission to review these requests"}
onClick={() => setApproving(row.original)}
>
Approve
</Button>
</Group>

View File

@@ -17,6 +17,7 @@ import { useNavigate, useSearchParams } from "react-router-dom";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -182,23 +183,70 @@ export function ReadonlyBookingView({
// backend flag counts only unsigned SELF_HAUL handovers, so no status
// heuristics are needed here.
const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
// Delivery chosen on the contract only opens a last-mile request; EDR is
// committed to that leg once the request is approved (or a leg record
// exists). Until then the customer may still bring their own truck. Same
// rule as the API's `edrHaulsThisBooking`. Collection (the export leg) has
// no approval step, so the address alone decides it.
const hasLastMileChoice =
booking.tradeDirection !== "EXPORT" && !!booking.lastMileDeliveryAddress;
const lastMileRequests = useQuery({
queryKey: ["booking-last-mile-requests", booking.id],
queryFn: () => lastMileRequestsService.listForBooking(booking.id),
enabled: hasLastMileChoice,
});
const mileSummary = useQuery({
queryKey: ["booking-mile-summary", booking.id],
queryFn: () => bookingsService.mileSummary(booking.id),
enabled: hasLastMileChoice,
});
const lastMileCommitted =
hasLastMileChoice &&
((lastMileRequests.data ?? []).some((r) => r.status === "APPROVED") ||
!!mileSummary.data?.lastMile);
const lastMileCheckPending =
hasLastMileChoice && (lastMileRequests.isPending || mileSummary.isPending);
const usesCustomerTruck =
booking.tradeDirection === "IMPORT"
? !booking.lastMileDeliveryAddress
? !lastMileCommitted
: booking.tradeDirection === "EXPORT"
? !booking.firstMilePickupAddress
: !booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress;
const canAssignCustomerTruck =
booking.paymentStatus === "PAID" &&
usesCustomerTruck &&
(booking.tradeDirection === "IMPORT"
? // Import self-haul: pickup trucks are assigned only after the train has
// arrived at the destination.
(status === "ARRIVED" || booking.trainScheduleStatus === "ARRIVED")
: // Export / domestic self-haul: delivery trucks are assigned only before
// the cargo is loaded onto the train (PAID / TRUCK_ASSIGNED). Once loaded
// (IN_TRANSIT and beyond) assignment is closed.
["PAID", "TRUCK_ASSIGNED"].includes(status));
: !booking.firstMilePickupAddress && !lastMileCommitted;
// Why truck assignment is closed, or null when it is open. The card renders
// either way — a customer who opens Logistics and finds nothing there cannot
// tell a missing feature from a stage they have not reached yet.
const truckBlockedReason = (() => {
if (lastMileCheckPending) {
return "Checking whether EDR last-mile delivery has been approved for this booking…";
}
if (!usesCustomerTruck) {
return booking.tradeDirection !== "EXPORT" && lastMileCommitted
? "EDR last-mile delivery for this booking has been approved, so no customer truck is needed."
: "EDR is handling first-mile pickup for this booking, so no customer truck is needed.";
}
if (booking.paymentStatus !== "PAID") {
return "Truck assignment opens once payment for this booking is confirmed.";
}
if (booking.tradeDirection === "IMPORT") {
// Import self-haul: pickup trucks are assigned only after the train has
// arrived at the destination.
return status === "ARRIVED" || booking.trainScheduleStatus === "ARRIVED"
? null
: "Pickup trucks can be assigned once the train arrives at the destination.";
}
// Export / domestic self-haul: delivery trucks are assigned only before the
// cargo is loaded onto the train (PAID / TRUCK_ASSIGNED). Once loaded
// (IN_TRANSIT and beyond) assignment is closed.
return ["PAID", "TRUCK_ASSIGNED"].includes(status)
? null
: "The cargo is already loaded onto the train — truck assignment is closed.";
})();
// The customer asked for EDR delivery and can still self-haul instead — say
// so, because assigning a truck here makes that pending request unapprovable.
const truckNotice =
!truckBlockedReason && hasLastMileChoice && !lastMileCommitted
? "You requested EDR last-mile delivery for this booking, and it has not been approved yet. Assigning your own truck here replaces that request — it can no longer be approved once a truck is on the booking."
: null;
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
// Customs (Path B) bookings are created AND rebooked by Global Logistics, not
@@ -464,14 +512,16 @@ export function ReadonlyBookingView({
<BodyGrid
left={
<>
<WarehouseLocationCard bookingId={booking.id} />
{/* Truck assignment leads; where the cargo currently sits is
supporting detail beneath it. */}
<CustomerTruckAssignmentCard
booking={booking}
blockedReason={truckBlockedReason}
notice={truckNotice}
onAssigned={onBookingUpdated ?? (() => {})}
/>
{canAssignCustomerTruck && (
<CustomerTruckAssignmentCard
booking={booking}
onAssigned={onBookingUpdated ?? (() => {})}
/>
)}
<WarehouseLocationCard bookingId={booking.id} />
<MileSummaryCard booking={booking} />
</>

View File

@@ -1,131 +1,237 @@
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 { useMemo, useRef, useState } from "react";
import {
Alert,
Badge,
Button,
FileButton,
Group,
List,
Modal,
Stack,
Table,
Text,
} from "@mantine/core";
import { AlertCircle, AlertTriangle, CheckCircle, Download, Upload } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { client } from "@/utils/api";
import { URL_CONSTANTS } from "@/constants/URLS";
import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template";
import {
downloadTruckAssignmentTemplate,
parseTruckAssignmentFile,
truckTemplateContext,
type ParsedTruckFile,
} from "@/utils/truck-assignment-template";
import type { BookingDetail } from "../booking-detail-types";
interface BulkTruckUploadResponse {
success: number;
failed: number;
errors: Array<{ index: number; row: number; truck: string; reason: string }>;
}
interface BulkTruckUploadModalProps {
opened: boolean;
onClose: () => void;
bookingId: string;
booking: BookingDetail;
trucks: Freight.ICustomerTruck[];
onSuccess?: () => void;
}
/** Long error lists are unreadable in a modal — show the first few and count the rest. */
const MAX_LISTED_ERRORS = 8;
export function BulkTruckUploadModal({
opened,
onClose,
bookingId,
booking,
trucks,
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 queryClient = useQueryClient();
// `resetRef` lets the customer re-pick a corrected file with the same name —
// without it the input's onChange never fires the second time.
const resetFile = useRef<() => void>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [parsed, setParsed] = useState<ParsedTruckFile>({ rows: [], errors: [], rowNumbers: [] });
const [result, setResult] = useState<BulkTruckUploadResponse | null>(null);
const ctx = useMemo(() => truckTemplateContext(booking, trucks), [booking, trucks]);
const reset = () => {
resetFile.current?.();
setFileName(null);
setParsed({ rows: [], errors: [], rowNumbers: [] });
setResult(null);
};
const uploadMutation = useMutation({
mutationFn: async () => {
const { data } = await client.post(URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(bookingId), {
trucks: parsed,
});
return data;
const { data } = await client.post<BulkTruckUploadResponse | { data: BulkTruckUploadResponse }>(
URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(booking.id),
{ trucks: parsed.rows },
);
return ("data" in data ? data.data : data) as BulkTruckUploadResponse;
},
onSuccess: () => {
onSuccess: (response) => {
void queryClient.invalidateQueries({ queryKey: ["customer-trucks", booking.id] });
onSuccess?.();
setFile(null);
setParsed([]);
onClose();
// Only a clean run closes. A partial failure has to be shown, or the
// customer walks away believing all their trucks were created.
if (response.failed === 0) {
reset();
onClose();
return;
}
setResult(response);
},
});
const handleFileSelect = async (selectedFile: File | null) => {
if (!selectedFile) {
setFile(null);
setParsed([]);
setParseError(null);
const handleFile = async (selected: File | null) => {
setResult(null);
if (!selected) {
reset();
return;
}
setFileName(selected.name);
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([]);
setParsed(await parseTruckAssignmentFile(selected, ctx));
} catch (err) {
setParsed({
rows: [],
errors: [err instanceof Error ? err.message : "Could not read the file."],
rowNumbers: [],
});
}
};
const handleDownloadTemplate = () => {
generateTruckAssignmentTemplate("truck-assignments.xlsx");
};
const { rows, errors, rowNumbers } = parsed;
const listedErrors = errors.slice(0, MAX_LISTED_ERRORS);
const hiddenErrors = errors.length - listedErrors.length;
/** Map a server error back to the spreadsheet row the customer actually sees. */
const excelRowFor = (index: number, fallback: number) => rowNumbers[index] ?? fallback;
const columnLabel =
ctx.shape === "CONTAINER"
? "Containers"
: ctx.shape === "PER_ITEM"
? `Quantity (${ctx.itemNoun})`
: "Planned tons";
return (
<Modal
opened={opened}
onClose={onClose}
title="Bulk Upload Truck Assignments"
onClose={() => {
reset();
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.
Download the template for this booking, fill in one row per truck, then upload it.
{ctx.shape === "CONTAINER"
? " It lists this booking's containers and their sizes."
: ctx.remainingTons != null
? ` ${ctx.remainingTons} t are still to be hauled.`
: ""}
</Alert>
<Group>
<Button
leftSection={<Download size={16} />}
variant="light"
onClick={handleDownloadTemplate}
onClick={() => downloadTruckAssignmentTemplate(ctx)}
>
Download Template
Download template
</Button>
<FileButton resetRef={resetFile} accept=".xlsx,.xls" onChange={handleFile}>
{(props) => (
<Button {...props} variant="default" leftSection={<Upload size={16} />}>
{fileName ?? "Choose Excel file"}
</Button>
)}
</FileButton>
</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}
{errors.length > 0 && (
<Alert
icon={<AlertTriangle size={16} />}
color="red"
title="Import failed — fix the file and upload it again"
>
<List size="sm" spacing={4}>
{listedErrors.map((message) => (
<List.Item key={message}>{message}</List.Item>
))}
</List>
{hiddenErrors > 0 && (
<Text size="sm" mt={6}>
and {hiddenErrors} more.
</Text>
)}
</Alert>
)}
{parsed.length > 0 && (
{result && result.failed > 0 && (
<Alert
icon={<AlertTriangle size={16} />}
color="orange"
title={`${result.success} truck(s) added, ${result.failed} rejected`}
>
<Table verticalSpacing="xs" mt="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Row</Table.Th>
<Table.Th>Plate</Table.Th>
<Table.Th>Reason</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{result.errors.map((e) => (
<Table.Tr key={`${e.index}-${e.truck}`}>
<Table.Td>{excelRowFor(e.index, e.row)}</Table.Td>
<Table.Td>{e.truck}</Table.Td>
<Table.Td>
<Text size="sm">{e.reason}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Alert>
)}
{rows.length > 0 && (
<>
<div>
<Text fw={600} mb="xs">
Preview ({parsed.length} trucks)
Preview ({rows.length} truck{rows.length !== 1 ? "s" : ""})
</Text>
<Table striped highlightOnHover verticalSpacing="xs">
<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.Th>Row</Table.Th>
<Table.Th>Plate number</Table.Th>
<Table.Th>Driver</Table.Th>
<Table.Th>Truck type</Table.Th>
<Table.Th>{columnLabel}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{parsed.map((truck, idx) => (
<Table.Tr key={idx}>
{rows.map((truck, idx) => (
<Table.Tr key={`${truck.truckPlateNumber}-${idx}`}>
<Table.Td>
<Text size="sm" c="dimmed">
{rowNumbers[idx]}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{truck.truckPlateNumber}</Text>
</Table.Td>
@@ -136,18 +242,21 @@ export function BulkTruckUploadModal({
<Text size="sm">{truck.truckType}</Text>
</Table.Td>
<Table.Td>
{truck.containerNumbers?.length ? (
{ctx.shape === "CONTAINER" ? (
<Group gap="xs">
{truck.containerNumbers.map((c) => (
{(truck.containerNumbers ?? []).map((c) => (
<Badge key={c} size="sm">
{c}
</Badge>
))}
</Group>
) : (
<Text size="sm" c="dimmed">
) : ctx.shape === "PER_ITEM" ? (
<Text size="sm">
{truck.plannedQuantity}
{truck.plannedTons ? ` · ${truck.plannedTons} t` : ""}
</Text>
) : (
<Text size="sm">{truck.plannedTons} t</Text>
)}
</Table.Td>
</Table.Tr>
@@ -158,14 +267,14 @@ export function BulkTruckUploadModal({
<Group justify="space-between">
<Text size="sm" c="dimmed">
Ready to upload {parsed.length} truck(s)
Ready to upload {rows.length} truck{rows.length !== 1 ? "s" : ""}
</Text>
<Button
loading={uploadMutation.isPending}
onClick={() => uploadMutation.mutate()}
leftSection={<CheckCircle size={16} />}
>
Upload Trucks
Upload trucks
</Button>
</Group>
</>

View File

@@ -16,9 +16,20 @@ import {
TextInput,
} 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, Upload } from "lucide-react";
import { useState } from "react";
import { CUSTOMER_TRUCK_TYPES, type Freight } from "@edr/types";
import {
AlertTriangle,
CheckCircle2,
Clock,
Download,
Lock,
Pencil,
Plus,
Trash2,
Truck,
Upload,
} from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { api } from "@/services/api";
@@ -26,9 +37,14 @@ import { customerTrucksService } from "@/services/customer-trucks.service";
import { CardTitle, SectionCard } from "./layout";
import { BulkTruckUploadModal } from "./BulkTruckUploadModal";
import { generateTruckAssignmentTemplate } from "@/utils/truck-assignment-template";
import type { BookingDetail } from "../booking-detail-types";
import {
downloadTruckAssignmentTemplate,
isTwentyFoot,
truckTemplateContext,
} from "@/utils/truck-assignment-template";
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
const TRUCK_TYPES = [...CUSTOMER_TRUCK_TYPES];
// Waybill-style selectable copies (indexes 1-8 in the API catalog). The 2 gate
// copies (Port Operations, Gate Security & Carrier) are always printed.
@@ -64,9 +80,19 @@ const errorMessage = (error: unknown, fallback: string) => {
export function CustomerTruckAssignmentCard({
booking,
onAssigned,
blockedReason,
notice,
}: {
booking: Freight.IBooking;
booking: BookingDetail;
onAssigned: () => void;
/**
* Why assignment is closed right now, if it is. The card still renders — and
* still lists any trucks already assigned — with this shown in place of the
* form, rather than the whole card vanishing from the Logistics tab.
*/
blockedReason?: string | null;
/** Something the customer should know before assigning — shown above the form when it is open. */
notice?: string | null;
}) {
const queryClient = useQueryClient();
const trucksKey = ["customer-trucks", booking.id];
@@ -86,6 +112,12 @@ export function CustomerTruckAssignmentCard({
const [error, setError] = useState<string | null>(null);
const [bulkModalOpen, setBulkModalOpen] = useState(false);
// What this booking hauls: containers, loose tonnage (PER_TON), or counted
// items (PER_ITEM — machinery, RoRo vehicles). Drives the form, the Excel
// template and its parser from one place.
const ctx = useMemo(() => truckTemplateContext(booking, trucks), [booking, trucks]);
const isPerItem = ctx.shape === "PER_ITEM";
// Container numbers on the booking that aren't already loaded onto a truck.
const assignedNumbers = new Set(
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
@@ -94,9 +126,20 @@ export function CustomerTruckAssignmentCard({
const editingOwn = new Set(
(trucks.find((t) => t.id === editingId)?.containers ?? []).map((c) => c.containerNumber),
);
const availableContainers = (booking.containerNumbers ?? []).filter(
const containerSizes = new Map(ctx.containers.map((c) => [c.number, c.size]));
const unassignedContainers = (booking.containerNumbers ?? []).filter(
(n) => !assignedNumbers.has(n) || editingOwn.has(n),
);
// A 40ft fills the truck. Once one is picked, or two 20ft are, nothing more
// may be added — the API rejects it either way, so don't offer the choice.
const pickedHas40ft = containers.some((n) => !isTwentyFoot(containerSizes.get(n) ?? ""));
const availableContainers = unassignedContainers.filter((n) => {
if (containers.includes(n)) return true;
if (pickedHas40ft) return false;
// Something is already picked and it's 20ft — only another 20ft may join it.
if (containers.length > 0) return isTwentyFoot(containerSizes.get(n) ?? "");
return true;
});
// Containers on the booking not yet assigned to any truck (independent of edit).
const pendingAssignmentCount = (booking.containerNumbers ?? []).filter(
(n) => !assignedNumbers.has(n),
@@ -115,16 +158,12 @@ export function CustomerTruckAssignmentCard({
};
const startEdit = (t: Freight.ICustomerTruck) => {
const planned = t as Freight.ICustomerTruck & {
plannedTons?: number | string | null;
plannedQuantity?: number | null;
};
setPlateNumber(t.plateNumber ?? "");
setDriverName(t.driverName ?? "");
setTruckType(t.truckType ?? "");
setContainers((t.containers ?? []).map((c) => c.containerNumber));
setPlannedTons(planned.plannedTons != null ? Number(planned.plannedTons) : "");
setPlannedQty(planned.plannedQuantity != null ? Number(planned.plannedQuantity) : "");
setPlannedTons(t.plannedTons != null ? Number(t.plannedTons) : "");
setPlannedQty(t.plannedQuantity != null ? Number(t.plannedQuantity) : "");
setEditingId(t.id);
setError(null);
};
@@ -187,7 +226,13 @@ export function CustomerTruckAssignmentCard({
setError("Select 1 or 2 container numbers for this truck.");
return;
}
if (isBulk && plannedTons === "") {
// Counted cargo (machinery, RoRo vehicles) is committed by item count —
// tonnage is often unknown until the weighbridge, so it stays optional.
if (isBulk && isPerItem && plannedQty === "") {
setError(`Enter how many ${ctx.itemNoun} this truck will carry.`);
return;
}
if (isBulk && !isPerItem && plannedTons === "") {
setError("Enter the tonnes this truck will haul.");
return;
}
@@ -204,25 +249,27 @@ export function CustomerTruckAssignmentCard({
<CardTitle>External Truck Assignment</CardTitle>
</Group>
<Group gap={12}>
<Group gap="sm">
<Button
size="xs"
variant="default"
leftSection={<Download size={14} />}
onClick={() => generateTruckAssignmentTemplate("truck-assignments.xlsx")}
>
Download Template
</Button>
<Button
size="xs"
variant="light"
leftSection={<Upload size={14} />}
onClick={() => setBulkModalOpen(true)}
>
Bulk Upload
</Button>
</Group>
{pendingAssignmentCount > 0 && (
{!blockedReason && (
<Group gap="sm">
<Button
size="xs"
variant="default"
leftSection={<Download size={14} />}
onClick={() => downloadTruckAssignmentTemplate(ctx)}
>
Download Template
</Button>
<Button
size="xs"
variant="light"
leftSection={<Upload size={14} />}
onClick={() => setBulkModalOpen(true)}
>
Bulk Upload
</Button>
</Group>
)}
{!blockedReason && pendingAssignmentCount > 0 && (
<Text size="sm" fw={600} c="#b45309">
{pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment
</Text>
@@ -306,6 +353,19 @@ export function CustomerTruckAssignmentCard({
</Alert>
)}
{/* Assignment is closed for now — say why, and keep the trucks above
visible rather than hiding the whole card. */}
{blockedReason ? (
<Alert color="gray" variant="light" icon={<Lock size={16} />}>
{blockedReason}
</Alert>
) : (
<>
{notice && (
<Alert color="yellow" variant="light" icon={<AlertTriangle size={16} />}>
{notice}
</Alert>
)}
{!isBulk && (
<Alert color="blue" variant="light">
Truck capacity: assign either <b>1 x 40ft container</b> or up to{' '}
@@ -344,34 +404,39 @@ export function CustomerTruckAssignmentCard({
description="20ft: up to 2 per truck · 40ft: 1 per truck"
required
placeholder="Select container numbers"
data={availableContainers}
data={availableContainers.map((n) => {
const size = containerSizes.get(n);
return { value: n, label: size ? `${n} · ${size}` : n };
})}
value={containers}
onChange={setContainers}
maxValues={2}
maxValues={pickedHas40ft ? 1 : 2}
searchable
nothingFoundMessage="No unassigned containers"
/>
)}
{isBulk && (
<NumberInput
label="Tonnes to load"
label={isPerItem ? "Tonnes to load (optional)" : "Tonnes to load"}
description={(() => {
const total = Number(booking.cargoTotalWeightVgm) || 0;
const assigned = trucks
.filter((t) => t.id !== editingId)
.reduce((s, t) => {
const x = t as Freight.ICustomerTruck & {
netWeightTons?: number | string | null;
plannedTons?: number | string | null;
};
return s + (Number(x.netWeightTons ?? x.plannedTons) || 0);
}, 0);
.reduce(
(s, t) =>
s +
(Number(
(t as Freight.ICustomerTruck & { netWeightTons?: number | string | null })
.netWeightTons ?? t.plannedTons,
) || 0),
0,
);
const remaining = Math.max(0, Math.round((total - assigned) * 1000) / 1000);
return total > 0
? `${assigned} t of ${total} t already on trucks · ${remaining} t remaining`
: "Tonnage this truck hauls";
})()}
required
required={!isPerItem}
min={0}
value={plannedTons}
onChange={(v) => setPlannedTons(v === "" ? "" : Number(v))}
@@ -379,9 +444,17 @@ export function CustomerTruckAssignmentCard({
)}
{isBulk && (
<NumberInput
label="Items quantity (pcs)"
description="Optional piece count on this truck"
// Counted cargo commits by piece count; loose bulk records it
// only as a note alongside the tonnage that actually bills.
label={isPerItem ? `Number of ${ctx.itemNoun}` : "Items quantity (pcs)"}
description={
isPerItem
? `How many ${ctx.itemNoun} ride this truck`
: "Optional piece count on this truck"
}
required={isPerItem}
min={0}
allowDecimal={false}
value={plannedQty}
onChange={(v) => setPlannedQty(v === "" ? "" : Number(v))}
/>
@@ -410,6 +483,8 @@ export function CustomerTruckAssignmentCard({
</Text>
)
)}
</>
)}
{trucks.length > 0 && (
<Stack gap="xs">
@@ -464,7 +539,8 @@ export function CustomerTruckAssignmentCard({
<BulkTruckUploadModal
opened={bulkModalOpen}
onClose={() => setBulkModalOpen(false)}
bookingId={booking.id}
booking={booking}
trucks={trucks}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: trucksKey });
onAssigned();

View File

@@ -1,108 +1,551 @@
import * as XLSX from 'xlsx';
import * as XLSX from "xlsx";
import {
CUSTOMER_TRUCK_TYPES,
ISO_CONTAINER_NUMBER,
type Freight,
} from "@edr/types";
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': '',
},
];
import type { BookingDetail } from "@/pages/bookings/BookingDetailPage/booking-detail-types";
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 →'],
];
/**
* Bulk self-haul truck assignment via Excel.
*
* The sheet a customer gets depends on what they booked. A container booking
* names the containers each truck carries; a bulk booking has no containers at
* all — the truck hauls loose tonnage — and a break-bulk/RoRo booking is counted
* in items (machinery units, vehicles), not tons. Emitting one fixed set of
* columns for all three is what made this unusable for anything but containers.
*
* Parsing follows the house pattern in
* `pages/contracts/new-shipment-form/container-excel.ts`: read the sheet as a
* grid, match headers fuzzily, report 1-based Excel row numbers, and return
* all-or-nothing so a half-valid file never posts.
*/
const wb = XLSX.utils.book_new();
/** Two 20ft containers fit a truck bed; one 40ft fills it. Mirrors the API's `MAX_CONTAINERS_PER_TRUCK`. */
const MAX_CONTAINERS_PER_TRUCK = 2;
// 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');
export type TruckTemplateShape = "CONTAINER" | "PER_TON" | "PER_ITEM";
// 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 interface TruckTemplateContainer {
number: string;
/** "20ft" / "40ft", or "" when the booking line never recorded one. */
size: string;
/** Already riding another truck on this booking. */
assigned: boolean;
}
export function parseTruckAssignmentFile(
export interface TruckTemplateContext {
shape: TruckTemplateShape;
reference: string;
/** Human label for the cargo, e.g. "Machinery (MACHINERY)" or "Containerised cargo". */
cargoLabel: string;
/** Unit noun for PER_ITEM cargo — "machinery units", "vehicles", "items". */
itemNoun: string;
containers: TruckTemplateContainer[];
/** Bulk only: tonnage still to be hauled, when the booking declares a total. */
remainingTons: number | null;
}
/** A 40ft (or an unrecorded size, treated as one) fills the bed and travels alone. */
export function isTwentyFoot(size: string): boolean {
return size.includes("20");
}
/**
* PER_ITEM cargo is counted in pieces, and the piece has a name the customer
* recognises. RoRo bookings ride in as TRUCK / AUTOMOBILE / CARS.
*/
function itemNounFor(code: string | undefined, name: string | undefined): string {
switch ((code ?? "").toUpperCase()) {
case "TRUCK":
case "AUTOMOBILE":
case "CARS":
case "RORO":
return "vehicles";
case "MACHINERY":
return "machinery units";
default:
return name ? `${name.trim().toLowerCase()} items` : "items";
}
}
/**
* Everything the template and the parser need about one booking. Derived from
* data the detail endpoint already returns — no extra request.
*/
export function truckTemplateContext(
booking: BookingDetail,
trucks: Freight.ICustomerTruck[] = [],
): TruckTemplateContext {
const assigned = new Set(
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
);
// Container size lives on the booking_container LINE; the physical numbers
// live on its units. Walk both to get number → size.
const containers: TruckTemplateContainer[] = [];
for (const line of booking.bookingContainers ?? []) {
const size =
line.containerSize?.trim() ||
(line.containerType?.sizeFt ? `${line.containerType.sizeFt}ft` : "");
for (const unit of line.units ?? []) {
if (!unit.containerNumber) continue;
containers.push({
number: unit.containerNumber,
size,
assigned: assigned.has(unit.containerNumber),
});
}
}
// Fall back to the flat list when the line relation didn't come through — the
// numbers are still usable, we just can't police sizes client-side.
if (containers.length === 0) {
for (const number of booking.containerNumbers ?? []) {
containers.push({ number, size: "", assigned: assigned.has(number) });
}
}
const isContainer = String(booking.freightType) === "CONTAINER";
const unit = booking.cargoType?.unitOfMeasure;
const shape: TruckTemplateShape = isContainer
? "CONTAINER"
: unit === "PER_ITEM"
? "PER_ITEM"
: // PER_TON, NUMBER_OF_WAGONS and unset all haul tonnage by truck.
"PER_TON";
const cargoLabel = isContainer
? "Containerised cargo"
: booking.cargoType?.cargoTypeName
? `${booking.cargoType.cargoTypeName.trim()}${booking.cargoType.code ? ` (${booking.cargoType.code})` : ""}`
: "Bulk cargo";
// Planned tonnage already committed to live trucks draws the total down; the
// API applies the same rule on every add.
const totalTons = Number(booking.cargoTotalWeightVgm ?? 0);
const committed = trucks.reduce((sum, t) => sum + Number(t.plannedTons ?? 0), 0);
const remainingTons =
!isContainer && totalTons > 0
? Math.max(0, Math.round((totalTons - committed) * 1000) / 1000)
: null;
return {
shape,
reference: booking.reference ?? "",
cargoLabel,
itemNoun: itemNounFor(booking.cargoType?.code, booking.cargoType?.cargoTypeName),
containers,
remainingTons,
};
}
const PLATE_HEADER = "Truck Plate Number";
const DRIVER_HEADER = "Driver Name";
const TYPE_HEADER = "Truck Type";
const CONTAINER_1_HEADER = "Container 1";
const CONTAINER_2_HEADER = "Container 2";
const TONS_HEADER = "Planned Tons";
const QUANTITY_HEADER = "Planned Quantity";
function headersFor(shape: TruckTemplateShape): string[] {
const base = [PLATE_HEADER, DRIVER_HEADER, TYPE_HEADER];
if (shape === "CONTAINER") return [...base, CONTAINER_1_HEADER, CONTAINER_2_HEADER];
if (shape === "PER_ITEM") return [...base, QUANTITY_HEADER, TONS_HEADER];
return [...base, TONS_HEADER];
}
/** Sample rows built from the booking's own data, so the customer edits rather than invents. */
function sampleRowsFor(ctx: TruckTemplateContext): Array<Array<string | number>> {
const type = CUSTOMER_TRUCK_TYPES[0];
if (ctx.shape === "CONTAINER") {
const free = ctx.containers.filter((c) => !c.assigned);
if (free.length === 0) {
return [["3-12345 ET", "Abebe Kebede", type, "", ""]];
}
const rows: Array<Array<string | number>> = [];
let i = 0;
let plate = 12345;
while (i < free.length) {
const first = free[i];
// Pair only two 20ft; a 40ft (or an unknown size) takes the truck alone.
const second =
isTwentyFoot(first.size) && free[i + 1] && isTwentyFoot(free[i + 1].size)
? free[i + 1]
: null;
rows.push([
`3-${plate++} ET`,
"Abebe Kebede",
type,
first.number,
second?.number ?? "",
]);
i += second ? 2 : 1;
if (rows.length >= 5) break;
}
return rows;
}
const tons = ctx.remainingTons && ctx.remainingTons > 0 ? Math.min(30, ctx.remainingTons) : 30;
if (ctx.shape === "PER_ITEM") {
return [["3-12345 ET", "Abebe Kebede", type, 2, tons]];
}
return [["3-12345 ET", "Abebe Kebede", type, tons]];
}
function instructionsFor(ctx: TruckTemplateContext): Array<Array<string | number>> {
const rows: Array<Array<string | number>> = [
["TRUCK ASSIGNMENT — BULK UPLOAD"],
[],
["Booking", ctx.reference],
["Cargo", ctx.cargoLabel],
[],
["Fill in the 'Trucks' sheet. One row per truck. Do not rename the headers."],
[],
["Column", "Required", "Notes"],
[PLATE_HEADER, "Yes", "The truck's plate, as written on the vehicle."],
[DRIVER_HEADER, "Yes", "Full name of the driver."],
[TYPE_HEADER, "Yes", `One of: ${CUSTOMER_TRUCK_TYPES.join(", ")}`],
];
if (ctx.shape === "CONTAINER") {
rows.push(
[CONTAINER_1_HEADER, "Yes", "A container number from this booking — see the 'Containers' sheet."],
[CONTAINER_2_HEADER, "No", "Only when pairing two 20ft containers on one truck."],
[],
["TRUCK CAPACITY"],
["One 40ft container fills a truck and travels alone."],
["Two 20ft containers may share one truck."],
["Never mix a 40ft and a 20ft on the same truck."],
["Each container may be assigned to exactly one truck."],
);
} else if (ctx.shape === "PER_ITEM") {
rows.push(
[QUANTITY_HEADER, "Yes", `Whole number of ${ctx.itemNoun} on this truck.`],
[TONS_HEADER, "No", "Weight in tonnes, if known."],
[],
["This cargo is counted in items, not containers — leave containers out entirely."],
);
} else {
rows.push(
[TONS_HEADER, "Yes", "Tonnes this truck will haul. Decimals allowed."],
[],
["This is bulk cargo — the truck hauls loose tonnage and is weighed on exit."],
);
}
if (ctx.remainingTons != null) {
rows.push([], ["Tonnage still to be hauled", `${ctx.remainingTons} t`]);
}
return rows;
}
/** Build and download the template for this booking. */
export function downloadTruckAssignmentTemplate(
ctx: TruckTemplateContext,
filename?: string,
): void {
const workbook = XLSX.utils.book_new();
const instructions = XLSX.utils.aoa_to_sheet(instructionsFor(ctx));
instructions["!cols"] = [{ wch: 24 }, { wch: 12 }, { wch: 62 }];
XLSX.utils.book_append_sheet(workbook, instructions, "Instructions");
const headers = headersFor(ctx.shape);
const trucks = XLSX.utils.aoa_to_sheet([headers, ...sampleRowsFor(ctx)]);
trucks["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
XLSX.utils.book_append_sheet(workbook, trucks, "Trucks");
if (ctx.shape === "CONTAINER") {
const reference = XLSX.utils.aoa_to_sheet([
["Container Number", "Size", "Status"],
...ctx.containers.map((c) => [
c.number,
c.size || "unknown",
c.assigned ? "Already on a truck" : "Available",
]),
]);
reference["!cols"] = [{ wch: 20 }, { wch: 10 }, { wch: 20 }];
XLSX.utils.book_append_sheet(workbook, reference, "Containers");
}
XLSX.writeFile(
workbook,
filename ?? `truck-assignments-${ctx.reference || "booking"}.xlsx`,
);
}
/** Normalised header key — tolerates case, spaces, punctuation and stray units. */
function headerKey(raw: string): string {
return String(raw ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
}
/**
* Map each template column to the index it occupies in the uploaded sheet.
* Matching is by normalised substring so "Planned Tons (t)" still resolves;
* the two container columns are matched on their digit so "Container 1" can
* never be captured by the looser "container" test.
*/
function resolveColumns(headerRow: string[]): Record<string, number> {
const keys = headerRow.map(headerKey);
const find = (...candidates: string[]): number => {
for (const candidate of candidates) {
const index = keys.findIndex((k) => k.includes(candidate));
if (index >= 0) return index;
}
return -1;
};
return {
plate: find("plate", "truckplate"),
driver: find("driver"),
type: find("trucktype", "type"),
container1: find("container1", "containerone"),
container2: find("container2", "containertwo"),
tons: find("plannedtons", "tons", "weight"),
quantity: find("plannedquantity", "quantity", "count"),
};
}
const TRUCK_TYPE_LIST = CUSTOMER_TRUCK_TYPES.join(", ");
/** Case-insensitive match onto the canonical spelling the API expects. */
function canonicalTruckType(raw: string): string | null {
const needle = raw.trim().toLowerCase();
return CUSTOMER_TRUCK_TYPES.find((t) => t.toLowerCase() === needle) ?? null;
}
export interface ParsedTruckFile {
rows: Freight.AddCustomerTruckPayload[];
errors: string[];
/** Excel row number each parsed row came from, positionally aligned with `rows`. */
rowNumbers: number[];
}
/**
* Parse an uploaded sheet against this booking. Returns every problem at once —
* a customer fixing a 30-row file should not discover the mistakes one upload at
* a time — and returns no rows at all unless the whole file is clean.
*/
export async function parseTruckAssignmentFile(
file: File,
): Promise<
Array<{
truckPlateNumber: string;
driverName: string;
truckType: string;
containerNumbers?: string[];
}>
> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
ctx: TruckTemplateContext,
): Promise<ParsedTruckFile> {
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
const sheet =
workbook.Sheets["Trucks"] ??
Object.values(workbook.Sheets).find((s) => s !== workbook.Sheets["Instructions"]) ??
Object.values(workbook.Sheets)[0];
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 (!sheet) {
return { rows: [], errors: ["The file has no sheets."], rowNumbers: [] };
}
if (!wsData) {
reject(new Error('No data sheet found in Excel file'));
return;
}
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, {
header: 1,
raw: false,
defval: "",
});
const jsonData = XLSX.utils.sheet_to_json(wsData) as Array<Record<string, any>>;
// The header is the first row that names the plate column — customers add
// titles and notes above the table.
const headerIndex = grid.findIndex((row) =>
row.some((cell) => headerKey(cell).includes("plate")),
);
if (headerIndex < 0) {
return {
rows: [],
errors: [
`Could not find the "${PLATE_HEADER}" column. Use the downloaded template and keep its headers.`,
],
rowNumbers: [],
};
}
const trucks = jsonData.map((row) => {
const containers = [
row['Container 1'],
row['Container 2'],
]
.filter((c) => c && c.trim())
.map((c) => c.trim().toUpperCase());
const columns = resolveColumns(grid[headerIndex]);
const errors: string[] = [];
const rows: Freight.AddCustomerTruckPayload[] = [];
const rowNumbers: number[] = [];
return {
truckPlateNumber: row['Truck Plate Number']?.trim() || '',
driverName: row['Driver Name']?.trim() || '',
truckType: row['Truck Type']?.trim() || '',
containerNumbers: containers.length > 0 ? containers : undefined,
};
});
const containerSizes = new Map(ctx.containers.map((c) => [c.number, c.size]));
const alreadyAssigned = new Set(
ctx.containers.filter((c) => c.assigned).map((c) => c.number),
);
const plateSeen = new Map<string, number>();
const containerSeen = new Map<string, number>();
let tonsInFile = 0;
resolve(trucks);
} catch (error) {
reject(error);
}
for (let i = headerIndex + 1; i < grid.length; i++) {
const row = grid[i];
const rowNo = i + 1; // 1-based, as shown in Excel
const cell = (key: string): string => {
const index = columns[key];
return index >= 0 ? String(row[index] ?? "").trim() : "";
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsArrayBuffer(file);
});
const plate = cell("plate");
const driver = cell("driver");
const rawType = cell("type");
// A trailing blank row is normal, not an error.
if (!plate && !driver && !rawType && row.every((c) => !String(c ?? "").trim())) {
continue;
}
let rowOk = true;
if (!plate) {
errors.push(`Row ${rowNo}: truck plate number is required.`);
rowOk = false;
}
if (!driver) {
errors.push(`Row ${rowNo}: driver name is required.`);
rowOk = false;
}
const truckType = canonicalTruckType(rawType);
if (!truckType) {
errors.push(
`Row ${rowNo}: truck type "${rawType || "—"}" is not accepted. Use one of: ${TRUCK_TYPE_LIST}.`,
);
rowOk = false;
}
const plateKey = plate.toUpperCase();
if (plate) {
const seenAt = plateSeen.get(plateKey);
if (seenAt) {
errors.push(`Row ${rowNo}: plate ${plate} already appears on row ${seenAt}.`);
rowOk = false;
} else {
plateSeen.set(plateKey, rowNo);
}
}
const payload: Freight.AddCustomerTruckPayload = {
truckPlateNumber: plateKey,
driverName: driver,
truckType: truckType ?? "",
};
if (ctx.shape === "CONTAINER") {
const numbers = [cell("container1"), cell("container2")]
.map((n) => n.trim().toUpperCase())
.filter(Boolean);
if (numbers.length === 0) {
errors.push(`Row ${rowNo}: at least one container number is required.`);
rowOk = false;
}
if (numbers.length > MAX_CONTAINERS_PER_TRUCK) {
errors.push(`Row ${rowNo}: a truck carries at most ${MAX_CONTAINERS_PER_TRUCK} containers.`);
rowOk = false;
}
if (numbers.length === 2 && numbers[0] === numbers[1]) {
errors.push(`Row ${rowNo}: container ${numbers[0]} is listed twice on the same truck.`);
rowOk = false;
}
for (const number of numbers) {
if (!ISO_CONTAINER_NUMBER.test(number)) {
errors.push(
`Row ${rowNo}: container "${number}" is not a valid ISO number (four letters then seven digits, e.g. ABCD1234567).`,
);
rowOk = false;
continue;
}
if (ctx.containers.length > 0 && !containerSizes.has(number)) {
errors.push(`Row ${rowNo}: container ${number} is not on this booking.`);
rowOk = false;
continue;
}
if (alreadyAssigned.has(number)) {
errors.push(`Row ${rowNo}: container ${number} is already loaded onto another truck.`);
rowOk = false;
continue;
}
const seenAt = containerSeen.get(number);
if (seenAt) {
errors.push(`Row ${rowNo}: container ${number} is already used on row ${seenAt}.`);
rowOk = false;
continue;
}
containerSeen.set(number, rowNo);
}
// Pairing is allowed only when both are explicitly 20ft — a 40ft, or a
// container whose size was never recorded, fills the bed on its own. Same
// rule the API enforces in `assertTruckLoad`.
if (numbers.length === 2) {
const sizes = numbers.map((n) => containerSizes.get(n) ?? "");
if (sizes.some((size) => !isTwentyFoot(size))) {
errors.push(
`Row ${rowNo}: a truck carries either one 40ft container or two 20ft containers — ${numbers
.map((n, idx) => `${n} (${sizes[idx] || "size unknown"})`)
.join(" and ")} cannot share one.`,
);
rowOk = false;
}
}
payload.containerNumbers = numbers;
} else {
const tonsRaw = cell("tons");
const tons = Number(tonsRaw);
const quantityRaw = cell("quantity");
const quantity = Number(quantityRaw);
if (ctx.shape === "PER_ITEM") {
if (!quantityRaw || !Number.isInteger(quantity) || quantity <= 0) {
errors.push(
`Row ${rowNo}: planned quantity "${quantityRaw || "—"}" must be a whole number of ${ctx.itemNoun} greater than 0.`,
);
rowOk = false;
} else {
payload.plannedQuantity = quantity;
}
if (tonsRaw) {
if (Number.isNaN(tons) || tons <= 0) {
errors.push(`Row ${rowNo}: planned tons "${tonsRaw}" must be a number greater than 0.`);
rowOk = false;
} else {
payload.plannedTons = tons;
tonsInFile += tons;
}
}
} else {
if (!tonsRaw || Number.isNaN(tons) || tons <= 0) {
errors.push(
`Row ${rowNo}: planned tons "${tonsRaw || "—"}" must be a number greater than 0.`,
);
rowOk = false;
} else {
payload.plannedTons = tons;
tonsInFile += tons;
}
}
}
if (rowOk) {
rows.push(payload);
rowNumbers.push(rowNo);
}
}
if (
ctx.remainingTons != null &&
tonsInFile > ctx.remainingTons + 0.001 // numeric(14,3) — tolerate float drift
) {
errors.push(
`The file plans ${Math.round(tonsInFile * 1000) / 1000} t but only ${ctx.remainingTons} t are left to haul on this booking.`,
);
}
if (rows.length === 0 && errors.length === 0) {
errors.push("The sheet has no truck rows below the header.");
}
return errors.length > 0
? { rows: [], errors, rowNumbers: [] }
: { rows, errors: [], rowNumbers };
}

View File

@@ -648,6 +648,25 @@ export interface ICustomerTruckContainer {
containerNumber: string;
}
/**
* The truck-type vocabulary a customer picks from when self-hauling. The API
* validates against this exact list (`@IsIn`), the portal's dropdown renders it,
* and the bulk-upload template documents it — all three read this constant so a
* value the customer can type can never be one the API rejects.
*/
export const CUSTOMER_TRUCK_TYPES = [
"Flatbed",
"Container Chassis",
"Lowboy",
"Box Truck",
"Tipper",
] as const;
export type CustomerTruckType = (typeof CUSTOMER_TRUCK_TYPES)[number];
/** ISO 6346 container number: four letters then seven digits, e.g. ABCD1234567. */
export const ISO_CONTAINER_NUMBER = /^[A-Z]{4}\d{7}$/;
/** A customer self-haul truck on a booking, carrying 12 containers. */
export interface ICustomerTruck {
id: string;
@@ -659,6 +678,10 @@ export interface ICustomerTruck {
arrivedAt?: string | null;
departedAt?: string | null;
containers?: ICustomerTruckContainer[];
/** Bulk: planned tonnage this truck hauls. `numeric` — serialises as a string. */
plannedTons?: number | string | null;
/** Bulk PER_ITEM: planned item/piece count on this truck. */
plannedQuantity?: number | null;
}
/** Payload to add a customer self-haul truck (12 container numbers). */
@@ -666,7 +689,12 @@ export interface AddCustomerTruckPayload {
truckPlateNumber: string;
driverName: string;
truckType: string;
containerNumbers: string[];
/** Container bookings only — bulk trucks haul loose tonnage instead. */
containerNumbers?: string[];
/** Bulk: planned tonnage, drawn down against the booking's declared VGM. */
plannedTons?: number;
/** Bulk PER_ITEM: planned item/piece count. */
plannedQuantity?: number;
}
export interface IBooking extends BaseEntity {