mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
@@ -298,6 +298,42 @@ export class BookingLifecycleNotifierService {
|
|||||||
this.inApp(b, 'Operation request needs changes', msg);
|
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. */
|
/** Operation accepted → invoice ready; await payment / booking window. */
|
||||||
operationAccepted(b: Booking): void {
|
operationAccepted(b: Booking): void {
|
||||||
// No invoice and no pay window for a shipping line — the charge sits on
|
// No invoice and no pay window for a shipping line — the charge sits on
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
const date = new Date(scheduledDate);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
throw new BadRequestException("A valid schedule date is required");
|
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
|
// gate; quantity never blocks — oversized bookings get a partial split
|
||||||
// offer). The batch engine assigns the specific train within that
|
// offer). The batch engine assigns the specific train within that
|
||||||
// (route, day) pool later.
|
// (route, day) pool later.
|
||||||
if (!opts?.bypassDayPool) {
|
if (!bypassDayPool) {
|
||||||
const { hasDeparture, hasCompatible } =
|
const { hasDeparture, hasCompatible } =
|
||||||
await this.bookingsService.checkDayCompatibilityForBooking(
|
await this.bookingsService.checkDayCompatibilityForBooking(
|
||||||
booking,
|
booking,
|
||||||
@@ -1359,7 +1398,7 @@ export class BookingTransitionService {
|
|||||||
// persisted here the same way an export pick is. Customer import/domestic
|
// persisted here the same way an export pick is. Customer import/domestic
|
||||||
// bookings still never carry one (the batch engine assigns their train).
|
// bookings still never carry one (the batch engine assigns their train).
|
||||||
const requestedId =
|
const requestedId =
|
||||||
isExportTrain || opts?.bypassDayPool
|
isExportTrain || bypassDayPool
|
||||||
? (requestedTrainScheduleId ?? null)
|
? (requestedTrainScheduleId ?? null)
|
||||||
: null;
|
: null;
|
||||||
// Export rail rides the exact train the customer picked — never an
|
// 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<Booking> {
|
||||||
|
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, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "OPERATION_REQUEST_PENDING",
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
scheduledDate: date,
|
scheduledDate: date,
|
||||||
requestedTrainScheduleId: requestedId,
|
requestedTrainScheduleId: requestedId,
|
||||||
} as never);
|
} as never);
|
||||||
|
if (note) {
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
bookingId,
|
||||||
|
note,
|
||||||
|
"STAFF_NOTE",
|
||||||
|
actorId,
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.clearanceEvents.record({
|
await this.clearanceEvents.record({
|
||||||
bookingId,
|
bookingId,
|
||||||
action: 'OPERATION_REQUESTED',
|
action: "OPERATION_RESCHEDULED",
|
||||||
label: `Requested operation for shipment day ${scheduledDate}`,
|
label:
|
||||||
actorType: 'CUSTOMER',
|
`Operations moved the shipment day ` +
|
||||||
actorId: opts?.userId ?? null,
|
`${previousDay ? `from ${previousDay} ` : ""}to ${eatDay(date)}` +
|
||||||
metadata: { scheduledDate },
|
(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);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.operationRequestedToStaff(fresh);
|
this.notifier.operationRescheduled(fresh, previousDay, note);
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import {
|
|||||||
ReviewDocumentDto,
|
ReviewDocumentDto,
|
||||||
RequestOperationDto,
|
RequestOperationDto,
|
||||||
OperationReviewDto,
|
OperationReviewDto,
|
||||||
|
RescheduleOperationDto,
|
||||||
StaffRejectDto,
|
StaffRejectDto,
|
||||||
} from "./dto/request-changes.dto";
|
} from "./dto/request-changes.dto";
|
||||||
import { ContractViewDto } from "./dto/contract-view.dto";
|
import { ContractViewDto } from "./dto/contract-view.dto";
|
||||||
@@ -1344,6 +1345,29 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
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")
|
@Post(":id/clearance/review")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -107,6 +107,38 @@ export class RequestOperationDto {
|
|||||||
trainScheduleId?: string;
|
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 {
|
export class OperationReviewDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -289,6 +289,99 @@ export function bookingCloseCutoff(
|
|||||||
return new Date(departure.getTime() - offsetMinutes * 60_000);
|
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 {
|
export interface InitialWindowTimes {
|
||||||
windowOpensAt: Date;
|
windowOpensAt: Date;
|
||||||
windowClosesAt: Date;
|
windowClosesAt: Date;
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import { AvailableDaysQueryDto } from "../dto/available-days-query.dto";
|
|||||||
import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto";
|
import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto";
|
||||||
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
|
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
|
||||||
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.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 { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto";
|
||||||
import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto";
|
import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto";
|
||||||
import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto";
|
import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto";
|
||||||
@@ -985,6 +986,20 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
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")
|
@Patch("schedules/:id/schedule-date")
|
||||||
@TrainSchedulingUpdate()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {}) => ({
|
||||||
|
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<string, unknown> | null;
|
||||||
|
siblings?: Record<string, unknown>[];
|
||||||
|
now?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeService = (fx: Fixture) => {
|
||||||
|
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
||||||
|
const repo = {
|
||||||
|
update: jest.fn().mockImplementation(async (id: string, patch: Record<string, unknown>) => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -112,6 +112,7 @@ import {
|
|||||||
UploadImportDjiboutiDocumentDto,
|
UploadImportDjiboutiDocumentDto,
|
||||||
} from '../dto/import-djibouti-operation.dto';
|
} from '../dto/import-djibouti-operation.dto';
|
||||||
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.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 { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
|
||||||
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
|
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
|
||||||
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
|
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
|
||||||
@@ -197,12 +198,15 @@ import { orderConsistWagons } from '../consist-order.util';
|
|||||||
import {
|
import {
|
||||||
bookingCloseCutoff,
|
bookingCloseCutoff,
|
||||||
clampCloseToOfficeHours,
|
clampCloseToOfficeHours,
|
||||||
|
closeOffsetReopenCheck,
|
||||||
computeExportWindowTimes,
|
computeExportWindowTimes,
|
||||||
computeImportWindowTimes,
|
computeImportWindowTimes,
|
||||||
earliestSchedulableDeparture,
|
earliestSchedulableDeparture,
|
||||||
eatDay,
|
eatDay,
|
||||||
eatDayToUtc,
|
eatDayToUtc,
|
||||||
|
nextCycleOpensAt,
|
||||||
shiftEatDay,
|
shiftEatDay,
|
||||||
|
type OfficeHours,
|
||||||
} from '../batch-window.util';
|
} from '../batch-window.util';
|
||||||
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||||||
import { BookingJourneyService } from '../booking-journey.service';
|
import { BookingJourneyService } from '../booking-journey.service';
|
||||||
@@ -239,6 +243,19 @@ const HANDLING_FIELDS = [
|
|||||||
|
|
||||||
type HandlingField = (typeof HANDLING_FIELDS)[number][0];
|
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. */
|
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
|
||||||
function pickDefined<T extends object>(source: T): Partial<T> {
|
function pickDefined<T extends object>(source: T): Partial<T> {
|
||||||
return Object.fromEntries(
|
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<typeof closeOffsetReopenCheck>) {
|
||||||
|
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
|
* The booking-window config a specific schedule runs under: its frozen rule
|
||||||
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
|
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
|
||||||
@@ -1024,6 +1051,152 @@ export class TrainSchedulingService {
|
|||||||
return fresh ?? schedule;
|
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<TrainSchedule> {
|
||||||
|
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
|
* Correct a departure's operational run identifiers — the train number and
|
||||||
* voyage number yards and customs quote.
|
* voyage number yards and customs quote.
|
||||||
@@ -6313,8 +6486,11 @@ export class TrainSchedulingService {
|
|||||||
skip,
|
skip,
|
||||||
take,
|
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 {
|
return {
|
||||||
items: schedules.map((s) => this.mapScheduleListItem(s)),
|
items: schedules.map((s) => this.mapScheduleListItem(s, liveCfg)),
|
||||||
meta: buildPaginationMeta(total, page, pageSize),
|
meta: buildPaginationMeta(total, page, pageSize),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -8857,7 +9033,11 @@ export class TrainSchedulingService {
|
|||||||
throw new ConflictException('Could not allocate a unique schedule reference');
|
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) —
|
// Wagon figures must match the detail page's wagon plan (WagonPlanGrid) —
|
||||||
// see computeScheduleWagonUsage for why the stored counter cannot be used.
|
// see computeScheduleWagonUsage for why the stored counter cannot be used.
|
||||||
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
|
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
|
||||||
@@ -8921,6 +9101,18 @@ export class TrainSchedulingService {
|
|||||||
freightType: this.resolveScheduleFreightType(schedule),
|
freightType: this.resolveScheduleFreightType(schedule),
|
||||||
status: schedule.status,
|
status: schedule.status,
|
||||||
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
|
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,
|
cancellationReason: schedule.cancellationReason ?? null,
|
||||||
cancelledAt: schedule.cancelledAt ?? null,
|
cancelledAt: schedule.cancelledAt ?? null,
|
||||||
maxWagons: schedule.maxWagons ?? 0,
|
maxWagons: schedule.maxWagons ?? 0,
|
||||||
@@ -10965,6 +11157,14 @@ export class TrainSchedulingService {
|
|||||||
// settings" editor on the ops board (prefill + save one schedule's
|
// settings" editor on the ops board (prefill + save one schedule's
|
||||||
// override). docReview/payment are not snapshotted per schedule (only their
|
// override). docReview/payment are not snapshotted per schedule (only their
|
||||||
// sum, as the frozen reopen gap), so the editor prefills them from live config.
|
// 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: {
|
windowRule: {
|
||||||
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
|
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
|
||||||
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
|
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
|
||||||
@@ -10974,6 +11174,12 @@ export class TrainSchedulingService {
|
|||||||
: null,
|
: null,
|
||||||
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
||||||
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? 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,
|
docReviewMinutes: windowCfg.docReviewMinutes,
|
||||||
// Editor prefill: this schedule's own override when staff set one,
|
// Editor prefill: this schedule's own override when staff set one,
|
||||||
// else the live global for the schedule's direction (import/export
|
// else the live global for the schedule's direction (import/export
|
||||||
@@ -13037,3 +13243,4 @@ export class TrainSchedulingService {
|
|||||||
return fresh ?? schedule;
|
return fresh ?? schedule;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
//
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react";
|
import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react";
|
||||||
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||||
|
import { OperationRescheduleModal } from "./OperationRescheduleModal";
|
||||||
import { useBookingActionDialog } from "./useBookingActionDialog";
|
import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
isAllocateAction,
|
isAllocateAction,
|
||||||
isClearanceNavAction,
|
isClearanceNavAction,
|
||||||
isContractNavAction,
|
isContractNavAction,
|
||||||
|
isRescheduleAction,
|
||||||
listRowHasActions,
|
listRowHasActions,
|
||||||
type BookingActionContext,
|
type BookingActionContext,
|
||||||
} from "@/features/bookings/booking-actions.config";
|
} from "@/features/bookings/booking-actions.config";
|
||||||
@@ -44,6 +47,8 @@ export function BookingActionsMenu({
|
|||||||
|
|
||||||
const flow = useBookingActionDialog(row.id, context);
|
const flow = useBookingActionDialog(row.id, context);
|
||||||
const { actions, pendingAction, mutations } = flow;
|
const { actions, pendingAction, mutations } = flow;
|
||||||
|
// Day / train reschedule has its own modal (date + export train picker).
|
||||||
|
const [rescheduleOpen, setRescheduleOpen] = useState(false);
|
||||||
|
|
||||||
const goToContract = () =>
|
const goToContract = () =>
|
||||||
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
||||||
@@ -67,6 +72,8 @@ export function BookingActionsMenu({
|
|||||||
goToClearanceTab();
|
goToClearanceTab();
|
||||||
} else if (isAllocateAction(action.id)) {
|
} else if (isAllocateAction(action.id)) {
|
||||||
onAllocateBooking?.();
|
onAllocateBooking?.();
|
||||||
|
} else if (isRescheduleAction(action.id)) {
|
||||||
|
setRescheduleOpen(true);
|
||||||
} else {
|
} else {
|
||||||
flow.openAction(action);
|
flow.openAction(action);
|
||||||
}
|
}
|
||||||
@@ -109,6 +116,14 @@ export function BookingActionsMenu({
|
|||||||
consolidationPartnerId={row.consolidationPartnerId}
|
consolidationPartnerId={row.consolidationPartnerId}
|
||||||
consolidationPartnerReference={row.consolidationPartnerReference}
|
consolidationPartnerReference={row.consolidationPartnerReference}
|
||||||
/>
|
/>
|
||||||
|
<OperationRescheduleModal
|
||||||
|
bookingId={row.id}
|
||||||
|
opened={rescheduleOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onSuppressRowClick?.();
|
||||||
|
setRescheduleOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,6 +198,14 @@ export function BookingActionsMenu({
|
|||||||
consolidationPartnerId={row.consolidationPartnerId}
|
consolidationPartnerId={row.consolidationPartnerId}
|
||||||
consolidationPartnerReference={row.consolidationPartnerReference}
|
consolidationPartnerReference={row.consolidationPartnerReference}
|
||||||
/>
|
/>
|
||||||
|
<OperationRescheduleModal
|
||||||
|
bookingId={row.id}
|
||||||
|
opened={rescheduleOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onSuppressRowClick?.();
|
||||||
|
setRescheduleOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Date | null>(null);
|
||||||
|
const [trainId, setTrainId] = useState<string | null>(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 (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size="md"
|
||||||
|
title={
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||||
|
<CalendarClock size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text fw={600} lh={1.2}>
|
||||||
|
Change train / shipment day
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" lh={1.2}>
|
||||||
|
{booking?.reference ?? "Booking"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{detailQuery.isLoading || !booking ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||||
|
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."
|
||||||
|
: ""}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Group gap="xs" wrap="wrap">
|
||||||
|
<Badge variant="light" color="gray">
|
||||||
|
Currently {currentDayKey ?? "no day"}
|
||||||
|
</Badge>
|
||||||
|
{booking.trainScheduleSummary ? (
|
||||||
|
<Badge variant="light" color="gray">
|
||||||
|
{booking.trainScheduleSummary.trainNumber ??
|
||||||
|
booking.trainScheduleSummary.reference ??
|
||||||
|
"train"}
|
||||||
|
{booking.trainScheduleSummary.isRequested ? " (requested)" : ""}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<DateInput
|
||||||
|
label="New shipment day"
|
||||||
|
description={
|
||||||
|
daysQuery.data && daysQuery.data.length
|
||||||
|
? "Days with an open departure on this route are selectable."
|
||||||
|
: "Pick the train departure day."
|
||||||
|
}
|
||||||
|
value={day}
|
||||||
|
onChange={(v) => 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 ? (
|
||||||
|
<Text size="xs" c="red">
|
||||||
|
No open departure on this route for {dayKey}.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isExportRail ? (
|
||||||
|
<Select
|
||||||
|
label="Export train"
|
||||||
|
placeholder={
|
||||||
|
!day
|
||||||
|
? "Pick a day first"
|
||||||
|
: trainsQuery.isLoading
|
||||||
|
? "Loading trains…"
|
||||||
|
: "Select a train with room"
|
||||||
|
}
|
||||||
|
data={trainOptions}
|
||||||
|
value={trainId}
|
||||||
|
onChange={setTrainId}
|
||||||
|
disabled={!day || trainsQuery.isLoading}
|
||||||
|
nothingFoundMessage="No export train on this day"
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Note to customer (optional)"
|
||||||
|
placeholder="Why the day is changing…"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.currentTarget.value)}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="xs">
|
||||||
|
<Button variant="default" radius="md" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
radius="md"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CalendarClock size={16} />}
|
||||||
|
loading={mutations.rescheduleOperation.isPending}
|
||||||
|
disabled={!canSave}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
Save new day
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OperationRescheduleModal;
|
||||||
@@ -1,11 +1,27 @@
|
|||||||
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
import {
|
||||||
import { DateInput } from "@mantine/dates";
|
Alert,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, Pencil, Send } from "lucide-react";
|
import { AlertTriangle, Pencil, Send } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||||
|
import { api } from "@/services/api";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import {
|
||||||
|
eatDay,
|
||||||
|
exportTrainOption,
|
||||||
|
formatEatDay,
|
||||||
|
isExportRailBooking,
|
||||||
|
} from "@/features/bookings/shipmentDay";
|
||||||
|
|
||||||
export interface BookingChangesRequestedAlertProps {
|
export interface BookingChangesRequestedAlertProps {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
@@ -27,8 +43,11 @@ export interface BookingChangesRequestedAlertProps {
|
|||||||
*
|
*
|
||||||
* The customer cannot act on this — GL created the booking on their behalf — so
|
* The customer cannot act on this — GL created the booking on their behalf — so
|
||||||
* the note and the way out both live here, on the page GL works from. Resubmit
|
* the note and the way out both live here, on the page GL works from. Resubmit
|
||||||
* re-requests operation on the chosen shipment day; the server re-checks the day
|
* re-requests operation on the chosen shipment day: only days with an open
|
||||||
* has a departure that can carry the cargo and refuses with the reason if not.
|
* departure on the booking's route are selectable, and an export rail booking
|
||||||
|
* also picks the train it rides (the API refuses an export resubmit without
|
||||||
|
* one). The server re-checks the day and train and refuses with the reason if
|
||||||
|
* they no longer work.
|
||||||
*/
|
*/
|
||||||
export function BookingChangesRequestedAlert({
|
export function BookingChangesRequestedAlert({
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -39,16 +58,82 @@ export function BookingChangesRequestedAlert({
|
|||||||
editHref,
|
editHref,
|
||||||
onResubmitted,
|
onResubmitted,
|
||||||
}: BookingChangesRequestedAlertProps) {
|
}: BookingChangesRequestedAlertProps) {
|
||||||
const [day, setDay] = useState<Date | null>(
|
// The chosen departure day, as an EAT day key (YYYY-MM-DD). Only days that
|
||||||
scheduledDate ? new Date(scheduledDate) : null,
|
// actually have an open departure on the booking's route are offered.
|
||||||
|
const [dayKey, setDayKey] = useState<string | null>(
|
||||||
|
scheduledDate ? eatDay(scheduledDate) : null,
|
||||||
);
|
);
|
||||||
|
const [trainId, setTrainId] = useState<string | null>(null);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
// The booking's route and direction decide which days are offered and
|
||||||
|
// whether a train has to be picked — fetched only when this user can resubmit.
|
||||||
|
const { data: booking } = useBookingDetail(
|
||||||
|
canResubmit ? bookingId : undefined,
|
||||||
|
);
|
||||||
|
const isExportRail = booking ? isExportRailBooking(booking) : false;
|
||||||
|
|
||||||
|
// Seed the train from the customer's / previous pick once the booking loads.
|
||||||
|
useEffect(() => {
|
||||||
|
if (booking?.trainScheduleSummary?.id) {
|
||||||
|
setTrainId((current) => current ?? booking.trainScheduleSummary!.id);
|
||||||
|
}
|
||||||
|
}, [booking]);
|
||||||
|
|
||||||
|
const daysQuery = useQuery({
|
||||||
|
...api.trainScheduling.availableDays.queryOptions({
|
||||||
|
input: {
|
||||||
|
originYardId: booking?.originYard?.id ?? null,
|
||||||
|
destinationYardId: booking?.destinationYard?.id ?? null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enabled:
|
||||||
|
canResubmit &&
|
||||||
|
Boolean(booking?.originYard?.id && booking?.destinationYard?.id),
|
||||||
|
});
|
||||||
|
const dayOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(new Set((daysQuery.data ?? []).map((d) => eatDay(d))))
|
||||||
|
.sort()
|
||||||
|
.map((key) => ({ value: key, label: formatEatDay(key) })),
|
||||||
|
[daysQuery.data],
|
||||||
|
);
|
||||||
|
// A previously held day that no longer has a departure is not offered — the
|
||||||
|
// select shows nothing until GL picks a real one.
|
||||||
|
const dayHasDeparture =
|
||||||
|
dayKey != null && dayOptions.some((o) => o.value === dayKey);
|
||||||
|
// Any instant inside the chosen EAT day; the API keys on the day.
|
||||||
|
const dayIso = dayKey ? `${dayKey}T12:00:00.000Z` : "";
|
||||||
|
|
||||||
|
const trainsQuery = useQuery({
|
||||||
|
...api.trainScheduling.exportTrains.queryOptions({
|
||||||
|
input: { bookingId, date: dayIso },
|
||||||
|
}),
|
||||||
|
enabled: canResubmit && isExportRail && dayHasDeparture,
|
||||||
|
});
|
||||||
|
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 canSend = dayHasDeparture && (!isExportRail || Boolean(trainId));
|
||||||
|
|
||||||
const resubmit = async () => {
|
const resubmit = async () => {
|
||||||
if (!day) return;
|
if (!canSend) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await bookingsService.proceedToOperation(bookingId, day.toISOString());
|
await bookingsService.proceedToOperation(
|
||||||
|
bookingId,
|
||||||
|
dayIso,
|
||||||
|
isExportRail && trainId ? trainId : undefined,
|
||||||
|
);
|
||||||
toast.success("Sent back to Operations for review");
|
toast.success("Sent back to Operations for review");
|
||||||
onResubmitted?.();
|
onResubmitted?.();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -91,8 +176,9 @@ export function BookingChangesRequestedAlert({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
This booking was created by GL Ethiopia, so the customer cannot fix it.
|
This booking was created by GL Ethiopia, so the customer cannot fix
|
||||||
Make the correction Operations asked for, then send it back for review.{" "}
|
it. Make the correction Operations asked for, then send it back for
|
||||||
|
review.{" "}
|
||||||
<Text
|
<Text
|
||||||
component={Link}
|
component={Link}
|
||||||
to={`/dashboard/bookings/${bookingId}/clearance`}
|
to={`/dashboard/bookings/${bookingId}/clearance`}
|
||||||
@@ -106,21 +192,54 @@ export function BookingChangesRequestedAlert({
|
|||||||
|
|
||||||
{canResubmit ? (
|
{canResubmit ? (
|
||||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||||
<DateInput
|
<Select
|
||||||
label="Shipment day"
|
label="Departure day"
|
||||||
description="Keep the day or pick another with an open departure"
|
description="Existing departures on this route"
|
||||||
value={day}
|
placeholder={
|
||||||
onChange={(v) => setDay(v ? new Date(v) : null)}
|
daysQuery.isLoading
|
||||||
minDate={new Date()}
|
? "Loading departures…"
|
||||||
|
: dayOptions.length
|
||||||
|
? "Select a departure day"
|
||||||
|
: "No open departure on this route"
|
||||||
|
}
|
||||||
|
data={dayOptions}
|
||||||
|
value={dayHasDeparture ? dayKey : null}
|
||||||
|
onChange={setDayKey}
|
||||||
|
disabled={daysQuery.isLoading || !dayOptions.length}
|
||||||
|
nothingFoundMessage="No open departure on this route"
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
searchable
|
||||||
size="sm"
|
size="sm"
|
||||||
w={230}
|
w={230}
|
||||||
/>
|
/>
|
||||||
|
{isExportRail ? (
|
||||||
|
<Select
|
||||||
|
label="Export train"
|
||||||
|
description="The train this shipment rides"
|
||||||
|
placeholder={
|
||||||
|
!dayHasDeparture
|
||||||
|
? "Pick a day first"
|
||||||
|
: trainsQuery.isLoading
|
||||||
|
? "Loading trains…"
|
||||||
|
: "Select a train with room"
|
||||||
|
}
|
||||||
|
data={trainOptions}
|
||||||
|
value={trainId}
|
||||||
|
onChange={setTrainId}
|
||||||
|
disabled={!dayHasDeparture || trainsQuery.isLoading}
|
||||||
|
nothingFoundMessage="No export train on this day"
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
searchable
|
||||||
|
size="sm"
|
||||||
|
w={340}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
radius="md"
|
radius="md"
|
||||||
size="sm"
|
size="sm"
|
||||||
loading={sending}
|
loading={sending}
|
||||||
disabled={!day}
|
disabled={!canSend}
|
||||||
leftSection={<Send size={15} />}
|
leftSection={<Send size={15} />}
|
||||||
onClick={() => void resubmit()}
|
onClick={() => void resubmit()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -578,13 +578,39 @@ export default function GlCreateBookingForm() {
|
|||||||
}
|
}
|
||||||
}, [bookingRequest, prefilled]);
|
}, [bookingRequest, prefilled]);
|
||||||
|
|
||||||
// Rebook seed: copy the source booking's container lines once. (Bulk weight /
|
// Rebook seed: copy the source booking's real cargo once — container lines
|
||||||
// item count isn't on the booking payload yet, so bulk rebooks fall through to
|
// (with their per-unit details) or the bulk weight / item count / wagons.
|
||||||
// the normal contract seed and GL re-enters the quantity.)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!copyFromBooking || prefilled) return;
|
if (!copyFromBooking || prefilled) return;
|
||||||
const lines = copyFromBooking.bookingContainers ?? [];
|
const lines = copyFromBooking.bookingContainers ?? [];
|
||||||
if (!lines.length) return;
|
if (!lines.length) {
|
||||||
|
// Bulk booking: seed the quantity fields from what was actually booked.
|
||||||
|
// A break-bulk (per-item) booking stores the real tons in
|
||||||
|
// bulkTotalWeightTons and the item count in cargoTotalWeightVgm.
|
||||||
|
const perItem = copyFromBooking.bulkTotalWeightTons != null;
|
||||||
|
const tons = perItem
|
||||||
|
? copyFromBooking.bulkTotalWeightTons
|
||||||
|
: copyFromBooking.cargoTotalWeightVgm;
|
||||||
|
const items = perItem
|
||||||
|
? copyFromBooking.cargoTotalWeightVgm
|
||||||
|
: copyFromBooking.bulkItemCount;
|
||||||
|
if (!(Number(tons) > 0) && !(Number(items) > 0)) return;
|
||||||
|
setPrefilled(true);
|
||||||
|
if (copyFromBooking.cargoFreeText) {
|
||||||
|
setCargoDescription(copyFromBooking.cargoFreeText);
|
||||||
|
}
|
||||||
|
setBulk((b) => ({
|
||||||
|
...b,
|
||||||
|
cargoWeightTons: Number(tons) > 0 ? String(tons) : "",
|
||||||
|
itemCount: Number(items) > 0 ? String(items) : "",
|
||||||
|
requestedWagons:
|
||||||
|
copyFromBooking.bulkRequestedWagons != null &&
|
||||||
|
copyFromBooking.bulkRequestedWagons > 0
|
||||||
|
? String(copyFromBooking.bulkRequestedWagons)
|
||||||
|
: b.requestedWagons,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The booking stores a numeric sizeFt (20) but the contract scope — and the
|
// The booking stores a numeric sizeFt (20) but the contract scope — and the
|
||||||
// create payload the server validates — uses its own size strings ("20ft").
|
// create payload the server validates — uses its own size strings ("20ft").
|
||||||
// Seed with the scope's string so the rebook payload matches what a fresh
|
// Seed with the scope's string so the rebook payload matches what a fresh
|
||||||
@@ -632,13 +658,23 @@ export default function GlCreateBookingForm() {
|
|||||||
|
|
||||||
// Seed one shipment line per contracted size exactly once — same seeding the
|
// Seed one shipment line per contracted size exactly once — same seeding the
|
||||||
// portal form does. Subsequent renders reuse the lines.
|
// portal form does. Subsequent renders reuse the lines.
|
||||||
|
//
|
||||||
|
// Functional update on purpose: when the page is reached by an in-app click
|
||||||
|
// the contract AND the rebook source are both already cached, so this effect
|
||||||
|
// and the copyFrom seed above fire in the SAME commit. Reading
|
||||||
|
// `containerLines` from the closure here saw the pre-seed empty array and
|
||||||
|
// overwrote the copied lines with blank 0 × 20ft / 0 × 40ft rows (a hard
|
||||||
|
// refresh loaded them in sequence and looked fine). The updater sees the
|
||||||
|
// copied lines already queued and leaves them alone.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!contract || prefilled || seededRef.current) return;
|
if (!contract || prefilled || seededRef.current) return;
|
||||||
seededRef.current = true;
|
seededRef.current = true;
|
||||||
if (isContainer && containerSizes.length > 0 && containerLines.length === 0) {
|
if (isContainer && containerSizes.length > 0) {
|
||||||
setContainerLines(containerSizes.map(emptyLine));
|
setContainerLines((prev) =>
|
||||||
|
prev.length === 0 ? containerSizes.map(emptyLine) : prev,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [contract, prefilled, isContainer, containerSizes, containerLines.length]);
|
}, [contract, prefilled, isContainer, containerSizes]);
|
||||||
|
|
||||||
const quantities: GlShipmentQuantities = useMemo(
|
const quantities: GlShipmentQuantities = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
|
import { Info, Unlock } from "lucide-react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import DurationField from "@/components/trainScheduling/DurationField";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const EAT = "Africa/Addis_Ababa";
|
||||||
|
|
||||||
|
/** "3 days" / "2 hr" / "45 min" for a minute count. */
|
||||||
|
function describeMinutes(minutes: number): string {
|
||||||
|
if (minutes <= 0) return "at departure";
|
||||||
|
if (minutes % 1440 === 0) {
|
||||||
|
const d = minutes / 1440;
|
||||||
|
return `${d} day${d === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (minutes % 60 === 0) {
|
||||||
|
const h = minutes / 60;
|
||||||
|
return `${h} hr`;
|
||||||
|
}
|
||||||
|
return `${minutes} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEat(value: string | Date | null | undefined): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
timeZone: EAT,
|
||||||
|
weekday: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseError(error: unknown, fallback: string): string {
|
||||||
|
if (isAxiosError(error)) {
|
||||||
|
const message = error.response?.data?.message;
|
||||||
|
if (Array.isArray(message)) return message.join(", ");
|
||||||
|
if (typeof message === "string") return message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReduceCloseOffsetModalProps {
|
||||||
|
scheduleId: string | null;
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Called after a successful save (e.g. to refetch a list). */
|
||||||
|
onSaved?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reopen a schedule whose booking shut ONLY because of its close offset, by
|
||||||
|
* shortening that offset (3 days → 1 day, 2 hours, …). The API decides
|
||||||
|
* eligibility (`closeOffsetReopen`); every other kind of closed window is
|
||||||
|
* explained and left alone.
|
||||||
|
*/
|
||||||
|
export default function ReduceCloseOffsetModal({
|
||||||
|
scheduleId,
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: ReduceCloseOffsetModalProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
...api.trainScheduling.scheduleDetail.queryOptions({
|
||||||
|
input: { id: scheduleId ?? "" },
|
||||||
|
}),
|
||||||
|
enabled: opened && Boolean(scheduleId),
|
||||||
|
});
|
||||||
|
const schedule = detailQuery.data;
|
||||||
|
const reopen = schedule?.closeOffsetReopen ?? null;
|
||||||
|
const currentOffset = reopen?.offsetMinutes ?? null;
|
||||||
|
|
||||||
|
const save = useMutation(
|
||||||
|
api.trainScheduling.reduceScheduleCloseOffset.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// New offset, in minutes (what the API stores). Seeded to the current offset
|
||||||
|
// so the field reads as "shorten this", never as an empty box.
|
||||||
|
const [offsetMinutes, setOffsetMinutes] = useState<number | "">("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened) return;
|
||||||
|
setOffsetMinutes(currentOffset ?? "");
|
||||||
|
}, [opened, currentOffset]);
|
||||||
|
|
||||||
|
const departure = schedule?.scheduledDepartureDate
|
||||||
|
? new Date(schedule.scheduledDepartureDate)
|
||||||
|
: null;
|
||||||
|
const newCutoff = useMemo(() => {
|
||||||
|
if (!departure || offsetMinutes === "") return null;
|
||||||
|
const n = Number(offsetMinutes);
|
||||||
|
if (!Number.isFinite(n) || n < 0) return null;
|
||||||
|
return new Date(departure.getTime() - n * 60_000);
|
||||||
|
}, [departure, offsetMinutes]);
|
||||||
|
|
||||||
|
const value = offsetMinutes === "" ? NaN : Number(offsetMinutes);
|
||||||
|
const isShorter =
|
||||||
|
Number.isFinite(value) && currentOffset != null && value < currentOffset;
|
||||||
|
const cutoffInPast = newCutoff != null && newCutoff.getTime() <= Date.now();
|
||||||
|
const canSave =
|
||||||
|
reopen?.eligible === true && isShorter && value >= 0 && !cutoffInPast;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!scheduleId || !canSave) return;
|
||||||
|
try {
|
||||||
|
await save.mutateAsync({
|
||||||
|
id: scheduleId,
|
||||||
|
payload: { closeOffsetMinutes: Math.round(value) },
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "Booking window reopened",
|
||||||
|
description: `Booking now closes ${describeMinutes(Math.round(value))} before departure.`,
|
||||||
|
});
|
||||||
|
onSaved?.();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Could not reopen booking",
|
||||||
|
description: parseError(err, "The close offset was not changed."),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size="md"
|
||||||
|
title={
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||||
|
<Unlock size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text fw={600} lh={1.2}>
|
||||||
|
Reopen booking — shorten close offset
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" lh={1.2}>
|
||||||
|
{schedule?.route?.name ?? "This schedule"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{detailQuery.isLoading || !schedule ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : !reopen?.eligible ? (
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
icon={<Info size={16} />}
|
||||||
|
title="The close offset is not what closed this train"
|
||||||
|
>
|
||||||
|
{reopen?.reason ??
|
||||||
|
"This schedule cannot be reopened by shortening its close offset."}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||||
|
Booking on this train closed early because of its close offset — the
|
||||||
|
train itself has not left. Shorten the offset and the desk reopens
|
||||||
|
at its next opening (right away if it is open now) until the new
|
||||||
|
cutoff.
|
||||||
|
{schedule.direction !== "EXPORT"
|
||||||
|
? " Every train on this route departing the same day that is closed for the same reason reopens with it."
|
||||||
|
: ""}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={600} mb={6}>
|
||||||
|
Current close
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" wrap="wrap">
|
||||||
|
<Badge variant="light" color="red">
|
||||||
|
Closes {describeMinutes(currentOffset ?? 0)} before departure
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
closed {formatEat(reopen.cutoffAt)} EAT · departs{" "}
|
||||||
|
{formatEat(departure)} EAT
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<DurationField
|
||||||
|
label="New close offset (before departure)"
|
||||||
|
description="Must be shorter than the current offset. 0 = booking stays open until the train departs."
|
||||||
|
value={offsetMinutes}
|
||||||
|
nativeUnit="minutes"
|
||||||
|
min={0}
|
||||||
|
onChange={setOffsetMinutes}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{newCutoff ? (
|
||||||
|
<Text size="sm">
|
||||||
|
Booking would now close{" "}
|
||||||
|
<Text span fw={600}>
|
||||||
|
{formatEat(newCutoff)} EAT
|
||||||
|
</Text>
|
||||||
|
{cutoffInPast ? (
|
||||||
|
<Text span c="red">
|
||||||
|
{" "}
|
||||||
|
— that is already in the past; shorten it further.
|
||||||
|
</Text>
|
||||||
|
) : !isShorter ? (
|
||||||
|
<Text span c="red">
|
||||||
|
{" "}
|
||||||
|
— not shorter than the current offset.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="xs">
|
||||||
|
<Button variant="default" radius="md" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
radius="md"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Unlock size={16} />}
|
||||||
|
loading={save.isPending}
|
||||||
|
disabled={!canSave}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
Reopen booking
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -263,6 +263,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/bookings/${id}/clearance/export-release`,
|
`/bookings/${id}/clearance/export-release`,
|
||||||
// Re-request operation after Operations sent the booking back for changes.
|
// Re-request operation after Operations sent the booking back for changes.
|
||||||
CLEARANCE_PROCEED: (id: string) => `/bookings/${id}/clearance/proceed`,
|
CLEARANCE_PROCEED: (id: string) => `/bookings/${id}/clearance/proceed`,
|
||||||
|
// Operations changes a pending request's shipment day / train themselves.
|
||||||
|
OPERATION_RESCHEDULE: (id: string) => `/bookings/${id}/operation/reschedule`,
|
||||||
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
|
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
|
||||||
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
||||||
},
|
},
|
||||||
@@ -435,6 +437,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/schedules/${id}/booking-window`,
|
`/train-scheduling/schedules/${id}/booking-window`,
|
||||||
WINDOW_RULE: (id: string) =>
|
WINDOW_RULE: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/window-rule`,
|
`/train-scheduling/schedules/${id}/window-rule`,
|
||||||
|
CLOSE_OFFSET: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/close-offset`,
|
||||||
SCHEDULE_DATE: (id: string) =>
|
SCHEDULE_DATE: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/schedule-date`,
|
`/train-scheduling/schedules/${id}/schedule-date`,
|
||||||
MERGE_PREVIEW: (id: string, targetTrainId: string) =>
|
MERGE_PREVIEW: (id: string, targetTrainId: string) =>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
|
CalendarClock,
|
||||||
Check,
|
Check,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
Play,
|
Play,
|
||||||
@@ -28,6 +29,7 @@ export type BookingActionId =
|
|||||||
| "complete"
|
| "complete"
|
||||||
| "operationAccept"
|
| "operationAccept"
|
||||||
| "operationRequestChanges"
|
| "operationRequestChanges"
|
||||||
|
| "operationReschedule"
|
||||||
| "cancel";
|
| "cancel";
|
||||||
|
|
||||||
export type BookingActionInputKind =
|
export type BookingActionInputKind =
|
||||||
@@ -128,6 +130,22 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations changes the shipment day / train themselves, instead of returning
|
||||||
|
* the request to the customer. Opens its own modal (day + export train picker),
|
||||||
|
* not the generic confirm dialog — see isRescheduleAction.
|
||||||
|
*/
|
||||||
|
const OPERATION_RESCHEDULE_ACTION: BookingActionDef = {
|
||||||
|
id: "operationReschedule",
|
||||||
|
label: "Change train / shipment day",
|
||||||
|
shortLabel: "Reschedule",
|
||||||
|
description: "Move the request to another shipment day or train yourself",
|
||||||
|
confirmTitle: "",
|
||||||
|
confirmDescription: "",
|
||||||
|
variant: "outline",
|
||||||
|
icon: CalendarClock,
|
||||||
|
};
|
||||||
|
|
||||||
// Marketing/operations review of a drawdown order's operation request.
|
// Marketing/operations review of a drawdown order's operation request.
|
||||||
const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
|
const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
|
||||||
{
|
{
|
||||||
@@ -156,6 +174,7 @@ const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
|
|||||||
inputLabel: "Message to customer",
|
inputLabel: "Message to customer",
|
||||||
inputPlaceholder: "Describe what needs to change…",
|
inputPlaceholder: "Describe what needs to change…",
|
||||||
},
|
},
|
||||||
|
OPERATION_RESCHEDULE_ACTION,
|
||||||
];
|
];
|
||||||
|
|
||||||
const CANCEL_ACTION: BookingActionDef = {
|
const CANCEL_ACTION: BookingActionDef = {
|
||||||
@@ -202,6 +221,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
|||||||
complete: FREIGHT_PERMS.bookings.operations,
|
complete: FREIGHT_PERMS.bookings.operations,
|
||||||
operationAccept: FREIGHT_PERMS.bookings.operations,
|
operationAccept: FREIGHT_PERMS.bookings.operations,
|
||||||
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
|
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
|
||||||
|
operationReschedule: FREIGHT_PERMS.bookings.operations,
|
||||||
allocateBooking: FREIGHT_PERMS.trainScheduling.update,
|
allocateBooking: FREIGHT_PERMS.trainScheduling.update,
|
||||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||||
};
|
};
|
||||||
@@ -253,6 +273,11 @@ export function getBookingActions(
|
|||||||
case "OPERATION_REQUEST_PENDING":
|
case "OPERATION_REQUEST_PENDING":
|
||||||
actions = withCancel(OPERATION_REVIEW_ACTIONS);
|
actions = withCancel(OPERATION_REVIEW_ACTIONS);
|
||||||
break;
|
break;
|
||||||
|
case "OPERATION_CHANGES_REQUESTED":
|
||||||
|
// Waiting on the customer — but Operations may also resolve their own
|
||||||
|
// change request by setting the day / train directly.
|
||||||
|
actions = [OPERATION_RESCHEDULE_ACTION];
|
||||||
|
break;
|
||||||
case "PAID":
|
case "PAID":
|
||||||
// Allocate is handled by the Operations "Ready to allocate" queue, not the
|
// Allocate is handled by the Operations "Ready to allocate" queue, not the
|
||||||
// per-booking action menu. Start transit was removed entirely. No per-row
|
// per-booking action menu. Start transit was removed entirely. No per-row
|
||||||
@@ -300,6 +325,11 @@ export function isAllocateAction(id: BookingActionId): boolean {
|
|||||||
return id === "allocateBooking";
|
return id === "allocateBooking";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens the day / train reschedule modal instead of the generic confirm dialog. */
|
||||||
|
export function isRescheduleAction(id: BookingActionId): boolean {
|
||||||
|
return id === "operationReschedule";
|
||||||
|
}
|
||||||
|
|
||||||
/** Opens the booking detail on the Clearance tab without a confirm dialog. */
|
/** Opens the booking detail on the Clearance tab without a confirm dialog. */
|
||||||
export function isClearanceNavAction(id: BookingActionId): boolean {
|
export function isClearanceNavAction(id: BookingActionId): boolean {
|
||||||
return id === "reviewClearance";
|
return id === "reviewClearance";
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Shared helpers for the staff shipment-day / export-train pickers (the
|
||||||
|
* operation reschedule modal and the GL "returned for changes" resubmit).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const EAT_TIMEZONE = "Africa/Addis_Ababa";
|
||||||
|
|
||||||
|
/** YYYY-MM-DD of an instant in East Africa Time — the booking day key. */
|
||||||
|
export function eatDay(value: string | Date): string {
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: EAT_TIMEZONE,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Mon, 07 Sep, 09:00" in EAT; "—" for a missing or invalid value. */
|
||||||
|
export function formatEat(value: string | Date | null | undefined): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
timeZone: EAT_TIMEZONE,
|
||||||
|
weekday: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Wed, 09 Sep 2026" for a YYYY-MM-DD EAT day key. */
|
||||||
|
export function formatEatDay(dayKey: string): string {
|
||||||
|
const date = new Date(`${dayKey}T12:00:00.000Z`);
|
||||||
|
if (Number.isNaN(date.getTime())) return dayKey;
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
timeZone: EAT_TIMEZONE,
|
||||||
|
weekday: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the API road-service rule: ServiceType.code ROAD, TRUCK, ROAD_*, TRUCK_* */
|
||||||
|
export function isRoadServiceCode(code: string | null | undefined): boolean {
|
||||||
|
const c = (code ?? "").toUpperCase();
|
||||||
|
return (
|
||||||
|
c === "ROAD" ||
|
||||||
|
c === "TRUCK" ||
|
||||||
|
c.startsWith("ROAD_") ||
|
||||||
|
c.startsWith("TRUCK_")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export rail bookings are the only ones that carry a train pick. */
|
||||||
|
export function isExportRailBooking(booking: {
|
||||||
|
tradeDirection?: string | null;
|
||||||
|
serviceType?: { code?: string | null } | null;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
booking.tradeDirection === "EXPORT" &&
|
||||||
|
!isRoadServiceCode(booking.serviceType?.code)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select option for one export train; closed or too-small trains are disabled. */
|
||||||
|
export function exportTrainOption(t: {
|
||||||
|
scheduleId: string;
|
||||||
|
trainNumber: string | null;
|
||||||
|
trainName: string | null;
|
||||||
|
departure: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
fits: boolean;
|
||||||
|
freeWagons: number;
|
||||||
|
neededWagons: number;
|
||||||
|
}): { value: string; label: string; disabled: boolean } {
|
||||||
|
return {
|
||||||
|
value: t.scheduleId,
|
||||||
|
label:
|
||||||
|
`${t.trainNumber ?? t.trainName ?? "Train"} · departs ${formatEat(t.departure)} · ` +
|
||||||
|
`${t.freeWagons} free / needs ${t.neededWagons}` +
|
||||||
|
(!t.isOpen ? " · closed" : !t.fits ? " · no room" : ""),
|
||||||
|
disabled: !t.isOpen || !t.fits,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -64,6 +64,17 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
onError: (error) => toast.error(parseApiError(error, "Failed to reject booking")),
|
onError: (error) => toast.error(parseApiError(error, "Failed to reject booking")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rescheduleOperation = useMutation({
|
||||||
|
mutationFn: (payload: {
|
||||||
|
scheduledDate: string;
|
||||||
|
trainScheduleId?: string;
|
||||||
|
note?: string;
|
||||||
|
}) => api.bookings.rescheduleOperation.call({ id: bookingId, ...payload }),
|
||||||
|
onSuccess: (data) => onSuccess(data, "Shipment day updated"),
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(parseApiError(error, "Failed to change the shipment day")),
|
||||||
|
});
|
||||||
|
|
||||||
const reviewOperation = useMutation({
|
const reviewOperation = useMutation({
|
||||||
mutationFn: (payload: {
|
mutationFn: (payload: {
|
||||||
decision: "ACCEPT" | "REQUEST_CHANGES";
|
decision: "ACCEPT" | "REQUEST_CHANGES";
|
||||||
@@ -162,6 +173,7 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
startTransit.isPending ||
|
startTransit.isPending ||
|
||||||
complete.isPending ||
|
complete.isPending ||
|
||||||
reviewOperation.isPending ||
|
reviewOperation.isPending ||
|
||||||
|
rescheduleOperation.isPending ||
|
||||||
cancel.isPending;
|
cancel.isPending;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -170,6 +182,7 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
requestChanges,
|
requestChanges,
|
||||||
staffReject,
|
staffReject,
|
||||||
reviewOperation,
|
reviewOperation,
|
||||||
|
rescheduleOperation,
|
||||||
generateContract,
|
generateContract,
|
||||||
signContract,
|
signContract,
|
||||||
payBooking,
|
payBooking,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
Ruler,
|
Ruler,
|
||||||
Send,
|
Send,
|
||||||
Train,
|
Train,
|
||||||
|
Unlock,
|
||||||
Weight,
|
Weight,
|
||||||
Workflow as WorkflowIcon,
|
Workflow as WorkflowIcon,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
@@ -77,6 +78,7 @@ import { StationWorkControls } from "@/components/trainScheduling/StationWorkCon
|
|||||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
|
import ReduceCloseOffsetModal from "@/components/trainScheduling/ReduceCloseOffsetModal";
|
||||||
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||||
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
||||||
import { SwitchGovernmentBookingModal } from "@/components/trainScheduling/SwitchGovernmentBookingModal";
|
import { SwitchGovernmentBookingModal } from "@/components/trainScheduling/SwitchGovernmentBookingModal";
|
||||||
@@ -135,6 +137,8 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||||
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||||
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
||||||
|
// Reopen a train whose booking shut only because of its close offset.
|
||||||
|
const [closeOffsetOpen, setCloseOffsetOpen] = useState(false);
|
||||||
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
||||||
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
||||||
const [gatepassReference, setGatepassReference] = useState("");
|
const [gatepassReference, setGatepassReference] = useState("");
|
||||||
@@ -1325,6 +1329,19 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
{/* Booking shut only by the close offset — the one closed state
|
||||||
|
staff can undo here, so it gets a visible button. */}
|
||||||
|
{schedule.closeOffsetReopen?.eligible ? (
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
color="edr-green"
|
||||||
|
size="compact-sm"
|
||||||
|
leftSection={<Unlock size={14} />}
|
||||||
|
onClick={() => setCloseOffsetOpen(true)}
|
||||||
|
>
|
||||||
|
Reduce close offset
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{/* Merging rewrites the consist, so it is offered only while
|
{/* Merging rewrites the consist, so it is offered only while
|
||||||
the departure can still be edited. */}
|
the departure can still be edited. */}
|
||||||
{canEditBookings ? (
|
{canEditBookings ? (
|
||||||
@@ -1441,6 +1458,15 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
Window settings
|
Window settings
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
{schedule.closeOffsetReopen?.eligible ? (
|
||||||
|
<Menu.Item
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Unlock size={15} />}
|
||||||
|
onClick={() => setCloseOffsetOpen(true)}
|
||||||
|
>
|
||||||
|
Reopen booking (shorten close offset)
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||||
<Menu.Item onClick={() => setMaintenanceOpen(true)}>
|
<Menu.Item onClick={() => setMaintenanceOpen(true)}>
|
||||||
Reschedule train
|
Reschedule train
|
||||||
@@ -1820,6 +1846,13 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
onSaved={() => void detailQuery.refetch()}
|
onSaved={() => void detailQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ReduceCloseOffsetModal
|
||||||
|
scheduleId={scheduleId ?? null}
|
||||||
|
opened={closeOffsetOpen}
|
||||||
|
onClose={() => setCloseOffsetOpen(false)}
|
||||||
|
onSaved={() => void detailQuery.refetch()}
|
||||||
|
/>
|
||||||
|
|
||||||
<LoadEmptyContainersModal
|
<LoadEmptyContainersModal
|
||||||
opened={loadEmptiesOpen}
|
opened={loadEmptiesOpen}
|
||||||
onClose={() => setLoadEmptiesOpen(false)}
|
onClose={() => setLoadEmptiesOpen(false)}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
Table2,
|
Table2,
|
||||||
Train,
|
Train,
|
||||||
|
Unlock,
|
||||||
Users,
|
Users,
|
||||||
Weight,
|
Weight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -58,6 +59,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
|||||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||||
import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus";
|
import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus";
|
||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
|
import ReduceCloseOffsetModal from "@/components/trainScheduling/ReduceCloseOffsetModal";
|
||||||
import CreateScheduleWindowFields, {
|
import CreateScheduleWindowFields, {
|
||||||
buildWindowRulePayload,
|
buildWindowRulePayload,
|
||||||
type WindowFormState,
|
type WindowFormState,
|
||||||
@@ -145,6 +147,9 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
|
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
|
||||||
|
// "Shorten close offset": reopens a train whose booking shut only because
|
||||||
|
// of its close offset (the API flags exactly those rows).
|
||||||
|
const [closeOffsetId, setCloseOffsetId] = useState<string | null>(null);
|
||||||
// Dispatch is irreversible from this screen, so it goes through an explicit
|
// Dispatch is irreversible from this screen, so it goes through an explicit
|
||||||
// confirmation.
|
// confirmation.
|
||||||
const [dispatchTarget, setDispatchTarget] = useState<TrainScheduleListItem | null>(null);
|
const [dispatchTarget, setDispatchTarget] = useState<TrainScheduleListItem | null>(null);
|
||||||
@@ -373,12 +378,26 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
size: 210,
|
size: 330,
|
||||||
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
|
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const schedule = row.original;
|
const schedule = row.original;
|
||||||
return (
|
return (
|
||||||
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{/* Booking shut only by the close offset: a visible button, since
|
||||||
|
this is the one closed state staff can fix from the board. */}
|
||||||
|
{schedule.closeOffsetReopen?.eligible ? (
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
color="edr-green"
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Unlock size={14} />}
|
||||||
|
onClick={() => setCloseOffsetId(schedule.id)}
|
||||||
|
>
|
||||||
|
Reduce offset
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
color="indigo"
|
color="indigo"
|
||||||
@@ -434,6 +453,15 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
Booking window settings
|
Booking window settings
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
{schedule.closeOffsetReopen?.eligible ? (
|
||||||
|
<Menu.Item
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Unlock size={15} />}
|
||||||
|
onClick={() => setCloseOffsetId(schedule.id)}
|
||||||
|
>
|
||||||
|
Reopen booking (shorten close offset)
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
{/* Start the run. Same transition as the detail page's
|
{/* Start the run. Same transition as the detail page's
|
||||||
Dispatch button — that page also shows unassigned-wagon
|
Dispatch button — that page also shows unassigned-wagon
|
||||||
and not-loaded warnings, so it stays the fuller surface. */}
|
and not-loaded warnings, so it stays the fuller surface. */}
|
||||||
@@ -805,6 +833,13 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
onSaved={() => void schedulesQuery.refetch()}
|
onSaved={() => void schedulesQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ReduceCloseOffsetModal
|
||||||
|
scheduleId={closeOffsetId}
|
||||||
|
opened={closeOffsetId != null}
|
||||||
|
onClose={() => setCloseOffsetId(null)}
|
||||||
|
onSaved={() => void schedulesQuery.refetch()}
|
||||||
|
/>
|
||||||
|
|
||||||
<EditScheduleDateModal
|
<EditScheduleDateModal
|
||||||
scheduleId={editDateSchedule?.id ?? null}
|
scheduleId={editDateSchedule?.id ?? null}
|
||||||
currentDate={editDateSchedule?.scheduleDate ?? null}
|
currentDate={editDateSchedule?.scheduleDate ?? null}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import type {
|
|||||||
TrainScheduleFilters,
|
TrainScheduleFilters,
|
||||||
TrainScheduleListFilters,
|
TrainScheduleListFilters,
|
||||||
TrainScheduleListResponse,
|
TrainScheduleListResponse,
|
||||||
|
ReduceScheduleCloseOffsetPayload,
|
||||||
UpdateScheduleWindowRulePayload,
|
UpdateScheduleWindowRulePayload,
|
||||||
TrainSchedulePreviewPayload,
|
TrainSchedulePreviewPayload,
|
||||||
TrainSchedulePreviewResponse,
|
TrainSchedulePreviewResponse,
|
||||||
@@ -713,6 +714,18 @@ export const api = {
|
|||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
reduceScheduleCloseOffset: endpoint<
|
||||||
|
{ id: string; payload: ReduceScheduleCloseOffsetPayload },
|
||||||
|
TrainScheduleDetail
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"reduce-schedule-close-offset",
|
||||||
|
({ id, payload }) =>
|
||||||
|
trainSchedulingService.reduceScheduleCloseOffset(id, payload),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
updateScheduleDate: endpoint<
|
updateScheduleDate: endpoint<
|
||||||
{ id: string; scheduleDate: string },
|
{ id: string; scheduleDate: string },
|
||||||
TrainScheduleDetail
|
TrainScheduleDetail
|
||||||
@@ -3220,11 +3233,23 @@ export const api = {
|
|||||||
bookingsService.reviewOperation(id, decision, { note }),
|
bookingsService.reviewOperation(id, decision, { note }),
|
||||||
),
|
),
|
||||||
|
|
||||||
proceedToOperation: endpoint<
|
rescheduleOperation: endpoint<
|
||||||
{ id: string; scheduledDate: string },
|
{
|
||||||
|
id: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
trainScheduleId?: string;
|
||||||
|
note?: string;
|
||||||
|
},
|
||||||
BookingDetail
|
BookingDetail
|
||||||
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
|
>("bookings", "rescheduleOperation", ({ id, ...payload }) =>
|
||||||
bookingsService.proceedToOperation(id, scheduledDate),
|
bookingsService.rescheduleOperation(id, payload),
|
||||||
|
),
|
||||||
|
|
||||||
|
proceedToOperation: endpoint<
|
||||||
|
{ id: string; scheduledDate: string; trainScheduleId?: string },
|
||||||
|
BookingDetail
|
||||||
|
>("bookings", "proceedToOperation", ({ id, scheduledDate, trainScheduleId }) =>
|
||||||
|
bookingsService.proceedToOperation(id, scheduledDate, trainScheduleId),
|
||||||
),
|
),
|
||||||
|
|
||||||
generateContract: endpoint<{ id: string }, BookingDetail>(
|
generateContract: endpoint<{ id: string }, BookingDetail>(
|
||||||
|
|||||||
@@ -355,8 +355,21 @@ export const bookingsService = {
|
|||||||
* customer path uses the same endpoint from the portal; GL needs it here
|
* customer path uses the same endpoint from the portal; GL needs it here
|
||||||
* because a customs booking is GL's to fix, not the customer's.
|
* because a customs booking is GL's to fix, not the customer's.
|
||||||
*/
|
*/
|
||||||
proceedToOperation: (id: string, scheduledDate: string) =>
|
proceedToOperation: (id: string, scheduledDate: string, trainScheduleId?: string) =>
|
||||||
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), { scheduledDate }),
|
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), {
|
||||||
|
scheduledDate,
|
||||||
|
...(trainScheduleId ? { trainScheduleId } : {}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations changes the shipment day (and, for export rail, the train) of a
|
||||||
|
* pending operation request on the customer's behalf — the booking stays at
|
||||||
|
* OPERATION_REQUEST_PENDING for the normal accept.
|
||||||
|
*/
|
||||||
|
rescheduleOperation: (
|
||||||
|
id: string,
|
||||||
|
payload: { scheduledDate: string; trainScheduleId?: string; note?: string },
|
||||||
|
) => postBooking<BookingDetail>(B.OPERATION_RESCHEDULE(id), payload),
|
||||||
|
|
||||||
generateContract: (id: string) =>
|
generateContract: (id: string) =>
|
||||||
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),
|
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import type {
|
|||||||
MarshallingStop,
|
MarshallingStop,
|
||||||
ScheduleMergePreview,
|
ScheduleMergePreview,
|
||||||
TrainScheduleDetail,
|
TrainScheduleDetail,
|
||||||
|
ReduceScheduleCloseOffsetPayload,
|
||||||
UpdateScheduleWindowRulePayload,
|
UpdateScheduleWindowRulePayload,
|
||||||
TrainScheduleFilters,
|
TrainScheduleFilters,
|
||||||
TrainScheduleListFilters,
|
TrainScheduleListFilters,
|
||||||
@@ -307,6 +308,17 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reduceScheduleCloseOffset: async (
|
||||||
|
scheduleId: string,
|
||||||
|
payload: ReduceScheduleCloseOffsetPayload,
|
||||||
|
): Promise<TrainScheduleDetail> => {
|
||||||
|
const response = await client.patch<TrainScheduleDetail>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.CLOSE_OFFSET(scheduleId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
updateScheduleDate: async (
|
updateScheduleDate: async (
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
scheduleDate: string,
|
scheduleDate: string,
|
||||||
|
|||||||
@@ -250,6 +250,9 @@ export interface TrainScheduleListItem {
|
|||||||
totalLengthMeters: number;
|
totalLengthMeters: number;
|
||||||
bookingsCount: number;
|
bookingsCount: number;
|
||||||
status: TrainScheduleStatus | string;
|
status: TrainScheduleStatus | string;
|
||||||
|
windowPhase?: BookingWindowPhase | string | null;
|
||||||
|
/** Set when the API computed it: is this row shut only by its close offset? */
|
||||||
|
closeOffsetReopen?: CloseOffsetReopenInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TrainScheduleSortField =
|
export type TrainScheduleSortField =
|
||||||
@@ -593,11 +596,35 @@ export interface ScheduleWindowRule {
|
|||||||
windowDurationHours: number | null;
|
windowDurationHours: number | null;
|
||||||
importWindowLeadDays: number | null;
|
importWindowLeadDays: number | null;
|
||||||
exportBookingLeadHours: number | null;
|
exportBookingLeadHours: number | null;
|
||||||
|
/** Effective minutes-before-departure booking closes (import); null = at departure. */
|
||||||
|
importCloseOffsetMinutes?: number | null;
|
||||||
|
/** Effective minutes-before-departure booking closes (export); null = at departure. */
|
||||||
|
exportCloseOffsetMinutes?: number | null;
|
||||||
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
|
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
|
||||||
docReviewMinutes: number;
|
docReviewMinutes: number;
|
||||||
paymentWindowMinutes: number;
|
paymentWindowMinutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a schedule's booking shut ONLY because of its close offset — the one
|
||||||
|
* closed state staff can undo from the board by shortening that offset.
|
||||||
|
*/
|
||||||
|
export interface CloseOffsetReopenInfo {
|
||||||
|
eligible: boolean;
|
||||||
|
/** Why it is not eligible; null when it is. */
|
||||||
|
reason: string | null;
|
||||||
|
/** Minutes before departure booking currently closes; null without an offset. */
|
||||||
|
offsetMinutes: number | null;
|
||||||
|
/** ISO instant booking closed at (departure − offset); null without an offset. */
|
||||||
|
cutoffAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorten a schedule's close offset so its booking window reopens. */
|
||||||
|
export interface ReduceScheduleCloseOffsetPayload {
|
||||||
|
/** New minutes-before-departure booking closes; 0 = close at departure. */
|
||||||
|
closeOffsetMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Editable window-rule override for one schedule; every field optional. */
|
/** Editable window-rule override for one schedule; every field optional. */
|
||||||
export interface UpdateScheduleWindowRulePayload {
|
export interface UpdateScheduleWindowRulePayload {
|
||||||
windowOpenHour?: number;
|
windowOpenHour?: number;
|
||||||
@@ -672,6 +699,8 @@ export interface TrainScheduleDetail {
|
|||||||
paymentPhaseEndsAt?: string | null;
|
paymentPhaseEndsAt?: string | null;
|
||||||
/** Booking-window rule snapshot — prefills the per-schedule settings editor. */
|
/** Booking-window rule snapshot — prefills the per-schedule settings editor. */
|
||||||
windowRule?: ScheduleWindowRule | null;
|
windowRule?: ScheduleWindowRule | null;
|
||||||
|
/** Is booking shut only by the close offset? Drives "shorten close offset". */
|
||||||
|
closeOffsetReopen?: CloseOffsetReopenInfo | null;
|
||||||
route?: {
|
route?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user