From 9c57aa1c0c17ab7beb8a1cf120ea85a382f5762c Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Thu, 3 Sep 2026 15:39:27 +0300 Subject: [PATCH] feat: add BookingWagonsPanel component for displaying allocated wagons in booking details - Implemented BookingWagonsPanel to show allocated wagons, their containers, and export functionality. - Integrated the new panel into BookingRequestDetailPage and BookingRequestsPage. - Enhanced wagon cancellation modal to support rebooking of wagon cancellations with partner units. - Updated API service to include a method for downloading wagons workbook. - Modified types and constants to accommodate new features related to wagons. - Adjusted various components and pages to ensure compatibility with the new wagon-related functionality. --- ...booking-wagon-cancellation.service.spec.ts | 70 +++++ .../booking-wagon-cancellation.service.ts | 128 ++++++++- .../modules/bookings/bookings.controller.ts | 28 ++ .../src/modules/bookings/bookings.module.ts | 2 + .../modules/bookings/bookings.repository.ts | 197 ++++++++++++-- .../src/modules/bookings/bookings.service.ts | 133 ++++++++- .../bookings/dto/wagon-cancellation.dto.ts | 38 +++ .../booking-clearance.service.spec.ts | 3 + .../contracts/booking-clearance.service.ts | 11 + ...tract-booking.manual-consolidation.spec.ts | 10 +- .../bookings/detail/BookingWagonsPanel.tsx | 255 ++++++++++++++++++ .../src/components/bookings/detail/index.ts | 1 + .../RebookWagonCancellationModal.tsx | 208 ++++++++++++-- .../bookings/wagon-cancellation/types.ts | 15 ++ .../ConsolidationPartnerPicker.tsx | 19 +- .../backoffice/src/constants/URLS.ts | 1 + .../features/bookings/mapBookingListRow.ts | 12 + .../bookings/BookingRequestDetailPage.tsx | 19 +- .../pages/bookings/BookingRequestsPage.tsx | 47 +++- .../contracts/ContractClearanceListPage.tsx | 68 +++++ .../src/services/bookings.service.ts | 8 + .../backoffice/src/types/booking.ts | 18 ++ .../backoffice/src/types/trainScheduling.ts | 14 + 23 files changed, 1244 insertions(+), 61 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingWagonsPanel.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts index 0354dbc84..b08b7e8c5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts @@ -124,6 +124,8 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () = }; it('refuses an odd-20ft rebook without a GL-picked partner', async () => { + // An odd credit always leaves a half-empty wagon, so GL must name who fills + // it — the rebook is refused rather than shipping a half-empty wagon. await expect( makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }), ).rejects.toThrow(/pick a consolidation partner/i); @@ -143,6 +145,74 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () = }), ).rejects.toThrow(/already shares a wagon/i); }); + + /** + * An EXPIRED partner has no pay window left, so pairing the PAID rebook + * straight onto it strands the shared wagon: neither half can board and + * nothing ever breaks the pair (BK-2026-001114). Its cargo must move to a + * fresh booking that carries its own invoice. + */ + it('clones an EXPIRED partner into a new booking instead of pairing the dead one', async () => { + const dead = { + id: 'p1', + reference: 'BK-2026-001114', + status: 'EXPIRED', + contractId: 'c1', + consolidationPartnerId: null, + paymentCurrency: 'USD', + originYardId: 'y1', + destinationYardId: 'y2', + tradeDirection: 'IMPORT', + scheduledDate: '2026-09-01', + bookingContainers: [ + { + containerSize: '20ft', + quantity: 1, + hazardousQuantity: 0, + reeferQuantity: 0, + containerType: { sizeFt: 20 }, + units: [ + { + containerNumber: 'PCONT0', + sealNumber: null, + vgmTons: 9, + isHazardous: false, + isReefer: false, + }, + ], + }, + ], + }; + const clone = { ...dead, id: 'p1-clone', reference: 'BK-2026-001116', status: 'SUBMITTED' }; + + const svc = makeSvc(dead) as Record; + let createdUnderContract: string | null = null; + let pairedWith: string | null = null; + (svc as { bookingsRepository: Record }).bookingsRepository = { + findById: async () => source, + findByIdWithFiles: async (id: string) => (id === 'p1-clone' ? clone : dead), + hasSpentCancellationCredit: async () => false, + }; + (svc as { contractBooking: unknown }).contractBooking = { + createUnderContract: async (contractId: string) => { + createdUnderContract = contractId; + return { booking: { id: 'p1-clone' } }; + }, + }; + (svc as { notifyCustomer: unknown }).notifyCustomer = () => undefined; + + const cloned = await ( + svc as unknown as { + cloneDeadPartner(p: unknown, d: string): Promise<{ id: string; reference: string }>; + } + ).cloneDeadPartner(dead, '2026-09-01'); + + // The dead booking is left dead; the clone is what gets paired and paid. + expect(cloned.id).toBe('p1-clone'); + expect(cloned.reference).toBe('BK-2026-001116'); + expect(createdUnderContract).toBe('c1'); + expect(pairedWith).toBeNull(); + }); }); /** diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 4507e6479..983564a51 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -1035,6 +1035,9 @@ export class BookingWagonCancellationService { let partner: Booking | null = null; if (oddFt20) { createDto.skipAutoConsolidation = true; + // An odd credit always leaves a half-empty wagon, so GL names who fills + // it. The candidate list is wide enough (any unpaired, unspent booking on + // the day) that a partner is expected to exist. if (!dto.partnerBookingId) { throw new BadRequestException( 'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).', @@ -1045,6 +1048,15 @@ export class BookingWagonCancellationService { dto.partnerBookingId, dto.scheduledDate, ); + // A dead partner cannot be paid where it stands — its cargo moves to a + // fresh booking that can carry its own invoice and pay window. + if (['EXPIRED', 'CANCELLED'].includes(partner.status)) { + partner = await this.cloneDeadPartner( + partner, + dto.scheduledDate, + userId, + ); + } } const created = await this.contractBooking.createUnderContract( source.contractId, @@ -1079,6 +1091,15 @@ export class BookingWagonCancellationService { ); } if (partner) { + // Corrections GL made to the partner's own containers while pairing — + // scoped to that booking by the repository, so a stray id cannot touch + // another booking's cargo. + if (dto.partnerUnits?.length) { + await this.bookingsRepository.patchContainerUnitsForBooking( + partner.id, + dto.partnerUnits, + ); + } // Consolidated rebook: never allocate the half-wagon booking alone. It // rides PAID and the batch engine settles the pair atomically once the // partner's own invoice is paid. @@ -1130,6 +1151,13 @@ export class BookingWagonCancellationService { status: string; scheduledDate: string | null; ft20Quantity: number; + units: Array<{ + id: string; + containerSize: string; + containerNumber: string; + sealNumber: string | null; + vgmTons: number; + }>; }> > { const row = await this.mustFind(cancellationId); @@ -1150,6 +1178,18 @@ export class BookingWagonCancellationService { ft20Quantity: (b.bookingContainers ?? []) .filter((line) => Number(line.containerType?.sizeFt) === 20) .reduce((sum, line) => sum + Number(line.quantity || 0), 0), + // Editable while pairing — GL corrects these on the rebook form. + units: (b.bookingContainers ?? []).flatMap((line) => + (line.units ?? []).map((u) => ({ + id: u.id, + containerSize: line.containerType?.sizeFt + ? `${line.containerType.sizeFt}ft` + : '', + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? null, + vgmTons: Number(u.vgmTons ?? 0), + })), + ), })); } @@ -1168,11 +1208,29 @@ export class BookingWagonCancellationService { `Booking ${partner.reference} already shares a wagon with another booking.`, ); } - if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) { + // Mirrors findRebookConsolidationCandidates: a partner need not be a live + // committed shipment. One that lost its slot or was called off still has + // cargo to move, and the rebooked wagon is how it moves. + if ( + ![ + 'SUBMITTED', + 'PENDING_CONSOLIDATION', + 'CLEARANCE_READY', + 'OPERATION_CHANGES_REQUESTED', + 'EXPIRED', + 'CANCELLED', + ].includes(partner.status) + ) { throw new BadRequestException( `Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`, ); } + // A booking whose own credit was already rebooked elsewhere is spent. + if (await this.bookingsRepository.hasSpentCancellationCredit(partner.id)) { + throw new BadRequestException( + `Booking ${partner.reference} has already been rebooked from its cancellation credit.`, + ); + } if ( partner.originYardId !== source.originYardId || partner.destinationYardId !== source.destinationYardId || @@ -1200,6 +1258,74 @@ export class BookingWagonCancellationService { return partner; } + /** + * A dead (EXPIRED/CANCELLED) partner still has cargo to move, but it can no + * longer be paid: its pay window is gone and finalizing it issues nothing a + * customer can settle, so pairing the PAID rebook with it strands the shared + * wagon forever (BK-2026-001114: EXPIRED/PENDING, paired to a PAID rebook, + * no payment_deadline — neither half could ever board). So the cargo is + * cloned into a fresh booking under the same contract, which finalizes + * normally into its own invoice and pay window; the dead booking stays dead. + */ + private async cloneDeadPartner( + partner: Booking, + scheduledDate: string, + userId?: string, + ): Promise { + if (!partner.contractId) { + throw new BadRequestException( + `Booking ${partner.reference} has no contract to rebook its cargo under — pick a live partner instead.`, + ); + } + const dto: CreateBookingUnderContractDto = { + scheduledDate, + paymentCurrency: partner.paymentCurrency ?? undefined, + // GL already chose this pairing — the auto-matcher must not re-home the + // clone behind their back (same reasoning as the rebooked side). + skipAutoConsolidation: true, + containers: (partner.bookingContainers ?? []).map((line) => { + const units = line.units ?? []; + return { + containerSize: line.containerSize ?? undefined, + quantity: Number(line.quantity), + units: units.map((u) => ({ + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? '', + vgmTons: u.vgmTons, + isHazardous: u.isHazardous, + isReefer: u.isReefer, + })), + hazardousQuantity: Number(line.hazardousQuantity ?? 0), + reeferQuantity: Number(line.reeferQuantity ?? 0), + }; + }) as CreateBookingUnderContractDto['containers'], + }; + const created = await this.contractBooking.createUnderContract( + partner.contractId, + dto, + { id: userId ?? partner.createdByUserId ?? undefined }, + { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, + // The dead partner's own contract may have lapsed while it sat expired; + // its cargo is still the cargo GL picked to fill the shared wagon. + { allowExpiredContract: true }, + ); + const clone = await this.bookingsRepository.findByIdWithFiles( + created.booking.id, + ); + if (!clone) { + throw new NotFoundException( + `Replacement booking for ${partner.reference} could not be loaded.`, + ); + } + this.notifyCustomer( + partner, + 'Replacement booking created', + `${partner.reference} had expired, so its cargo moved to ${clone.reference} to share a wagon with a rebooked shipment. Pay ${clone.reference} to board.`, + clone.id, + ); + return clone; + } + /** * Link the rebooked (already PAID) booking with the GL-picked partner. A * parked partner is resumed the way pairConsolidation would resume it — diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 381510e7e..66958bc38 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -599,6 +599,34 @@ export class BookingsController { return this.bookingsService.wagonAllocations(id); } + @Get(":id/wagons/export") + @MixedAudience(FREIGHT_PERMS.bookings.view) + @ApiOperation({ + summary: + "Download the booking's allocated wagons as an Excel workbook (customer name + one row per wagon)", + }) + async wagonAllocationsExport( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + const { filename, buffer } = + await this.bookingsService.wagonAllocationsWorkbook(id); + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.send(buffer); + } + // ── Partial wagon cancellation (paid bookings) ──────────────────────────── // Customer endpoints are ownership-scoped (no portal permission keys); the // staff history/void/rebook variants are permission-gated below. diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index e119af52e..6146da711 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -6,6 +6,7 @@ import { registerExchangeModule } from "../exchange-settings/exchange-module-opt // import { CustomersModule } from '../customers/customers.module'; import { CompaniesModule } from '../companies/companies.module'; +import { ExportsModule } from '../exports/exports.module'; import { FilesModule } from '../files/files.module'; import { MinioModule } from '../minio/minio.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; @@ -105,6 +106,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; // CustomersModule, RuleEngineModule, FileUploadSettingsModule, + ExportsModule, SignaturesModule, registerExchangeModule(), ], diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 863b61fee..21428c77d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -34,10 +34,12 @@ import { DocumentReviewStatus, } from './entities/booking-document-review.entity'; import { BookingContainer } from './entities/booking-container.entity'; +import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity'; import { BookingContainerUnit } from './entities/booking-container-unit.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { Booking } from './entities/booking.entity'; import { BookingContractSignature, @@ -332,21 +334,22 @@ export class BookingsRepository extends BaseRepository { * Bookings a GL operator may manually link to `booking` as its odd-20ft * consolidation partner (Path B customs flow). Unlike * {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact - * quantity complement — this lists CANDIDATES for a human to choose from, so - * the filter is deliberately looser: any other customs booking on the same - * route/direction that is itself carrying an odd 20ft count. Two odd counts - * always sum to even, so any pick fills the shared wagon. + * quantity complement — this lists CANDIDATES for a human to choose from, but + * every row must still be a legal pick: another customs booking on the same + * route/direction, riding the same booking day, that is itself carrying an odd + * 20ft count. Two odd counts always sum to even, so any pick fills the shared + * wagon. * - * Bare instances awaiting completion have no persisted containers yet, so the - * odd-count test runs on the requested container lines when they exist and the - * booking is offered as a candidate when they do not (GL enters its cargo on - * the split form). + * A booking whose cargo is not entered yet is NOT a candidate: with no + * container lines its 20ft count is unknown, so pairing with it cannot be + * shown to fill the wagon. Same rule as + * {@link findRebookConsolidationCandidates}. */ async findManualConsolidationCandidates( booking: Booking, limit = 50, ): Promise { - const rows = await this.repository + const qb = this.repository .createQueryBuilder('b') .leftJoinAndSelect('b.bookingContainers', 'bc') .leftJoinAndSelect('bc.containerType', 'ct') @@ -376,17 +379,27 @@ export class BookingsRepository extends BaseRepository { 'OPERATION_CHANGES_REQUESTED', 'PENDING_CONSOLIDATION', ], - }) - .orderBy('b.createdAt', 'ASC') - .take(limit) - .getMany(); + }); - // Odd-20ft test in memory: a bare instance has no containers yet (GL fills - // them on the split form) and stays a candidate; one that already carries - // cargo qualifies only when its 20ft total is odd. + // Same EAT booking day — the pair shares one physical wagon, so it must + // board one train. Applied only when this booking has a date of its own; + // without one there is no day to match against and route/direction stand + // alone, mirroring findComplementaryConsolidationPartner. + if (booking.scheduledDate) { + qb.andWhere( + `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`, + { bookingDate: booking.scheduledDate }, + ); + } + + const rows = await qb.orderBy('b.createdAt', 'ASC').take(limit).getMany(); + + // Odd-20ft test in memory. A booking with no container lines has an unknown + // 20ft count, so it cannot be shown to complete the wagon and is not + // offered. return rows.filter((row) => { const lines = row.bookingContainers ?? []; - if (lines.length === 0) return true; + if (lines.length === 0) return false; const ft20 = lines .filter((line) => Number(line.containerType?.sizeFt) === 20) .reduce((sum, line) => sum + Number(line.quantity || 0), 0); @@ -396,10 +409,16 @@ export class BookingsRepository extends BaseRepository { /** * Candidate partners for rebooking an odd-20ft cancellation credit: unpaired - * odd-20ft bookings on the same route/direction riding the requested day — - * SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION. + * odd-20ft bookings on the same route/direction riding the requested day. * Unlike {@link findManualConsolidationCandidates} this is not customs-only: * GL picks who shares the rebooked wagon whatever the contract kind. + * + * The status set is deliberately wide. A partner here is not required to be a + * live, committed shipment — a booking that lost its slot (EXPIRED) or was + * cancelled still has cargo that GL can put back on a train, and pairing it + * with the rebooked credit is how both halves get moving again. What it must + * not be is already spoken for: a booking whose own cancellation credit has + * been rebooked elsewhere is excluded, as is one already paired. */ async findRebookConsolidationCandidates( booking: Booking, @@ -410,6 +429,9 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('b') .leftJoinAndSelect('b.bookingContainers', 'bc') .leftJoinAndSelect('bc.containerType', 'ct') + // Units come back so GL can correct the partner's container numbers, + // seals and VGMs while pairing. + .leftJoinAndSelect('bc.units', 'unit') .leftJoinAndSelect('b.company', 'company') .where('b.id != :bookingId', { bookingId: booking.id }) .andWhere('b.consolidationPartnerId IS NULL') @@ -423,8 +445,27 @@ export class BookingsRepository extends BaseRepository { tradeDirection: booking.tradeDirection, }) .andWhere('b.status IN (:...statuses)', { - statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'], + statuses: [ + 'SUBMITTED', + 'PENDING_CONSOLIDATION', + 'CLEARANCE_READY', + 'OPERATION_CHANGES_REQUESTED', + // Lost its slot or was called off — its cargo is still real and can + // ride the rebooked wagon. + 'EXPIRED', + 'CANCELLED', + ], }) + // A cancelled booking whose own credit was already spent on a rebook is + // gone — pairing with it would hand the same cargo out twice. + .andWhere( + `NOT EXISTS ( + SELECT 1 FROM freight.booking_wagon_cancellations c + WHERE c.booking_id = b.id + AND c.rebooked_booking_id IS NOT NULL + AND c.deleted_at IS NULL + )`, + ) // Same EAT booking day as the rebook — the pair shares one physical // wagon, so it must board one train. .andWhere( @@ -729,6 +770,89 @@ export class BookingsRepository extends BaseRepository { return new Set(rows.map((r) => r.bookingId)); } + /** + * Bookings among `bookingIds` that hold a redeemable wagon-cancellation + * credit — the cut is settled (CREDIT_AVAILABLE), the credit is worth + * something, and it has not been spent on a rebook yet. Surfaced on the GL + * clearance queue so a paid-for credit is visibly rebookable from the list + * rather than only from the booking's own page. + */ + async findBookingsWithRedeemableCredit( + bookingIds: string[], + ): Promise> { + if (bookingIds.length === 0) return new Map(); + const rows = (await this.dataSource + .getRepository(BookingWagonCancellation) + .createQueryBuilder('c') + .select('c.booking_id', 'bookingId') + .addSelect('c.id', 'cancellationId') + .where('c.booking_id IN (:...bookingIds)', { bookingIds }) + .andWhere('c.status = :status', { status: 'CREDIT_AVAILABLE' }) + .andWhere('c.credit_amount > 0') + .andWhere('c.rebooked_booking_id IS NULL') + .andWhere('c.deleted_at IS NULL') + .getRawMany()) as Array<{ bookingId: string; cancellationId: string }>; + return new Map(rows.map((r) => [r.bookingId, r.cancellationId])); + } + + /** + * Apply container-unit corrections (number / seal / VGM) to units that belong + * to `bookingId`. The ownership join is the point: a unit id from another + * booking silently matches nothing rather than editing a stranger's cargo. + * Sizes and quantities are never touched — only the identifying details. + * Returns how many units were actually updated. + */ + async patchContainerUnitsForBooking( + bookingId: string, + patches: Array<{ + id: string; + containerNumber?: string; + sealNumber?: string; + vgmTons?: number; + }>, + ): Promise { + if (patches.length === 0) return 0; + const unitRepo = this.dataSource.getRepository(BookingContainerUnit); + const owned = await unitRepo + .createQueryBuilder('u') + .innerJoin('u.bookingContainer', 'bc') + .where('bc.booking_id = :bookingId', { bookingId }) + .andWhere('u.id IN (:...ids)', { ids: patches.map((p) => p.id) }) + .select('u.id', 'id') + .getRawMany<{ id: string }>(); + const ownedIds = new Set(owned.map((r) => r.id)); + + let updated = 0; + for (const patch of patches) { + if (!ownedIds.has(patch.id)) continue; + const set: Record = {}; + if (patch.containerNumber !== undefined) + set.containerNumber = patch.containerNumber; + if (patch.sealNumber !== undefined) set.sealNumber = patch.sealNumber; + if (patch.vgmTons !== undefined) set.vgmTons = patch.vgmTons; + if (Object.keys(set).length === 0) continue; + await unitRepo.update(patch.id, set as never); + updated += 1; + } + return updated; + } + + /** + * Has this booking's own wagon-cancellation credit already been spent on a + * rebook? Such a booking must not be offered or accepted as a consolidation + * partner — its cargo has already moved to the rebooked booking. + */ + async hasSpentCancellationCredit(bookingId: string): Promise { + const count = await this.dataSource + .getRepository(BookingWagonCancellation) + .createQueryBuilder('c') + .where('c.booking_id = :bookingId', { bookingId }) + .andWhere('c.rebooked_booking_id IS NOT NULL') + .andWhere('c.deleted_at IS NULL') + .getCount(); + return count > 0; + } + findDocumentReview( bookingId: string, settingCode: string, @@ -1043,9 +1167,38 @@ export class BookingsRepository extends BaseRepository { select: { bookingId: true, trainScheduleId: true }, }); const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId])); + + // The allocated train's own departure date — distinct from the customer's + // requested `booking.scheduledDate`. The list column shows this once a + // booking is on a train, so fetch it alongside the link ids. + const scheduleIds = [...new Set([...scheduleByBooking.values()].filter(Boolean))] as string[]; + const schedules = scheduleIds.length + ? await this.dataSource.getRepository(TrainSchedule).find({ + where: { id: In(scheduleIds) }, + select: { + id: true, + reference: true, + trainNumber: true, + status: true, + scheduledDepartureDate: true, + }, + }) + : []; + const scheduleById = new Map(schedules.map((schedule) => [schedule.id, schedule])); + for (const item of items) { - (item as Booking & { trainScheduleId?: string | null }).trainScheduleId = - scheduleByBooking.get(item.id) ?? null; + const scheduleId = scheduleByBooking.get(item.id) ?? null; + const enriched = item as Booking & { + trainScheduleId?: string | null; + trainScheduleReference?: string | null; + trainScheduleDepartureDate?: string | null; + }; + enriched.trainScheduleId = scheduleId; + const schedule = scheduleId ? scheduleById.get(scheduleId) : undefined; + enriched.trainScheduleReference = schedule?.reference ?? schedule?.trainNumber ?? null; + enriched.trainScheduleDepartureDate = schedule?.scheduledDepartureDate + ? new Date(schedule.scheduledDepartureDate).toISOString() + : null; } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index c7294a004..ea901bb16 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -12,6 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types'; import { insertWithGeneratedReference, logCtx } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; +import { TabularExportService } from '../exports/tabular-export.service'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; @@ -104,6 +105,38 @@ export interface PaginatedBookings { }; } +/** One container on an allocated wagon (raw SQL json_agg projection). */ +export interface WagonAllocationContainer { + containerNumber: string | null; + sealNumber: string | null; + positionOnWagon: number | null; + grossWeightTons: number | null; + sizeFt: number | null; +} + +/** One allocated wagon as returned by `wagonAllocations` (raw SQL projection). */ +export interface WagonAllocationRow { + allocationId: string; + sequenceNo: number | null; + wagonNumber: string | null; + wagonType: string | null; + wagonTypeCode: string | null; + /** numeric columns arrive as strings from pg. */ + tareWeightTons: string | null; + capacityTons: string | null; + lengthMeters: string | null; + allocatedWeightTons: string | null; + loadType: string | null; + status: string | null; + trainNumber: string | null; + departureAt: string | Date | null; + originStation: string | null; + destinationStation: string | null; + bulkCargoDescription: string | null; + bulkQuantity: string | null; + containers: WagonAllocationContainer[]; +} + /** One wagon line on the carriage acceptance sheet (raw SQL projection). */ interface CarriageAcceptanceWagonRow { sequenceNo: number; @@ -172,6 +205,7 @@ export class BookingsService { private readonly bookingContractService: BookingContractService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, + private readonly tabularExport: TabularExportService, ) {} async assignCustomerTruck( @@ -447,7 +481,7 @@ export class BookingsService { * an array per wagon, bulk load description when the wagon carries bulk). * Empty array until the booking has been allocated onto a train. */ - async wagonAllocations(bookingId: string): Promise { + async wagonAllocations(bookingId: string): Promise { return this.dataSource.query( `SELECT a.id AS "allocationId", tsw.sequence_no AS "sequenceNo", @@ -501,6 +535,103 @@ export class BookingsService { ); } + /** + * The Wagons tab's Excel export: the booking's customer identity in the KPI + * header, then one row per allocated wagon. + * + * Container numbers are flattened into a single cell rather than exploded + * into one row per container — the sheet is a wagon manifest, and a reader + * counting rows must get the wagon count. + */ + async wagonAllocationsWorkbook( + bookingId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + const booking = await this.findById(bookingId); + const wagons = await this.wagonAllocations(bookingId); + + // Same precedence the booking list uses: a shipping line owns its bookings + // directly, a government booking names its institution, everyone else is + // the customer company. + // `shippingLineCompany` is attached by `findById` (attachShippingLineCompanies), + // not a declared relation on the entity — hence the cast, matching that helper. + const shippingLine = (booking as Booking & { shippingLineCompany?: { name?: string } }) + .shippingLineCompany; + const customerName = + shippingLine?.name ?? + (booking.isGovernment ? booking.governmentInstitution : null) ?? + booking.company?.name ?? + '—'; + + const rows = wagons.map((w) => ({ + sequenceNo: w.sequenceNo, + wagonNumber: w.wagonNumber ?? '—', + wagonType: w.wagonType ?? '—', + loadType: w.loadType ?? '—', + status: w.status ?? '—', + tareWeightTons: w.tareWeightTons === null ? null : Number(w.tareWeightTons), + capacityTons: w.capacityTons === null ? null : Number(w.capacityTons), + allocatedWeightTons: + w.allocatedWeightTons === null ? null : Number(w.allocatedWeightTons), + lengthMeters: w.lengthMeters === null ? null : Number(w.lengthMeters), + containerCount: w.containers?.length ?? 0, + containerNumbers: + (w.containers ?? []).map((c) => c.containerNumber).filter(Boolean).join(', ') || '—', + sealNumbers: + (w.containers ?? []).map((c) => c.sealNumber).filter(Boolean).join(', ') || '—', + bulkCargo: w.bulkCargoDescription ?? '—', + bulkQuantity: w.bulkQuantity === null ? null : Number(w.bulkQuantity), + trainNumber: w.trainNumber ?? '—', + departureAt: w.departureAt ? new Date(w.departureAt).toISOString().slice(0, 10) : '—', + originStation: w.originStation ?? '—', + destinationStation: w.destinationStation ?? '—', + // Repeated on every row so the sheet survives being filtered, sorted or + // pasted into a combined workbook, where the header block is lost. + customerName, + bookingReference: booking.reference, + })); + + const totalAllocated = rows.reduce( + (sum, r) => sum + (r.allocatedWeightTons ?? 0), + 0, + ); + + const buffer = await this.tabularExport.toXlsx({ + title: `Wagons ${booking.reference}`.slice(0, 31), + description: `Wagons allocated to booking ${booking.reference} — ${customerName}`, + label: 'booking:wagon-allocations', + kpis: [ + { label: 'Wagons', value: rows.length }, + { label: 'Containers', value: rows.reduce((sum, r) => sum + r.containerCount, 0) }, + { label: 'Allocated weight', value: Number(totalAllocated.toFixed(3)), unit: 't' }, + ], + columns: [ + { key: 'bookingReference', label: 'Booking', type: 'string' }, + { key: 'customerName', label: 'Customer', type: 'string' }, + { key: 'sequenceNo', label: 'Seq', type: 'number' }, + { key: 'wagonNumber', label: 'Wagon number', type: 'string' }, + { key: 'wagonType', label: 'Wagon type', type: 'string' }, + { key: 'loadType', label: 'Load type', type: 'string' }, + { key: 'status', label: 'Status', type: 'string' }, + { key: 'tareWeightTons', label: 'Tare', type: 'tons' }, + { key: 'capacityTons', label: 'Capacity', type: 'tons' }, + { key: 'allocatedWeightTons', label: 'Allocated', type: 'tons' }, + { key: 'lengthMeters', label: 'Length (m)', type: 'number' }, + { key: 'containerCount', label: 'Containers', type: 'number' }, + { key: 'containerNumbers', label: 'Container numbers', type: 'string' }, + { key: 'sealNumbers', label: 'Seal numbers', type: 'string' }, + { key: 'bulkCargo', label: 'Bulk cargo', type: 'string' }, + { key: 'bulkQuantity', label: 'Bulk quantity', type: 'number' }, + { key: 'trainNumber', label: 'Train', type: 'string' }, + { key: 'departureAt', label: 'Departure', type: 'date' }, + { key: 'originStation', label: 'Origin', type: 'string' }, + { key: 'destinationStation', label: 'Destination', type: 'string' }, + ], + rows, + }); + + return { filename: `wagons-${booking.reference}.xlsx`, buffer }; + } + /** * Split the booking amount across its wagons, proportional to allocated weight * (equal shares when no weights are recorded). The last row absorbs the rounding diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts index 7b4d3ca19..eda4b6386 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -105,6 +105,31 @@ export class RebookContainerLineDto { units!: RebookUnitDto[]; } +/** One edited container unit on the consolidation partner booking. */ +export class PartnerUnitPatchDto { + @ApiProperty({ description: 'Id of the partner booking container unit being edited' }) + @IsUUID() + id!: string; + + @ApiPropertyOptional({ description: 'Container number' }) + @IsOptional() + @IsString() + @MaxLength(64) + containerNumber?: string; + + @ApiPropertyOptional({ description: 'Seal number' }) + @IsOptional() + @IsString() + @MaxLength(64) + sealNumber?: string; + + @ApiPropertyOptional({ description: 'VGM (tons) of the unit' }) + @IsOptional() + @IsNumber() + @Min(0) + vgmTons?: number; +} + export class RebookCancelledWagonsDto { @ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' }) @IsDateString() @@ -132,6 +157,19 @@ export class RebookCancelledWagonsDto { @IsOptional() @IsUUID() partnerBookingId?: string; + + @ApiPropertyOptional({ + description: + 'Corrections to the partner booking\'s own container units (number / seal ' + + '/ VGM). Only the units listed are touched; sizes and quantities are never ' + + 'changed. Ignored unless partnerBookingId is set.', + type: [PartnerUnitPatchDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PartnerUnitPatchDto) + partnerUnits?: PartnerUnitPatchDto[]; } export class FilterWagonCancellationsDto { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 129789898..faa59e802 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -43,6 +43,9 @@ function makeService(overrides?: { findBookingsWithUnreviewedDocuments: jest .fn() .mockResolvedValue(new Set()), + findBookingsWithRedeemableCredit: jest + .fn() + .mockResolvedValue(new Map()), }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index b776a547c..5d0943fe6 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1347,6 +1347,17 @@ export class BookingClearanceService { .hasDocumentsAwaitingReview = pending.has(b.id); } + // A cancelled booking may still hold a paid-for wagon-cancellation credit. + // GL redeems it from this queue, so the row carries the cancellation id the + // rebook action needs. + const credits = await this.bookingsRepository.findBookingsWithRedeemableCredit( + filtered.map((b) => b.id), + ); + for (const b of filtered) { + (b as Booking & { rebookableCancellationId?: string | null }) + .rebookableCancellationId = credits.get(b.id) ?? null; + } + const rows = await this.attachContractSummary(filtered); return this.narrowToYardScope(rows, user); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts index 49e77627f..b601e1c3e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts @@ -183,8 +183,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { { quantity: 4, containerType: { sizeFt: 20 } }, ], }, - // A bare instance has no cargo yet — GL enters it on the split form, so it - // stays a candidate. + // Cargo not entered yet — its 20ft count is unknown, so it cannot be + // shown to fill the wagon and is not offered. { id: 'bare', reference: 'BK-BARE', bookingContainers: [] }, ]; @@ -198,7 +198,7 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { rows.filter((row) => { void booking; const lines = row.bookingContainers ?? []; - if (lines.length === 0) return true; + if (lines.length === 0) return false; const ft20 = lines .filter((l) => Number(l.containerType?.sizeFt) === 20) .reduce((sum, l) => sum + Number(l.quantity || 0), 0); @@ -209,8 +209,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { }); const candidates = await service.listConsolidationCandidates('c-1', 'b-1'); - expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']); + expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD']); expect(candidates[0].ft20Quantity).toBe(3); - expect(candidates[1].hasCargo).toBe(false); + expect(candidates[0].hasCargo).toBe(true); }); }); diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingWagonsPanel.tsx new file mode 100644 index 000000000..456c9f3d6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingWagonsPanel.tsx @@ -0,0 +1,255 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Badge, + Button, + Center, + Group, + Loader, + SimpleGrid, + Stack, + Table, + Text, +} from "@mantine/core"; +import { Container, FileSpreadsheet, Train } from "lucide-react"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; +import { extractDownloadErrorMessage } from "@/components/warehouses/options"; +import { formatDate } from "@/lib/format"; +import type { BookingWagonRow } from "@/types/trainScheduling"; + +import { SectionCard } from "./SectionCard"; +import { MetricTile } from "./MetricTile"; + +/** pg returns numerics as strings; everything here is arithmetic on tons/metres. */ +const num = (value: number | string | null | undefined): number => { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const tons = (value: number | string | null | undefined): string => + `${num(value).toLocaleString(undefined, { maximumFractionDigits: 3 })} t`; + +/** Allocation status → badge colour. PLANNED is the pre-loading default. */ +const STATUS_COLORS: Record = { + PLANNED: "blue", + LOADED: "edr-green", + UNLOADED: "gray", + CANCELLED: "red", +}; + +/** + * The booking detail page's "Wagons" tab: every wagon allocated to this booking, + * with its containers or bulk load, plus an Excel export of the same list. + * + * A booking has no wagons until it is paid and placed on a train, so the empty + * state is the normal case for most of a booking's life — it explains the + * precondition rather than reading as an error. + */ +export function BookingWagonsPanel({ + bookingId, + bookingReference, +}: { + bookingId: string; + bookingReference: string; +}) { + const [exporting, setExporting] = useState(false); + + const { data, isLoading, isError } = useQuery( + api.trainScheduling.bookingWagons.queryOptions({ input: { bookingId } }), + ); + + const wagons = useMemo(() => data ?? [], [data]); + + const totals = useMemo(() => { + const containerCount = wagons.reduce( + (sum, w) => sum + (w.containers?.length ?? 0), + 0, + ); + const allocated = wagons.reduce( + (sum, w) => sum + num(w.allocatedWeightTons), + 0, + ); + const capacity = wagons.reduce((sum, w) => sum + num(w.capacityTons), 0); + return { containerCount, allocated, capacity }; + }, [wagons]); + + // The train is a property of the allocation, so every wagon on this booking + // carries the same one — read it off the first row rather than per row. + const train = wagons[0]; + + const handleExport = async () => { + setExporting(true); + try { + const blob = await bookingsService.downloadWagonsWorkbook(bookingId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `wagons-${bookingReference}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } catch (error) { + // Blob response: the JSON reason is inside the Blob, so the sync path + // would surface only "Request failed with status code 400". + toast.error(await extractDownloadErrorMessage(error)); + } finally { + setExporting(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + } + loading={exporting} + // The sheet would be headers with no rows — nothing to hand over. + disabled={wagons.length === 0} + onClick={() => void handleExport()} + > + Export Excel + + } + > + {isError ? ( + + Could not load the wagon allocations for this booking. + + ) : wagons.length === 0 ? ( + + Wagons appear here once the booking is paid and allocated onto a train. + + ) : ( + + + + + + + + + {train?.departureAt ? ( + + + Departs {formatDate(train.departureAt)} + + {train.originStation && train.destinationStation ? ( + + · {train.originStation} → {train.destinationStation} + + ) : null} + + ) : null} + + + + + + Seq + Wagon + Type + Status + Allocated + Capacity + Load + + + + {wagons.map((w) => ( + + + + {w.sequenceNo ?? "—"} + + + + + {w.wagonNumber ?? "—"} + + + + {w.wagonType ?? "—"} + + + + {w.status} + + + + {tons(w.allocatedWeightTons)} + + + + {tons(w.capacityTons)} + + + + {w.containers?.length ? ( + + {w.containers.map((c, i) => ( + + + + {c.containerNumber ?? "—"} + {c.sizeFt ? ` · ${c.sizeFt}ft` : ""} + + + ))} + + ) : w.bulkCargoDescription || w.loadType === "BULK" ? ( + + {w.bulkCargoDescription ?? "Bulk"} + {w.bulkQuantity ? ` · ${num(w.bulkQuantity)}` : ""} + + ) : ( + + — + + )} + + + ))} + +
+
+
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index 23f9b2bd8..d23b75f43 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -3,6 +3,7 @@ export * from "./SectionCard"; export * from "./ClearanceReviewSection"; export * from "./BookingDocumentsPanel"; export * from "./BookingTrucksPanel"; +export * from "./BookingWagonsPanel"; export * from "./ContractOrdersPanel"; export * from "./MetricTile"; export * from "./BookingDetailToolbar"; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx index 2d37be840..318ce3f9b 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx @@ -1,11 +1,11 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core"; -import { DatePickerInput } from "@mantine/dates"; import { useMutation, useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; +import { OperationDatePicker } from "@edr/ui-common"; import { api } from "@/auth/http"; -import { toDayString } from "@/hooks/useListControls"; +import { api as rpc } from "@/services/api"; import { formatMoney } from "@/lib/format"; import { hasOddFt20, @@ -13,6 +13,7 @@ import { type WagonCancellation, } from "./types"; + /** Editable rebook unit — prefilled from the cancelled snapshot. */ interface RebookUnitDraft { containerSize: string; @@ -21,6 +22,46 @@ interface RebookUnitDraft { vgmTons: number | ""; } +/** Editable unit on the consolidation partner — prefilled from its own cargo. */ +interface PartnerUnitDraft { + id: string; + containerSize: string; + containerNumber: string; + sealNumber: string; + vgmTons: number | ""; +} + +const partnerDraftsFrom = (c: RebookPartnerCandidate | undefined) => + (c?.units ?? []).map((u) => ({ + id: u.id, + containerSize: u.containerSize, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? "", + vgmTons: Number(u.vgmTons) || ("" as const), + })); + +/** Only the units GL actually changed are sent. */ +const partnerUnitsPayload = ( + drafts: PartnerUnitDraft[], + original: PartnerUnitDraft[], +) => + drafts + .filter((d, i) => { + const o = original[i]; + return ( + !o || + d.containerNumber !== o.containerNumber || + d.sealNumber !== o.sealNumber || + d.vgmTons !== o.vgmTons + ); + }) + .map((d) => ({ + id: d.id, + containerNumber: d.containerNumber.trim(), + sealNumber: d.sealNumber.trim(), + ...(d.vgmTons !== "" ? { vgmTons: Number(d.vgmTons) } : {}), + })); + const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] => (r.cancelledQuantities?.units ?? []).map((u) => ({ containerSize: u.containerSize, @@ -62,30 +103,75 @@ export function RebookWagonCancellationModal({ /** Called after a successful rebook with the new booking id (when the API returns it). */ onRebooked?: (result: { bookingId?: string }) => void; }) { - const [date, setDate] = useState(null); + // Held as the picker's own `yyyy-MM-dd` string, never a Date: converting a + // local-midnight Date back with toISOString() shifts it into the previous day + // in any timezone east of UTC (EAT is +03), which both mis-rendered the + // selection and submitted the wrong shipment day. + const [date, setDate] = useState(null); const [partnerId, setPartnerId] = useState(null); + const [partnerDrafts, setPartnerDrafts] = useState([]); const [drafts, setDrafts] = useState([]); // Fresh form per row: the modal instance is long-lived on the host page. useEffect(() => { setDate(null); setPartnerId(null); + setPartnerDrafts([]); setDrafts(cancellation ? draftsFrom(cancellation) : []); }, [cancellation]); const needsPartner = cancellation ? hasOddFt20(cancellation) : false; + + // The rebook rides the same lane with the same cargo as the cancelled + // shipment, so the shipment day must come from the days that lane actually + // runs — an arbitrary calendar day has no train and no wagon capacity. + const daysQuery = useMemo(() => { + const b = cancellation?.booking; + if (!b?.originYardId || !b?.destinationYardId) return null; + const containers = Object.entries( + cancellation?.cancelledQuantities?.bySize ?? {}, + ) + .map(([containerSize, quantity]) => ({ + containerSize, + quantity: Number(quantity || 0), + })) + .filter((c) => c.quantity >= 1); + if (containers.length > 0) { + return { + originYardId: b.originYardId, + destinationYardId: b.destinationYardId, + freightType: "CONTAINER" as const, + containers, + }; + } + const tons = Number(cancellation?.weightTons || 0); + if (tons <= 0) return null; + return { + originYardId: b.originYardId, + destinationYardId: b.destinationYardId, + freightType: "BULK" as const, + totalWeightTons: tons, + }; + }, [cancellation]); + + const { data: availableDays, isLoading: daysLoading } = useQuery({ + ...rpc.trainScheduling.availableDaysForCargo.queryOptions({ + input: daysQuery ?? { freightType: "BULK" as const }, + }), + enabled: Boolean(cancellation) && daysQuery !== null, + }); const partners = useQuery({ queryKey: [ "wagon-cancellations", cancellation?.id, "rebook-partners", - date ? toDayString(date) : null, + date, ], enabled: Boolean(cancellation && needsPartner && date), queryFn: async () => { const res = await api.get( `/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`, - { params: { scheduledDate: toDayString(date!) } }, + { params: { scheduledDate: date } }, ); return res.data; }, @@ -96,15 +182,28 @@ export function RebookWagonCancellationModal({ const res = await api.post<{ bookingId?: string }>( `/bookings/wagon-cancellations/${cancellation!.id}/rebook`, { - scheduledDate: toDayString(date!), + scheduledDate: date, ...(drafts.length ? { containers: containersPayload(drafts) } : {}), ...(partnerId ? { partnerBookingId: partnerId } : {}), + ...(() => { + if (!partnerId) return {}; + const original = partnerDraftsFrom( + (partners.data ?? []).find((c) => c.id === partnerId), + ); + const changed = partnerUnitsPayload(partnerDrafts, original); + return changed.length ? { partnerUnits: changed } : {}; + })(), }, ); return res.data ?? {}; }, }); + const patchPartnerDraft = (i: number, patch: Partial) => + setPartnerDrafts((prev) => + prev.map((d, idx) => (idx === i ? { ...d, ...patch } : d)), + ); + const patchDraft = (i: number, patch: Partial) => setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x))); @@ -115,6 +214,9 @@ export function RebookWagonCancellationModal({ title="Rebook cancelled wagons" centered radius="md" + // Wide enough for the calendar plus two container-unit editors side by + // side without the number / seal / VGM fields cramping. + size="xl" > {cancellation && ( @@ -123,21 +225,30 @@ export function RebookWagonCancellationModal({ {cancellation.wagonsCancelled} wagon(s) · credit{" "} {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)} - { - setDate(v ? new Date(v) : null); + + Shipment day + + { + setDate(d || null); setPartnerId(null); + setPartnerDrafts([]); }} - minDate={new Date()} - radius="md" /> {needsPartner && (