diff --git a/apps/edr-freight-api/src/migrations/3790000000000-EmptyContainerReturnCompanyName.ts b/apps/edr-freight-api/src/migrations/3790000000000-EmptyContainerReturnCompanyName.ts new file mode 100644 index 000000000..b0088da66 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3790000000000-EmptyContainerReturnCompanyName.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Empties backfilled into the yard belong to a company that may not be a + * registered customer yet, so `customer_id` cannot hold it. `company_name` is + * the typed fallback, and the display label when the customer IS registered. + */ +export class EmptyContainerReturnCompanyName3790000000000 implements MigrationInterface { + name = 'EmptyContainerReturnCompanyName3790000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + ADD COLUMN IF NOT EXISTS company_name varchar(200) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + DROP COLUMN IF EXISTS company_name + `); + } +} diff --git a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts index 9af97f1e8..92a5a611a 100644 --- a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts +++ b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts @@ -1,6 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { + ArrayMaxSize, + ArrayMinSize, ArrayNotEmpty, IsArray, IsDateString, @@ -9,6 +11,7 @@ import { IsOptional, IsString, IsUUID, + MaxLength, Min, ValidateNested, } from 'class-validator'; @@ -150,6 +153,14 @@ export class CreateEmptyContainerReturnDto { @IsUUID() customerId?: string; + @ApiPropertyOptional({ + description: 'Owning company name — free text when the company is not a registered customer.', + }) + @IsOptional() + @IsString() + @MaxLength(200) + companyName?: string; + @ApiPropertyOptional() @IsOptional() @IsDateString() @@ -196,6 +207,16 @@ export class CreateEmptyContainerReturnDto { returnedBy?: 'EDR' | 'CUSTOMER'; } +export class BulkCreateEmptyContainerReturnsDto { + @ApiProperty({ type: [CreateEmptyContainerReturnDto] }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(1000) + @ValidateNested({ each: true }) + @Type(() => CreateEmptyContainerReturnDto) + returns!: CreateEmptyContainerReturnDto[]; +} + export class LoadEmptyContainerItemDto { @ApiProperty({ format: 'uuid' }) @IsUUID() diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts index 263dd30d2..7d53825eb 100644 --- a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts +++ b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts @@ -27,6 +27,15 @@ export class EmptyContainerReturn extends BaseEntity { @Column({ name: 'customer_id', type: 'uuid', nullable: true }) customerId?: string | null; + /** + * Owning company as text. Set when the box was backfilled for a company that + * is not (yet) a registered customer, so `customer_id` cannot carry it. When + * a registered company IS picked, both are set — the name is the label the + * list renders without a join. + */ + @Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true }) + companyName?: string | null; + @Column({ name: 'return_date', type: 'timestamptz' }) returnDate!: Date; diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts index ae80ea9c8..1116e9440 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts @@ -11,6 +11,7 @@ import { BookingsService } from '../bookings/bookings.service'; import { AssignCustomsRiskDto, CreateDjiboutiIncidentDto, + BulkCreateEmptyContainerReturnsDto, CreateEmptyContainerReturnDto, ImportOperationActionDto, LoadEmptyContainersOnTrainDto, @@ -125,6 +126,15 @@ export class ImportOperationsController { return this.service.createEmptyReturn(dto); } + @Post('empty-container-returns/bulk') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ + summary: 'Bulk-record empties already in the yard but never entered in the system', + }) + bulkCreateEmptyReturns(@Body() dto: BulkCreateEmptyContainerReturnsDto) { + return this.service.bulkCreateEmptyReturns(dto); + } + @Post('empty-container-returns/load-on-train') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index be94e3f32..415438884 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import { In, Not, Repository } from 'typeorm'; import { NotificationAudience, NotificationType } from '@edr/types'; import { LogoSettingsService } from '../logo-settings/logo-settings.service'; @@ -10,6 +10,7 @@ import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; import { + BulkCreateEmptyContainerReturnsDto, CreateDjiboutiIncidentDto, CreateEmptyContainerReturnDto, ImportOperationActionDto, @@ -24,7 +25,10 @@ import { type DjiboutiIncidentType, } from './entities/djibouti-incident.entity'; import { assertWagonLoad } from './empty-container-wagon.util'; -import { EmptyContainerReturn } from './entities/empty-container-return.entity'; +import { + EmptyContainerReturn, + type EmptyContainerReturnStatus, +} from './entities/empty-container-return.entity'; import { ImportCustomsFinalization, type ImportCustomsDocumentType, @@ -174,6 +178,7 @@ export class ImportOperationsService { containerNumber: dto.containerNumber, bookingId: dto.bookingId ?? null, customerId: dto.customerId ?? null, + companyName: dto.companyName ?? null, returnDate, containerSize: dto.containerSize ?? null, facility: dto.facility ?? null, @@ -199,6 +204,66 @@ export class ImportOperationsService { return saved; } + /** + * Bulk backfill of empties already sitting in a yard but never recorded. + * All-or-nothing: if any container number already has an open (not COMPLETED) + * return, nothing is written — re-uploading the same sheet must not duplicate + * boxes. No interchange notification is sent; these are historical rows, not + * a live handover. + */ + async bulkCreateEmptyReturns(dto: BulkCreateEmptyContainerReturnsDto) { + const numbers = dto.returns.map((r) => r.containerNumber.trim().toUpperCase()); + + const seen = new Set(); + const dupInFile = numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false))); + if (dupInFile.length > 0) { + throw new BadRequestException( + `Container number(s) repeated in the upload: ${[...new Set(dupInFile)].join(', ')}`, + ); + } + + const existing = await this.emptyReturns.find({ + where: { + containerNumber: In(numbers), + status: Not('COMPLETED' as EmptyContainerReturnStatus), + }, + select: { containerNumber: true }, + }); + if (existing.length > 0) { + throw new BadRequestException( + `Already recorded as returned: ${existing.map((r) => r.containerNumber).join(', ')}`, + ); + } + + const rows = dto.returns.map((r, i) => { + const returnDate = r.returnDate ? new Date(r.returnDate) : new Date(); + return this.emptyReturns.create({ + containerNumber: numbers[i], + bookingId: r.bookingId ?? null, + customerId: r.customerId ?? null, + companyName: r.companyName ?? null, + returnDate, + containerSize: r.containerSize ?? null, + facility: r.facility ?? null, + yard: r.yard ?? null, + zone: r.zone ?? null, + condition: r.condition ?? null, + handoverNote: r.handoverNote ?? null, + performedBy: r.performedBy ?? null, + returnedBy: r.returnedBy ?? null, + statusHistory: [ + { + status: 'RETURNED' as const, + changedAt: returnDate.toISOString(), + performedBy: r.performedBy ?? null, + }, + ], + }); + }); + + return this.emptyReturns.save(rows); + } + /** * Load returned empties onto an export departure. A wagon takes ONE 40ft or * TWO 20ft — never a mix, never three. Empties already sitting on a wagon of diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index fbf555444..4fc921ccb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -895,6 +895,28 @@ export class TrainSchedulingController { return res.send(buffer); } + @Get("schedules/:id/marshalling/stops") + @TrainSchedulingView() + @ApiOperation({ summary: "Corridor stops with a logged consist change, in order (Marshalling 2, 3, 4…)" }) + marshallingStops(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.marshallingStops(id); + } + + @Get("schedules/:id/marshalling/document/:stopIndex") + @TrainSchedulingView() + @ApiOperation({ summary: "Download the numbered marshalling PDF for one corridor stop" }) + async marshallingDocumentAt( + @Param("id", ParseUUIDPipe) id: string, + @Param("stopIndex", ParseIntPipe) stopIndex: number, + @Res() res: Response, + ) { + const { filename, buffer } = await this.trainSchedulingService.marshallingDocumentAt(id, stopIndex); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + // ---- batch / booking-window staff actions ---- @Post("schedules/:id/run-batch") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 2b8971e81..d6e778216 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -1209,7 +1209,7 @@ describe('TrainSchedulingService', () => { expect(html).toContain('To load en route1 containers'); }); - it('prints coupled/switched wagons logged at this stop, and omits the box when there are none', () => { + it('prints the consist-changes table for this stop, and omits it when there are none', () => { const schedule = { id: 'schedule-1', trainNumber: '8302', @@ -1223,16 +1223,21 @@ describe('TrainSchedulingService', () => { const withChanges = build(schedule, { consistChangesAtStop: [ - { action: 'ADD', wagonNumber: 'W-1002' }, - { action: 'SWITCH', wagonNumber: 'W-0501 → W-1003' }, + { wagonNumber: 'W-1002', event: 'Coupled', containerNumbers: 'EMPTY WAGON' }, + { wagonNumber: 'W-1005', event: 'Coupled', containerNumbers: 'CONT-004, CONT-005' }, + { wagonNumber: 'W-0501 → W-1003', event: 'Switched', containerNumbers: 'CONT-011' }, ], }); - expect(withChanges).toContain('Consist changed at this stop'); - expect(withChanges).toContain('Coupled: W-1002'); - expect(withChanges).toContain('Uncoupled — replaced: W-0501 → W-1003'); + expect(withChanges).toContain('Consist Changed At This Stop'); + expect(withChanges).toContain('W-1002'); + expect(withChanges).toContain('Coupled'); + expect(withChanges).toContain('EMPTY WAGON'); + expect(withChanges).toContain('CONT-004, CONT-005'); + expect(withChanges).toContain('W-0501 → W-1003'); + expect(withChanges).toContain('Switched'); const withoutChanges = build(schedule, {}); - expect(withoutChanges).not.toContain('Consist changed at this stop'); + expect(withoutChanges).not.toContain('Consist Changed At This Stop'); }); it('lists loaded empty containers by number and states they are empty', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 1fc0da319..dbf0c7f7c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -3617,7 +3617,83 @@ export class TrainSchedulingService { return { wagons, unassignedBookings }; } - async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + /** + * Every corridor stop where the consist actually changed for this schedule + * (coupled, uncoupled, or switched — any flavor), in the order the train + * reached them. Origin is never in this list — it's always its own doc (the + * plain import/export load list), so numbering here starts at 2. A stop with + * only a routine checkpoint and no consist change never gets a row, which is + * the point: "Marshalling 2, 3, 4…" tracks events, not raw stop count. + */ + async marshallingStops( + scheduleId: string, + ): Promise> { + const rows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ + where: { trainScheduleId: scheduleId }, + order: { occurredAt: 'ASC' }, + }); + const firstSeenAt = new Map(); + for (const row of rows) { + if (!row.yardId || firstSeenAt.has(row.yardId)) continue; + firstSeenAt.set(row.yardId, row.occurredAt); + } + const orderedYardIds = [...firstSeenAt.entries()] + .sort((a, b) => a[1].getTime() - b[1].getTime()) + .map(([yardId]) => yardId); + const labels = await this.yardLabelsById(orderedYardIds); + return orderedYardIds.map((yardId, i) => ({ + stopIndex: i + 2, + yardId, + yardLabel: labels.get(yardId) ?? yardId, + firstOccurredAt: firstSeenAt.get(yardId)!.toISOString(), + })); + } + + /** + * The coupled/uncoupled/switched rows for one stop, in the locked table + * shape (wagon, event, containers). "EMPTY WAGON" replaces the container + * list rather than a blank cell — the column always exists so a loaded and + * an empty coupling read as the same table, not two different layouts. + * Cargo for ADD/REMOVE rows is read off the schedule's OWN slot allocations + * for that physical wagon: an ADD is a leg slot boarding already loaded (see + * stampSlotLegs) or an empty couple (plannedWagonCouples) with none; a + * REMOVE is a slot alighting with its cargo, or an empty trim. A SWITCH row + * carries the incoming wagon's id — the slot's cargo already rides it. + */ + private consistChangesAt( + schedule: TrainSchedule, + logRows: ScheduleWagonAdjustmentLog[], + ): Array<{ wagonNumber: string; event: 'Coupled' | 'Uncoupled' | 'Switched'; containerNumbers: string }> { + const slotByPhysicalWagonId = new Map( + (schedule.trainSet?.wagons ?? []) + .filter((wagon) => wagon.physicalWagonId) + .map((wagon) => [wagon.physicalWagonId as string, wagon]), + ); + return logRows.map((row) => { + const slot = slotByPhysicalWagonId.get(row.wagonId); + const containerNumbers = (slot?.allocations ?? []) + .flatMap((allocation) => allocation.containerItems ?? []) + .map((item) => item.containerNumber) + .filter(Boolean) + .join(', '); + return { + wagonNumber: row.wagonNumber, + event: row.action === 'ADD' ? 'Coupled' : row.action === 'REMOVE' ? 'Uncoupled' : 'Switched', + containerNumbers: containerNumbers || 'EMPTY WAGON', + }; + }); + } + + /** + * The numbered marshalling document for one corridor stop (see + * marshallingStops — stopIndex 2+, origin is its own separate doc). + * ponytail: the wagon table always shows the CURRENT on-board state, not a + * point-in-time reconstruction of what stood on the train at that past + * stop — a full historical snapshot is a much bigger feature nobody has + * asked for. What's stop-specific is the consist-changes table below it, + * which IS scoped to that stop's own logged events. + */ + async marshallingDocumentAt(scheduleId: string, stopIndex: number): Promise<{ filename: string; buffer: Buffer }> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -3627,24 +3703,66 @@ export class TrainSchedulingService { 'Intercity marshalling document applies only to dispatched or arrived trains', ); } + const stops = await this.marshallingStops(scheduleId); + const stop = stops.find((s) => s.stopIndex === stopIndex); + if (!stop) { + throw new NotFoundException( + `No marshalling document at stop ${stopIndex} for this schedule — nothing coupled/uncoupled there, or the stop doesn't exist`, + ); + } + const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); + const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ + where: { trainScheduleId: scheduleId, yardId: stop.yardId }, + order: { occurredAt: 'ASC' }, + }); + const html = this.buildExportLoadListHtml(schedule, { + title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`, + positionLabel: `At ${stop.yardLabel}`, + wagons, + unassignedBookings, + emptyContainers: await this.loadedEmptyContainers(scheduleId), + logoImageUrl: await this.logoSettings.getLogoImageUrl(), + consistChangesAtStop: this.consistChangesAt(schedule, logRows), + }); + // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. + const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`); + const reference = schedule.trainNumber ?? schedule.id; + return { + filename: `marshalling-${stopIndex}-${this.safeDocumentName(reference)}.pdf`, + buffer, + }; + } + + /** + * Back-compat alias: the single "current" intercity doc (Marshalling 2) the + * old one-document-per-schedule UI calls. Resolves to the LATEST stop with + * a logged consist change; falls back to the current-position doc with no + * changes table when nothing has coupled/uncoupled yet (e.g. right after + * dispatch, before any mid-corridor stop). + */ + async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + const stops = await this.marshallingStops(scheduleId); + const latest = stops[stops.length - 1]; + if (latest) { + return this.marshallingDocumentAt(scheduleId, latest.stopIndex); + } + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') { + throw new BadRequestException( + 'Intercity marshalling document applies only to dispatched or arrived trains', + ); + } const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); const last = checkpoints[checkpoints.length - 1]; const positionLabel = last ? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}` : `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`; - const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); - // Couples/switches logged AT THIS STOP — what staff standing here actually - // just did to the consist. Bare trims (REMOVE, no replacement) are left - // out: nothing new to point staff at for those. Origin adjustments (a - // different yard) don't show up on this stop's document. - const consistChangesAtStop = last - ? await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ - where: { trainScheduleId: scheduleId, yardId: last.yardId, action: In(['ADD', 'SWITCH']) }, - order: { occurredAt: 'DESC' }, - }) - : []; const html = this.buildExportLoadListHtml(schedule, { title: 'Intercity Marshalling Document / Load List (Marshalling 2)', positionLabel, @@ -3652,7 +3770,6 @@ export class TrainSchedulingService { unassignedBookings, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), - consistChangesAtStop, }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); @@ -3722,10 +3839,14 @@ export class TrainSchedulingService { // Slots that couple to the train downstream (slot id → board yard label). // Their cargo renders as TO LOAD AT and stays out of the loaded tallies. pendingBoardYardLabelBySlot?: Map; - // Intercity (Marshalling 2) only: couples/switches logged at the stop - // this document is printed at (see ScheduleWagonAdjustmentLog). Origin - // import/export docs never pass this, so they render no such box. - consistChangesAtStop?: ScheduleWagonAdjustmentLog[]; + // Numbered marshalling docs only (see marshallingDocumentAt / + // consistChangesAt) — couples/uncouples/switches logged at THIS stop. + // Origin import/export docs never pass this, so they render no such box. + consistChangesAtStop?: Array<{ + wagonNumber: string; + event: 'Coupled' | 'Uncoupled' | 'Switched'; + containerNumbers: string; + }>; }, ): string { const esc = (value: unknown) => @@ -3890,6 +4011,7 @@ export class TrainSchedulingService { .tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; } .tile strong { font-size: 11px; } ${logoImageCss()} + h2 { margin: 16px 0 6px; font-size: 12px; color: #0f766e; text-transform: uppercase; letter-spacing: .05em; } table { width: 100%; border-collapse: collapse; } th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } @@ -3938,19 +4060,27 @@ export class TrainSchedulingService { ${ opts?.consistChangesAtStop?.length - ? `
- Consist changed at this stop: - ${(() => { - const coupled = opts.consistChangesAtStop.filter((row) => row.action === 'ADD'); - const switched = opts.consistChangesAtStop.filter((row) => row.action === 'SWITCH'); - return [ - coupled.length ? `Coupled: ${esc(coupled.map((row) => row.wagonNumber).join(', '))}` : '', - switched.length ? `Uncoupled — replaced: ${esc(switched.map((row) => row.wagonNumber).join(', '))}` : '', - ] - .filter(Boolean) - .join('  |  '); - })()} -
` + ? `

Consist Changed At This Stop

+ + + + + + + + + + ${opts.consistChangesAtStop + .map( + (row) => ` + + + + `, + ) + .join('')} + +
Wagon NoEventContainer No
${esc(row.wagonNumber)}${esc(row.event)}${esc(row.containerNumbers)}
` : '' } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/BulkContainerReturnModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/BulkContainerReturnModal.tsx new file mode 100644 index 000000000..36b6d68e4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/BulkContainerReturnModal.tsx @@ -0,0 +1,354 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Alert, + Anchor, + Autocomplete, + Badge, + Button, + FileInput, + Group, + List, + Modal, + ScrollArea, + Select, + Stack, + Table, + Text, +} from "@mantine/core"; +import { Upload } from "lucide-react"; + +import { useToast } from "@/hooks/use-toast"; +import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses"; +import { importOperationsService } from "@/services/importOperations.service"; +import { warehouseService } from "@/services/warehouse.service"; +import type { CreateEmptyContainerReturnPayload } from "@/types/importOperations"; + +import { + downloadContainerReturnTemplate, + parseContainerReturnExcel, + type ParsedReturnRow, +} from "./container-return-excel"; +import { useCompanyOptions } from "./useCompanyOptions"; + +interface BulkContainerReturnModalProps { + opened: boolean; + onClose: () => void; + onUploaded: () => void; +} + +/** + * Backfill of empties physically in a yard but never entered in the system. + * The sheet carries per-container detail; the fields above the file are the + * defaults for every row whose cell is blank, so the common case is a sheet of + * container numbers plus one warehouse picked here. + */ +export default function BulkContainerReturnModal({ + opened, + onClose, + onUploaded, +}: BulkContainerReturnModalProps) { + const { toast } = useToast(); + const companies = useCompanyOptions(); + + const [file, setFile] = useState(null); + const [rows, setRows] = useState([]); + const [parseErrors, setParseErrors] = useState([]); + const [parsing, setParsing] = useState(false); + + const [company, setCompany] = useState(""); + const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null); + const [warehouseId, setWarehouseId] = useState(null); + const [yardId, setYardId] = useState(null); + const [zoneId, setZoneId] = useState(null); + const [returnDate, setReturnDate] = useState(new Date().toISOString().split("T")[0]); + + const { data: warehousesResponse } = useQuery({ + queryKey: ["warehouses-list"], + queryFn: () => warehouseService.list({}), + }); + const warehouses = ((warehousesResponse as any)?.data ?? warehousesResponse ?? []) as any[]; + const { data: yards } = useWarehouseYards(warehouseId ?? undefined); + const { data: zones } = useWarehouseZones(yardId ?? undefined); + + useEffect(() => { + setYardId(null); + setZoneId(null); + }, [warehouseId]); + useEffect(() => setZoneId(null), [yardId]); + + const warehouseOptions = Array.isArray(warehouses) + ? warehouses.map((wh) => ({ value: wh.id, label: wh.code ? `${wh.name} (${wh.code})` : wh.name })) + : []; + const yardOptions = (yards ?? []) + .filter((y) => y.status === "ACTIVE") + .map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })); + const zoneOptions = (zones ?? []) + .filter((z) => z.status === "ACTIVE") + .map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })); + + const defaults = useMemo( + () => ({ + facility: warehouses.find((wh) => wh.id === warehouseId)?.name ?? "", + yard: yards?.find((y) => y.id === yardId)?.name ?? "", + zone: zones?.find((z) => z.id === zoneId)?.name ?? "", + }), + [warehouses, warehouseId, yards, yardId, zones, zoneId], + ); + + const reset = () => { + setFile(null); + setRows([]); + setParseErrors([]); + }; + + const handleFile = async (next: File | null) => { + setFile(next); + setRows([]); + setParseErrors([]); + if (!next) return; + setParsing(true); + const result = await parseContainerReturnExcel(next); + setParsing(false); + setRows(result.rows); + setParseErrors(result.errors); + }; + + // Row cell wins; the field above the file fills the blanks. + const toPayload = (row: ParsedReturnRow): CreateEmptyContainerReturnPayload => { + const companyName = row.companyName || company; + return { + containerNumber: row.containerNumber, + containerSize: row.containerSize ?? undefined, + companyName: companyName || undefined, + customerId: companyName ? companies.resolveId(companyName) : undefined, + returnedBy: row.returnedBy ?? returnedBy ?? undefined, + returnDate: row.returnDate ?? new Date(returnDate).toISOString(), + facility: row.facility || defaults.facility || undefined, + yard: row.yard || defaults.yard || undefined, + zone: row.zone || defaults.zone || undefined, + condition: row.condition || undefined, + handoverNote: row.handoverNote || undefined, + }; + }; + + const uploadMutation = useMutation({ + mutationFn: () => importOperationsService.bulkCreateEmptyReturns(rows.map(toPayload)), + onSuccess: (created) => { + toast({ title: `${created.length} container return${created.length === 1 ? "" : "s"} recorded` }); + reset(); + onUploaded(); + onClose(); + }, + onError: (error: any) => { + toast({ + variant: "destructive", + title: "Bulk upload failed", + description: error?.response?.data?.message || error?.message, + }); + }, + }); + + // Every row needs a warehouse from somewhere — the API stores facility as + // free text, so a blank one would silently produce unplaceable containers. + const missingFacility = rows.filter((r) => !r.facility && !defaults.facility).length; + const missingReturnedBy = rows.filter((r) => !r.returnedBy && !returnedBy).length; + const blockers = [ + missingFacility > 0 ? `${missingFacility} row(s) have no facility — pick a default warehouse.` : null, + missingReturnedBy > 0 ? `${missingReturnedBy} row(s) have no "Returned By" — pick a default.` : null, + ].filter(Boolean) as string[]; + + return ( + { + reset(); + onClose(); + }} + title="Bulk Upload Container Returns" + size="xl" + > + + + For empties already sitting in the yard but not yet on the system. Values below fill any + blank cell in the sheet.{" "} + downloadContainerReturnTemplate()}> + Download template + + + + + + + + + + + } + value={file} + onChange={(next) => void handleFile(next)} + /> +
+ + Returned Date (default) + + setReturnDate(e.target.value)} + style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ced4da", width: "100%" }} + /> +
+
+ + {parsing && Reading file…} + + {parseErrors.length > 0 && ( + + + + {parseErrors.map((err) => ( + {err} + ))} + + + + )} + + {blockers.length > 0 && ( + + + {blockers.map((b) => ( + {b} + ))} + + + )} + + {rows.length > 0 && ( + + + + Preview + + {rows.length} containers + + + + + + Container + Size + Company + Returned By + Date + Facility + Yard + + + + {rows.map((row) => { + const payload = toPayload(row); + return ( + + {payload.containerNumber} + {payload.containerSize ? `${payload.containerSize} ft` : "—"} + + + {payload.companyName || "—"} + {payload.companyName && !payload.customerId && ( + + New + + )} + + + {payload.returnedBy ?? "—"} + + {payload.returnDate + ? new Date(payload.returnDate).toLocaleDateString() + : "—"} + + {payload.facility ?? "—"} + {payload.yard ?? "—"} + + ); + })} + +
+
+
+ )} + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/container-return-excel.test.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/container-return-excel.test.ts new file mode 100644 index 000000000..48b23485d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/container-return-excel.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import * as XLSX from "xlsx"; + +import { parseContainerReturnExcel } from "./container-return-excel"; + +/** Build an in-memory .xlsx and hand it back as a File, like the dropzone would. */ +function sheetFile(aoa: unknown[][]): File { + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Sheet1"); + const buf = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer; + return new File([buf], "returns.xlsx"); +} + +const HEADERS = [ + "Container Number", + "Container Size", + "Company", + "Returned By", + "Returned Date", + "Facility", + "Yard", + "Zone", + "Condition", + "Handover Note", +]; + +describe("parseContainerReturnExcel", () => { + it("parses a good sheet, normalizing size and returned-by", async () => { + const result = await parseContainerReturnExcel( + sheetFile([ + ["Yard tally — August"], // title row above the header is ignored + HEADERS, + ["temu1234567", "40ft", "Acme PLC", "EDR last mile", "2026-08-14", "Gelan", "A", "1", "", ""], + ["MSCU7654321", "20", "Other Trading", "Self haul", "2026-08-15", "Gelan", "", "", "Dented", "n"], + ]), + ); + + expect(result.errors).toEqual([]); + expect(result.rows).toHaveLength(2); + expect(result.rows[0].containerNumber).toBe("TEMU1234567"); + expect(result.rows[0].containerSize).toBe("40"); + expect(result.rows[0].returnedBy).toBe("EDR"); + expect(result.rows[0].companyName).toBe("Acme PLC"); + expect(result.rows[0].returnDate?.startsWith("2026-08-14")).toBe(true); + expect(result.rows[1].containerSize).toBe("20"); + expect(result.rows[1].returnedBy).toBe("CUSTOMER"); + }); + + it("rejects the whole file when a container number is invalid", async () => { + const result = await parseContainerReturnExcel( + sheetFile([HEADERS, ["NOTACONTAINER", "40", "Acme", "EDR", "", "", "", "", "", ""]]), + ); + + expect(result.rows).toEqual([]); + expect(result.errors[0]).toContain("Row 2"); + }); + + it("rejects duplicate container numbers", async () => { + const result = await parseContainerReturnExcel( + sheetFile([ + HEADERS, + ["TEMU1234567", "40", "Acme", "EDR", "", "", "", "", "", ""], + ["temu1234567", "20", "Acme", "EDR", "", "", "", "", "", ""], + ]), + ); + + expect(result.rows).toEqual([]); + expect(result.errors.some((e) => e.includes("appears 2 times"))).toBe(true); + }); + + it("errors when there is no container-number column", async () => { + const result = await parseContainerReturnExcel(sheetFile([["Company", "Yard"], ["Acme", "A"]])); + expect(result.errors[0]).toContain("Container Number"); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/container-return-excel.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/container-return-excel.ts new file mode 100644 index 000000000..bdc29a13d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/container-return-excel.ts @@ -0,0 +1,231 @@ +import * as XLSX from "xlsx"; + +// Excel import for empties already sitting in an EDR yard that were never +// entered in the system. One spreadsheet row per container. All-or-nothing — +// any bad row rejects the whole file with row-numbered errors, so a partial +// backfill can never silently drop boxes. + +// ISO 6346: 4-letter prefix (owner code + category id) + 7 digits. +const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/; + +export interface ParsedReturnRow { + containerNumber: string; + containerSize: "20" | "40" | null; + companyName: string; + returnedBy: "EDR" | "CUSTOMER" | null; + returnDate: string | null; + facility: string; + yard: string; + zone: string; + condition: string; + handoverNote: string; +} + +export interface ContainerReturnExcelResult { + rows: ParsedReturnRow[]; + errors: string[]; +} + +type ColumnKey = + | "containerNumber" + | "containerSize" + | "companyName" + | "returnedBy" + | "returnDate" + | "facility" + | "yard" + | "zone" + | "condition" + | "handoverNote"; + +/** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */ +function headerKey(raw: string): ColumnKey | null { + const h = raw.toLowerCase().replace(/[^a-z]/g, ""); + if (!h) return null; + if (h.includes("size") || h.includes("type")) return "containerSize"; + if (h.includes("company") || h.includes("customer") || h.includes("consignee")) return "companyName"; + if (h.includes("returnedby") || h.includes("haul") || h.includes("truck")) return "returnedBy"; + if (h.includes("date")) return "returnDate"; + if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility"; + if (h.includes("yard")) return "yard"; + if (h.includes("zone")) return "zone"; + if (h.includes("condition") || h.includes("damage")) return "condition"; + if (h.includes("note") || h.includes("remark")) return "handoverNote"; + // Least specific last, so "Container Size" is not eaten by "container". + if (h.includes("container") || h.includes("number")) return "containerNumber"; + return null; +} + +/** "20", "20ft", "40 HC" … → '20' | '40' | null. */ +function normalizeSize(raw: string): "20" | "40" | null { + const digits = raw.replace(/[^0-9]/g, ""); + if (digits.startsWith("20")) return "20"; + if (digits.startsWith("40") || digits.startsWith("45")) return "40"; + return null; +} + +/** "EDR", "EDR last mile", "customer", "self haul" … */ +function normalizeReturnedBy(raw: string): "EDR" | "CUSTOMER" | null { + const v = raw.toLowerCase(); + if (!v.trim()) return null; + if (v.includes("edr")) return "EDR"; + if (v.includes("customer") || v.includes("self")) return "CUSTOMER"; + return null; +} + +/** + * Excel dates arrive either as a serial number (raw cells) or as text. Returns + * an ISO instant, or null when the cell is empty/unparseable. + */ +function normalizeDate(raw: string): string | null { + const v = raw.trim(); + if (!v) return null; + // Excel serial: days since 1899-12-30. + if (/^\d{1,6}(\.\d+)?$/.test(v)) { + const serial = Number(v); + if (serial > 20000 && serial < 80000) { + return new Date(Math.round((serial - 25569) * 86400000)).toISOString(); + } + } + const parsed = new Date(v); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +/** + * Parse an uploaded workbook into one row per empty container. Returns either + * the full row set or the list of row-numbered problems — never both. + */ +export async function parseContainerReturnExcel(file: File): Promise { + let sheet: XLSX.WorkSheet | undefined; + try { + const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" }); + sheet = workbook.Sheets[workbook.SheetNames[0]]; + } catch { + return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] }; + } + if (!sheet) return { rows: [], errors: ["The file has no sheets."] }; + + const grid = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" }); + + // First row carrying a container-number column is the header; titles and + // blank rows above it are ignored. + let headerRowIdx = -1; + let columns: Array = []; + for (let i = 0; i < grid.length; i++) { + const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? ""))); + if (mapped.includes("containerNumber")) { + headerRowIdx = i; + columns = mapped; + break; + } + } + if (headerRowIdx < 0) { + return { + rows: [], + errors: [ + 'Could not find a "Container Number" column — download the template to see the expected format.', + ], + }; + } + + const rows: ParsedReturnRow[] = []; + const errors: string[] = []; + const numberCounts = new Map(); + + for (let i = headerRowIdx + 1; i < grid.length; i++) { + const cells = grid[i] ?? []; + if (cells.every((c) => String(c ?? "").trim() === "")) continue; + const rowNo = i + 1; // 1-based, as shown in Excel + + const cell = (key: ColumnKey) => { + const idx = columns.indexOf(key); + return idx >= 0 ? String(cells[idx] ?? "").trim() : ""; + }; + + const containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, ""); + if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) { + errors.push( + `Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`, + ); + } else { + numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1); + } + + const sizeRaw = cell("containerSize"); + const containerSize = sizeRaw ? normalizeSize(sizeRaw) : null; + if (sizeRaw && !containerSize) { + errors.push(`Row ${rowNo}: container size "${sizeRaw}" is not 20 or 40.`); + } + + const returnedByRaw = cell("returnedBy"); + const returnedBy = normalizeReturnedBy(returnedByRaw); + if (returnedByRaw && !returnedBy) { + errors.push(`Row ${rowNo}: returned by "${returnedByRaw}" must be EDR or CUSTOMER.`); + } + + const dateRaw = cell("returnDate"); + const returnDate = normalizeDate(dateRaw); + if (dateRaw && !returnDate) { + errors.push(`Row ${rowNo}: returned date "${dateRaw}" is not a date.`); + } + + rows.push({ + containerNumber, + containerSize, + companyName: cell("companyName"), + returnedBy, + returnDate, + facility: cell("facility"), + yard: cell("yard"), + zone: cell("zone"), + condition: cell("condition"), + handoverNote: cell("handoverNote"), + }); + } + + numberCounts.forEach((count, num) => { + if (count > 1) { + errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`); + } + }); + + if (rows.length === 0 && errors.length === 0) { + errors.push("The sheet has no container rows below the header."); + } + + return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] }; +} + +/** Download the import template with one filled sample row. */ +export function downloadContainerReturnTemplate() { + const headers = [ + "Container Number", + "Container Size", + "Company", + "Returned By", + "Returned Date", + "Facility", + "Yard", + "Zone", + "Condition", + "Handover Note", + ]; + const sample = [ + "TEMU1234567", + "40", + "Acme Import PLC", + "CUSTOMER", + new Date().toISOString().split("T")[0], + "Gelan Multipurpose port", + "Yard A", + "Zone 1", + "Sound", + "Backfilled from yard tally sheet", + ]; + + const sheet = XLSX.utils.aoa_to_sheet([headers, sample]); + sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) })); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, sheet, "Container Returns"); + XLSX.writeFile(workbook, "container-return-import-template.xlsx"); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/useCompanyOptions.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/useCompanyOptions.ts new file mode 100644 index 000000000..ce8286454 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/useCompanyOptions.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; + +import { customersService } from "@/services/customers.service"; + +/** + * Registered customer companies, as Autocomplete options. The picker is an + * Autocomplete rather than a Select on purpose: a company that is not on the + * system yet is typed in, and only the name is kept. + */ +export function useCompanyOptions() { + const { data, isLoading } = useQuery({ + queryKey: ["companies-autocomplete"], + queryFn: () => customersService.list({ page: 1, pageSize: 1000 }), + staleTime: 5 * 60 * 1000, + }); + + const companies = data?.items ?? []; + + return { + loading: isLoading, + names: companies.map((c) => c.name), + /** Exact (case-insensitive) name match → company id, else undefined. */ + resolveId: (name: string): string | undefined => + companies.find((c) => c.name.trim().toLowerCase() === name.trim().toLowerCase())?.id, + }; +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 3fc6e53c0..16635162a 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -791,6 +791,7 @@ export const URL_CONSTANTS = { CUSTOMS_RELEASE_PERMITTED: (bookingId: string) => `/import-operations/customs/${bookingId}/release-permitted`, EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns", + EMPTY_CONTAINER_RETURNS_BULK: "/import-operations/empty-container-returns/bulk", EMPTY_CONTAINER_RETURN_STATUS: (id: string) => `/import-operations/empty-container-returns/${id}/status`, EMPTY_CONTAINER_RETURNS_LOAD_ON_TRAIN: diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx index 12539a136..dcae9e85a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -18,8 +18,9 @@ import { Textarea, Select, Checkbox, + Autocomplete, } from "@mantine/core"; -import { ChevronDown, ChevronRight, FileText, History } from "lucide-react"; +import { ChevronDown, ChevronRight, FileText, History, Upload } from "lucide-react"; import { DataTable, type ColumnDef } from "@edr/ui-common"; import { PageContainer, PageHeader } from "@/components/page"; @@ -27,6 +28,8 @@ import ListControls from "@/components/common/ListControls"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import { extractDownloadErrorMessage } from "@/components/warehouses/options"; import { openPdfBlob } from "@/components/warehouses/pdf"; +import BulkContainerReturnModal from "@/components/warehouses/BulkContainerReturnModal"; +import { useCompanyOptions } from "@/components/warehouses/useCompanyOptions"; import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart"; import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart"; import { useListControls, toDayString } from "@/hooks/useListControls"; @@ -102,6 +105,7 @@ export default function ContainerReturnsPage() { const [filterType, setFilterType] = useState("all"); const [returnModalOpen, setReturnModalOpen] = useState(false); const [standaloneModalOpen, setStandaloneModalOpen] = useState(false); + const [bulkModalOpen, setBulkModalOpen] = useState(false); const [activeKey, setActiveKey] = useState(null); const [historyRow, setHistoryRow] = useState(null); const [allocateRow, setAllocateRow] = useState(null); @@ -287,11 +291,14 @@ export default function ContainerReturnsPage() { bookingId: string; customerId: string | null; returnType: "EDR" | "CUSTOMER"; + companyName?: string; containers: Array<{ containerNumber: string; containerSize?: EmptyContainerSize; returnDate: string; warehouse: string; + yard?: string; + zone?: string; condition?: string; handoverNote?: string; }>; @@ -306,7 +313,10 @@ export default function ContainerReturnsPage() { returnDate: new Date(container.returnDate).toISOString(), bookingId: truck.bookingId, customerId: truck.customerId ?? undefined, + companyName: truck.companyName, facility: container.warehouse, + yard: container.yard, + zone: container.zone, condition: container.condition, handoverNote: container.handoverNote, returnedBy: truck.returnType, @@ -319,7 +329,9 @@ export default function ContainerReturnsPage() { onSuccess: () => { toast({ title: "Container returns recorded" }); qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] }); + qc.invalidateQueries({ queryKey: ["empty-container-returns"] }); setReturnModalOpen(false); + setStandaloneModalOpen(false); setActiveKey(null); }, onError: (error: any) => { @@ -376,6 +388,11 @@ export default function ContainerReturnsPage() { header: "Booking Ref", cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"), }, + { + id: "company", + header: "Company", + cell: ({ row }) => row.original.companyName || "—", + }, { id: "returnedBy", header: "Returned By", @@ -510,9 +527,16 @@ export default function ContainerReturnsPage() { { label: "Customer Self-Haul", value: "customer" }, ]} /> - + + + + {returnedContainers.length > 0 && ( @@ -688,6 +712,12 @@ export default function ContainerReturnsPage() { loading={createReturnsMutation.isPending} /> + setBulkModalOpen(false)} + onUploaded={() => qc.invalidateQueries({ queryKey: ["empty-container-returns"] })} + /> + setAllocateRow(null)} @@ -991,6 +1021,7 @@ interface StandaloneReturnModalProps { function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) { const [containerNumber, setContainerNumber] = useState(""); + const [company, setCompany] = useState(""); const [containerSize, setContainerSize] = useState(null); const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null); const [returnDate, setReturnDate] = useState(new Date().toISOString().split("T")[0]); @@ -1009,6 +1040,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? []; + const companies = useCompanyOptions(); const { data: yards } = useWarehouseYards(warehouse ?? undefined); const { data: zones } = useWarehouseZones(yardId ?? undefined); @@ -1047,7 +1079,8 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon trucks: [ { bookingId: null, - customerId: null, + customerId: company ? (companies.resolveId(company) ?? null) : null, + companyName: company || undefined, returnType: returnedBy, containers: [ { @@ -1066,6 +1099,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon }); setContainerNumber(""); + setCompany(""); setContainerSize(null); setReturnedBy(null); setReturnDate(new Date().toISOString().split("T")[0]); @@ -1092,6 +1126,16 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon required /> + +