From 076884623e3a84ab4402375d43080748fc92a479 Mon Sep 17 00:00:00 2001 From: hager Date: Thu, 3 Sep 2026 04:36:55 +0000 Subject: [PATCH] feat(warehouses): load trains from the warehouse, mirroring the schedule Ready to Load listed inventory and offered an auto-load picker, but the warehouse floor works train by train: a train stands at the yard, its bookings board wagon by wagon, it rolls. That view existed only on the train schedule workspace, which the warehouse staff do not run. Ready to Load now has an Items / By train switch. By train is a mirror of the schedule's own column, placed where the loading actually happens. Nothing in it has its own rules. Train position, the per-yard loading and unloading windows, and the per-booking Load / Wagons / Unload actions all come from the train-scheduling endpoints the schedule workspace already calls, so a Load that would be refused there is disabled here with the same reason and the two surfaces cannot disagree. No train-scheduling code is touched. What the warehouse adds is what the schedule cannot see: which of the train's bookings are physically in the shed, with their GRN and inspection state, laid out along the flow the staff follow -- receive and GRN, inspect, ready, open the loading window, train at yard, load per wagon, dispatch, unload at port. Wagons - The wagon modal calls the same per-wagon journey endpoints, so every server gate (train at the yard, window started, PAID, GRN) is the schedule's own. - Wagons go one at a time in order: the server flips the booking to IN_TRANSIT or ARRIVED on whichever call clears the last wagon, so sequential is required, not merely tidy. A failure stops the run, the wagons already sent stay done, and the toast says how many, so a retry only resends the rest. - Deliberately not mirrored: cancelling wagons that will not ride, and the direct truck-to-train handover. Both are commercial decisions (fees, credits, GRN waiver) that belong to the schedule workspace. loadable-trains takes includeDispatched. Loading follows the train after it rolls, since a mid-corridor warehouse boards its cargo when the train stands at its yard, and the train-centric view needs the same set the schedule offers Load on. The default stays pre-dispatch only, so the existing auto-load picker is unchanged. Also fixes the backoffice build: ReceiveInventoryModal used MultiSelect without importing it, left behind by the self-haul assignment work. --- .../last-mile-requests.service.ts | 7 + .../last-mile/last-mile.advance-gate.spec.ts | 214 ++++ .../modules/last-mile/last-mile.service.ts | 155 ++- .../warehouse-inventory.controller.ts | 11 +- .../warehouses/warehouse-inventory.service.ts | 14 +- .../warehouses/ReceiveInventoryModal.tsx | 56 +- .../warehouses/TrainLoadingWorkspace.tsx | 927 ++++++++++++++++++ .../warehouses/TrainWagonLoadModal.tsx | 320 ++++++ .../src/components/warehouses/index.ts | 2 + .../src/pages/operations/LastMilePage.tsx | 25 +- .../src/services/last-mile.service.ts | 6 + .../src/services/warehouse.service.ts | 12 +- 12 files changed, 1719 insertions(+), 30 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/TrainLoadingWorkspace.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/TrainWagonLoadModal.tsx diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index 37612d318..8d56bd0d7 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -378,6 +378,13 @@ export class LastMileRequestsService { contractGeneratedAt: new Date(), } as Partial); + // Only now, with the request APPROVED, is an advance actually owed on the + // leg. The warehouse auto-accept (IMPORT inspection PASSED) may have already + // opened that leg at READY_TO_TRANSIT, so pull it back to PAYMENT_PENDING — + // otherwise this booking would be dispatchable before the customer has + // signed the contract or paid a birr. No-op for a leg this call just created. + await this.lastMileService.holdForAdvance(lastMile.id); + if (booking.companyId) { void this.notifications.notify({ recipients: { companyId: booking.companyId }, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts new file mode 100644 index 000000000..8e0b8ba55 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts @@ -0,0 +1,214 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { ADVANCE_UNPAID_MESSAGE, LastMileService } from './last-mile.service'; +import type { LastMileStatus } from './entities/last-mile.entity'; +import type { UpdateLastMileDto } from './dto/update-last-mile.dto'; + +/** + * The advance gate: a delivery becomes dispatchable (READY_TO_TRANSIT) or moves + * (IN_TRANSIT) only once the customer has paid the advance the Truck & Machinery + * chief approved. + * + * It used to leak both ways. The warehouse auto-accept (IMPORT inspection + * PASSED) opens the leg at READY_TO_TRANSIT and runs independently of the + * review, so whichever side acted second found the other already done: accept + * first and the leg was dispatchable before an advance was ever asked for; + * approve first and create() handed the existing dispatchable leg straight back + * untouched. + */ +function makeService( + opts: { + /** APPROVED requests on the booking carrying a positive advance. */ + advancesDue?: number; + /** PAID LAST_MILE_ADVANCE invoices on the leg. */ + advancesPaid?: number; + status?: LastMileStatus; + } = {}, +) { + const leg = { + id: 'lm-1', + bookingId: 'b-1', + status: opts.status ?? 'READY_TO_TRANSIT', + vehicleId: 'v-1', + booking: { reference: 'BK-001' }, + }; + + const query = jest.fn((sql: string) => { + if (sql.includes('customer_truck_assignments')) return Promise.resolve([]); + // The batched list enrichment, not the gate's own lookup. + if (sql.includes('FROM freight.last_mile lm')) return Promise.resolve([]); + if (sql.includes('freight.last_mile_requests')) { + return Promise.resolve([{ count: opts.advancesDue ?? 0 }]); + } + // Discriminated on the charge type, not the table: attachMileFinancials + // also queries freight.invoices (for the booking-invoice advance line). + if (sql.includes('LAST_MILE_ADVANCE')) { + return Promise.resolve([{ count: opts.advancesPaid ?? 0 }]); + } + if (sql.includes('FROM freight.bookings')) { + return Promise.resolve([ + { tradeDirection: 'IMPORT', firstMile: null, lastMile: 'Bole, Addis Ababa' }, + ]); + } + return Promise.resolve([]); + }); + + const lastMileRepository = { + findAll: jest.fn().mockResolvedValue([]), + findById: jest.fn().mockResolvedValue(leg), + create: jest.fn((row: unknown) => Promise.resolve({ id: 'lm-1', ...(row as object) })), + update: jest.fn((_id: string, patch: object) => Promise.resolve({ ...leg, ...patch })), + }; + + const service = new LastMileService( + lastMileRepository as never, + {} as never, // bookingsRepository + { + findById: jest.fn().mockResolvedValue({ + id: 'v-1', + plateNumber: 'AA-123', + assignedDriverId: 'd-1', + assignedDriverName: 'Driver', + }), + setAvailability: jest.fn(), + releaseIfUnused: jest.fn(), + } as never, // vehiclesService + {} as never, // driversService + {} as never, // smsClient + { + query, + // DELIVERED frees the trucks this leg was holding. + manager: { + find: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + } as unknown as DataSource, + { record: jest.fn() } as never, // history + { findBySourceIds: jest.fn().mockResolvedValue([]) } as never, // billing + { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) } as never, // ratesService + {} as never, // filesService + ); + + return { service, lastMileRepository, leg }; +} + +const createdStatus = (repo: { create: jest.Mock }) => + (repo.create.mock.calls[0]?.[0] as { status?: string } | undefined)?.status; + +describe('LastMileService - advance gate on creation', () => { + it('opens an auto-accepted leg at PAYMENT_PENDING when an advance is owed', async () => { + const { service, lastMileRepository } = makeService({ advancesDue: 1 }); + + // The warehouse path asks for no status at all - it used to get + // READY_TO_TRANSIT and hand the customer a dispatchable unpaid delivery. + await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never); + + expect(createdStatus(lastMileRepository)).toBe('PAYMENT_PENDING'); + }); + + it('still opens at READY_TO_TRANSIT when no approved request owes an advance', async () => { + const { service, lastMileRepository } = makeService({ advancesDue: 0 }); + + await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never); + + expect(createdStatus(lastMileRepository)).toBe('READY_TO_TRANSIT'); + }); +}); + +describe('LastMileService - advance gate on transitions', () => { + it('refuses IN_TRANSIT while the advance is unpaid', async () => { + const { service } = makeService({ advancesDue: 1, advancesPaid: 0 }); + + await expect( + service.update('lm-1', { status: 'IN_TRANSIT' } as UpdateLastMileDto), + ).rejects.toThrow(ADVANCE_UNPAID_MESSAGE); + }); + + it('refuses a leg being made dispatchable while the advance is unpaid', async () => { + const { service } = makeService({ + advancesDue: 1, + advancesPaid: 0, + status: 'PAYMENT_PENDING', + }); + + await expect( + service.update('lm-1', { status: 'READY_TO_TRANSIT' } as UpdateLastMileDto), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows IN_TRANSIT once the advance invoice is paid', async () => { + const { service } = makeService({ advancesDue: 1, advancesPaid: 1 }); + + const updated = await service.update('lm-1', { + status: 'IN_TRANSIT', + } as UpdateLastMileDto); + + expect(updated.status).toBe('IN_TRANSIT'); + }); + + it('requires one paid advance per approved departure', async () => { + // Containers arriving across two departures get a request - and an advance + // - each. One paid advance does not release the second. + const { service } = makeService({ advancesDue: 2, advancesPaid: 1 }); + + await expect( + service.update('lm-1', { status: 'IN_TRANSIT' } as UpdateLastMileDto), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('lets the paid listener through before the invoice row is visible', async () => { + // Billing emits inline, pre-commit, when the transition joins a caller's + // transaction - so the invoice still reads unpaid here. The event is the + // proof of payment; re-reading the row would refuse the transition the + // payment just earned. + const { service } = makeService({ + advancesDue: 1, + advancesPaid: 0, + status: 'PAYMENT_PENDING', + }); + + const updated = await service.update( + 'lm-1', + { status: 'READY_TO_TRANSIT' } as UpdateLastMileDto, + { advanceSettled: true }, + ); + + expect(updated.status).toBe('READY_TO_TRANSIT'); + }); + + it('leaves states that are not transit alone', async () => { + const { service } = makeService({ advancesDue: 1, advancesPaid: 0 }); + + await expect( + service.update('lm-1', { status: 'DELIVERED' } as UpdateLastMileDto), + ).resolves.toBeDefined(); + }); +}); + +describe('LastMileService.holdForAdvance', () => { + it('pulls an already-dispatchable leg back when approval imposes an advance', async () => { + const { service, lastMileRepository } = makeService({ + advancesDue: 1, + status: 'READY_TO_TRANSIT', + }); + + await service.holdForAdvance('lm-1'); + + expect(lastMileRepository.update).toHaveBeenCalledWith( + 'lm-1', + expect.objectContaining({ status: 'PAYMENT_PENDING' }), + ); + }); + + it('never rewrites a leg that is already on the road', async () => { + const { service, lastMileRepository } = makeService({ + advancesDue: 1, + status: 'IN_TRANSIT', + }); + + await service.holdForAdvance('lm-1'); + + expect(lastMileRepository.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 8ed5ae8aa..4f8acd03a 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -59,6 +59,13 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [ 'createdAt', ]; +/** The states that mean the delivery is dispatchable or already on the road. */ +const TRANSIT_STATUSES: LastMileStatus[] = ['READY_TO_TRANSIT', 'IN_TRANSIT']; + +export const ADVANCE_UNPAID_MESSAGE = + 'The last-mile advance has not been paid yet — this delivery cannot become ' + + 'dispatchable or move until the advance invoice is settled.'; + @Injectable() export class LastMileService { private readonly logger = new Logger(LastMileService.name); @@ -93,9 +100,47 @@ export class LastMileService { for (const r of records) { (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; } + await this.attachAdvanceState(records); await attachMileFinancials(this.dataSource, records, 'LAST_MILE'); } + /** + * Flag the legs whose advance is still owed, so the UI can disable the actions + * the API would refuse instead of firing them into a 400. Same rule as + * {@link advanceOutstanding}, batched over the whole page. + */ + private async attachAdvanceState(records: LastMile[]): Promise { + const ids = records.map((r) => r.id).filter(Boolean); + if (!ids.length) return; + const rows: Array<{ lastMileId: string; due: number; paid: number }> = + await this.dataSource.query( + `SELECT lm.id AS "lastMileId", + (SELECT COUNT(*) + FROM freight.last_mile_requests lmr + WHERE lmr.booking_id = lm.booking_id + AND lmr.deleted_at IS NULL + AND lmr.status = 'APPROVED' + AND COALESCE(lmr.approved_advance_amount, 0) > 0)::int AS "due", + (SELECT COUNT(*) + FROM freight.invoices i + WHERE i.source = 'last_mile' + AND i.source_id = lm.id::text + AND i.type = 'LAST_MILE_ADVANCE' + AND i.status = 'PAID' + AND i.deleted_at IS NULL)::int AS "paid" + FROM freight.last_mile lm + WHERE lm.id = ANY($1::uuid[]) AND lm.deleted_at IS NULL`, + [ids], + ); + const outstanding = new Map( + rows.map((r) => [r.lastMileId, Number(r.due) > Number(r.paid)]), + ); + for (const r of records) { + (r as LastMile & { advanceOutstanding?: boolean }).advanceOutstanding = + outstanding.get(r.id) ?? false; + } + } + /** Resolve a vehicle's driver + human labels, for stamping mile events onto * the driver's timeline and naming the vehicle. Best-effort — never throws. */ private async vehicleInfo( @@ -185,6 +230,67 @@ export class LastMileService { } } + /** + * Whether this booking still owes an advance on its delivery. + * + * An advance is owed for every APPROVED last-mile request carrying a positive + * approved amount — a booking whose containers arrive across several + * departures gets a request, and therefore an advance, per departure. Each is + * settled by a PAID `LAST_MILE_ADVANCE` invoice raised on the leg when the + * customer signs that request's contract, so the leg is clear only once it has + * as many paid advance invoices as the booking has approved requests. + * + * A booking with no approved request owes nothing and is unaffected: legs that + * never went through the confirmation flow keep behaving exactly as before. + * `lastMileId` is null while the leg is still being created — no invoice can + * point at a row that does not exist yet, so nothing can have been settled. + */ + private async advanceOutstanding( + bookingId: string, + lastMileId: string | null, + ): Promise { + const [due] = await this.dataSource.query( + `SELECT COUNT(*)::int AS "count" + FROM freight.last_mile_requests lmr + WHERE lmr.booking_id = $1 + AND lmr.deleted_at IS NULL + AND lmr.status = 'APPROVED' + AND COALESCE(lmr.approved_advance_amount, 0) > 0`, + [bookingId], + ); + const owed = Number(due?.count ?? 0); + if (!owed) return false; + if (!lastMileId) return true; + + const [paid] = await this.dataSource.query( + `SELECT COUNT(*)::int AS "count" + FROM freight.invoices i + WHERE i.source = 'last_mile' + AND i.source_id = $1 + AND i.type = 'LAST_MILE_ADVANCE' + AND i.status = 'PAID' + AND i.deleted_at IS NULL`, + [lastMileId], + ); + return Number(paid?.count ?? 0) < owed; + } + + /** + * Hold a leg at PAYMENT_PENDING because an advance has just been imposed on it. + * + * The warehouse auto-accept (IMPORT inspection PASSED) opens the leg + * independently of the chief's review, and opens it at READY_TO_TRANSIT. When + * that happens first, approval has to pull the leg back — otherwise the advance + * gate never holds on that ordering and the delivery is dispatchable unpaid. + * A leg already IN_TRANSIT or DELIVERED is left alone: that is a record of what + * happened, not a plan that can still be changed. + */ + async holdForAdvance(id: string): Promise { + const record = await this.findById(id); + if (record.status !== 'READY_TO_TRANSIT') return; + await this.update(id, { status: 'PAYMENT_PENDING' } as UpdateLastMileDto); + } + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); @@ -425,9 +531,16 @@ export class LastMileService { await this.assertEdrHaulsThisBooking(dto.bookingId); + // A leg that owes an advance is not dispatchable, whatever the caller asked + // for. The warehouse auto-accept path asks for no status at all and used to + // land straight in READY_TO_TRANSIT, which let an unpaid delivery go. + const status: LastMileStatus = (await this.advanceOutstanding(dto.bookingId, null)) + ? 'PAYMENT_PENDING' + : (dto.status ?? 'READY_TO_TRANSIT'); + const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, - status: dto.status ?? 'READY_TO_TRANSIT', + status, advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'LAST')), @@ -461,11 +574,16 @@ export class LastMileService { async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { if (payload.type === 'LAST_MILE_ADVANCE') { - // Advance paid → the leg becomes dispatchable, not delivered. - await this.update(payload.sourceId, { - status: 'READY_TO_TRANSIT', - advancedPayment: payload.totalAmount, - } as unknown as UpdateLastMileDto); + // Advance paid → the leg becomes dispatchable, not delivered. This event + // IS the settlement, so it carries its own way past the advance gate. + await this.update( + payload.sourceId, + { + status: 'READY_TO_TRANSIT', + advancedPayment: payload.totalAmount, + } as unknown as UpdateLastMileDto, + { advanceSettled: true }, + ); this.logger.log( `Last-mile ${payload.sourceId} READY_TO_TRANSIT on advance invoice ${payload.invoiceId} payment`, ); @@ -484,9 +602,32 @@ export class LastMileService { } } - async update(id: string, dto: UpdateLastMileDto): Promise { + /** + * `opts.advanceSettled` is the paid listener's own bypass, and nothing else + * should pass it: the invoice event is itself the proof of payment, and it can + * reach us inline before the invoice row commits (billing emits before commit + * when the transition is enlisted in a caller-supplied manager), so re-reading + * the invoice here would still see it unpaid and refuse the very transition the + * payment just earned. + */ + async update( + id: string, + dto: UpdateLastMileDto, + opts: { advanceSettled?: boolean } = {}, + ): Promise { const existing = await this.findById(id); + // Nothing becomes dispatchable, and nothing moves, until the advance is paid. + if ( + !opts.advanceSettled && + dto.status !== undefined && + dto.status !== existing.status && + TRANSIT_STATUSES.includes(dto.status) && + (await this.advanceOutstanding(existing.bookingId, id)) + ) { + throw new BadRequestException(ADVANCE_UNPAID_MESSAGE); + } + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle // assigned in this same request). if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 15a3f6c4a..77755fa2f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -199,9 +199,14 @@ export class WarehouseInventoryController { @Get('loadable-trains') @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) - @ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' }) - loadableTrains() { - return this.inventoryService.loadableTrains(); + @ApiOperation({ + summary: + 'EXPORT trains with inventory waiting to be loaded — pre-dispatch by default; `includeDispatched=true` adds rolling trains still picking cargo up along the corridor', + }) + loadableTrains(@Query('includeDispatched') includeDispatched?: string) { + return this.inventoryService.loadableTrains({ + includeDispatched: includeDispatched === 'true' || includeDispatched === '1', + }); } @Get('train/:scheduleId/loadable-items') diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 82231bacc..ef68f3673 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2091,7 +2091,17 @@ export class WarehouseInventoryService { */ private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE; - async loadableTrains(): Promise { + /** + * @param includeDispatched also list DISPATCHED trains. Loading follows the + * train after it rolls — a mid-corridor warehouse boards its cargo when the + * train stands at its yard — so the warehouse's train-centric loading view + * needs the same set the schedule workspace offers Load on. The default + * (pre-dispatch only) keeps the existing auto-load picker unchanged. + */ + async loadableTrains(opts: { includeDispatched?: boolean } = {}): Promise { + const statuses = opts.includeDispatched + ? ['DRAFT', 'SCHEDULED', 'DISPATCHED'] + : ['DRAFT', 'SCHEDULED']; const rows: Array< LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null } > = await this.dataSource.query( @@ -2129,7 +2139,7 @@ export class WarehouseInventoryService { AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, - [['DRAFT', 'SCHEDULED']], + [statuses], ); return rows diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index b10e937c3..1f3a1cf81 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -11,8 +11,10 @@ import { Loader, Menu, Modal, + MultiSelect, NumberInput, ScrollArea, + SegmentedControl, Select, SimpleGrid, Stack, @@ -91,6 +93,7 @@ import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { StoreInventoryModal } from './StoreInventoryModal'; import { WarehouseInquiryTable } from './WarehouseInquiryTable'; +import { TrainLoadingWorkspace } from './TrainLoadingWorkspace'; import { YardLoadingWindows } from './YardLoadingWindows'; import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options'; import { openPdfBlob } from './pdf'; @@ -1861,6 +1864,9 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: api.warehouses.readyToLoadExport.queryOptions({ enabled }), ); const qc = useQueryClient(); + // "Items" is the inventory list with the auto-load picker; "By train" mirrors + // the train schedule's per-booking Load / Wagons / Unload workspace here. + const [view, setView] = useState<'items' | 'train'>('items'); const [trainPickerOpen, setTrainPickerOpen] = useState(false); const [expandedRow, setExpandedRow] = useState(null); const [targetScheduleId, setTargetScheduleId] = useState(null); @@ -1952,16 +1958,50 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: } }; + if (view === 'train') { + return ( + + + setView(v as 'items' | 'train')} + data={[ + { value: 'items', label: 'Items' }, + { value: 'train', label: 'By train' }, + ]} + /> + + Per-booking Load, wagon-by-wagon loading and unloading — the train schedule's own + actions, run from the warehouse. + + + + + ); + } + return ( - - - {selected.size > 0 ? ( - <>{selected.size} of {controls.filteredRows.length} selected - ) : ( - <>{controls.filteredRows.length} item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load - )} - + + + setView(v as 'items' | 'train')} + data={[ + { value: 'items', label: 'Items' }, + { value: 'train', label: 'By train' }, + ]} + /> + + {selected.size > 0 ? ( + <>{selected.size} of {controls.filteredRows.length} selected + ) : ( + <>{controls.filteredRows.length} item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load + )} + +