diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index ad0e62ef3..caffee8c0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -298,6 +298,42 @@ export class BookingLifecycleNotifierService { this.inApp(b, 'Operation request needs changes', msg); } + /** + * Operations moved the shipment day (and possibly the train) themselves + * instead of asking the customer to. The booking stays under review, so the + * customer only needs to know the new day — nothing to resubmit. + */ + operationRescheduled(b: Booking, previousDay: string | null, note?: string): void { + const newDay = b.scheduledDate + ? b.scheduledDate.toLocaleDateString('en-GB', { timeZone: 'Africa/Addis_Ababa' }) + : 'a new day'; + const msg = + `Operations moved the shipment day of booking ${b.reference} ` + + `${previousDay ? `from ${previousDay} ` : ''}to ${newDay}.` + + (note ? ` Note from Operations: ${note}` : '') + + ' The request stays under review — no action is needed on your side.'; + if (b.customsClearingEnabled) { + this.logger.log(`OPERATION RESCHEDULED (to GL) — ${this.ref(b)}`); + void this.inbox.notify({ + recipients: + b.createdByRole === 'GL_ET' && b.createdByUserId + ? { userIds: [b.createdByUserId] } + : CLEARANCE_DESK, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: `Booking ${b.reference} shipment day changed`, + body: msg, + link: b.contractId + ? `/dashboard/contracts/clearance/${b.contractId}` + : `/dashboard/bookings/${b.id}/clearance`, + data: { bookingId: b.id, reference: b.reference, note: note ?? null }, + }); + return; + } + void this.notifyContact(b, msg, 'OPERATION RESCHEDULED'); + this.inApp(b, 'Shipment day changed by Operations', msg); + } + /** Operation accepted → invoice ready; await payment / booking window. */ operationAccepted(b: Booking): void { // No invoice and no pay window for a shipping line — the charge sits on diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index b55c8d4b0..8cfec366d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -217,3 +217,170 @@ describe('BookingTransitionService — requestOperation export space gate', () = ); }); }); + +/** + * Staff reschedule of an operation request: instead of returning the booking + * to the customer, Operations sets the new shipment day (and the export train) + * themselves. Same day-pool / export gates as the customer request; the booking + * lands (back) at OPERATION_REQUEST_PENDING and the customer is told. + */ +describe('BookingTransitionService — staff reschedule of an operation request', () => { + function makeService(over: { + status?: string; + tradeDirection?: 'EXPORT' | 'IMPORT'; + hasDeparture?: boolean; + } = {}) { + const booking = { + id: 'b-1', + reference: 'BKG-1', + status: over.status ?? 'OPERATION_REQUEST_PENDING', + tradeDirection: over.tradeDirection ?? 'IMPORT', + originYardId: 'o-1', + destinationYardId: 'd-1', + totalAmount: 1000, + contractId: null, + scheduledDate: new Date('2026-07-01T00:00:00.000Z'), + requestedTrainScheduleId: null, + serviceType: { code: 'RAIL_CONTAINER' }, + }; + const bookingsRepository = { + update: jest.fn().mockResolvedValue({ id: 'b-1' }), + createReviewNote: jest.fn().mockResolvedValue(undefined), + }; + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking), + checkDayCompatibilityForBooking: jest.fn().mockResolvedValue({ + hasDeparture: over.hasDeparture ?? true, + hasCompatible: true, + }), + }; + const bookingBatchService = { + pickExportSchedule: jest.fn().mockResolvedValue('sched-1'), + }; + const notifier = { operationRescheduled: jest.fn() }; + const clearanceEvents = { record: jest.fn() }; + + const service = new BookingTransitionService( + bookingsRepository as never, + {} as never, // ruleEngineService + {} as never, // pricingService + {} as never, // contractService + {} as never, // filesService + {} as never, // fileUploadSettingsService + bookingBatchService as never, + bookingsService as never, + { isPhasedCustomsBooking: () => false } as never, + {} as never, // workflowService + {} as never, // invoiceService + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + notifier as never, + clearanceEvents as never, + { emit: jest.fn() } as never, // events + ); + return { service, bookingsRepository, bookingBatchService, notifier, clearanceEvents }; + } + + it('refuses a booking that has not requested operation', async () => { + const { service, bookingsRepository } = makeService({ status: 'CLEARANCE_READY' }); + await expect( + service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'), + ).rejects.toBeInstanceOf(ConflictException); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it('refuses a day with no departure on the route and changes nothing', async () => { + const { service, bookingsRepository } = makeService({ hasDeparture: false }); + await expect( + service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it('moves an import request to the new day, keeps it pending, logs it and tells the customer', async () => { + const { service, bookingsRepository, notifier, clearanceEvents } = makeService(); + await service.rescheduleOperationRequest( + 'b-1', + '2026-07-20T00:00:00.000Z', + 'sched-9', // ignored for import — the batch engine assigns the train + 'staff-1', + ); + expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { + status: 'OPERATION_REQUEST_PENDING', + scheduledDate: new Date('2026-07-20T00:00:00.000Z'), + requestedTrainScheduleId: null, + }); + expect(bookingsRepository.createReviewNote).not.toHaveBeenCalled(); + expect(clearanceEvents.record).toHaveBeenCalledWith( + expect.objectContaining({ + bookingId: 'b-1', + action: 'OPERATION_RESCHEDULED', + actorType: 'STAFF', + actorId: 'staff-1', + metadata: expect.objectContaining({ + previousScheduledDate: '2026-07-01', + scheduledDate: '2026-07-20', + }), + }), + ); + expect(notifier.operationRescheduled).toHaveBeenCalledWith( + expect.objectContaining({ id: 'b-1' }), + '2026-07-01', + undefined, + ); + }); + + it('resolves a change request staff had raised: back to pending, note kept as a staff note', async () => { + const { service, bookingsRepository, notifier } = makeService({ + status: 'OPERATION_CHANGES_REQUESTED', + }); + await service.rescheduleOperationRequest( + 'b-1', + '2026-07-20T00:00:00.000Z', + null, + 'staff-1', + { note: ' Moved to the Monday train ' }, + ); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }), + ); + expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( + 'b-1', + 'Moved to the Monday train', + 'STAFF_NOTE', + 'staff-1', + ); + expect(notifier.operationRescheduled).toHaveBeenCalledWith( + expect.anything(), + '2026-07-01', + 'Moved to the Monday train', + ); + }); + + it('export rail: requires the train and persists the pick after the space gate', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService({ + tradeDirection: 'EXPORT', + }); + await expect( + service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'), + ).rejects.toThrow(/select a train/i); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + + await service.rescheduleOperationRequest( + 'b-1', + '2026-07-20T00:00:00.000Z', + 'sched-9', + 'staff-1', + ); + expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledWith( + expect.objectContaining({ requestedTrainScheduleId: 'sched-9' }), + ); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ + status: 'OPERATION_REQUEST_PENDING', + requestedTrainScheduleId: 'sched-9', + }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 7324da975..69da49299 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1311,6 +1311,45 @@ export class BookingTransitionService { ); } + const { date, requestedId } = await this.resolveOperationDay( + booking, + scheduledDate, + requestedTrainScheduleId, + opts?.bypassDayPool, + ); + + await this.bookingsRepository.update(bookingId, { + status: "OPERATION_REQUEST_PENDING", + scheduledDate: date, + requestedTrainScheduleId: requestedId, + } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'OPERATION_REQUESTED', + label: `Requested operation for shipment day ${scheduledDate}`, + actorType: 'CUSTOMER', + actorId: opts?.userId ?? null, + metadata: { scheduledDate }, + }); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.operationRequestedToStaff(fresh); + return fresh; + } + + /** + * Validate a shipment day (and, for export rail, the picked train) for a + * booking the way the customer's operation request does, and resolve what + * gets persisted: the binding `scheduledDate` and the `requestedTrainScheduleId` + * (export rail / shipping-line only — import and domestic trains are assigned + * by the batch engine, so their pick is dropped). Shared by the customer + * request and the staff reschedule so both enforce the same gates. + */ + private async resolveOperationDay( + booking: Booking, + scheduledDate: string, + requestedTrainScheduleId?: string | null, + bypassDayPool?: boolean, + ): Promise<{ date: Date; requestedId: string | null }> { const date = new Date(scheduledDate); if (Number.isNaN(date.getTime())) { throw new BadRequestException("A valid schedule date is required"); @@ -1322,7 +1361,7 @@ export class BookingTransitionService { // gate; quantity never blocks — oversized bookings get a partial split // offer). The batch engine assigns the specific train within that // (route, day) pool later. - if (!opts?.bypassDayPool) { + if (!bypassDayPool) { const { hasDeparture, hasCompatible } = await this.bookingsService.checkDayCompatibilityForBooking( booking, @@ -1359,7 +1398,7 @@ export class BookingTransitionService { // persisted here the same way an export pick is. Customer import/domestic // bookings still never carry one (the batch engine assigns their train). const requestedId = - isExportTrain || opts?.bypassDayPool + isExportTrain || bypassDayPool ? (requestedTrainScheduleId ?? null) : null; // Export rail rides the exact train the customer picked — never an @@ -1403,21 +1442,76 @@ export class BookingTransitionService { } } + return { date, requestedId }; + } + + /** + * Operations changes the shipment day and/or train of a booking the customer + * has already requested operation on — instead of bouncing it back to the + * customer with a change request, staff set the new day (and, for export + * rail, the train) themselves. The same day-pool / export-space gates as the + * customer's own request apply, so staff cannot park a booking on a day with + * no departure or a train with no room. + * + * Allowed at OPERATION_REQUEST_PENDING (staff review) and at + * OPERATION_CHANGES_REQUESTED (staff resolve their own change request); either + * way the booking lands back at OPERATION_REQUEST_PENDING for the normal + * accept. The customer is told the new day, with the staff note when given. + */ + async rescheduleOperationRequest( + bookingId: string, + scheduledDate: string, + requestedTrainScheduleId: string | null | undefined, + actorId: string, + options: { note?: string } = {}, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, [ + "OPERATION_REQUEST_PENDING", + "OPERATION_CHANGES_REQUESTED", + ]); + + const { date, requestedId } = await this.resolveOperationDay( + booking, + scheduledDate, + requestedTrainScheduleId, + ); + const previousDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null; + const previousTrainId = booking.requestedTrainScheduleId ?? null; + const note = options.note?.trim() || undefined; + await this.bookingsRepository.update(bookingId, { status: "OPERATION_REQUEST_PENDING", scheduledDate: date, requestedTrainScheduleId: requestedId, } as never); + if (note) { + await this.bookingsRepository.createReviewNote( + bookingId, + note, + "STAFF_NOTE", + actorId, + ); + } await this.clearanceEvents.record({ bookingId, - action: 'OPERATION_REQUESTED', - label: `Requested operation for shipment day ${scheduledDate}`, - actorType: 'CUSTOMER', - actorId: opts?.userId ?? null, - metadata: { scheduledDate }, + action: "OPERATION_RESCHEDULED", + label: + `Operations moved the shipment day ` + + `${previousDay ? `from ${previousDay} ` : ""}to ${eatDay(date)}` + + (requestedId && requestedId !== previousTrainId ? " and changed the train" : ""), + actorType: "STAFF", + actorId, + metadata: { + previousScheduledDate: previousDay, + scheduledDate: eatDay(date), + previousTrainScheduleId: previousTrainId, + trainScheduleId: requestedId, + note: note ?? null, + }, }); const fresh = await this.bookingsService.findById(bookingId); - this.notifier.operationRequestedToStaff(fresh); + this.notifier.operationRescheduled(fresh, previousDay, note); return fresh; } 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 66958bc38..85a98ef57 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -82,6 +82,7 @@ import { ReviewDocumentDto, RequestOperationDto, OperationReviewDto, + RescheduleOperationDto, StaffRejectDto, } from "./dto/request-changes.dto"; import { ContractViewDto } from "./dto/contract-view.dto"; @@ -1344,6 +1345,29 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(":id/operation/reschedule") + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ + summary: + "Operations changes a pending operation request's shipment day and/or " + + "train on the customer's behalf (OPERATION_REQUEST_PENDING | " + + "OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)", + }) + async rescheduleOperationRequest( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: RescheduleOperationDto, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.transitionService.rescheduleOperationRequest( + id, + dto.scheduledDate, + dto.trainScheduleId ?? null, + resolveAuthUserId(user), + { note: dto.note }, + ); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(":id/clearance/review") @BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments) @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 9631def73..bf718bb70 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -107,6 +107,38 @@ export class RequestOperationDto { trainScheduleId?: string; } +/** + * Operations changes a pending operation request's shipment day and/or train + * on the customer's behalf (instead of returning it for changes). + */ +export class RescheduleOperationDto { + @ApiProperty({ + description: + 'The new shipment day (train departure day). ISO date — must have an ' + + 'open departure on the booking route that can carry the cargo.', + example: '2026-07-15', + }) + @IsDateString() + scheduledDate!: string; + + @ApiPropertyOptional({ + description: + 'EXPORT rail only: the train (schedule id) to ride, from ' + + 'GET /bookings/:id/export-trains for the new day. Required for export ' + + 'rail; ignored for import/domestic/road bookings.', + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + + @ApiPropertyOptional({ + description: 'Optional note to the customer explaining the change.', + }) + @IsOptional() + @IsString() + note?: string; +} + export class OperationReviewDto { @ApiProperty({ description: diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index aa0540544..983736f5e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -289,6 +289,99 @@ export function bookingCloseCutoff( return new Date(departure.getTime() - offsetMinutes * 60_000); } +/** The schedule fields the close-offset reopen guard reads. */ +export interface CloseOffsetReopenSchedule { + status: string; + direction?: string | null; + windowPhase?: string | null; + bookingWindowStatus?: string | null; + scheduledDepartureDate?: Date | null; +} + +export interface CloseOffsetReopenCheck { + /** True when shortening the close offset is the one thing that reopens booking. */ + eligible: boolean; + /** Why the schedule is not eligible; null when it is. */ + reason: string | null; + /** Minutes before departure this schedule currently stops taking bookings. */ + offsetMinutes: number | null; + /** The cutoff that shut booking (departure − offset); null without an offset. */ + cutoffAt: Date | null; +} + +/** + * Is this schedule's booking shut ONLY because of its close offset? That is the + * one case staff may fix from the board by shortening the offset (3 days → 1 + * day, 2 hours, …) so the desk reopens before departure. Every other way a + * window ends stays closed: the train departed, it is full, it never had an + * offset (booking ran until departure), or the window is still mid-cycle. + * + * The last guard — "a cycle would fit before departure with no offset at all" — + * is what makes the offset the ONLY problem: when the desk's next opening lands + * after the train leaves, no offset change can help. + */ +export function closeOffsetReopenCheck( + schedule: CloseOffsetReopenSchedule, + cfg: { + importCloseOffsetMinutes?: number | null; + exportCloseOffsetMinutes?: number | null; + windowOpenHour: number; + windowCloseHour: number; + }, + now: Date, +): CloseOffsetReopenCheck { + const departure = schedule.scheduledDepartureDate ?? null; + const offsetRaw = + schedule.direction === 'EXPORT' + ? cfg.exportCloseOffsetMinutes + : cfg.importCloseOffsetMinutes; + const offsetMinutes = offsetRaw != null && offsetRaw > 0 ? offsetRaw : null; + const cutoffAt = + departure && offsetMinutes != null + ? bookingCloseCutoff(departure, schedule.direction, cfg) + : null; + const no = (reason: string): CloseOffsetReopenCheck => ({ + eligible: false, + reason, + offsetMinutes, + cutoffAt, + }); + + if (schedule.status !== 'DRAFT' && schedule.status !== 'SCHEDULED') { + return no(`A ${schedule.status.toLowerCase()} train cannot reopen booking.`); + } + if (!departure || departure.getTime() <= now.getTime()) { + return no('This train has already departed (or has no departure date).'); + } + if (offsetMinutes == null) { + return no( + 'This train has no close offset — booking ran until departure, so there is nothing to shorten.', + ); + } + if (schedule.windowPhase !== 'DONE') { + return no( + schedule.windowPhase == null + ? 'This train does not run a managed booking window.' + : `Booking is not closed yet — the window is in its ${schedule.windowPhase} phase.`, + ); + } + if (schedule.bookingWindowStatus === 'FULL') { + return no( + 'Booking closed because the train is full, not because of the close offset.', + ); + } + const hours: OfficeHours = { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }; + if (nextCycleOpensAt(now, hours, departure) == null) { + return no( + 'The desk would not reopen before departure even with no close offset — the offset is not what is blocking booking.', + ); + } + return { eligible: true, reason: null, offsetMinutes, cutoffAt }; +} + export interface InitialWindowTimes { windowOpensAt: Date; windowClosesAt: Date; 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 1416d1d16..738a29b11 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 @@ -66,6 +66,7 @@ import { AvailableDaysQueryDto } from "../dto/available-days-query.dto"; import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto"; import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto"; import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto"; +import { ReduceScheduleCloseOffsetDto } from "../dto/reduce-schedule-close-offset.dto"; import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto"; import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto"; import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto"; @@ -985,6 +986,20 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Patch("schedules/:id/close-offset") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "Shorten the booking-close offset of a schedule whose booking shut only because of that offset, so its window reopens before departure", + }) + async reduceScheduleCloseOffset( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ReduceScheduleCloseOffsetDto, + ) { + await this.trainSchedulingService.reduceScheduleCloseOffset(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + @Patch("schedules/:id/schedule-date") @TrainSchedulingUpdate() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/reduce-schedule-close-offset.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/reduce-schedule-close-offset.dto.ts new file mode 100644 index 000000000..b638a6b3b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/reduce-schedule-close-offset.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, Min } from 'class-validator'; + +/** + * Shorten the booking-close offset of ONE schedule whose booking shut only + * because of that offset (staff action on the ops board). The value replaces the + * schedule's frozen offset; 0 means "close at departure". + */ +export class ReduceScheduleCloseOffsetDto { + @ApiProperty({ + example: 120, + description: + 'New minutes-before-departure at which booking closes. Must be shorter than the current offset; 0 = close at departure.', + }) + @Type(() => Number) + @IsInt() + @Min(0) + closeOffsetMinutes!: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/schedule-close-offset.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/schedule-close-offset.spec.ts new file mode 100644 index 000000000..6deb0679d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/schedule-close-offset.spec.ts @@ -0,0 +1,266 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +import { closeOffsetReopenCheck } from './batch-window.util'; +import { TrainSchedulingService } from './services/train-scheduling.service'; + +/** + * "Reopen booking by shortening the close offset": a train whose booking shut + * ONLY because of its close offset (3 days → cut to 1 day / 2 hours) gets its + * window re-armed. Every other closed state is refused. Pure guard first, then + * the service against stub repositories. + */ +describe('close-offset reopen', () => { + // Wednesday 2026-09-09 10:00 EAT (07:00Z). A 3-day offset closes Sunday 10:00 EAT. + const DEPARTURE = new Date('2026-09-09T07:00:00.000Z'); + // Monday 2026-09-07 09:00 EAT — inside the desk day, past the 3-day cutoff. + const NOW = new Date('2026-09-07T06:00:00.000Z'); + const THREE_DAYS = 3 * 1_440; + + const cfg = { + importWindowLeadDays: 3, + exportBookingLeadHours: 24, + windowOpenHour: 8, + windowCloseHour: 17, + windowDurationHours: 3, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + exportPaymentWindowMinutes: 60, + importCloseOffsetMinutes: THREE_DAYS, + exportCloseOffsetMinutes: THREE_DAYS, + }; + + const closedByOffset = (over: Record = {}) => ({ + id: 'S1', + reference: 'S-2026-00001', + status: 'SCHEDULED', + direction: 'IMPORT', + windowPhase: 'DONE', + bookingWindowStatus: 'CLOSED', + scheduledDepartureDate: DEPARTURE, + originStationId: 'Y-ADD', + destinationStationId: 'Y-DJ', + ruleWindowOpenHour: 8, + ruleWindowCloseHour: 17, + ruleWindowDurationHours: 3, + ruleImportWindowLeadDays: 3, + ruleExportBookingLeadHours: 24, + ruleImportCloseOffsetMinutes: THREE_DAYS, + ruleExportCloseOffsetMinutes: THREE_DAYS, + ...over, + }); + + describe('closeOffsetReopenCheck', () => { + it('is eligible when DONE, not full, departure ahead, and an offset shut it', () => { + const check = closeOffsetReopenCheck(closedByOffset(), cfg, NOW); + expect(check.eligible).toBe(true); + expect(check.offsetMinutes).toBe(THREE_DAYS); + expect(check.cutoffAt?.toISOString()).toBe('2026-09-06T07:00:00.000Z'); + }); + + it.each([ + ['dispatched train', { status: 'DISPATCHED' }, /dispatched/i], + ['already departed', { scheduledDepartureDate: new Date('2026-09-01T07:00:00.000Z') }, /departed/i], + ['full train', { bookingWindowStatus: 'FULL' }, /full/i], + ['window still open', { windowPhase: 'OPEN' }, /not closed yet/i], + ['legacy row with no window', { windowPhase: null }, /managed booking window/i], + ])('refuses a %s', (_label, over, reason) => { + const check = closeOffsetReopenCheck(closedByOffset(over), cfg, NOW); + expect(check.eligible).toBe(false); + expect(check.reason).toMatch(reason); + }); + + it('refuses when the schedule never had an offset (booking ran to departure)', () => { + const check = closeOffsetReopenCheck( + closedByOffset(), + { ...cfg, importCloseOffsetMinutes: null }, + NOW, + ); + expect(check.eligible).toBe(false); + expect(check.reason).toMatch(/no close offset/i); + expect(check.cutoffAt).toBeNull(); + }); + + it('refuses when the desk could not reopen before departure even with no offset', () => { + // Tuesday 18:00 EAT, desk 8–17: next opening is Wednesday 08:00, but the + // train departs Wednesday 07:00 EAT — the offset is not the blocker. + const lateNow = new Date('2026-09-08T15:00:00.000Z'); + const earlyDeparture = new Date('2026-09-09T04:00:00.000Z'); + const check = closeOffsetReopenCheck( + closedByOffset({ scheduledDepartureDate: earlyDeparture }), + cfg, + lateNow, + ); + expect(check.eligible).toBe(false); + expect(check.reason).toMatch(/would not reopen before departure/i); + }); + + it('reads the export offset for an EXPORT schedule', () => { + const check = closeOffsetReopenCheck( + closedByOffset({ direction: 'EXPORT' }), + { ...cfg, importCloseOffsetMinutes: null, exportCloseOffsetMinutes: 120 }, + NOW, + ); + expect(check.eligible).toBe(true); + expect(check.offsetMinutes).toBe(120); + }); + }); + + describe('TrainSchedulingService.reduceScheduleCloseOffset', () => { + type Fixture = { + schedule: Record | null; + siblings?: Record[]; + now?: Date; + }; + + const makeService = (fx: Fixture) => { + const updates: Array<{ id: string; patch: Record }> = []; + const repo = { + update: jest.fn().mockImplementation(async (id: string, patch: Record) => { + updates.push({ id, patch }); + }), + }; + const siblingsQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(fx.siblings ?? []), + }; + const dataSource = { + getRepository: jest.fn().mockReturnValue(repo), + manager: { + getRepository: jest + .fn() + .mockReturnValue({ createQueryBuilder: () => siblingsQb }), + }, + }; + const service = Object.create( + TrainSchedulingService.prototype, + ) as TrainSchedulingService; + const emitted: string[] = []; + Object.assign(service, { + dataSource, + trainSchedulesRepository: { + findById: jest.fn().mockResolvedValue(fx.schedule), + }, + getWindowConfig: jest.fn().mockResolvedValue(cfg), + emitWindowState: jest.fn().mockImplementation(async (id: string) => { + emitted.push(id); + }), + logger: { log: jest.fn(), warn: jest.fn() }, + }); + jest.useFakeTimers().setSystemTime(fx.now ?? NOW); + return { service, updates, emitted }; + }; + + afterEach(() => jest.useRealTimers()); + + it('404s on an unknown schedule', async () => { + const { service } = makeService({ schedule: null }); + await expect( + service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('refuses a train whose window is not shut by its offset', async () => { + const { service, updates } = makeService({ + schedule: closedByOffset({ bookingWindowStatus: 'FULL' }), + }); + await expect( + service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }), + ).rejects.toThrow(/full/i); + expect(updates).toHaveLength(0); + }); + + it('refuses an offset that is not shorter than the current one', async () => { + const { service, updates } = makeService({ schedule: closedByOffset() }); + await expect( + service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: THREE_DAYS }), + ).rejects.toThrow(/shorter than the current 3 days/i); + expect(updates).toHaveLength(0); + }); + + it('refuses an offset whose new cutoff is still before the next desk opening', async () => { + // 2 days before departure = Monday 10:00 EAT; now is Monday 09:00 so a + // cycle fits… but 2 days 1 hour (Mon 09:00) does not. + const { service } = makeService({ schedule: closedByOffset() }); + await expect( + service.reduceScheduleCloseOffset('S1', { + closeOffsetMinutes: 2 * 1_440 + 60, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('shortens the offset, re-arms the window at now (desk open) and caps it at the new cutoff', async () => { + const { service, updates, emitted } = makeService({ schedule: closedByOffset() }); + + // 1 day before departure → new cutoff Tuesday 10:00 EAT. + await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 }); + + expect(updates).toHaveLength(1); + const [{ id, patch }] = updates; + expect(id).toBe('S1'); + expect(patch).toMatchObject({ + ruleImportCloseOffsetMinutes: 1_440, + windowRuleCustom: true, + windowPhase: 'PRE_WINDOW', + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + // Desk is open at 09:00 → reopens now; 3h cycle → 12:00 EAT (09:00Z). + expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString()); + expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-07T09:00:00.000Z'); + expect(emitted).toEqual(['S1']); + }); + + it('stores 0 as null (booking runs to departure) and caps the cycle at departure', async () => { + // Tuesday 16:00 EAT: 3h cycle would run past the 17:00 desk close. + const tueAfternoon = new Date('2026-09-08T13:00:00.000Z'); + const { service, updates } = makeService({ + schedule: closedByOffset(), + now: tueAfternoon, + }); + await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 0 }); + const [{ patch }] = updates; + expect(patch.ruleImportCloseOffsetMinutes).toBeNull(); + expect((patch.windowOpensAt as Date).toISOString()).toBe(tueAfternoon.toISOString()); + // Desk close (17:00 EAT = 14:00Z) ends the cycle before departure. + expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-08T14:00:00.000Z'); + }); + + it('reopens route+day siblings shut by the same offset and leaves the rest alone', async () => { + const { service, updates } = makeService({ + schedule: closedByOffset(), + siblings: [ + closedByOffset({ id: 'S2' }), + // Already full — booking did not close because of the offset. + closedByOffset({ id: 'S3', bookingWindowStatus: 'FULL' }), + // Still mid-cycle — must keep the state its customers see. + closedByOffset({ id: 'S4', windowPhase: 'PAYMENT' }), + ], + }); + await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 }); + expect(updates.map((u) => u.id)).toEqual(['S1', 'S2']); + expect(updates[1].patch).toMatchObject({ + ruleImportCloseOffsetMinutes: 1_440, + windowPhase: 'PRE_WINDOW', + }); + }); + + it('an EXPORT reopen is a single FCFS window to the new cutoff and touches no sibling', async () => { + const { service, updates } = makeService({ + schedule: closedByOffset({ direction: 'EXPORT' }), + siblings: [closedByOffset({ id: 'S2', direction: 'EXPORT' })], + }); + await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 120 }); + expect(updates).toHaveLength(1); + const [{ patch }] = updates; + expect(patch).toMatchObject({ + ruleExportCloseOffsetMinutes: 120, + windowPhase: 'PRE_WINDOW', + }); + expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString()); + // Departure 07:00Z − 2h. + expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-09T05:00:00.000Z'); + }); + }); +}); 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 bfbeafed6..9c83ed987 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 @@ -112,6 +112,7 @@ import { UploadImportDjiboutiDocumentDto, } from '../dto/import-djibouti-operation.dto'; import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto'; +import { ReduceScheduleCloseOffsetDto } from '../dto/reduce-schedule-close-offset.dto'; import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto'; import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto'; import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto'; @@ -197,12 +198,15 @@ import { orderConsistWagons } from '../consist-order.util'; import { bookingCloseCutoff, clampCloseToOfficeHours, + closeOffsetReopenCheck, computeExportWindowTimes, computeImportWindowTimes, earliestSchedulableDeparture, eatDay, eatDayToUtc, + nextCycleOpensAt, shiftEatDay, + type OfficeHours, } from '../batch-window.util'; import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity'; import { BookingJourneyService } from '../booking-journey.service'; @@ -239,6 +243,19 @@ const HANDLING_FIELDS = [ type HandlingField = (typeof HANDLING_FIELDS)[number][0]; +/** "3 days" / "2 hours" / "45 minutes" for an error message. */ +function describeMinutes(minutes: number): string { + if (minutes % 1_440 === 0) { + const d = minutes / 1_440; + return `${d} day${d === 1 ? '' : 's'}`; + } + if (minutes % 60 === 0) { + const h = minutes / 60; + return `${h} hour${h === 1 ? '' : 's'}`; + } + return `${minutes} minute${minutes === 1 ? '' : 's'}`; +} + /** Drops the keys a partial override left undefined, so `...` merges keep the base value. */ function pickDefined(source: T): Partial { return Object.fromEntries( @@ -266,6 +283,16 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) { }; } +/** Wire shape of a close-offset reopen check (dates as ISO strings). */ +function toCloseOffsetReopenInfo(check: ReturnType) { + return { + eligible: check.eligible, + reason: check.reason, + offsetMinutes: check.offsetMinutes, + cutoffAt: check.cutoffAt ? check.cutoffAt.toISOString() : null, + }; +} + /** * The booking-window config a specific schedule runs under: its frozen rule * snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live @@ -1024,6 +1051,152 @@ export class TrainSchedulingService { return fresh ?? schedule; } + /** + * Shorten the booking-close offset of ONE schedule whose booking shut ONLY + * because of that offset, and re-arm its window so the desk reopens. A 3-day + * offset that closed booking with the train still days away can be cut to a + * day or a couple of hours; the window then opens at the next desk opening + * (now, if the desk is open) and runs its normal cycles until the new cutoff. + * + * Refused for every other kind of closed window (departed, full, no offset, + * still mid-cycle) — see `closeOffsetReopenCheck`. The new offset must be + * shorter than the current one and must leave room for a cycle before the + * new cutoff. The offset is frozen onto the schedule (the global value is + * untouched) and the row is marked custom so a later global-rules save does + * not re-stamp it. + * + * IMPORT/DOMESTIC: the same shorter offset is applied to every route+day + * sibling that is likewise shut only by its offset, so the group keeps its + * single shared timeline (each capped at its own new cutoff). EXPORT windows + * are per-train, so an export change touches only this schedule. + */ + async reduceScheduleCloseOffset( + id: string, + dto: ReduceScheduleCloseOffsetDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findById(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + const now = new Date(); + const liveCfg = await this.getWindowConfig(); + const cfg = effectiveWindowConfig(schedule, liveCfg); + const check = closeOffsetReopenCheck(schedule, cfg, now); + if (!check.eligible || check.offsetMinutes == null) { + throw new BadRequestException( + check.reason ?? 'This schedule cannot reopen by shortening its close offset.', + ); + } + + const newOffset = dto.closeOffsetMinutes; + if (newOffset >= check.offsetMinutes) { + throw new BadRequestException( + `The new close offset must be shorter than the current ${describeMinutes( + check.offsetMinutes, + )} before departure.`, + ); + } + + const isExport = schedule.direction === 'EXPORT'; + // 0 is stored as null so "no offset" keeps its single canonical value. + const offsetPatch = isExport + ? { ruleExportCloseOffsetMinutes: newOffset || null } + : { ruleImportCloseOffsetMinutes: newOffset || null }; + const merged: BookingWindowConfig = { + ...cfg, + ...(isExport + ? { exportCloseOffsetMinutes: newOffset || null } + : { importCloseOffsetMinutes: newOffset || null }), + }; + const hours: OfficeHours = { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }; + const cutoff = bookingCloseCutoff( + schedule.scheduledDepartureDate, + schedule.direction, + merged, + ); + // The desk reopens at the next office-hours opening (now, when it is open), + // exactly as a reopen cycle would — and only if that lands before the cutoff. + const opensAt = nextCycleOpensAt(now, hours, cutoff); + if (opensAt == null) { + throw new BadRequestException( + 'Even with this offset the desk would not reopen before booking closes again ' + + `(new cutoff ${cutoff.toISOString()}) — shorten the offset further.`, + ); + } + let closesAt: Date; + if (isExport) { + // Export runs one FCFS window: from the reopen until the cutoff. + closesAt = cutoff; + } else { + closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); + closesAt = clampCloseToOfficeHours(opensAt, closesAt, hours); + if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff; + } + + const cap = (d: Date, bound: Date): Date => + d.getTime() > bound.getTime() ? bound : d; + const targets: Array<{ id: string; cutoff: Date }> = [{ id, cutoff }]; + if (!isExport) { + const siblings = await this.findGroupSiblings( + this.dataSource.manager, + schedule.originStationId, + schedule.destinationStationId, + schedule.scheduledDepartureDate, + id, + ); + for (const sib of siblings) { + const sibCfg = effectiveWindowConfig(sib, liveCfg); + const sibCheck = closeOffsetReopenCheck(sib, sibCfg, now); + // Only a sibling that is ALSO shut purely by an offset longer than the + // new one joins in; anything else keeps the state its customers saw. + if ( + !sibCheck.eligible || + sibCheck.offsetMinutes == null || + sibCheck.offsetMinutes <= newOffset || + !sib.scheduledDepartureDate + ) { + continue; + } + const sibCutoff = bookingCloseCutoff(sib.scheduledDepartureDate, sib.direction, { + ...sibCfg, + importCloseOffsetMinutes: newOffset || null, + }); + if (opensAt.getTime() >= sibCutoff.getTime()) continue; + targets.push({ id: sib.id, cutoff: sibCutoff }); + } + } + + const repo = this.dataSource.getRepository(TrainSchedule); + for (const t of targets) { + await repo.update(t.id, { + ...offsetPatch, + // Staff-set — exempt from the global re-stamp. + windowRuleCustom: true, + // Back to PRE_WINDOW: the window tick opens it at windowOpensAt and runs + // the normal cycle from there (bookingWindowStatus flips OPEN then). + windowPhase: 'PRE_WINDOW', + windowOpensAt: cap(opensAt, t.cutoff), + windowClosesAt: cap(closesAt, t.cutoff), + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + } + this.logger.log( + `Close offset of schedule ${schedule.reference ?? id} shortened ` + + `${check.offsetMinutes} → ${newOffset} min before departure` + + ` (+${targets.length - 1} route+day sibling(s)) — booking reopens ` + + `${opensAt.toISOString()}, closes ${closesAt.toISOString()}`, + ); + for (const t of targets) void this.emitWindowState(t.id); + + const fresh = await this.trainSchedulesRepository.findById(id); + return fresh ?? schedule; + } + /** * Correct a departure's operational run identifiers — the train number and * voyage number yards and customs quote. @@ -6313,8 +6486,11 @@ export class TrainSchedulingService { skip, take, }); + // Live window config: each row's frozen rule overlays it to decide whether + // the "shorten close offset" action applies (see closeOffsetReopenCheck). + const liveCfg = await this.getWindowConfig(); return { - items: schedules.map((s) => this.mapScheduleListItem(s)), + items: schedules.map((s) => this.mapScheduleListItem(s, liveCfg)), meta: buildPaginationMeta(total, page, pageSize), }; } @@ -8857,7 +9033,11 @@ export class TrainSchedulingService { throw new ConflictException('Could not allocate a unique schedule reference'); } - private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) { + private mapScheduleListItem( + schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule, + /** Live window config; when given, the row carries its close-offset reopen state. */ + liveCfg?: BookingWindowConfig, + ) { // Wagon figures must match the detail page's wagon plan (WagonPlanGrid) — // see computeScheduleWagonUsage for why the stored counter cannot be used. const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } = @@ -8921,6 +9101,18 @@ export class TrainSchedulingService { freightType: this.resolveScheduleFreightType(schedule), status: schedule.status, bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN', + windowPhase: schedule.windowPhase ?? null, + // Whether booking shut ONLY because of the close offset — the board offers + // "shorten close offset" on exactly these rows. + closeOffsetReopen: liveCfg + ? toCloseOffsetReopenInfo( + closeOffsetReopenCheck( + schedule, + effectiveWindowConfig(schedule, liveCfg), + new Date(), + ), + ) + : null, cancellationReason: schedule.cancellationReason ?? null, cancelledAt: schedule.cancelledAt ?? null, maxWagons: schedule.maxWagons ?? 0, @@ -10965,6 +11157,14 @@ export class TrainSchedulingService { // settings" editor on the ops board (prefill + save one schedule's // override). docReview/payment are not snapshotted per schedule (only their // sum, as the frozen reopen gap), so the editor prefills them from live config. + // Shut only by its close offset? Drives the "shorten close offset" action. + closeOffsetReopen: toCloseOffsetReopenInfo( + closeOffsetReopenCheck( + schedule, + effectiveWindowConfig(schedule, windowCfg), + new Date(), + ), + ), windowRule: { windowOpenHour: schedule.ruleWindowOpenHour ?? null, windowCloseHour: schedule.ruleWindowCloseHour ?? null, @@ -10974,6 +11174,12 @@ export class TrainSchedulingService { : null, importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null, + // The offsets this train actually runs under (its frozen snapshot, or + // the live global for a legacy row) — null = booking runs to departure. + importCloseOffsetMinutes: + effectiveWindowConfig(schedule, windowCfg).importCloseOffsetMinutes ?? null, + exportCloseOffsetMinutes: + effectiveWindowConfig(schedule, windowCfg).exportCloseOffsetMinutes ?? null, docReviewMinutes: windowCfg.docReviewMinutes, // Editor prefill: this schedule's own override when staff set one, // else the live global for the schedule's direction (import/export @@ -13037,3 +13243,4 @@ export class TrainSchedulingService { return fresh ?? schedule; } } +// \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index 31b026c4e..9a812df5e 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -1,8 +1,10 @@ +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react"; import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core"; import { BookingConfirmDialog } from "./BookingConfirmDialog"; +import { OperationRescheduleModal } from "./OperationRescheduleModal"; import { useBookingActionDialog } from "./useBookingActionDialog"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions"; @@ -10,6 +12,7 @@ import { isAllocateAction, isClearanceNavAction, isContractNavAction, + isRescheduleAction, listRowHasActions, type BookingActionContext, } from "@/features/bookings/booking-actions.config"; @@ -44,6 +47,8 @@ export function BookingActionsMenu({ const flow = useBookingActionDialog(row.id, context); const { actions, pendingAction, mutations } = flow; + // Day / train reschedule has its own modal (date + export train picker). + const [rescheduleOpen, setRescheduleOpen] = useState(false); const goToContract = () => navigate(`/dashboard/booking-requests/${row.id}/contract`); @@ -67,6 +72,8 @@ export function BookingActionsMenu({ goToClearanceTab(); } else if (isAllocateAction(action.id)) { onAllocateBooking?.(); + } else if (isRescheduleAction(action.id)) { + setRescheduleOpen(true); } else { flow.openAction(action); } @@ -109,6 +116,14 @@ export function BookingActionsMenu({ consolidationPartnerId={row.consolidationPartnerId} consolidationPartnerReference={row.consolidationPartnerReference} /> + { + onSuppressRowClick?.(); + setRescheduleOpen(false); + }} + /> ); } @@ -183,6 +198,14 @@ export function BookingActionsMenu({ consolidationPartnerId={row.consolidationPartnerId} consolidationPartnerReference={row.consolidationPartnerReference} /> + { + onSuppressRowClick?.(); + setRescheduleOpen(false); + }} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx new file mode 100644 index 000000000..8fa1d5c46 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx @@ -0,0 +1,255 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Badge, + Box, + Button, + Group, + Loader, + Modal, + Select, + Stack, + Text, + Textarea, + ThemeIcon, +} from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { CalendarClock, Info } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; + +import { api } from "@/services/api"; +import { + useBookingDetail, + useBookingMutations, +} from "@/hooks/bookings/useBookings"; + +export interface OperationRescheduleModalProps { + bookingId: string; + opened: boolean; + onClose: () => void; +} + +/** + * Operations moves a pending operation request to another shipment day and, + * for export rail, another train — instead of returning it to the customer. + * The server re-runs the customer's own gates (open departure that day, wagon + * that can carry the cargo, export train with room) and refuses with the + * reason if the new day does not work. + */ +export function OperationRescheduleModal({ + bookingId, + opened, + onClose, +}: OperationRescheduleModalProps) { + const detailQuery = useBookingDetail(opened ? bookingId : undefined); + const booking = detailQuery.data; + const mutations = useBookingMutations(bookingId); + + const isExportRail = booking ? isExportRailBooking(booking) : false; + + const [day, setDay] = useState(null); + const [trainId, setTrainId] = useState(null); + const [note, setNote] = useState(""); + + // Seed from the booking each time the modal opens: the current day and, for + // export, the train the customer picked (the detail's requested/allocated train). + useEffect(() => { + if (!opened || !booking) return; + setDay(booking.scheduledDate ? new Date(booking.scheduledDate) : null); + setTrainId(booking.trainScheduleSummary?.id ?? null); + setNote(""); + }, [opened, booking]); + + const dayKey = day ? eatDay(day) : null; + const currentDayKey = booking?.scheduledDate + ? eatDay(booking.scheduledDate) + : null; + + // Days with an open departure on the booking's route — a planning hint; the + // server still validates the pick. + const daysQuery = useQuery({ + ...api.trainScheduling.availableDays.queryOptions({ + input: { + originYardId: booking?.originYard?.id ?? null, + destinationYardId: booking?.destinationYard?.id ?? null, + }, + }), + enabled: + opened && + Boolean(booking?.originYard?.id && booking?.destinationYard?.id), + }); + const availableDays = useMemo( + () => new Set((daysQuery.data ?? []).map((d) => eatDay(d))), + [daysQuery.data], + ); + const dayHasDeparture = dayKey ? availableDays.has(dayKey) : false; + + // Export rail: the day's export trains with free space, so staff pick one. + const trainsQuery = useQuery({ + ...api.trainScheduling.exportTrains.queryOptions({ + input: { bookingId, date: day ? day.toISOString() : "" }, + }), + enabled: opened && isExportRail && Boolean(day), + }); + const trainOptions = useMemo( + () => (trainsQuery.data ?? []).map(exportTrainOption), + [trainsQuery.data], + ); + // A train belongs to one day: changing the day drops a pick from another day. + useEffect(() => { + if (!isExportRail || !trainsQuery.data) return; + if (trainId && !trainsQuery.data.some((t) => t.scheduleId === trainId)) { + setTrainId(null); + } + }, [isExportRail, trainsQuery.data, trainId]); + + const unchanged = + dayKey != null && + dayKey === currentDayKey && + (!isExportRail || trainId === (booking?.trainScheduleSummary?.id ?? null)); + const canSave = + Boolean(day) && !unchanged && (!isExportRail || Boolean(trainId)); + + const handleSave = () => { + if (!day || !canSave) return; + mutations.rescheduleOperation.mutate( + { + scheduledDate: day.toISOString(), + ...(isExportRail && trainId ? { trainScheduleId: trainId } : {}), + ...(note.trim() ? { note: note.trim() } : {}), + }, + { onSuccess: () => onClose() }, + ); + }; + + return ( + + + + + + + Change train / shipment day + + + {booking?.reference ?? "Booking"} + + + + } + > + {detailQuery.isLoading || !booking ? ( + + + + ) : ( + + }> + Sets the shipment day + {isExportRail ? " and the export train " : " "} + for the customer, so nothing has to go back to them. The request + stays under review for the normal accept, and the customer is told + the new day. + {!isExportRail + ? " Import and domestic trains are assigned by the batch engine on the chosen day." + : ""} + + + + + Currently {currentDayKey ?? "no day"} + + {booking.trainScheduleSummary ? ( + + {booking.trainScheduleSummary.trainNumber ?? + booking.trainScheduleSummary.reference ?? + "train"} + {booking.trainScheduleSummary.isRequested ? " (requested)" : ""} + + ) : null} + + + setDay(v ? new Date(v) : null)} + minDate={new Date()} + excludeDate={ + daysQuery.data && daysQuery.data.length + ? (d) => !availableDays.has(eatDay(d)) + : undefined + } + popoverProps={{ withinPortal: true }} + /> + {day && + daysQuery.data && + daysQuery.data.length && + !dayHasDeparture ? ( + + No open departure on this route for {dayKey}. + + ) : null} + + {isExportRail ? ( +