Merge pull request #1337 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-18 16:19:34 +03:00
committed by GitHub
53 changed files with 4019 additions and 218 deletions

View File

@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Approval gate for consolidated (shared-wagon) bookings.
*
* A booking that fills its own wagons goes straight from GL completion to the
* operations queue. A CONSOLIDATED booking does not: it shares one physical
* wagon with another customer's booking, which means two customers' cargo, two
* invoices and two liabilities riding the same wagon. That pairing is a
* commercial decision, so it is reviewed by a person before Operations sees it.
*
* The pair is approved as a UNIT — one row covers both halves (booking_id +
* partner_booking_id) so an approver can never approve one side of a shared
* wagon and leave the other pending. Rows are never deleted; decided rows are
* the audit trail of who approved which pairing and when.
*
* One PENDING row per booking at a time (partial unique index on each side of
* the pair): a second request while one is undecided is a coordination failure,
* not a workflow.
*/
export class ConsolidationApprovals3570000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.consolidation_approvals_status_enum
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.consolidation_approvals (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
booking_id uuid NOT NULL REFERENCES freight.bookings (id),
partner_booking_id uuid NOT NULL REFERENCES freight.bookings (id),
status freight.consolidation_approvals_status_enum NOT NULL DEFAULT 'PENDING',
-- Who put the pairing up for review (the GL user who completed it) and
-- who decided it. Both are recorded: the point of the gate is that they
-- are different people.
requested_by uuid,
requested_at timestamptz NOT NULL DEFAULT now(),
decided_by uuid,
decided_at timestamptz,
decision_note varchar(500),
-- Snapshot of what was approved, so the audit trail still reads
-- correctly after the bookings themselves move on.
scheduled_date timestamptz,
booking_reference varchar(50),
partner_booking_reference varchar(50),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_booking_status
ON freight.consolidation_approvals (booking_id, status)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_status
ON freight.consolidation_approvals (status)
`);
// The workflow invariant, enforced where it cannot race: at most one
// undecided request per booking — on EITHER side of the pair, so the same
// wagon can never collect two pending requests from its two halves.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending
ON freight.consolidation_approvals (booking_id)
WHERE status = 'PENDING' AND deleted_at IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending_partner
ON freight.consolidation_approvals (partner_booking_id)
WHERE status = 'PENDING' AND deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.consolidation_approvals`,
);
await queryRunner.query(
`DROP TYPE IF EXISTS freight.consolidation_approvals_status_enum`,
);
}
}

View File

@@ -470,6 +470,36 @@ export class BookingLifecycleNotifierService {
); );
} }
/**
* A shared-wagon pairing is waiting for a human decision. Two customers' cargo
* on one wagon is a commercial call, so this never auto-advances.
*/
consolidationApprovalRequestedToStaff(b: Booking, partnerReference: string): void {
this.inAppStaff(
b,
'Shared wagon needs approval',
`Booking ${this.ref(b)} shares a wagon with ${partnerReference} — approve the consolidation before it reaches Operations.`,
);
}
/** The pairing was approved; both halves move on to Operations together. */
consolidationApprovedToStaff(b: Booking, partnerReference: string): void {
this.inAppStaff(
b,
'Shared wagon approved',
`The shared wagon for ${this.ref(b)} and ${partnerReference} was approved — both bookings are now with Operations.`,
);
}
/** The pairing was rejected; both halves go back to GL for changes. */
consolidationRejectedToStaff(b: Booking, partnerReference: string, reason: string): void {
this.inAppStaff(
b,
'Shared wagon rejected',
`The shared wagon for ${this.ref(b)} and ${partnerReference} was rejected: ${reason}`,
);
}
/** Customer uploaded clearance documents — review is next. */ /** Customer uploaded clearance documents — review is next. */
clearanceDocsUploadedToStaff(b: Booking): void { clearanceDocsUploadedToStaff(b: Booking): void {
this.inAppStaff( this.inAppStaff(

View File

@@ -0,0 +1,132 @@
import { BookingTransitionService } from './booking-transition.service';
import { Booking } from './entities/booking.entity';
/**
* Staff decisions on a consolidated pair. Two bookings sharing a wagon must move
* together: accepting one alone would put half a wagon into the approval chain,
* and cancelling one alone would strand the other on a wagon it can no longer
* fill. All-or-nothing — if either half throws, neither booking moved.
*/
describe('BookingTransitionService — paired staff decisions', () => {
function makeService(booking: Partial<Booking>) {
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking as Booking),
};
// Runs the callback so a throw propagates, which is what the all-or-nothing
// guarantee reduces to from this service's point of view.
const dataSource = {
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
};
const service = new BookingTransitionService(
{} as never, // bookingsRepository
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
bookingsService as never,
{} as never, // bookingClearanceService
{} as never, // workflowService
{} as never, // invoiceService
{} as never, // containerValidationService
{} as never, // notifier
{} as never, // events
undefined, // milestoneService
dataSource as never,
);
return { service, dataSource };
}
const paired = {
id: 'b-1',
reference: 'BK-1',
consolidationPartnerId: 'b-2',
} as Booking;
it('accepts both halves with the same validity window', async () => {
const { service } = makeService(paired);
const accept = jest
.spyOn(service, 'acceptIntake')
.mockImplementation(async (id) => ({ id }) as Booking);
const result = await service.applyPairedDecision('b-1', 'accept', 'staff-1', {
validityDays: 30,
});
expect(accept).toHaveBeenCalledTimes(2);
expect(accept).toHaveBeenNthCalledWith(1, 'b-1', 'staff-1', 30);
expect(accept).toHaveBeenNthCalledWith(2, 'b-2', 'staff-1', 30);
expect(result.booking.id).toBe('b-1');
expect(result.partner.id).toBe('b-2');
});
it('cancels both halves with the same reason', async () => {
const { service } = makeService(paired);
const cancel = jest
.spyOn(service, 'cancel')
.mockImplementation(async (id) => ({ id }) as Booking);
await service.applyPairedDecision('b-1', 'cancel', 'staff-1', {
reason: 'customer withdrew',
});
expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew');
expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew');
});
it('propagates a failure on the second half so neither is committed', async () => {
const { service, dataSource } = makeService(paired);
jest
.spyOn(service, 'cancel')
.mockImplementationOnce(async (id) => ({ id }) as Booking)
.mockImplementationOnce(async () => {
throw new Error('partner is already in transit');
});
await expect(
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
).rejects.toThrow('partner is already in transit');
// Both halves ran inside one transaction, so the throw rolls the first back.
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
});
it('refuses a booking that has no partner', async () => {
const { service } = makeService({
id: 'b-1',
consolidationPartnerId: null,
} as Booking);
await expect(
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
).rejects.toThrow(/no consolidation partner/i);
});
it('requires a validity window to accept', async () => {
const { service } = makeService(paired);
const accept = jest.spyOn(service, 'acceptIntake');
await expect(
service.applyPairedDecision('b-1', 'accept', 'staff-1', {}),
).rejects.toThrow(/validity/i);
expect(accept).not.toHaveBeenCalled();
});
it('routes operationAccept through the operation review on both halves', async () => {
const { service } = makeService(paired);
const review = jest
.spyOn(service, 'reviewOperationRequest')
.mockImplementation(async (id) => ({ id }) as Booking);
await service.applyPairedDecision('b-1', 'operationAccept', 'staff-1', {});
expect(review).toHaveBeenNthCalledWith(1, 'b-1', 'ACCEPT', 'staff-1', {
note: undefined,
});
expect(review).toHaveBeenNthCalledWith(2, 'b-2', 'ACCEPT', 'staff-1', {
note: undefined,
});
});
});

View File

@@ -455,6 +455,75 @@ export class BookingTransitionService {
return this.cancel(bookingId, reason ?? "Customer cancelled before payment"); return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
} }
/**
* Run a staff decision across BOTH halves of a consolidated pair.
*
* Two bookings that share a wagon must move together: accepting one while the
* other stays behind would put half a wagon into the approval chain, and
* cancelling one alone would strand the other on a wagon it can no longer
* fill. All-or-nothing — if either half throws, the transaction rolls back and
* neither booking moved.
*
* Each half still runs the ordinary single-booking transition, so pricing,
* invoicing and notifications stay per booking: the customers are billed and
* notified separately, exactly as they are today.
*/
async applyPairedDecision(
bookingId: string,
decision: "accept" | "cancel" | "operationAccept" | "requestChanges",
actorId: string,
options: { reason?: string; note?: string; validityDays?: number } = {},
): Promise<{ booking: Booking; partner: Booking }> {
const booking = await this.bookingsService.findById(bookingId);
const partnerId = booking.consolidationPartnerId;
if (!partnerId) {
throw new BadRequestException(
"This booking has no consolidation partner — use the single-booking action.",
);
}
const runOne = async (id: string): Promise<Booking> => {
switch (decision) {
case "accept":
// Same requirement as the single-booking accept: the approval chain
// needs a contract validity window.
if (!(Number(options.validityDays) > 0)) {
throw new BadRequestException(
"Contract validity (days) is required to accept.",
);
}
return this.acceptIntake(id, actorId, Number(options.validityDays));
case "cancel":
return this.cancel(
id,
options.reason ?? "Cancelled with its consolidation partner",
);
case "operationAccept":
return this.reviewOperationRequest(id, "ACCEPT", actorId, {
note: options.note,
});
case "requestChanges":
return this.requestChanges(id, options.note ?? "", actorId);
}
};
// Without a DataSource (unit tests hand-construct this service) fall back to
// running the two halves directly — the ordering guarantee still holds, only
// the rollback does not.
if (!this.dataSource) {
const own = await runOne(bookingId);
const other = await runOne(partnerId);
return { booking: own, partner: other };
}
return this.dataSource.transaction(async () => {
// Sequential: one connection per transaction context.
const own = await runOne(bookingId);
const other = await runOne(partnerId);
return { booking: own, partner: other };
});
}
async cancel(bookingId: string, reason: string): Promise<Booking> { async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [ assertBookingStatus(booking, [

View File

@@ -50,6 +50,7 @@ import { BookingReferenceDataService } from './booking-reference-data.service';
import { scopedDirections } from '../user-trade-access/trade-scope.util'; import { scopedDirections } from '../user-trade-access/trade-scope.util';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { BookingsService } from './bookings.service'; import { BookingsService } from './bookings.service';
import { ConsolidationApprovalService } from './consolidation-approval.service';
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
import { CreateBookingDto } from './dto/create-booking.dto'; import { CreateBookingDto } from './dto/create-booking.dto';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
@@ -58,7 +59,10 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { import {
AcceptIntakeDto, AcceptIntakeDto,
ApproveConsolidationDto,
CancelBookingDto, CancelBookingDto,
PairedDecisionDto,
RejectConsolidationDto,
RejectBookingDto, RejectBookingDto,
RequestChangesDto, RequestChangesDto,
ReviewDocumentDto, ReviewDocumentDto,
@@ -165,6 +169,7 @@ export class BookingsController {
private readonly lastMileService: LastMileService, private readonly lastMileService: LastMileService,
private readonly userTradeAccessService: UserTradeAccessService, private readonly userTradeAccessService: UserTradeAccessService,
private readonly wagonCancellationService: BookingWagonCancellationService, private readonly wagonCancellationService: BookingWagonCancellationService,
private readonly consolidationApprovalService: ConsolidationApprovalService,
) {} ) {}
@Post() @Post()
@@ -1541,6 +1546,92 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
// ── Shared-wagon (consolidation) approval gate ────────────────────────────
// A consolidated pair is held here, not in the operations queue: two
// customers' cargo on one wagon is a commercial call, so a person signs off
// on the pairing before Operations sees either half.
@Get("consolidation-approvals/queue")
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
@ApiOperation({
summary:
"Shared-wagon pairings awaiting approval, oldest first. Each row covers BOTH bookings on the wagon.",
})
consolidationApprovalQueue() {
return this.consolidationApprovalService.queue();
}
@Get(":id/consolidation-approvals")
@BookingStaff(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary:
"Approval history for this booking's shared wagon — who decided what, when, and why.",
})
consolidationApprovalHistory(@Param("id", ParseUUIDPipe) id: string) {
return this.consolidationApprovalService.historyForBooking(id);
}
@Post("consolidation-approvals/:approvalId/approve")
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
@ApiOperation({
summary:
"Approve a shared wagon: both bookings leave the gate and continue to Operations together.",
})
approveConsolidation(
@Param("approvalId", ParseUUIDPipe) approvalId: string,
@Body() dto: ApproveConsolidationDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.consolidationApprovalService.approve(
approvalId,
resolveAuthUserId(user) ?? "",
dto.note,
);
}
@Post("consolidation-approvals/:approvalId/reject")
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
@ApiOperation({
summary:
"Reject a shared wagon: both bookings go back to GL for changes with the reason.",
})
rejectConsolidation(
@Param("approvalId", ParseUUIDPipe) approvalId: string,
@Body() dto: RejectConsolidationDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.consolidationApprovalService.reject(
approvalId,
resolveAuthUserId(user) ?? "",
dto.reason,
);
}
@Post(":id/paired-decision")
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({
summary:
"Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.",
})
async pairedDecision(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: PairedDecisionDto,
@CurrentUser() user: AuthUserPayload,
) {
const { booking, partner } = await this.transitionService.applyPairedDecision(
id,
dto.decision,
resolveAuthUserId(user),
{ reason: dto.reason, note: dto.note, validityDays: dto.validityDays },
);
// Sequential enrichment: both go back so the UI can refresh either tab.
const enrichedBooking =
await this.transitionService.enrichBookingResponse(booking);
const enrichedPartner =
await this.transitionService.enrichBookingResponse(partner);
return { booking: enrichedBooking, partner: enrichedPartner };
}
@Post(":id/cancel") @Post(":id/cancel")
@BookingStaff(FREIGHT_PERMS.bookings.cancel) @BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: "Cancel booking" }) @ApiOperation({ summary: "Cancel booking" })

View File

@@ -30,6 +30,9 @@ import { BookingsController } from './bookings.controller';
// import { PayController } from './pay.controller'; // import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository'; import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service'; import { ConsolidationService } from './consolidation.service';
import { ConsolidationApprovalService } from './consolidation-approval.service';
import { ConsolidationApprovalsRepository } from './consolidation-approvals.repository';
import { ConsolidationApproval } from './entities/consolidation-approval.entity';
import { ContainerValidationService } from './container-validation.service'; import { ContainerValidationService } from './container-validation.service';
import { BookingsService } from './bookings.service'; import { BookingsService } from './bookings.service';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
@@ -72,6 +75,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingWagonCancellation, BookingWagonCancellation,
CustomerTruckAssignment, CustomerTruckAssignment,
CustomerTruckContainer, CustomerTruckContainer,
ConsolidationApproval,
]), ]),
BillingModule, BillingModule,
DocumentsModule, DocumentsModule,
@@ -98,6 +102,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingsService, BookingsService,
BookingsRepository, BookingsRepository,
ConsolidationService, ConsolidationService,
ConsolidationApprovalService,
ConsolidationApprovalsRepository,
ContainerValidationService, ContainerValidationService,
BookingReferenceDataService, BookingReferenceDataService,
BookingPricingService, BookingPricingService,
@@ -126,6 +132,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingLifecycleNotifierService, BookingLifecycleNotifierService,
BookingTransitionService, BookingTransitionService,
ConsolidationService, ConsolidationService,
ConsolidationApprovalService,
ConsolidationApprovalsRepository,
CustomerTruckService, CustomerTruckService,
ContainerReceiptService, ContainerReceiptService,
BookingWagonCancellationService, BookingWagonCancellationService,

View File

@@ -308,6 +308,72 @@ export class BookingsRepository extends BaseRepository<Booking> {
.find({ where: { contractId } }); .find({ where: { contractId } });
} }
/**
* Bookings a GL operator may manually link to `booking` as its odd-20ft
* consolidation partner (Path B customs flow). Unlike
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
* quantity complement — this lists CANDIDATES for a human to choose from, so
* the filter is deliberately looser: any other customs booking on the same
* route/direction that is itself carrying an odd 20ft count. Two odd counts
* always sum to even, so any pick fills the shared wagon.
*
* Bare instances awaiting completion have no persisted containers yet, so the
* odd-count test runs on the requested container lines when they exist and the
* booking is offered as a candidate when they do not (GL enters its cargo on
* the split form).
*/
async findManualConsolidationCandidates(
booking: Booking,
limit = 50,
): Promise<Booking[]> {
const rows = await this.repository
.createQueryBuilder('b')
.leftJoinAndSelect('b.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
.leftJoinAndSelect('b.company', 'company')
.where('b.id != :bookingId', { bookingId: booking.id })
// Never offer a booking that already shares a wagon with someone else.
.andWhere('b.consolidationPartnerId IS NULL')
// Customs-only: this manual flow exists because a customs (Path B)
// instance is completed by GL, not by the customer.
.andWhere('b.customsClearingEnabled = true')
// Same physical wagon ⇒ same route and same direction.
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,
})
.andWhere('b.destinationYardId = :destinationYardId', {
destinationYardId: booking.destinationYardId,
})
.andWhere('b.tradeDirection = :tradeDirection', {
tradeDirection: booking.tradeDirection,
})
// Bookable = clearance finished and the booking is waiting to be completed,
// the same set completeUnderContract accepts, plus one already parked for a
// partner.
.andWhere('b.status IN (:...statuses)', {
statuses: [
'CLEARANCE_READY',
'OPERATION_CHANGES_REQUESTED',
'PENDING_CONSOLIDATION',
],
})
.orderBy('b.createdAt', 'ASC')
.take(limit)
.getMany();
// Odd-20ft test in memory: a bare instance has no containers yet (GL fills
// them on the split form) and stays a candidate; one that already carries
// cargo qualifies only when its 20ft total is odd.
return rows.filter((row) => {
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return true;
const ft20 = lines
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
return ft20 % 2 === 1;
});
}
/** /**
* Find another booking whose container quantity complements this one to fill whole wagon(s) * Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever * (same route, same container type, partial wagon on both sides). Only 20ft lines ever
@@ -508,6 +574,25 @@ export class BookingsRepository extends BaseRepository<Booking> {
} as never); } as never);
} }
/**
* Link two bookings as consolidation partners WITHOUT touching their statuses.
* Used by the manual GL pairing, where both bookings have just been completed
* into their live status — unlike {@link pairConsolidation}, which exists to
* resume bookings parked in PENDING_CONSOLIDATION and rewrites status as part
* of that resume.
*/
async linkConsolidationPartners(
bookingId: string,
partnerId: string,
): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: partnerId,
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: bookingId,
} as never);
}
/** Un-pair a consolidation. */ /** Un-pair a consolidation. */
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> { async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, { await this.repository.update(bookingId, {

View File

@@ -0,0 +1,202 @@
import {
ConsolidationApprovalService,
CONSOLIDATION_APPROVAL_PENDING,
} from './consolidation-approval.service';
import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity';
import { Booking } from './entities/booking.entity';
/**
* The shared-wagon approval gate. Two customers' cargo on one wagon is a
* commercial call, so the pair is held for a human decision instead of going
* straight to Operations.
*
* The invariants that matter: both halves are held and released TOGETHER (a
* decision on one side of a shared wagon is meaningless without the other), the
* requester cannot approve their own pairing, and a decided pairing cannot be
* decided twice.
*/
describe('ConsolidationApprovalService', () => {
const PENDING = {
id: 'ap-1',
bookingId: 'b-1',
partnerBookingId: 'b-2',
status: ConsolidationApprovalStatus.Pending,
requestedBy: 'gl-user',
};
function makeService(overrides: {
approvals?: Partial<Record<string, jest.Mock>>;
bookingsRepository?: Partial<Record<string, jest.Mock>>;
} = {}) {
const approvals = {
findPendingForBooking: jest.fn().mockResolvedValue(null),
findById: jest.fn().mockResolvedValue(PENDING),
create: jest.fn().mockResolvedValue({ id: 'ap-1' }),
decide: jest.fn().mockResolvedValue(true),
findQueue: jest.fn().mockResolvedValue([]),
findAllForBooking: jest.fn().mockResolvedValue([]),
...overrides.approvals,
};
const bookingsRepository = {
update: jest.fn().mockResolvedValue(undefined),
createReviewNote: jest.fn().mockResolvedValue(undefined),
...overrides.bookingsRepository,
};
const bookingsService = {
findById: jest.fn(async (id: string) =>
({ id, reference: `BK-${id}` }) as Booking,
),
};
const notifier = {
consolidationApprovalRequestedToStaff: jest.fn(),
consolidationApprovedToStaff: jest.fn(),
consolidationRejectedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
};
const dataSource = {
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
};
const service = new ConsolidationApprovalService(
approvals as never,
bookingsRepository as never,
bookingsService as never,
notifier as never,
dataSource as never,
);
return { service, approvals, bookingsRepository, notifier };
}
it('holds BOTH halves at the gate when a pairing is created', async () => {
const { service, approvals, bookingsRepository, notifier } = makeService();
await service.requestApproval('b-1', 'b-2', 'gl-user');
expect(approvals.create).toHaveBeenCalledWith(
expect.objectContaining({
bookingId: 'b-1',
partnerBookingId: 'b-2',
requestedBy: 'gl-user',
}),
);
// Neither half may sit in the operations queue while the wagon is unreviewed.
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
status: CONSOLIDATION_APPROVAL_PENDING,
});
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
status: CONSOLIDATION_APPROVAL_PENDING,
});
expect(
notifier.consolidationApprovalRequestedToStaff,
).toHaveBeenCalledTimes(1);
});
it('does not open a second review for a pairing already pending', async () => {
const { service, approvals } = makeService({
approvals: {
findPendingForBooking: jest.fn().mockResolvedValue(PENDING),
},
});
const result = await service.requestApproval('b-1', 'b-2', 'gl-user');
expect(result).toBe(PENDING);
expect(approvals.create).not.toHaveBeenCalled();
});
it('releases BOTH halves to Operations on approval, logging who decided', async () => {
const { service, approvals, bookingsRepository, notifier } = makeService();
await service.approve('ap-1', 'approver-1', 'looks fine');
expect(approvals.decide).toHaveBeenCalledWith(
'ap-1',
ConsolidationApprovalStatus.Approved,
'approver-1',
'looks fine',
);
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
status: 'OPERATION_REQUEST_PENDING',
});
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
status: 'OPERATION_REQUEST_PENDING',
});
// Operations only learns about the pair now — the gate is what kept it out.
expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2);
});
it('sends BOTH halves back to GL on rejection, with the reason on each', async () => {
const { service, approvals, bookingsRepository } = makeService();
await service.reject('ap-1', 'approver-1', 'partner cargo is wrong');
expect(approvals.decide).toHaveBeenCalledWith(
'ap-1',
ConsolidationApprovalStatus.Rejected,
'approver-1',
'partner cargo is wrong',
);
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
'b-1',
'partner cargo is wrong',
'CHANGES_REQUESTED',
);
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
'b-2',
'partner cargo is wrong',
'CHANGES_REQUESTED',
);
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
status: 'OPERATION_CHANGES_REQUESTED',
});
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
status: 'OPERATION_CHANGES_REQUESTED',
});
});
it('refuses to let the requester approve their own pairing', async () => {
const { service, bookingsRepository } = makeService();
await expect(
service.approve('ap-1', 'gl-user'),
).rejects.toThrow(/must be approved by someone else/i);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it('requires a reason to reject', async () => {
const { service, approvals } = makeService();
await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow(
/reason is required/i,
);
expect(approvals.decide).not.toHaveBeenCalled();
});
it('refuses a pairing that was already decided', async () => {
const { service, bookingsRepository } = makeService({
approvals: {
findById: jest.fn().mockResolvedValue({
...PENDING,
status: ConsolidationApprovalStatus.Approved,
}),
},
});
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
/already approved/i,
);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it('loses cleanly when another approver decides the same pairing first', async () => {
// decide() writes only against a still-PENDING row, so the loser of the race
// affects nothing and must not move the bookings.
const { service } = makeService({
approvals: { decide: jest.fn().mockResolvedValue(false) },
});
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
/already decided by someone else/i,
);
});
});

View File

@@ -0,0 +1,255 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
Logger,
NotFoundException,
forwardRef,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import { Booking } from "./entities/booking.entity";
import {
ConsolidationApproval,
ConsolidationApprovalStatus,
} from "./entities/consolidation-approval.entity";
import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repository";
import { BookingsRepository } from "./bookings.repository";
import { BookingsService } from "./bookings.service";
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
/** The gate's own holding status — neither half reaches Operations from here. */
export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
/**
* The shared-wagon approval gate.
*
* A booking that fills its own wagons goes straight from GL completion to the
* operations queue. A consolidated one does not: two customers' cargo rides one
* physical wagon under two separate invoices, so a person reviews the pairing
* before Operations sees either half.
*
* Both halves are held and released TOGETHER — the wagon is shared, so a
* decision on one is meaningless without the other. Every request is kept,
* decided or not: the table is the audit trail of who approved which pairing,
* when, and why.
*/
@Injectable()
export class ConsolidationApprovalService {
private readonly logger = new Logger(ConsolidationApprovalService.name);
constructor(
private readonly approvals: ConsolidationApprovalsRepository,
private readonly bookingsRepository: BookingsRepository,
@Inject(forwardRef(() => BookingsService))
private readonly bookingsService: BookingsService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
) {}
/**
* Park a newly consolidated pair for review instead of letting it continue to
* Operations. Called from the completion path once the two halves are linked.
*
* Idempotent: a pair that already has an undecided request is left alone, so a
* retried completion cannot open a second review of the same wagon.
*/
async requestApproval(
bookingId: string,
partnerBookingId: string,
requestedBy: string | null,
): Promise<ConsolidationApproval> {
const existing = await this.approvals.findPendingForBooking(bookingId);
if (existing) return existing;
// Sequential reads: one connection per transaction context.
const booking = await this.bookingsService.findById(bookingId);
const partner = await this.bookingsService.findById(partnerBookingId);
if (!booking || !partner) {
throw new NotFoundException("Both bookings of the pair must exist.");
}
const approval = await this.approvals.create({
bookingId,
partnerBookingId,
requestedBy,
scheduledDate: booking.scheduledDate ?? null,
bookingReference: booking.reference ?? null,
partnerBookingReference: partner.reference ?? null,
});
// Hold BOTH halves: the wagon is shared, so neither may advance alone.
await this.bookingsRepository.update(bookingId, {
status: CONSOLIDATION_APPROVAL_PENDING,
} as never);
await this.bookingsRepository.update(partnerBookingId, {
status: CONSOLIDATION_APPROVAL_PENDING,
} as never);
this.notifier.consolidationApprovalRequestedToStaff(
booking,
partner.reference ?? partnerBookingId,
);
this.logger.log(
`Consolidation ${booking.reference} + ${partner.reference} awaiting approval (${approval.id}).`,
);
return approval;
}
/**
* Approve the pairing: both halves leave the gate and continue to Operations,
* which is exactly where a non-consolidated booking would already be.
*
* All-or-nothing — the two status writes and the decision record share one
* transaction, so the audit trail can never claim an approval that did not
* take effect.
*/
async approve(
approvalId: string,
decidedBy: string,
note?: string,
): Promise<{ booking: Booking; partner: Booking }> {
const approval = await this.loadPending(approvalId);
this.assertDifferentPerson(approval, decidedBy);
await this.dataSource.transaction(async () => {
const claimed = await this.approvals.decide(
approval.id,
ConsolidationApprovalStatus.Approved,
decidedBy,
note,
);
// Lost the race to another approver deciding the same pairing.
if (!claimed) {
throw new ConflictException(
"This consolidation was already decided by someone else.",
);
}
await this.bookingsRepository.update(approval.bookingId, {
status: "OPERATION_REQUEST_PENDING",
} as never);
await this.bookingsRepository.update(approval.partnerBookingId, {
status: "OPERATION_REQUEST_PENDING",
} as never);
});
const booking = await this.bookingsService.findById(approval.bookingId);
const partner = await this.bookingsService.findById(
approval.partnerBookingId,
);
this.notifier.consolidationApprovedToStaff(
booking,
partner.reference ?? approval.partnerBookingId,
);
// Operations only now learns about the pair — the gate is what kept it out.
this.notifier.operationRequestedToStaff(booking);
this.notifier.operationRequestedToStaff(partner);
return { booking, partner };
}
/**
* Reject the pairing: both halves go back to GL as OPERATION_CHANGES_REQUESTED
* with the reason, so the cargo or the partner can be changed and resubmitted.
*/
async reject(
approvalId: string,
decidedBy: string,
reason: string,
): Promise<{ booking: Booking; partner: Booking }> {
if (!reason?.trim()) {
throw new BadRequestException(
"A reason is required to reject a consolidation.",
);
}
const approval = await this.loadPending(approvalId);
this.assertDifferentPerson(approval, decidedBy);
await this.dataSource.transaction(async () => {
const claimed = await this.approvals.decide(
approval.id,
ConsolidationApprovalStatus.Rejected,
decidedBy,
reason.trim(),
);
if (!claimed) {
throw new ConflictException(
"This consolidation was already decided by someone else.",
);
}
await this.bookingsRepository.createReviewNote(
approval.bookingId,
reason.trim(),
"CHANGES_REQUESTED",
);
await this.bookingsRepository.createReviewNote(
approval.partnerBookingId,
reason.trim(),
"CHANGES_REQUESTED",
);
await this.bookingsRepository.update(approval.bookingId, {
status: REJECTED_STATUS,
} as never);
await this.bookingsRepository.update(approval.partnerBookingId, {
status: REJECTED_STATUS,
} as never);
});
const booking = await this.bookingsService.findById(approval.bookingId);
const partner = await this.bookingsService.findById(
approval.partnerBookingId,
);
this.notifier.consolidationRejectedToStaff(
booking,
partner.reference ?? approval.partnerBookingId,
reason.trim(),
);
return { booking, partner };
}
/** Pending pairings awaiting a decision, oldest first. */
queue(): Promise<ConsolidationApproval[]> {
return this.approvals.findQueue();
}
/** Full decision history for one booking — who decided what, and when. */
historyForBooking(bookingId: string): Promise<ConsolidationApproval[]> {
return this.approvals.findAllForBooking(bookingId);
}
/** The undecided request covering this booking, if any. */
pendingForBooking(bookingId: string): Promise<ConsolidationApproval | null> {
return this.approvals.findPendingForBooking(bookingId);
}
private async loadPending(approvalId: string): Promise<ConsolidationApproval> {
const approval = await this.approvals.findById(approvalId);
if (!approval) {
throw new NotFoundException(`Approval ${approvalId} not found`);
}
if (approval.status !== ConsolidationApprovalStatus.Pending) {
throw new ConflictException(
`This consolidation was already ${approval.status.toLowerCase()}.`,
);
}
return approval;
}
/**
* Makerchecker: the point of the gate is a second pair of eyes, so the GL
* user who created the pairing cannot also approve it.
*/
private assertDifferentPerson(
approval: ConsolidationApproval,
decidedBy: string,
): void {
if (approval.requestedBy && approval.requestedBy === decidedBy) {
throw new BadRequestException(
"You created this consolidation — it must be approved by someone else.",
);
}
}
}

View File

@@ -0,0 +1,120 @@
import { Injectable } from "@nestjs/common";
import { DataSource, In, Repository } from "typeorm";
import {
ConsolidationApproval,
ConsolidationApprovalStatus,
} from "./entities/consolidation-approval.entity";
/**
* Persistence for the shared-wagon approval gate. Rows are never deleted —
* decided rows are the audit trail of who approved which pairing and when.
*/
@Injectable()
export class ConsolidationApprovalsRepository {
private readonly repository: Repository<ConsolidationApproval>;
constructor(private readonly dataSource: DataSource) {
this.repository = this.dataSource.getRepository(ConsolidationApproval);
}
/**
* The undecided request covering `bookingId`, from EITHER side of the pair —
* one row governs both halves, and the caller may hold either one.
*/
findPendingForBooking(
bookingId: string,
): Promise<ConsolidationApproval | null> {
return this.repository.findOne({
where: [
{ bookingId, status: ConsolidationApprovalStatus.Pending },
{
partnerBookingId: bookingId,
status: ConsolidationApprovalStatus.Pending,
},
],
});
}
/** Every request touching this booking, newest first (the audit trail). */
findAllForBooking(bookingId: string): Promise<ConsolidationApproval[]> {
return this.repository.find({
where: [{ bookingId }, { partnerBookingId: bookingId }],
order: { createdAt: "DESC" },
});
}
findById(id: string): Promise<ConsolidationApproval | null> {
return this.repository.findOne({ where: { id } });
}
/** Pending requests for the review queue, oldest first (FIFO). */
findQueue(): Promise<ConsolidationApproval[]> {
return this.repository.find({
where: { status: ConsolidationApprovalStatus.Pending },
relations: {
booking: { company: true },
partnerBooking: { company: true },
},
order: { requestedAt: "ASC" },
});
}
create(input: {
bookingId: string;
partnerBookingId: string;
requestedBy?: string | null;
scheduledDate?: Date | null;
bookingReference?: string | null;
partnerBookingReference?: string | null;
}): Promise<ConsolidationApproval> {
return this.repository.save(
this.repository.create({
...input,
status: ConsolidationApprovalStatus.Pending,
requestedAt: new Date(),
}),
);
}
/**
* Record the decision. Written only against a row still PENDING, so two
* approvers racing on the same pairing cannot both succeed — the second
* update matches nothing and the caller sees `false`.
*/
async decide(
id: string,
status:
| ConsolidationApprovalStatus.Approved
| ConsolidationApprovalStatus.Rejected,
decidedBy: string | null,
decisionNote?: string | null,
): Promise<boolean> {
const result = await this.repository.update(
{ id, status: ConsolidationApprovalStatus.Pending },
{
status,
decidedBy,
decidedAt: new Date(),
decisionNote: decisionNote ?? null,
},
);
return (result.affected ?? 0) > 0;
}
/** Undecided requests covering any of these bookings (list badging). */
findPendingForBookings(
bookingIds: string[],
): Promise<ConsolidationApproval[]> {
if (bookingIds.length === 0) return Promise.resolve([]);
return this.repository.find({
where: [
{ bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending },
{
partnerBookingId: In(bookingIds),
status: ConsolidationApprovalStatus.Pending,
},
],
});
}
}

View File

@@ -125,3 +125,59 @@ export class OperationReviewDto {
@IsString() @IsString()
note?: string; note?: string;
} }
/**
* A staff decision applied to BOTH halves of a consolidated pair. The two
* bookings share a wagon, so they advance or cancel together — never one alone.
*/
export class PairedDecisionDto {
@ApiProperty({
enum: ["accept", "cancel", "operationAccept", "requestChanges"],
description: 'Which staff decision to apply to both bookings.',
})
@IsIn(["accept", "cancel", "operationAccept", "requestChanges"])
decision!: "accept" | "cancel" | "operationAccept" | "requestChanges";
@ApiPropertyOptional({ description: "Cancellation reason (decision=cancel)." })
@IsOptional()
@IsString()
reason?: string;
@ApiPropertyOptional({
description: "Message to the customer (decision=requestChanges).",
})
@IsOptional()
@IsString()
note?: string;
@ApiPropertyOptional({
description: "Contract validity window in days (decision=accept).",
})
@IsOptional()
@IsInt()
@Min(1)
validityDays?: number;
}
/** Approve a shared-wagon pairing. The note is optional context for the audit. */
export class ApproveConsolidationDto {
@ApiPropertyOptional({
description: "Optional note recorded with the approval.",
maxLength: 500,
})
@IsOptional()
@IsString()
note?: string;
}
/** Reject a shared-wagon pairing. A reason is mandatory — GL has to act on it. */
export class RejectConsolidationDto {
@ApiProperty({
description:
"Why the pairing is rejected. Sent back to GL on both bookings.",
maxLength: 500,
})
@IsString()
@MinLength(1)
reason!: string;
}

View File

@@ -58,6 +58,10 @@ export const BOOKING_STATUSES = [
// the booking enters the batch holding pool. // the booking enters the batch holding pool.
'OPERATION_REQUEST_PENDING', 'OPERATION_REQUEST_PENDING',
'OPERATION_CHANGES_REQUESTED', 'OPERATION_CHANGES_REQUESTED',
// Shared-wagon review gate: a consolidated pair waits for a human decision
// before either half reaches Operations. Two customers' cargo on one wagon is
// a commercial call, so it is never auto-advanced.
'CONSOLIDATION_APPROVAL_PENDING',
'OPERATION_PRICE_PENDING_CONFIRM', 'OPERATION_PRICE_PENDING_CONFIRM',
] as const; ] as const;

View File

@@ -0,0 +1,98 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Booking } from "./booking.entity";
export enum ConsolidationApprovalStatus {
Pending = "PENDING",
Approved = "APPROVED",
Rejected = "REJECTED",
}
/**
* Approval gate for a consolidated (shared-wagon) booking pair.
*
* A booking that fills its own wagons goes straight from GL completion to the
* operations queue. A consolidated one does not: two customers' cargo rides one
* physical wagon, under two separate invoices and two separate liabilities. That
* pairing is a commercial decision, so a person reviews it before Operations
* sees either half.
*
* The pair is approved as a UNIT — one row covers both halves — so nobody can
* approve one side of a shared wagon and leave the other pending. Rows are never
* deleted: decided rows are the audit trail of who approved which pairing, when,
* and why.
*/
@Entity({ schema: "freight", name: "consolidation_approvals" })
@Index(["bookingId", "status"])
@Index(["status"])
export class ConsolidationApproval extends BaseEntity {
@Column({ name: "booking_id", type: "uuid" })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: "booking_id" })
booking?: Booking;
/** The other half of the shared wagon. */
@Column({ name: "partner_booking_id", type: "uuid" })
partnerBookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: "partner_booking_id" })
partnerBooking?: Booking;
@Column({
name: "status",
type: "enum",
enum: ConsolidationApprovalStatus,
default: ConsolidationApprovalStatus.Pending,
})
status!: ConsolidationApprovalStatus;
/** IAM user id of the GL staff whose completion created the pairing. */
@Column({ name: "requested_by", type: "uuid", nullable: true })
requestedBy?: string | null;
@Column({ name: "requested_at", type: "timestamptz", default: () => "now()" })
requestedAt!: Date;
/** IAM user id of the approver; null while pending. */
@Column({ name: "decided_by", type: "uuid", nullable: true })
decidedBy?: string | null;
@Column({ name: "decided_at", type: "timestamptz", nullable: true })
decidedAt?: Date | null;
/** Why it was approved or rejected. Required on reject, optional on approve. */
@Column({
name: "decision_note",
type: "varchar",
length: 500,
nullable: true,
})
decisionNote?: string | null;
// ── Snapshot ──────────────────────────────────────────────────────────────
// Copied at request time so the audit trail still reads correctly after the
// bookings themselves move on (rebooked to another day, cancelled, renamed).
@Column({ name: "scheduled_date", type: "timestamptz", nullable: true })
scheduledDate?: Date | null;
@Column({
name: "booking_reference",
type: "varchar",
length: 50,
nullable: true,
})
bookingReference?: string | null;
@Column({
name: "partner_booking_reference",
type: "varchar",
length: 50,
nullable: true,
})
partnerBookingReference?: string | null;
}

View File

@@ -30,6 +30,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // trainSchedulingService {} as never, // trainSchedulingService
{} as never, // bookingBatchService {} as never, // bookingBatchService
{} as never, // bookingTransitionService {} as never, // bookingTransitionService
{} as never, // consolidationApprovalService
); );
return { service, contractsRepository }; return { service, contractsRepository };
} }
@@ -156,6 +157,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never, // consolidationApprovalService
); );
return { service, contractsRepository }; return { service, contractsRepository };
} }

View File

@@ -64,6 +64,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
{} as never, // trainSchedulingService {} as never, // trainSchedulingService
{} as never, // bookingBatchService {} as never, // bookingBatchService
{} as never, // bookingTransitionService {} as never, // bookingTransitionService
{} as never, // consolidationApprovalService
); );
return { return {
service, service,

View File

@@ -26,6 +26,7 @@ describe('ContractBookingService — customs booking gate', () => {
{} as never, // trainSchedulingService {} as never, // trainSchedulingService
{} as never, // bookingBatchService {} as never, // bookingBatchService
{} as never, // bookingTransitionService {} as never, // bookingTransitionService
{} as never, // consolidationApprovalService
); );
} }

View File

@@ -0,0 +1,216 @@
import { ContractBookingService } from './contract-booking.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* Manual (GL-driven) odd-20ft consolidation. On a customs contract GL completes
* the booking, so GL also picks who shares its wagon: two bookings each carrying
* an odd 20ft count are completed together onto one wagon.
*
* The two invariants that matter are that the pair is all-or-nothing (a failure
* on either half must leave NEITHER booking completed and no link written) and
* that the two bookings stay financially separate — one completion each, so one
* price and one invoice each.
*/
describe('ContractBookingService — manual odd-20ft consolidation', () => {
function makeService(overrides: {
bookingsRepository?: Partial<Record<string, jest.Mock>>;
dataSource?: unknown;
}) {
const bookingsRepository = {
findByIdWithFiles: jest.fn(),
findManualConsolidationCandidates: jest.fn().mockResolvedValue([]),
linkConsolidationPartners: jest.fn().mockResolvedValue(undefined),
...overrides.bookingsRepository,
};
// A transaction that simply runs the callback — enough to assert the
// all-or-nothing contract: whatever throws inside propagates out, and the
// caller observes no link written.
const dataSource = overrides.dataSource ?? {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb({})),
};
const service = new ContractBookingService(
{ findByIdWithRelations: jest.fn() } as never,
bookingsRepository as never,
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // invoiceService
{} as never, // bookingNotifier
dataSource as never,
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
// The pairing is parked for approval rather than going straight to
// Operations; the gate itself is covered by its own spec.
{ requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never,
);
return { service, bookingsRepository, dataSource };
}
const partnerBooking = {
id: 'b-2',
reference: 'BK-2',
contractId: 'c-2',
consolidationPartnerId: null,
} as unknown as Booking;
const pairDto = {
partnerBookingId: 'b-2',
booking: { scheduledDate: '2026-09-01' },
partner: { scheduledDate: '2026-09-01' },
};
it('completes both halves and links them', async () => {
const { service, bookingsRepository } = makeService({
bookingsRepository: {
findByIdWithFiles: jest
.fn()
// partner lookup before the transaction
.mockResolvedValueOnce(partnerBooking)
// the two reloads after it
.mockResolvedValueOnce({ id: 'b-1', reference: 'BK-1' } as Booking)
.mockResolvedValueOnce({ id: 'b-2', reference: 'BK-2' } as Booking),
},
});
// Each half runs the ordinary completion machine — one call per booking, so
// each is priced and invoiced on its own.
const complete = jest
.spyOn(service, 'completeUnderContract')
.mockImplementation(
async (_contractId, bookingId) =>
({
booking: { id: bookingId } as Booking,
warnings: [],
}) as never,
);
const result = await service.completeConsolidatedPair(
'c-1',
'b-1',
pairDto as never,
);
expect(complete).toHaveBeenCalledTimes(2);
// The partner is completed against ITS OWN contract, not this one.
expect(complete.mock.calls[0][0]).toBe('c-1');
expect(complete.mock.calls[1][0]).toBe('c-2');
// Neither half may re-enter the automatic matcher — GL links them here.
expect(complete.mock.calls[0][2]).toMatchObject({
skipAutoConsolidation: true,
});
expect(complete.mock.calls[1][2]).toMatchObject({
skipAutoConsolidation: true,
});
expect(bookingsRepository.linkConsolidationPartners).toHaveBeenCalledWith(
'b-1',
'b-2',
);
expect(result.booking.id).toBe('b-1');
expect(result.partner.id).toBe('b-2');
});
it('links nothing when the partner half fails (all-or-nothing)', async () => {
const { service, bookingsRepository } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue(partnerBooking),
},
});
jest
.spyOn(service, 'completeUnderContract')
.mockImplementationOnce(
async () => ({ booking: { id: 'b-1' } as Booking, warnings: [] }) as never,
)
.mockImplementationOnce(async () => {
throw new Error('no train space for the partner');
});
await expect(
service.completeConsolidatedPair('c-1', 'b-1', pairDto as never),
).rejects.toThrow('no train space for the partner');
// The link is the last write in the transaction — it must never happen when
// a half failed, so the rollback leaves no dangling pairing.
expect(bookingsRepository.linkConsolidationPartners).not.toHaveBeenCalled();
});
it('refuses a partner that already shares a wagon', async () => {
const { service } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue({
...partnerBooking,
consolidationPartnerId: 'b-9',
}),
},
});
await expect(
service.completeConsolidatedPair('c-1', 'b-1', pairDto as never),
).rejects.toThrow(/already shares a wagon/i);
});
it('refuses to consolidate a booking with itself', async () => {
const { service } = makeService({});
await expect(
service.completeConsolidatedPair('c-1', 'b-1', {
...pairDto,
partnerBookingId: 'b-1',
} as never),
).rejects.toThrow(/cannot be consolidated with itself/i);
});
it('offers only bookings whose own 20ft count is odd', async () => {
// Two odd counts always sum to even, so an odd partner is exactly what fills
// the wagon; an even one would leave the pair partial again.
const rows = [
{
id: 'odd',
reference: 'BK-ODD',
bookingContainers: [
{ quantity: 3, containerType: { sizeFt: 20 } },
],
},
{
id: 'even',
reference: 'BK-EVEN',
bookingContainers: [
{ quantity: 4, containerType: { sizeFt: 20 } },
],
},
// A bare instance has no cargo yet — GL enters it on the split form, so it
// stays a candidate.
{ id: 'bare', reference: 'BK-BARE', bookingContainers: [] },
];
const { service } = makeService({
bookingsRepository: {
findByIdWithFiles: jest
.fn()
.mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking),
findManualConsolidationCandidates: jest.fn(async (booking: Booking) =>
// Mirror the repository's in-memory odd filter.
rows.filter((row) => {
void booking;
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return true;
const ft20 = lines
.filter((l) => Number(l.containerType?.sizeFt) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
return ft20 % 2 === 1;
}),
),
},
});
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']);
expect(candidates[0].ft20Quantity).toBe(3);
expect(candidates[1].hasCargo).toBe(false);
});
});

View File

@@ -59,6 +59,7 @@ describe('ContractBookingService — changes-requested resubmit restating cargo'
trainSchedulingService as never, trainSchedulingService as never,
{} as never, // bookingBatchService {} as never, // bookingBatchService
{} as never, // bookingTransitionService {} as never, // bookingTransitionService
{} as never, // consolidationApprovalService
); );
return { service, bookingsRepository, invoiceService }; return { service, bookingsRepository, invoiceService };
} }

View File

@@ -20,6 +20,7 @@ import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingTransitionService } from '../bookings/booking-transition.service'; import { BookingTransitionService } from '../bookings/booking-transition.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ConsolidationService } from '../bookings/consolidation.service'; import { ConsolidationService } from '../bookings/consolidation.service';
import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
@@ -44,6 +45,7 @@ import {
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { isEffectivelyExpired } from './utils/contract-expiry.util'; import { isEffectivelyExpired } from './utils/contract-expiry.util';
import { import {
CompleteConsolidatedPairDto,
CreateBookingContainerLineDto, CreateBookingContainerLineDto,
CreateBookingUnderContractDto, CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto'; } from './dto/create-booking-under-contract.dto';
@@ -62,6 +64,25 @@ export interface CreateBookingUnderContractResult {
warnings: string[]; warnings: string[];
} }
/**
* A booking GL may pick as the shared-wagon partner of an odd-20ft customs
* booking. `hasCargo` is false for a bare instance whose containers GL still has
* to enter on the split completion form.
*/
export interface ConsolidationCandidate {
id: string;
reference: string;
contractId: string | null;
companyName: string | null;
status: string;
tradeDirection: string | null;
originYardId: string | null;
destinationYardId: string | null;
scheduledDate: string | null;
ft20Quantity: number;
hasCargo: boolean;
}
/** /**
* Outstanding split remainder of a contract: what was booked in the first split * Outstanding split remainder of a contract: what was booked in the first split
* booking's pre-split snapshot MINUS everything currently booked. Container * booking's pre-split snapshot MINUS everything currently booked. Container
@@ -110,6 +131,8 @@ export class ContractBookingService {
private readonly bookingBatchService: BookingBatchService, private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => BookingTransitionService)) @Inject(forwardRef(() => BookingTransitionService))
private readonly bookingTransitionService: BookingTransitionService, private readonly bookingTransitionService: BookingTransitionService,
@Inject(forwardRef(() => ConsolidationApprovalService))
private readonly consolidationApprovalService: ConsolidationApprovalService,
) {} ) {}
async createUnderContract( async createUnderContract(
@@ -598,6 +621,146 @@ export class ContractBookingService {
return created; return created;
} }
/**
* Candidate partners a GL operator may link to an odd-20ft customs booking.
* Manual counterpart to the automatic pairing in {@link consolidateDrawdown} —
* a customs instance is completed by GL, so GL also chooses who shares its
* wagon rather than waiting for the auto-matcher to find an exact complement.
*/
async listConsolidationCandidates(
contractId: string,
bookingId: string,
): Promise<ConsolidationCandidate[]> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.contractId !== contractId) {
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
}
const rows = await this.bookingsRepository.findManualConsolidationCandidates(
booking,
);
return rows.map((row) => {
const lines = row.bookingContainers ?? [];
return {
id: row.id,
reference: row.reference,
contractId: row.contractId ?? null,
companyName: row.company?.name ?? null,
status: row.status,
tradeDirection: row.tradeDirection ?? null,
originYardId: row.originYardId ?? null,
destinationYardId: row.destinationYardId ?? null,
scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null,
ft20Quantity: lines
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
hasCargo: lines.length > 0,
};
});
}
/**
* Complete an odd-20ft customs booking together with the partner booking GL
* picked for its shared wagon. Both halves run the ordinary
* {@link completeUnderContract} machine — same gates, same pricing, same
* per-booking invoice, so each customer still pays only its own shipment — and
* are linked as consolidation partners at the end.
*
* All-or-nothing: the two completions plus the pairing run inside one
* transaction, so a failure on either half leaves neither booking completed
* and no half-linked wagon behind. `runInTransaction` is used rather than a
* manual QueryRunner so the nested services join the same transactional
* context through the shared DataSource.
*/
async completeConsolidatedPair(
contractId: string,
bookingId: string,
dto: CompleteConsolidatedPairDto,
actorPermissions?: unknown,
/** IAM id of the GL user creating the pairing — recorded on the approval. */
actorUserId?: string | null,
): Promise<{
booking: Booking;
partner: Booking;
warnings: string[];
}> {
if (dto.partnerBookingId === bookingId) {
throw new BadRequestException(
'A booking cannot be consolidated with itself.',
);
}
const partner = await this.bookingsRepository.findByIdWithFiles(
dto.partnerBookingId,
);
if (!partner) {
throw new NotFoundException(
`Partner booking ${dto.partnerBookingId} not found`,
);
}
if (partner.consolidationPartnerId) {
throw new ConflictException(
`Booking ${partner.reference} already shares a wagon with another booking.`,
);
}
if (!partner.contractId) {
throw new BadRequestException(
`Booking ${partner.reference} is not a contract booking and cannot be completed here.`,
);
}
const warnings: string[] = [];
const { ownId, partnerId } = await this.dataSource.transaction(async () => {
const own = await this.completeUnderContract(
contractId,
bookingId,
{ ...dto.booking, skipAutoConsolidation: true },
// Both halves are completed by the same GL actor that reached this
// endpoint — the customs gate in completeUnderContract re-checks it.
actorPermissions,
);
warnings.push(...own.warnings);
const other = await this.completeUnderContract(
partner.contractId as string,
partner.id,
{ ...dto.partner, skipAutoConsolidation: true },
actorPermissions,
);
warnings.push(...other.warnings);
// Link the two halves. Written directly (not via pairConsolidation) because
// both bookings have just been completed into their live status here —
// pairConsolidation exists to RESUME bookings parked in
// PENDING_CONSOLIDATION and would overwrite that status.
await this.bookingsRepository.linkConsolidationPartners(
own.booking.id,
other.booking.id,
);
return { ownId: own.booking.id, partnerId: other.booking.id };
});
// Both halves have just been completed into the operations queue by the
// ordinary completion machine. A shared wagon does not go there unreviewed:
// pull the pair back into the approval gate, which releases them to
// Operations only once a person signs off on the pairing.
await this.consolidationApprovalService.requestApproval(
ownId,
partnerId,
actorUserId ?? null,
);
// Sequential reads: one connection per transaction context.
const finalBooking = await this.bookingsRepository.findByIdWithFiles(ownId);
const finalPartner = await this.bookingsRepository.findByIdWithFiles(partnerId);
return {
booking: finalBooking!,
partner: finalPartner ?? partner,
warnings,
};
}
/** /**
* Complete a bare initiated booking after its per-booking clearance is * Complete a bare initiated booking after its per-booking clearance is
* finalized (CLEARANCE_READY) or operations returned it for changes * finalized (CLEARANCE_READY) or operations returned it for changes
@@ -826,10 +989,18 @@ export class ContractBookingService {
// exactly like a drawdown created with cargo does. The shipment day is // exactly like a drawdown created with cargo does. The shipment day is
// stored first so the pairing event can resume straight into the // stored first so the pairing event can resume straight into the
// operations queue. // operations queue.
// Customs (Path B) instances are exempt from the AUTO-matcher: GL links
// their shared wagon by hand through completeConsolidatedPair, so nothing
// may claim a partner for them behind GL's back. A customs half completed
// as part of a manual pair carries `skipAutoConsolidation`; one completed
// alone still falls through to the automatic gate below, so an odd 20ft
// booking can never proceed on a partial wagon. Non-customs drawdowns are
// unaffected.
const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id); const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
if ( if (
withContainers && withContainers &&
freightType === 'CONTAINER' && freightType === 'CONTAINER' &&
!dto.skipAutoConsolidation &&
(await this.consolidationService.needsConsolidationFromBooking(withContainers)) (await this.consolidationService.needsConsolidationFromBooking(withContainers))
) { ) {
await this.bookingsRepository.update(booking.id, { await this.bookingsRepository.update(booking.id, {

View File

@@ -358,32 +358,39 @@ export class ContractClearanceService {
// Once GL creates the shipment booking, surface its reference + status so the // Once GL creates the shipment booking, surface its reference + status so the
// customer sees the concrete booking instead of a stale "will be created // customer sees the concrete booking instead of a stale "will be created
// shortly" message. Reuse the export booking load; fetch for import too. // shortly" message. Reuse the export booking load; fetch for import too.
let linkedBookingId: string | null = null;
let linkedBookingReference: string | null = null; let linkedBookingReference: string | null = null;
let linkedBookingStatus: string | null = null; let linkedBookingStatus: string | null = null;
let linkedBookingReviewNote: string | null = null; let linkedBookingReviewNote: string | null = null;
let linkedBookingScheduledDate: string | null = null; let linkedBookingScheduledDate: string | null = null;
if (cycle?.bookingId) { // The cycle is the historical link, but it is not written on every path (an
const booking = await this.bookingsService.findById(cycle.bookingId); // FCFS export booking and a GL drawdown both reach the operations queue
if (booking) { // without a cycle row), so fall back to the contract's own live booking —
linkedBookingReference = booking.reference ?? null; // otherwise the clearance page sees no linked booking at all and cannot show
linkedBookingStatus = booking.status ?? null; // its status or the actions that depend on it.
linkedBookingScheduledDate = booking.scheduledDate const booking = cycle?.bookingId
? new Date(booking.scheduledDate).toISOString() ? await this.bookingsService.findById(cycle.bookingId)
: null; : await this.contractsRepository.findLatestBookingForContract(contractId);
// Newest changes-requested note (reviewNotes ride along on findById). if (booking) {
linkedBookingReviewNote = linkedBookingId = booking.id ?? null;
[...(booking.reviewNotes ?? [])] linkedBookingReference = booking.reference ?? null;
.filter((n) => n.type === 'CHANGES_REQUESTED') linkedBookingStatus = booking.status ?? null;
.sort( linkedBookingScheduledDate = booking.scheduledDate
(a, b) => ? new Date(booking.scheduledDate).toISOString()
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), : null;
)[0]?.note ?? null; // Newest changes-requested note (reviewNotes ride along on findById).
if (contract.tradeDirection === 'EXPORT') { linkedBookingReviewNote =
nextAction = this.workflowService.computeNextActionForBooking( [...(booking.reviewNotes ?? [])]
booking, .filter((n) => n.type === 'CHANGES_REQUESTED')
bookingMilestones, .sort(
); (a, b) =>
} new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0]?.note ?? null;
if (contract.tradeDirection === 'EXPORT') {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
bookingMilestones,
);
} }
} }
@@ -420,7 +427,7 @@ export class ContractClearanceService {
bookingReady: boundary, bookingReady: boundary,
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
exportClearanceFinalized: Boolean(cycle?.completedAt), exportClearanceFinalized: Boolean(cycle?.completedAt),
linkedBookingId: cycle?.bookingId ?? null, linkedBookingId,
linkedBookingReference, linkedBookingReference,
linkedBookingStatus, linkedBookingStatus,
linkedBookingReviewNote, linkedBookingReviewNote,

View File

@@ -76,7 +76,10 @@ import {
import { SignContractDto } from './dto/sign-contract.dto'; import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
import { RenewContractDto } from './dto/renew-contract.dto'; import { RenewContractDto } from './dto/renew-contract.dto';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; import {
CompleteConsolidatedPairDto,
CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';
import { import {
CreateBookingRequestDto, CreateBookingRequestDto,
ReviewBookingRequestDto, ReviewBookingRequestDto,
@@ -1152,10 +1155,48 @@ export class ContractsController {
// Customs (Path B) instances may only be completed by GL Ethiopia — the // Customs (Path B) instances may only be completed by GL Ethiopia — the
// service checks the actor's contracts:create_booking permission. // service checks the actor's contracts:create_booking permission.
return this.contractBookingService.completeUnderContract( return this.contractBookingService.completeUnderContract(
id,
bookingId,
// skipAutoConsolidation is internal to the manual pair-completion path; a
// client must never suppress the wagon gate on a lone booking.
{ ...dto, skipAutoConsolidation: false },
user,
);
}
@Get(':id/bookings/:bookingId/consolidation-candidates')
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Bookings GL may link to this odd-20ft customs booking as its shared-wagon partner (same route and direction, customs, odd 20ft, unpaired).',
})
listConsolidationCandidates(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
) {
return this.contractBookingService.listConsolidationCandidates(id, bookingId);
}
@Post(':id/bookings/:bookingId/complete-consolidated')
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.',
})
completeConsolidatedPair(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: CompleteConsolidatedPairDto,
@CurrentUser() user: TCurrentUser & { sub?: string },
) {
return this.contractBookingService.completeConsolidatedPair(
id, id,
bookingId, bookingId,
dto, dto,
user, user,
// Recorded as the requester on the approval: the person who created the
// pairing may not be the one who approves it.
user?.id ?? user?.sub ?? null,
); );
} }

View File

@@ -656,6 +656,31 @@ export class ContractsRepository extends BaseRepository<Contract> {
.getCount(); .getCount();
} }
/**
* The live shipment booking on a contract, newest first.
*
* The clearance view historically reached the booking through
* `currentCycle().bookingId`, but a cycle row is not created on every path —
* an FCFS export booking and a GL drawdown both reach
* OPERATION_REQUEST_PENDING without one — so that lookup returns null and the
* clearance page loses the booking's status entirely. This resolves it from
* the bookings themselves, which is the authoritative link (bookings carry
* contract_id), and is used as the fallback when the cycle has no booking.
*/
async findLatestBookingForContract(
contractId: string,
): Promise<Booking | null> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.status NOT IN (:...terminal)', {
terminal: TERMINAL_BOOKING_STATUSES,
})
.orderBy('b.created_at', 'DESC')
.getOne();
}
async createReviewNote( async createReviewNote(
contractId: string, contractId: string,
body: string, body: string,

View File

@@ -1,4 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiHideProperty, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { import {
IsArray, IsArray,
@@ -219,4 +219,46 @@ export class CreateBookingUnderContractDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
notes?: string; notes?: string;
/**
* Internal: set by the manual GL pair-completion path, never by a client.
* Suppresses the automatic wagon-consolidation gate for this completion
* because the caller links the shared wagon itself. Excluded from the public
* schema so a client cannot set it to bypass the gate on a lone booking.
*/
@ApiHideProperty()
@IsOptional()
@IsBoolean()
skipAutoConsolidation?: boolean;
}
/**
* Complete an odd-20ft customs booking together with the partner booking GL
* picked to share its wagon. Each half carries its own full completion payload —
* the two bookings stay separately priced and separately invoiced, they only
* share the wagon.
*/
export class CompleteConsolidatedPairDto {
@ApiProperty({
format: 'uuid',
description: 'The booking chosen to share this bookings wagon.',
})
@IsUUID()
partnerBookingId!: string;
@ApiProperty({
type: CreateBookingUnderContractDto,
description: 'Completion payload for the booking in the URL.',
})
@ValidateNested()
@Type(() => CreateBookingUnderContractDto)
booking!: CreateBookingUnderContractDto;
@ApiProperty({
type: CreateBookingUnderContractDto,
description: 'Completion payload for the partner booking.',
})
@ValidateNested()
@Type(() => CreateBookingUnderContractDto)
partner!: CreateBookingUnderContractDto;
} }

View File

@@ -226,6 +226,14 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:bookings:wagon_cancellation_rebook", "edr_freight_app:bookings:wagon_cancellation_rebook",
"Rebook cancelled wagons for a customer", "Rebook cancelled wagons for a customer",
), ),
// Shared-wagon gate: two customers' cargo on one wagon is a commercial call,
// so it is signed off separately from the ordinary booking approvals — and
// never by the GL user who created the pairing.
perm(
"a1000001-0001-4000-8000-000000000029",
"edr_freight_app:bookings:approve_consolidation",
"Approve shared-wagon consolidation",
),
]; ];
/** /**
@@ -1782,6 +1790,7 @@ export const FREIGHT_PERMS = {
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
wagonCancellationRebook: wagonCancellationRebook:
"edr_freight_app:bookings:wagon_cancellation_rebook", "edr_freight_app:bookings:wagon_cancellation_rebook",
approveConsolidation: "edr_freight_app:bookings:approve_consolidation",
// Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS. // Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS.
getNotification: "edr_freight_app:bookings:get_notification", getNotification: "edr_freight_app:bookings:get_notification",
clearanceGetNotification: clearanceGetNotification:
@@ -2390,6 +2399,10 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.reject, FREIGHT_PERMS.bookings.reject,
FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.rejectApproval,
// Shared-wagon gate: two customers' cargo on one wagon is a commercial
// call. Granted to the approver roles and NOT to GL — GL creates the
// pairing, so GL approving it would defeat the second pair of eyes.
FREIGHT_PERMS.bookings.approveConsolidation,
FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.bookings.wagonCancellationView, FREIGHT_PERMS.bookings.wagonCancellationView,
FREIGHT_PERMS.bookings.wagonCancellationVoid, FREIGHT_PERMS.bookings.wagonCancellationVoid,
@@ -2446,6 +2459,10 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.approveDirector, FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.rejectApproval,
// Shared-wagon gate: two customers' cargo on one wagon is a commercial
// call. Granted to the approver roles and NOT to GL — GL creates the
// pairing, so GL approving it would defeat the second pair of eyes.
FREIGHT_PERMS.bookings.approveConsolidation,
FREIGHT_PERMS.bookings.generateContract, FREIGHT_PERMS.bookings.generateContract,
FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.approveDirector,
@@ -2458,6 +2475,10 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.approveCeo, FREIGHT_PERMS.bookings.approveCeo,
FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.rejectApproval,
// Shared-wagon gate: two customers' cargo on one wagon is a commercial
// call. Granted to the approver roles and NOT to GL — GL creates the
// pairing, so GL approving it would defeat the second pair of eyes.
FREIGHT_PERMS.bookings.approveConsolidation,
FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.approveCeo, FREIGHT_PERMS.contracts.approveCeo,
...allRuleEngineViewKeys(), ...allRuleEngineViewKeys(),
@@ -2536,6 +2557,10 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.reject, FREIGHT_PERMS.bookings.reject,
FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.rejectApproval,
// Shared-wagon gate: two customers' cargo on one wagon is a commercial
// call. Granted to the approver roles and NOT to GL — GL creates the
// pairing, so GL approving it would defeat the second pair of eyes.
FREIGHT_PERMS.bookings.approveConsolidation,
FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.bookings.wagonCancellationView, FREIGHT_PERMS.bookings.wagonCancellationView,
FREIGHT_PERMS.bookings.wagonCancellationVoid, FREIGHT_PERMS.bookings.wagonCancellationVoid,

View File

@@ -19,6 +19,7 @@ import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage";
import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import ConsolidationApprovalsPage from "./pages/bookings/ConsolidationApprovalsPage";
import NewBookingPage from "./pages/bookings/NewBookingPage"; import NewBookingPage from "./pages/bookings/NewBookingPage";
import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage"; import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage";
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage"; import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
@@ -390,6 +391,18 @@ const App = () => {
</RequirePermission> </RequirePermission>
} }
/> />
{/* Shared-wagon gate: consolidated pairs wait for a human decision
before either half reaches Operations. */}
<Route
path="consolidation-approvals"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.approveConsolidation}
>
<ConsolidationApprovalsPage />
</RequirePermission>
}
/>
<Route <Route
path="booking-requests/:id" path="booking-requests/:id"
element={ element={

View File

@@ -38,6 +38,7 @@ export function BookingActionsMenu({
reference: row.reference, reference: row.reference,
schedulingStatus: row.schedulingStatus, schedulingStatus: row.schedulingStatus,
customsClearingEnabled: row.customsClearingEnabled, customsClearingEnabled: row.customsClearingEnabled,
consolidationPartnerId: row.consolidationPartnerId,
}; };
const flow = useBookingActionDialog(row.id, context); const flow = useBookingActionDialog(row.id, context);
@@ -92,7 +93,13 @@ export function BookingActionsMenu({
); );
})} })}
</Group> </Group>
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} /> <ActionDialog
flow={flow}
pendingAction={pendingAction}
onSuppressRowClick={onSuppressRowClick}
consolidationPartnerId={row.consolidationPartnerId}
consolidationPartnerReference={row.consolidationPartnerReference}
/>
</> </>
); );
} }
@@ -149,7 +156,13 @@ export function BookingActionsMenu({
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} /> <ActionDialog
flow={flow}
pendingAction={pendingAction}
onSuppressRowClick={onSuppressRowClick}
consolidationPartnerId={row.consolidationPartnerId}
consolidationPartnerReference={row.consolidationPartnerReference}
/>
</Group> </Group>
); );
} }
@@ -158,10 +171,14 @@ function ActionDialog({
flow, flow,
pendingAction, pendingAction,
onSuppressRowClick, onSuppressRowClick,
consolidationPartnerId,
consolidationPartnerReference,
}: { }: {
flow: ReturnType<typeof useBookingActionDialog>; flow: ReturnType<typeof useBookingActionDialog>;
pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"]; pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"];
onSuppressRowClick?: () => void; onSuppressRowClick?: () => void;
consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null;
}) { }) {
return ( return (
<BookingConfirmDialog <BookingConfirmDialog
@@ -182,6 +199,17 @@ function ActionDialog({
}} }}
isPending={flow.mutations.isPending || flow.detailLoading} isPending={flow.mutations.isPending || flow.detailLoading}
confirmDisabled={flow.confirmDisabled} confirmDisabled={flow.confirmDisabled}
// Only the four pairable decisions land on both halves; the rest stay
// per booking, so the warning must not appear for them.
pairedWithReference={
consolidationPartnerId &&
pendingAction &&
["accept", "cancel", "operationAccept", "requestChanges"].includes(
pendingAction.id,
)
? (consolidationPartnerReference ?? "its wagon partner")
: null
}
/> />
); );
} }

View File

@@ -1,5 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Link2 } from "lucide-react";
import { import {
Alert,
Modal, Modal,
Group, Group,
Stack, Stack,
@@ -37,6 +39,12 @@ interface BookingConfirmDialogProps {
isPending: boolean; isPending: boolean;
confirmDisabled?: boolean; confirmDisabled?: boolean;
extra?: ReactNode; extra?: ReactNode;
/**
* Reference of the booking sharing this one's wagon. When set, the dialog
* warns that the decision lands on BOTH bookings — staff must not think they
* are acting on one.
*/
pairedWithReference?: string | null;
} }
export function BookingConfirmDialog({ export function BookingConfirmDialog({
@@ -52,6 +60,7 @@ export function BookingConfirmDialog({
isPending, isPending,
confirmDisabled = false, confirmDisabled = false,
extra, extra,
pairedWithReference = null,
}: BookingConfirmDialogProps) { }: BookingConfirmDialogProps) {
if (!action || !action.confirmTitle) return null; if (!action || !action.confirmTitle) return null;
@@ -125,6 +134,21 @@ export function BookingConfirmDialog({
{action.confirmDescription} {action.confirmDescription}
</Text> </Text>
)} )}
{pairedWithReference && (
<Alert
color="blue"
variant="light"
radius="md"
mt="sm"
icon={<Link2 size={16} />}
>
<Text size="sm">
This applies to <strong>{pairedWithReference}</strong> as well
the two bookings share a wagon and are decided together. If either
fails, neither changes.
</Text>
</Alert>
)}
</Box> </Box>
{/* Body */} {/* Body */}

View File

@@ -0,0 +1,82 @@
import { useQuery } from "@tanstack/react-query";
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
import { Link2 } from "lucide-react";
import { bookingsService } from "@/services/bookings.service";
import { formatDateTime } from "@/lib/format";
import { SectionCard } from "./SectionCard";
const STATUS_COLOR: Record<string, string> = {
PENDING: "yellow",
APPROVED: "teal",
REJECTED: "red",
};
/**
* Audit trail for this booking's shared wagon: every approval request against
* it, who decided, when, and why. Rendered only for a booking that is actually
* consolidated — there is nothing to show otherwise.
*/
export function ConsolidationApprovalCard({ bookingId }: { bookingId: string }) {
const { data } = useQuery({
queryKey: ["consolidation-approvals", "history", bookingId],
queryFn: () => bookingsService.consolidationApprovalHistory(bookingId),
enabled: Boolean(bookingId),
});
if (!data?.length) return null;
return (
<SectionCard icon={Link2} title="Shared wagon approval">
<Stack gap="md">
{data.map((row) => (
<Box
key={row.id}
style={{
borderLeft: "3px solid var(--mantine-color-gray-3)",
paddingLeft: 12,
}}
>
<Group gap={8} align="center" wrap="wrap" mb={4}>
<Badge
color={STATUS_COLOR[row.status] ?? "gray"}
variant="light"
radius="sm"
size="sm"
>
{row.status}
</Badge>
<Text fz={13} fw={600}>
{row.bookingReference ?? "—"} + {row.partnerBookingReference ?? "—"}
</Text>
</Group>
<Text fz={12} c="dimmed">
Requested {formatDateTime(row.requestedAt)}
{row.requestedBy ? ` by ${row.requestedBy}` : ""}
</Text>
{row.decidedAt ? (
<Text fz={12} c="dimmed">
{row.status === "APPROVED" ? "Approved" : "Rejected"}{" "}
{formatDateTime(row.decidedAt)}
{row.decidedBy ? ` by ${row.decidedBy}` : ""}
</Text>
) : (
<Text fz={12} c="yellow.8">
Waiting for a decision neither booking reaches Operations until
this is approved.
</Text>
)}
{row.decisionNote ? (
<Text fz={12.5} mt={4} style={{ whiteSpace: "pre-wrap" }}>
{row.decisionNote}
</Text>
) : null}
</Box>
))}
</Stack>
</SectionCard>
);
}

View File

@@ -14,6 +14,19 @@ function isValidValidityDays(value: string): boolean {
return Number.isInteger(days) && days >= 1 && days <= 365; return Number.isInteger(days) && days >= 1 && days <= 365;
} }
/**
* Decisions that must be applied to BOTH halves of a consolidated pair. The two
* bookings share one wagon: accepting one alone would put half a wagon into the
* approval chain, and cancelling one alone would strand the other on a wagon it
* can no longer fill.
*/
const PAIRED_DECISIONS = {
accept: "accept",
cancel: "cancel",
operationAccept: "operationAccept",
requestChanges: "requestChanges",
} as const;
export function useBookingActionDialog( export function useBookingActionDialog(
bookingId: string, bookingId: string,
context: BookingActionContext, context: BookingActionContext,
@@ -52,6 +65,30 @@ export function useBookingActionDialog(
const onSuccess = () => closeDialog(); const onSuccess = () => closeDialog();
// A booking on a shared wagon routes the four pairable decisions through the
// paired endpoint, which applies them to both halves all-or-nothing. Every
// other action stays per booking.
const pairedDecision =
PAIRED_DECISIONS[pendingAction.id as keyof typeof PAIRED_DECISIONS];
if (context.consolidationPartnerId && pairedDecision) {
if (pairedDecision === "accept") {
const days = Number(inputValue.trim());
if (!Number.isInteger(days) || days < 1 || days > 365) return;
mutations.pairedDecision.mutate(
{ decision: "accept", validityDays: days },
{ onSuccess },
);
return;
}
mutations.pairedDecision.mutate(
pairedDecision === "cancel"
? { decision: "cancel", reason: inputValue.trim() }
: { decision: pairedDecision, note: inputValue.trim() },
{ onSuccess },
);
return;
}
switch (pendingAction.id) { switch (pendingAction.id) {
case "accept": { case "accept": {
const days = Number(inputValue.trim()); const days = Number(inputValue.trim());

View File

@@ -11,6 +11,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { import {
ActionIcon, ActionIcon,
Alert, Alert,
Badge,
Box, Box,
Button, Button,
Center, Center,
@@ -40,6 +41,7 @@ import {
FileText, FileText,
FileUp, FileUp,
Flame, Flame,
Link2,
MapPin, MapPin,
Package, Package,
Receipt, Receipt,
@@ -57,7 +59,10 @@ import {
import { api } from "@/services/api"; import { api } from "@/services/api";
import { PageContainer } from "@/components/page"; import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader"; import { PageHeader } from "@/components/page/PageHeader";
import { contractsService } from "@/services/contracts.service"; import {
contractsService,
type ConsolidationCandidate,
} from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
import { import {
useContractCapacity, useContractCapacity,
@@ -80,6 +85,18 @@ import {
StepHeader, StepHeader,
StepLabel, StepLabel,
} from "./gl-booking-form/form-ui"; } from "./gl-booking-form/form-ui";
import {
ConsolidationPartnerPanel,
emptyPartnerLine,
} from "./gl-booking-form/ConsolidationPartnerPanel";
import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker";
/**
* Container sizes offered on the parent-booking panel. Fixed rather than taken
* from this contract's scope: the parent booking is a different customer on a
* different contract, so its sizes are its own.
*/
const PARTNER_SIZES = ["20ft", "40ft"];
/** All booking-window times are communicated in East Africa Time. */ /** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa"; const EAT_TZ = "Africa/Addis_Ababa";
@@ -240,6 +257,14 @@ export default function GlCreateBookingForm() {
enabled: Boolean(copyFromParam), enabled: Boolean(copyFromParam),
}); });
// The booking being completed — used to name the customer on the price
// confirmation when a second booking's price is shown beside it.
const { data: completeBooking } = useQuery({
queryKey: ["gl-complete-booking", completeBookingId],
queryFn: () => bookingsService.getById(completeBookingId!),
enabled: Boolean(completeBookingId),
});
// Same window-gating the customer sees: booking is only allowed while a // Same window-gating the customer sees: booking is only allowed while a
// window is OPEN for one of the contract's routes. Intercity contracts are // window is OPEN for one of the contract's routes. Intercity contracts are
// never window-gated — the shipment rides a passing train staff pick later. // never window-gated — the shipment rides a passing train staff pick later.
@@ -290,6 +315,18 @@ export default function GlCreateBookingForm() {
const [withReturn, setWithReturn] = useState(false); const [withReturn, setWithReturn] = useState(false);
const [prefilled, setPrefilled] = useState(false); const [prefilled, setPrefilled] = useState(false);
const [priceOpen, setPriceOpen] = useState(false); const [priceOpen, setPriceOpen] = useState(false);
// ── Odd-20ft shared wagon (customs / Path B) ──────────────────────────────
// An odd 20ft total leaves one container unpaired. On a customs contract GL
// resolves that here by linking a second booking that is also odd — two odd
// counts always sum to even — completing both together onto the shared wagon.
const [consolidateOdd, setConsolidateOdd] = useState(false);
// Set once GL flips the toggle by hand, so the auto-on effect below never
// re-opens a panel GL deliberately closed.
const consolidateTouchedRef = useRef(false);
const [partnerPickerOpen, setPartnerPickerOpen] = useState(false);
const [partner, setPartner] = useState<ConsolidationCandidate | null>(null);
const [partnerLines, setPartnerLines] = useState<ContainerLineDraft[]>([]);
const [partnerCargoDescription, setPartnerCargoDescription] = useState("");
const seededRef = useRef(false); const seededRef = useRef(false);
const returnSeededRef = useRef(false); const returnSeededRef = useRef(false);
@@ -834,6 +871,48 @@ export default function GlCreateBookingForm() {
}, [isContainer, containerLines]); }, [isContainer, containerLines]);
const hasOdd20ft = ft20Total % 2 === 1; const hasOdd20ft = ft20Total % 2 === 1;
// Only a customs (Path B) instance being COMPLETED by GL can use the shared
// wagon: it is GL, not the customer, who links the two bookings. Anything else
// keeps the historical hard block on odd 20ft.
const oddConsolidationAvailable = Boolean(
completeBookingId && isContainer && contract?.customsClearingEnabled,
);
// Auto-on: entering an odd 20ft total opens the consolidation panel by itself,
// once. GL can still switch it off — then odd is blocked exactly as before.
useEffect(() => {
if (!oddConsolidationAvailable) return;
if (consolidateTouchedRef.current) return;
if (hasOdd20ft) setConsolidateOdd(true);
}, [oddConsolidationAvailable, hasOdd20ft]);
// Clear the partner as soon as the panel closes or stops applying, so a
// leftover selection can never ride along into a plain single-booking submit.
useEffect(() => {
if (consolidateOdd && oddConsolidationAvailable) return;
setPartner(null);
setPartnerLines([]);
setPartnerCargoDescription("");
}, [consolidateOdd, oddConsolidationAvailable]);
const consolidationActive =
oddConsolidationAvailable && consolidateOdd && hasOdd20ft;
// Once a parent booking is linked, each booking's cargo is entered under its
// own labelled heading so it is clear which containers belong to whom.
const splitView = Boolean(consolidationActive && partner);
const candidatesQuery = useQuery({
queryKey: ["consolidation-candidates", id, completeBookingId],
queryFn: () =>
contractsService.listConsolidationCandidates(
id ?? "",
completeBookingId ?? "",
),
enabled:
partnerPickerOpen && Boolean(id) && Boolean(completeBookingId),
});
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON"; const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
const bulkErrors = useMemo<BulkErrors>(() => { const bulkErrors = useMemo<BulkErrors>(() => {
@@ -886,7 +965,64 @@ export default function GlCreateBookingForm() {
!cargoDescriptionError !cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError; // The unpaired 20ft container is resolved by the shared wagon, so with an
// active consolidation an odd total stops being a blocker; without one it
// blocks exactly as before.
const oddBlocksSubmit = hasOdd20ft && !consolidationActive;
// Partner side: a linked partner must be picked, carry an odd 20ft count of
// its own (odd + odd = even fills the wagon) and have complete unit details.
const partnerFt20Total = useMemo(() => {
if (!consolidationActive) return 0;
return partnerLines
.filter((l) => parseInt(l.containerSize, 10) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
}, [consolidationActive, partnerLines]);
const partnerError = useMemo<string | undefined>(() => {
if (!consolidationActive) return undefined;
if (!partner) return "Select the booking that shares this wagon.";
const totalQty = partnerLines.reduce(
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
0,
);
if (totalQty < 1) {
return `Enter the containers for ${partner.reference}.`;
}
if (partnerFt20Total % 2 === 0) {
return `${partner.reference} must also carry an odd number of 20ft containers so the two bookings fill whole wagons together (it has ${partnerFt20Total}).`;
}
const incomplete = partnerLines.some((line) => {
const qty = Number(line.quantity || 0);
return qty >= 1 && line.units.length < qty;
});
if (incomplete) {
return `Enter the container details for all of ${partner.reference}'s containers.`;
}
const badUnit = partnerLines.some((line) =>
line.units.some(
(u) =>
!ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) ||
!(Number(u.vgmTons) > 0),
),
);
if (badUnit) {
return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`;
}
if (!partnerCargoDescription.trim()) {
return `Describe the cargo carried in ${partner.reference}'s containers.`;
}
return undefined;
}, [
consolidationActive,
partner,
partnerLines,
partnerFt20Total,
partnerCargoDescription,
]);
const formValid =
cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError;
/** The create-booking DTO from the current form state — shared by the /** The create-booking DTO from the current form state — shared by the
* authoritative price preview and the actual submit so what GL confirms is * authoritative price preview and the actual submit so what GL confirms is
@@ -953,6 +1089,44 @@ export default function GlCreateBookingForm() {
return payload; return payload;
}; };
/**
* Completion DTO for the partner half of a shared wagon. Route, day and train
* are deliberately copied from THIS booking: the two bookings ride the same
* wagon, so they must ride the same train on the same day. Only the cargo and
* the billing currency belong to the partner.
*/
const buildPartnerPayload = (): Freight.CreateBookingUnderContractDto | null => {
if (!partner || !consolidationActive) return null;
const payload: Freight.CreateBookingUnderContractDto = {
paymentCurrency,
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}),
...(trainScheduleId ? { trainScheduleId } : {}),
...(partnerCargoDescription.trim()
? { cargoFreeText: partnerCargoDescription.trim() }
: {}),
containers: partnerLines
.filter((l) => Number(l.quantity) >= 1)
.map((l) => ({
containerSize: l.containerSize,
quantity: Number(l.quantity),
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
units: l.units.map((u) => ({
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0,
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
})),
})),
};
return payload;
};
// Authoritative price preview (same pricing pass the booking persists at // Authoritative price preview (same pricing pass the booking persists at
// create): rail freight + first/last mile + overweight + every surcharge, // create): rail freight + first/last mile + overweight + every surcharge,
// plus the hard-block checks (20ft pairing, max capacity, container numbers // plus the hard-block checks (20ft pairing, max capacity, container numbers
@@ -964,6 +1138,22 @@ export default function GlCreateBookingForm() {
}); });
const validation = validateShipmentMutation.data ?? null; const validation = validateShipmentMutation.data ?? null;
// The partner is priced against ITS OWN contract, so the two totals shown in
// the confirm modal are each customer's real bill — nobody pays for the other.
const validatePartnerMutation = useMutation({
mutationFn: (input: {
contractId: string;
bookingId: string;
dto: Freight.CreateBookingUnderContractDto;
}) =>
contractsService.validateShipment(
input.contractId,
input.dto,
input.bookingId,
),
});
const partnerValidation = validatePartnerMutation.data ?? null;
const serverTotal = useMemo(() => { const serverTotal = useMemo(() => {
const items = validation?.lineItems; const items = validation?.lineItems;
if (!items?.length) return null; if (!items?.length) return null;
@@ -1010,8 +1200,52 @@ export default function GlCreateBookingForm() {
}; };
}, [serverTotal, priceTotal, overweightSurchargeAmount]); }, [serverTotal, priceTotal, overweightSurchargeAmount]);
const partnerTotal = useMemo(() => {
const items = partnerValidation?.lineItems;
if (!items?.length) return null;
return {
currency: partnerValidation?.currency ?? "ETB",
lines: items.map((li) => ({
label: li.description,
unitPrice: li.unitAmount,
unit: li.unit.toLowerCase(),
quantity: li.quantity,
amount: li.amount,
})),
total:
partnerValidation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
};
}, [partnerValidation]);
// The partner half must clear the same hard blocks as this one — the pair is
// booked all-or-nothing, so a block on either side blocks both.
const partnerBlockers = useMemo(() => {
if (!consolidationActive || !partnerValidation) return [];
return [
...(partnerValidation.pairingErrors ?? []),
...(partnerValidation.capacityErrors ?? []),
...(partnerValidation.containerClashErrors ?? []),
...(partnerValidation.spaceErrors ?? []),
];
}, [consolidationActive, partnerValidation]);
const completePairMutation = useMutation({
mutationFn: (input: {
payload: Freight.CreateBookingUnderContractDto;
partnerPayload: Freight.CreateBookingUnderContractDto;
partnerBookingId: string;
}) =>
contractsService.completeConsolidatedPair(id ?? "", completeBookingId ?? "", {
partnerBookingId: input.partnerBookingId,
booking: input.payload,
partner: input.partnerPayload,
}),
});
const submitPending = const submitPending =
mutations.createBooking.isPending || mutations.completeBooking.isPending; mutations.createBooking.isPending ||
mutations.completeBooking.isPending ||
completePairMutation.isPending;
// Block confirm until the authoritative server price is in hand — the client // Block confirm until the authoritative server price is in hand — the client
// estimate is display-only; booking on it would confirm an un-validated, // estimate is display-only; booking on it would confirm an un-validated,
@@ -1023,7 +1257,13 @@ export default function GlCreateBookingForm() {
capacityErrors.length > 0 || capacityErrors.length > 0 ||
containerClashErrors.length > 0 || containerClashErrors.length > 0 ||
spaceErrors.length > 0 || spaceErrors.length > 0 ||
!serverTotal; !serverTotal ||
// Same bar for the shared-wagon partner: its authoritative price must be in
// hand and its own hard blocks clear before either booking is confirmed.
(consolidationActive &&
(validatePartnerMutation.isPending ||
!partnerTotal ||
partnerBlockers.length > 0));
const openPriceModal = () => { const openPriceModal = () => {
// Surface the per-field errors (portal-parity validation) instead of // Surface the per-field errors (portal-parity validation) instead of
@@ -1039,6 +1279,15 @@ export default function GlCreateBookingForm() {
validateShipmentMutation.reset(); validateShipmentMutation.reset();
validateShipmentMutation.mutate(payload); validateShipmentMutation.mutate(payload);
} }
validatePartnerMutation.reset();
const partnerPayload = buildPartnerPayload();
if (partnerPayload && partner?.contractId) {
validatePartnerMutation.mutate({
contractId: partner.contractId,
bookingId: partner.id,
dto: partnerPayload,
});
}
}; };
const handleSubmit = () => { const handleSubmit = () => {
@@ -1054,6 +1303,25 @@ export default function GlCreateBookingForm() {
const payload = buildPayload(); const payload = buildPayload();
if (!payload) return; if (!payload) return;
// Shared wagon: both halves complete together, all-or-nothing on the server.
if (consolidationActive && partner && completeBookingId) {
// A hard block on the partner's own price preview blocks the pair.
if (partnerBlockers.length > 0) return;
const partnerPayload = buildPartnerPayload();
if (!partnerPayload) return;
completePairMutation.mutate(
{
payload,
partnerPayload,
partnerBookingId: partner.id,
},
{
onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`),
},
);
return;
}
if (completeBookingId) { if (completeBookingId) {
// Completion mode: cargo + day land on the already-cleared instance — // Completion mode: cargo + day land on the already-cleared instance —
// the request was linked and accepted at submission time. // the request was linked and accepted at submission time.
@@ -1347,6 +1615,18 @@ export default function GlCreateBookingForm() {
maxRows={4} maxRows={4}
styles={fieldStyles} styles={fieldStyles}
/> />
{/* With a parent booking linked, each booking's containers are
entered in its own labelled section, one after the other. */}
{splitView ? (
<Group gap={8} align="center">
<Badge color="edr-green" variant="light" radius="sm">
{completeBooking?.reference ?? "This booking"}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{completeBooking?.company?.name ?? "—"}
</Text>
</Group>
) : null}
{containerLines.length === 0 ? ( {containerLines.length === 0 ? (
<Text fz="sm" c="dimmed"> <Text fz="sm" c="dimmed">
This contract has no container sizes in scope. This contract has no container sizes in scope.
@@ -1526,7 +1806,71 @@ export default function GlCreateBookingForm() {
)) ))
)} )}
{hasOdd20ft ? ( {hasOdd20ft && oddConsolidationAvailable ? (
<Alert
color={consolidateOdd ? "edr-green" : "red"}
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Total})`}
>
<Stack gap={10}>
<Text fz={13}>
20ft containers travel two per wagon, so one container here
is unpaired. On a customs booking you can pair it with
another customer's odd booking and complete both onto the
shared wagon — each booking is still priced and invoiced
separately.
</Text>
<Switch
checked={consolidateOdd}
color="edr-green"
label="Share a wagon with another booking"
onChange={(e) => {
consolidateTouchedRef.current = true;
setConsolidateOdd(e.currentTarget.checked);
}}
/>
{consolidateOdd ? (
<Group gap={10} align="center" wrap="wrap">
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
leftSection={<Link2 size={14} />}
onClick={() => setPartnerPickerOpen(true)}
>
{partner
? `Parent booking: ${partner.reference} — change`
: "Parent booking"}
</Button>
{partner ? (
<Button
size="xs"
radius="md"
variant="subtle"
color="gray"
onClick={() => {
setPartner(null);
setPartnerLines([]);
setPartnerCargoDescription("");
}}
>
Remove
</Button>
) : null}
</Group>
) : (
<Text fz={12.5} c="red.7">
With sharing off, book an even number of 20ft containers
— add one more or remove one (e.g. {ft20Total + 1} or{" "}
{ft20Total - 1} instead of {ft20Total}).
</Text>
)}
</Stack>
</Alert>
) : hasOdd20ft ? (
<Alert <Alert
color="red" color="red"
variant="light" variant="light"
@@ -1540,6 +1884,34 @@ export default function GlCreateBookingForm() {
— the booking cannot be created with an unpaired 20ft container. — the booking cannot be created with an unpaired 20ft container.
</Alert> </Alert>
) : null} ) : null}
{splitView && partner ? (
<>
<Divider my={4} />
<Group gap={8} align="center">
<Badge color="blue" variant="light" radius="sm">
{partner.reference}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{partner.companyName ?? "—"}
</Text>
</Group>
<Text fz={12.5} c="dimmed">
Parent booking — ships on the same day and train, billed to
its own customer.
</Text>
<ConsolidationPartnerPanel
lines={partnerLines}
onLinesChange={setPartnerLines}
cargoDescription={partnerCargoDescription}
onCargoDescriptionChange={setPartnerCargoDescription}
showHazardous={Boolean(contract.isHazardous)}
showReefer={Boolean(contract.isReefer)}
showErrors={showErrors}
error={partnerError}
/>
</>
) : null}
</Stack> </Stack>
</StepCard> </StepCard>
) : ( ) : (
@@ -1806,12 +2178,31 @@ export default function GlCreateBookingForm() {
> >
Fix the highlighted fields before reviewing the price. Fix the highlighted fields before reviewing the price.
</Alert> </Alert>
) : partnerError ? (
// The review button is disabled while the parent booking is
// incomplete, so the click that would reveal the errors never
// lands — say what is outstanding without waiting for it.
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
{partnerError}
</Alert>
) : null} ) : null}
<Group justify="flex-end"> <Group justify="flex-end">
<Tooltip <Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`} label={
oddBlocksSubmit
? `Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`
: (partnerError ?? "")
}
withArrow withArrow
disabled={!hasOdd20ft} // Only explain a block that is actually in force: an odd count
// linked to a parent booking is resolved by the shared wagon.
disabled={!oddBlocksSubmit && !partnerError}
> >
{/* Mantine tooltips get no pointer events from a disabled button, {/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */} so the wrapper carries the hover target. */}
@@ -1821,9 +2212,11 @@ export default function GlCreateBookingForm() {
radius="md" radius="md"
leftSection={<Receipt size={16} />} leftSection={<Receipt size={16} />}
onClick={openPriceModal} onClick={openPriceModal}
// Same hard block the customer portal applies at review time — // An unpaired 20ft can never be planned onto a wagon — unless
// an unpaired 20ft can never be planned onto a wagon. // a parent booking is linked to share it, which is what
disabled={hasOdd20ft} // oddBlocksSubmit accounts for. The parent's own cargo must be
// complete too, or there is nothing to price.
disabled={oddBlocksSubmit || Boolean(partnerError)}
> >
Review price &amp; book Review price &amp; book
</Button> </Button>
@@ -1833,6 +2226,24 @@ export default function GlCreateBookingForm() {
</Box> </Box>
</Box> </Box>
<ConsolidationPartnerPicker
opened={partnerPickerOpen}
onClose={() => setPartnerPickerOpen(false)}
candidates={candidatesQuery.data ?? []}
isLoading={candidatesQuery.isLoading}
isError={candidatesQuery.isError}
onSelect={(candidate) => {
setPartner(candidate);
// Seed a 20ft and a 40ft line. The parent booking sits on its OWN
// contract, whose size scope need not match this one's, so the panel
// offers both sizes rather than mirroring this contract's scope; a
// size the parent does not ship is simply left at 0.
setPartnerLines(PARTNER_SIZES.map(emptyPartnerLine));
setPartnerCargoDescription("");
setPartnerPickerOpen(false);
}}
/>
<Modal <Modal
opened={priceOpen} opened={priceOpen}
onClose={() => { onClose={() => {
@@ -1984,6 +2395,18 @@ export default function GlCreateBookingForm() {
)} )}
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}> <Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
{/* Whose bill this is. Only worth naming when a second booking is
on screen — on a lone booking there is nothing to confuse it with. */}
{consolidationActive && partner ? (
<Group gap={8} align="center" mb={12} wrap="wrap">
<Badge color="edr-green" variant="light" radius="sm">
{completeBooking?.reference ?? "This booking"}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{completeBooking?.company?.name ?? contract.company?.name ?? "—"}
</Text>
</Group>
) : null}
<Stack gap={10}> <Stack gap={10}>
{displayTotal.lines.map((line, i) => ( {displayTotal.lines.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm"> <Group key={i} justify="space-between" wrap="nowrap" gap="sm">
@@ -2028,6 +2451,123 @@ export default function GlCreateBookingForm() {
</Group> </Group>
</Paper> </Paper>
{consolidationActive && partner ? (
<Paper
withBorder
radius={16}
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Group gap={8} align="center" mb={12} wrap="wrap">
<Badge color="blue" variant="light" radius="sm">
{partner.reference}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{partner.companyName ?? "—"}
</Text>
</Group>
{validatePartnerMutation.isPending ? (
<Group gap={8} c="dimmed">
<Loader size="xs" color="edr-green" />
<Text fz="sm" c="dimmed">
Pricing the partner booking
</Text>
</Group>
) : partnerBlockers.length > 0 ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Cannot book ${partner.reference}`}
>
<Stack gap={6}>
{partnerBlockers.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Both bookings are confirmed together, so this must be
fixed before either can be booked.
</Text>
</Stack>
</Alert>
) : partnerTotal ? (
<>
<Stack gap={10}>
{partnerTotal.lines.map((line, i) => (
<Group
key={i}
justify="space-between"
wrap="nowrap"
gap="sm"
>
<Box style={{ minWidth: 0 }}>
<Text fz="sm" fw={500}>
{line.label}
</Text>
<Text fz="xs" c="dimmed">
{line.quantity.toLocaleString()} ×{" "}
{line.unitPrice.toLocaleString()}{" "}
{partnerTotal.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
<Text
fz="sm"
fw={600}
style={{ whiteSpace: "nowrap" }}
>
{line.amount.toLocaleString()}{" "}
{partnerTotal.currency}
</Text>
</Group>
))}
</Stack>
<Divider my="md" />
<Group justify="space-between" align="flex-end">
<Text
fz="xs"
fw={700}
tt="uppercase"
c="blue"
style={{ letterSpacing: "0.06em" }}
>
Total
</Text>
<Text fw={800} fz={28}>
{partnerTotal.total.toLocaleString()}{" "}
<Text span fz={16} fw={700} c="dimmed">
{partnerTotal.currency}
</Text>
</Text>
</Group>
</>
) : (
<Text fz="sm" c="dimmed">
No price yet for the partner booking.
</Text>
)}
</Paper>
) : null}
{consolidationActive && partner ? (
<Alert
color="blue"
variant="light"
radius="md"
icon={<Link2 size={16} />}
>
<Text fz="sm">
These two bookings share one wagon but stay separate: each is
invoiced to its own customer and paid separately. Confirming
books both together if either fails, neither is booked.
</Text>
</Alert>
) : null}
<Group justify="space-between" mt="xs"> <Group justify="space-between" mt="xs">
<Button <Button
variant="default" variant="default"
@@ -2046,7 +2586,11 @@ export default function GlCreateBookingForm() {
disabled={confirmDisabled} disabled={confirmDisabled}
onClick={handleSubmit} onClick={handleSubmit}
> >
{completeBookingId ? "Confirm & complete" : "Confirm & book"} {consolidationActive && partner
? "Confirm & book both"
: completeBookingId
? "Confirm & complete"
: "Confirm & book"}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -0,0 +1,255 @@
import { type KeyboardEvent } from "react";
import {
Box,
Checkbox,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
/**
* Container editor for the PARTNER half of a shared wagon. Deliberately a
* reduced version of the main form's editor: the partner contributes only cargo
* — route, shipment day and train are inherited from the booking it shares the
* wagon with, and hazardous/reefer/return counts are derived from the per-unit
* ticks rather than typed line totals.
*/
export interface PartnerUnitDraft {
containerNumber: string;
sealNumber: string;
vgmTons: string;
isHazardous: boolean;
isReefer: boolean;
isReturn: boolean;
}
export interface PartnerLineDraft {
containerSize: string;
quantity: string;
hazardousQuantity: string;
reeferQuantity: string;
returnQuantity: string;
units: PartnerUnitDraft[];
}
export function emptyPartnerUnit(): PartnerUnitDraft {
return {
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
};
}
export function emptyPartnerLine(size: string): PartnerLineDraft {
return {
containerSize: size,
quantity: "0",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [],
};
}
/** Quantities are magnitudes — swallow the minus key before it reaches the field. */
const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "-") event.preventDefault();
};
/** Grow or shrink a line's unit rows to match its quantity. */
function syncUnits(line: PartnerLineDraft, quantity: number): PartnerLineDraft {
const target = Math.max(0, Math.floor(quantity) || 0);
const units = [...line.units];
while (units.length < target) units.push(emptyPartnerUnit());
units.length = target;
return {
...line,
units,
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: String(units.filter((u) => u.isReefer).length),
};
}
interface Props {
lines: PartnerLineDraft[];
onLinesChange: (lines: PartnerLineDraft[]) => void;
cargoDescription: string;
onCargoDescriptionChange: (value: string) => void;
/** Whether per-container hazardous / refrigerated ticks apply. */
showHazardous: boolean;
showReefer: boolean;
/** Surface field errors only after the operator tried to continue. */
showErrors: boolean;
error?: string;
}
export function ConsolidationPartnerPanel({
lines,
onLinesChange,
cargoDescription,
onCargoDescriptionChange,
showHazardous,
showReefer,
showErrors,
error,
}: Props) {
const patchLine = (index: number, patch: Partial<PartnerLineDraft>) => {
onLinesChange(
lines.map((line, i) => (i === index ? { ...line, ...patch } : line)),
);
};
const patchUnit = (
lineIndex: number,
unitIndex: number,
patch: Partial<PartnerUnitDraft>,
) => {
onLinesChange(
lines.map((line, i) => {
if (i !== lineIndex) return line;
const units = line.units.map((unit, u) =>
u === unitIndex ? { ...unit, ...patch } : unit,
);
return {
...line,
units,
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: String(units.filter((u) => u.isReefer).length),
};
}),
);
};
return (
<Stack gap={14}>
{error && showErrors ? (
<Text fz={12.5} c="red.7">
{error}
</Text>
) : null}
{lines.map((line, lineIdx) => (
<Box
key={`${line.containerSize}-${lineIdx}`}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 16 }}
>
<Text fz={14} fw={700} mb={10}>
{line.containerSize} containers
</Text>
<TextInput
type="number"
onKeyDown={blockNegative}
label="Quantity *"
min={0}
value={line.quantity}
onChange={(e) => patchLine(lineIdx, { quantity: e.currentTarget.value })}
// Sync off the typed value, not the captured `line` — that snapshot
// still holds the pre-edit quantity and would write it back.
onBlur={(e) => {
const typed = e.currentTarget.value;
patchLine(lineIdx, {
...syncUnits({ ...line, quantity: typed }, Number(typed || 0)),
quantity: typed,
});
}}
mb={12}
/>
{line.units.map((unit, unitIdx) => (
<Box key={unitIdx} mb={10}>
<Text fz={12} fw={600} c="#5B6B7B" mb={6}>
Container {unitIdx + 1}
</Text>
<Group gap={12} grow align="flex-start">
<TextInput
label="Container number *"
placeholder="e.g. MSCU1234567"
value={unit.containerNumber}
error={
showErrors && !unit.containerNumber.trim()
? "Required."
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
containerNumber: e.currentTarget.value.toUpperCase(),
})
}
/>
<TextInput
label="Seal number"
value={unit.sealNumber}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
sealNumber: e.currentTarget.value,
})
}
/>
<TextInput
type="number"
onKeyDown={blockNegative}
label="VGM (tons) *"
min={0}
value={unit.vgmTons}
error={
showErrors && !(Number(unit.vgmTons) > 0)
? "Required."
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, { vgmTons: e.currentTarget.value })
}
/>
</Group>
{showHazardous || showReefer ? (
<Group gap={16} mt={8}>
{showHazardous ? (
<Checkbox
size="xs"
label="Hazardous"
checked={unit.isHazardous}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
isHazardous: e.currentTarget.checked,
})
}
/>
) : null}
{showReefer ? (
<Checkbox
size="xs"
label="Refrigerated"
checked={unit.isReefer}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
isReefer: e.currentTarget.checked,
})
}
/>
) : null}
</Group>
) : null}
</Box>
))}
</Box>
))}
<TextInput
label="Cargo description *"
placeholder="What these containers carry"
value={cargoDescription}
error={
showErrors && !cargoDescription.trim() ? "Required." : undefined
}
onChange={(e) => onCargoDescriptionChange(e.currentTarget.value)}
/>
</Stack>
);
}

View File

@@ -0,0 +1,137 @@
import {
Alert,
Badge,
Box,
Button,
Center,
Group,
Loader,
Modal,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { AlertCircle, Link2 } from "lucide-react";
import type { ConsolidationCandidate } from "@/services/contracts.service";
/**
* Picker for the booking that shares this booking's wagon. The server has
* already narrowed the list to bookings that can legally pair — same route and
* direction, customs clearing, an odd 20ft count of their own and not already
* linked to someone else — so every row here is a valid choice.
*/
interface Props {
opened: boolean;
onClose: () => void;
candidates: ConsolidationCandidate[];
isLoading: boolean;
isError: boolean;
onSelect: (candidate: ConsolidationCandidate) => void;
}
export function ConsolidationPartnerPicker({
opened,
onClose,
candidates,
isLoading,
isError,
onSelect,
}: Props) {
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="lg"
title={
<Group gap={10}>
<ThemeIcon variant="light" color="blue" radius="md" size={34}>
<Link2 size={18} />
</ThemeIcon>
<Box>
<Text fw={800} fz={16}>
Pick the parent booking
</Text>
<Text fz="xs" c="dimmed">
Customs bookings on the same route that also carry an odd number of
20ft containers.
</Text>
</Box>
</Group>
}
>
{isLoading ? (
<Center py="xl">
<Loader size="sm" color="edr-green" />
</Center>
) : isError ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
>
Could not load the candidate bookings. Close this and try again.
</Alert>
) : candidates.length === 0 ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="No booking available to share this wagon"
>
<Text fz="sm">
No other customs booking on this route currently carries an odd
number of 20ft containers. Either wait for one, or switch the
shared-wagon option off and book an even number of 20ft containers.
</Text>
</Alert>
) : (
<Stack gap={10}>
{candidates.map((candidate) => (
<Box
key={candidate.id}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 14 }}
>
<Group justify="space-between" align="center" wrap="wrap" gap={10}>
<Box style={{ minWidth: 0 }}>
<Group gap={8} align="center" wrap="wrap">
<Text fz={14} fw={700} c="#10202F">
{candidate.reference}
</Text>
<Badge size="sm" variant="light" color="gray" radius="sm">
{candidate.status.replaceAll("_", " ")}
</Badge>
</Group>
<Text fz={12.5} c="dimmed" mt={2}>
{candidate.companyName ?? "—"}
{candidate.tradeDirection
? ` · ${candidate.tradeDirection}`
: ""}
{" · "}
{candidate.hasCargo
? `${candidate.ft20Quantity} × 20ft`
: "cargo not entered yet"}
</Text>
</Box>
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
onClick={() => onSelect(candidate)}
>
Select
</Button>
</Group>
</Box>
))}
</Stack>
)}
</Modal>
);
}

View File

@@ -14,7 +14,7 @@ import {
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { MapPin, Plus, Search } from "lucide-react"; import { MapPin, Plus, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useState } from "react";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -24,7 +24,7 @@ import { api } from "@/services/api";
* AVAILABLE, unassigned wagons from every yard — filtered and paged on the API, * AVAILABLE, unassigned wagons from every yard — filtered and paged on the API,
* so the picker never page-walks the whole fleet into the browser. * so the picker never page-walks the whole fleet into the browser.
*/ */
export default function AvailableWagonsPanel({ function AvailableWagonsPanel({
homeYardId, homeYardId,
onAssign, onAssign,
assigning, assigning,
@@ -36,7 +36,7 @@ export default function AvailableWagonsPanel({
const [typeFilter, setTypeFilter] = useState<string>("ALL"); const [typeFilter, setTypeFilter] = useState<string>("ALL");
const [yardFilter, setYardFilter] = useState<string>("ALL"); const [yardFilter, setYardFilter] = useState<string>("ALL");
const [runOnly, setRunOnly] = useState(false); const [runOnly, setRunOnly] = useState(false);
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// The train's own run, e.g. "8001-8002" — only offered when the train has one. // The train's own run, e.g. "8001-8002" — only offered when the train has one.
@@ -60,6 +60,9 @@ export default function AvailableWagonsPanel({
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
}, },
}, },
// Keep the previous page on screen while the next one loads — otherwise
// paging and typing flash the list to "Loading wagons…" on every stroke.
placeholderData: (prev) => prev,
}), }),
); );
@@ -105,32 +108,32 @@ export default function AvailableWagonsPanel({
[wagonTypesQuery.data], [wagonTypesQuery.data],
); );
const toggle = (wagonId: string, checked: boolean) => { const toggle = useCallback((wagonId: string, checked: boolean) => {
setSelected((prev) => setSelected((prev) => {
checked ? [...prev, wagonId] : prev.filter((id) => id !== wagonId), const next = new Set(prev);
); if (checked) next.add(wagonId);
}; else next.delete(wagonId);
return next;
});
}, []);
// Select-all covers this page only — the rest of the matches are not loaded. // Select-all covers this page only — the rest of the matches are not loaded.
const allSelected = wagons.length > 0 && wagons.every((w) => selected.includes(w.id)); const allSelected = wagons.length > 0 && wagons.every((w) => selected.has(w.id));
const someSelected = wagons.some((w) => selected.includes(w.id)); const someSelected = wagons.some((w) => selected.has(w.id));
const toggleAll = (checked: boolean) => { const toggleAll = (checked: boolean) => {
setSelected((prev) => { setSelected((prev) => {
if (checked) { const next = new Set(prev);
const ids = new Set(prev); if (checked) wagons.forEach((w) => next.add(w.id));
wagons.forEach((w) => ids.add(w.id)); else wagons.forEach((w) => next.delete(w.id));
return [...ids]; return next;
}
const visible = new Set(wagons.map((w) => w.id));
return prev.filter((id) => !visible.has(id));
}); });
}; };
const handleAssign = () => { const handleAssign = () => {
if (!selected.length) return; if (!selected.size) return;
onAssign(selected); onAssign([...selected]);
setSelected([]); setSelected(new Set());
}; };
return ( return (
@@ -178,16 +181,24 @@ export default function AvailableWagonsPanel({
indeterminate={!allSelected && someSelected} indeterminate={!allSelected && someSelected}
onChange={(e) => toggleAll(e.currentTarget.checked)} onChange={(e) => toggleAll(e.currentTarget.checked)}
/> />
{selected.length ? ( {selected.size ? (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{selected.length} selected {selected.size} selected
</Text> </Text>
) : null} ) : null}
</Group> </Group>
) : null} ) : null}
<ScrollArea.Autosize mah={380} type="auto"> <ScrollArea.Autosize mah={380} type="auto">
<Stack gap={6}> {/* Previous results stay put while the next page loads (placeholderData),
so dim them rather than blanking the list. */}
<Stack
gap={6}
style={{
opacity: wagonsQuery.isFetching && !wagonsQuery.isLoading ? 0.55 : 1,
transition: "opacity 120ms ease",
}}
>
{wagonsQuery.isLoading ? ( {wagonsQuery.isLoading ? (
<Text py="md" ta="center" c="dimmed" size="sm"> <Text py="md" ta="center" c="dimmed" size="sm">
Loading wagons Loading wagons
@@ -198,57 +209,14 @@ export default function AvailableWagonsPanel({
</Text> </Text>
) : ( ) : (
wagons.map((wagon) => ( wagons.map((wagon) => (
<Group <WagonOption
key={wagon.id} key={wagon.id}
gap="sm" wagon={wagon}
wrap="nowrap" selected={selected.has(wagon.id)}
p="xs" homeYardId={homeYardId}
style={{ exportTrainNumber={exportTrainNumber}
border: "1px solid var(--mantine-color-gray-2)", onToggle={toggle}
borderRadius: "var(--mantine-radius-md)", />
}}
>
<Checkbox
size="sm"
checked={selected.includes(wagon.id)}
onChange={(e) => toggle(wagon.id, e.currentTarget.checked)}
aria-label={`Select wagon ${wagon.wagonNumber}`}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Badge
size="xs"
radius="sm"
variant="outline"
color={wagon.currentYardId === homeYardId ? "edr-green" : "gray"}
leftSection={<MapPin size={10} />}
>
{wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"}
</Badge>
{wagon.exportTrainNumber ? (
<Badge
size="xs"
radius="sm"
variant="light"
color={
wagon.exportTrainNumber === exportTrainNumber ? "edr-green" : "gray"
}
>
{wagon.exportTrainNumber}
{wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
: "Unknown type"}
</Text>
</Stack>
</Group>
)) ))
)} )}
</Stack> </Stack>
@@ -265,16 +233,19 @@ export default function AvailableWagonsPanel({
<Button <Button
leftSection={<Plus size={16} />} leftSection={<Plus size={16} />}
disabled={!selected.length} disabled={!selected.size}
loading={assigning} loading={assigning}
onClick={handleAssign} onClick={handleAssign}
> >
Add {selected.length ? `${selected.length} wagon${selected.length > 1 ? "s" : ""}` : "wagons"} to consist Add {selected.size ? `${selected.size} wagon${selected.size > 1 ? "s" : ""}` : "wagons"} to consist
</Button> </Button>
</Stack> </Stack>
); );
} }
/** Memoized: the workspace re-renders on every pending mutation. */
export default memo(AvailableWagonsPanel);
export interface AvailableWagonsPanelProps { export interface AvailableWagonsPanelProps {
/** The train's own yard — sorted first and highlighted; not a restriction. */ /** The train's own yard — sorted first and highlighted; not a restriction. */
homeYardId: string | null; homeYardId: string | null;
@@ -285,3 +256,81 @@ export interface AvailableWagonsPanelProps {
/** This train's even IMPORT run — label only; the export run does the matching. */ /** This train's even IMPORT run — label only; the export run does the matching. */
importTrainNumber?: string | null; importTrainNumber?: string | null;
} }
/**
* One selectable wagon row. Memoized: the picker re-renders on every keystroke
* and every selection change, but a row only actually changes when its own
* checkbox flips — so a full page of rows stays untouched.
*/
const WagonOption = memo(function WagonOption({
wagon,
selected,
homeYardId,
exportTrainNumber,
onToggle,
}: {
wagon: {
id: string;
wagonNumber: string;
currentYardId?: string | null;
currentYard?: { label?: string | null; code?: string | null } | null;
exportTrainNumber?: string | null;
importTrainNumber?: string | null;
wagonType?: { name?: string | null; capacityTons?: number | null } | null;
};
selected: boolean;
homeYardId: string | null;
exportTrainNumber?: string | null;
onToggle: (wagonId: string, checked: boolean) => void;
}) {
return (
<Group
gap="sm"
wrap="nowrap"
p="xs"
style={{
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-md)",
}}
>
<Checkbox
size="sm"
checked={selected}
onChange={(e) => onToggle(wagon.id, e.currentTarget.checked)}
aria-label={`Select wagon ${wagon.wagonNumber}`}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Badge
size="xs"
radius="sm"
variant="outline"
color={wagon.currentYardId === homeYardId ? "edr-green" : "gray"}
leftSection={<MapPin size={10} />}
>
{wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"}
</Badge>
{wagon.exportTrainNumber ? (
<Badge
size="xs"
radius="sm"
variant="light"
color={wagon.exportTrainNumber === exportTrainNumber ? "edr-green" : "gray"}
>
{wagon.exportTrainNumber}
{wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
: "Unknown type"}
</Text>
</Stack>
</Group>
);
});

View File

@@ -8,7 +8,7 @@ import {
} from "@hello-pangea/dnd"; } from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core"; import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react"; import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
import { type ReactNode } from "react"; import { memo, useCallback, useMemo, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service"; import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
@@ -32,7 +32,7 @@ const PortalAwareRow = ({
* The train's ordered wagon consist. Drag to reorder (persisted on drop), * The train's ordered wagon consist. Drag to reorder (persisted on drop),
* trash to detach a wagon back to the yard. * trash to detach a wagon back to the yard.
*/ */
export default function ConsistWagonList({ function ConsistWagonList({
wagons, wagons,
editable, editable,
onReorder, onReorder,
@@ -40,7 +40,7 @@ export default function ConsistWagonList({
onMaintenance, onMaintenance,
busy = false, busy = false,
}: ConsistWagonListProps) { }: ConsistWagonListProps) {
const onDragEnd = (result: DropResult) => { const onDragEnd = useCallback((result: DropResult) => {
if (!result.destination) return; if (!result.destination) return;
const from = result.source.index; const from = result.source.index;
const to = result.destination.index; const to = result.destination.index;
@@ -49,7 +49,18 @@ export default function ConsistWagonList({
const [moved] = next.splice(from, 1); const [moved] = next.splice(from, 1);
next.splice(to, 0, moved!); next.splice(to, 0, moved!);
onReorder(next.map((w) => w.id)); onReorder(next.map((w) => w.id));
}; }, [wagons, onReorder]);
// Legend of the types actually coupled, in consist order — the colour code is
// only readable if the row tints are keyed somewhere.
const legend = useMemo(
() => [
...new Map(
wagons.filter((w) => w.wagonType).map((w) => [w.wagonType!.code, w.wagonType!]),
).values(),
],
[wagons],
);
if (!wagons.length) { if (!wagons.length) {
return ( return (
@@ -59,16 +70,6 @@ export default function ConsistWagonList({
); );
} }
// Legend of the types actually coupled, in consist order — the colour code is
// only readable if the row tints are keyed somewhere.
const legend = [
...new Map(
wagons
.filter((w) => w.wagonType)
.map((w) => [w.wagonType!.code, w.wagonType!]),
).values(),
];
return ( return (
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}> <Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
@@ -118,6 +119,9 @@ export default function ConsistWagonList({
); );
} }
/** Memoized: a 40-wagon consist re-renders every row otherwise. */
export default memo(ConsistWagonList);
export interface ConsistWagonListProps { export interface ConsistWagonListProps {
wagons: TrainCompositionWagon[]; wagons: TrainCompositionWagon[];
editable: boolean; editable: boolean;
@@ -128,7 +132,7 @@ export interface ConsistWagonListProps {
busy?: boolean; busy?: boolean;
} }
function WagonRow({ const WagonRow = memo(function WagonRow({
wagon, wagon,
index, index,
dragProvided, dragProvided,
@@ -232,4 +236,4 @@ function WagonRow({
</Group> </Group>
</PortalAwareRow> </PortalAwareRow>
); );
} });

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react"; import { memo, useMemo } from "react";
import { Box, Group, Paper, Progress, Stack, Text, Tooltip } from "@mantine/core"; import { Box, Group, Paper, Progress, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks"; import { useElementSize } from "@mantine/hooks";
import { Box as BoxIcon, Container as ContainerIcon, Fuel, Gauge, TrainFront } from "lucide-react"; import { Box as BoxIcon, Container as ContainerIcon, Fuel, Gauge, TrainFront } from "lucide-react";
@@ -132,7 +132,7 @@ function Coupler() {
); );
} }
function LocomotiveCar({ const LocomotiveCar = memo(function LocomotiveCar({
code, code,
name, name,
maxPullWeightTons, maxPullWeightTons,
@@ -260,7 +260,7 @@ function LocomotiveCar({
</Box> </Box>
</Tooltip> </Tooltip>
); );
} });
const CONTAINER_GRADIENTS = [ const CONTAINER_GRADIENTS = [
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))", "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
@@ -271,7 +271,7 @@ const CONTAINER_BORDERS = [
"var(--mantine-color-blue-8)", "var(--mantine-color-blue-8)",
]; ];
function WagonCar({ wagon }: { wagon: NormalizedWagon }) { const WagonCar = memo(function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
// GROSS on both sides: cargo + tare vs rated payload + tare. // GROSS on both sides: cargo + tare vs rated payload + tare.
const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons); const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons);
const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons); const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons);
@@ -468,7 +468,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
</Box> </Box>
</Tooltip> </Tooltip>
); );
} });
/** Railway track: two rails over evenly-spaced sleepers. */ /** Railway track: two rails over evenly-spaced sleepers. */
function TrackBed() { function TrackBed() {
@@ -520,7 +520,7 @@ function TrackBed() {
); );
} }
export function TrainCompositionDiagram({ export const TrainCompositionDiagram = memo(function TrainCompositionDiagram({
locomotive, locomotive,
locomotives, locomotives,
wagons, wagons,
@@ -818,7 +818,7 @@ export function TrainCompositionDiagram({
</Stack> </Stack>
</Paper> </Paper>
); );
} });
function LegendDot({ color, label }: { color: string; label: string }) { function LegendDot({ color, label }: { color: string; label: string }) {
return ( return (

View File

@@ -191,6 +191,17 @@ export const URL_CONSTANTS = {
BY_ID: (id: string) => `/bookings/${id}`, BY_ID: (id: string) => `/bookings/${id}`,
QUEUE: (queue: string) => `/bookings/queues/${queue}`, QUEUE: (queue: string) => `/bookings/queues/${queue}`,
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`, STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
// Consolidated pair: one staff decision applied to both halves at once.
PAIRED_DECISION: (id: string) => `/bookings/${id}/paired-decision`,
// Shared-wagon approval gate: a consolidated pair waits for a human
// decision before either half reaches Operations.
CONSOLIDATION_APPROVAL_QUEUE: "/bookings/consolidation-approvals/queue",
CONSOLIDATION_APPROVAL_HISTORY: (id: string) =>
`/bookings/${id}/consolidation-approvals`,
CONSOLIDATION_APPROVE: (approvalId: string) =>
`/bookings/consolidation-approvals/${approvalId}/approve`,
CONSOLIDATION_REJECT: (approvalId: string) =>
`/bookings/consolidation-approvals/${approvalId}/reject`,
STAFF_REQUEST_CHANGES: (id: string) => STAFF_REQUEST_CHANGES: (id: string) =>
`/bookings/${id}/staff/request-changes`, `/bookings/${id}/staff/request-changes`,
STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`, STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
@@ -317,6 +328,12 @@ export const URL_CONSTANTS = {
AWAITING_SHIPMENT: "/contracts/awaiting-shipment", AWAITING_SHIPMENT: "/contracts/awaiting-shipment",
BOOKINGS_COMPLETE: (id: string, bookingId: string) => BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/complete`, `/contracts/${id}/bookings/${bookingId}/complete`,
// Odd-20ft shared-wagon consolidation (customs/Path B): candidates GL may
// link, and the all-or-nothing completion of both halves together.
CONSOLIDATION_CANDIDATES: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/consolidation-candidates`,
BOOKINGS_COMPLETE_CONSOLIDATED: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/complete-consolidated`,
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`, VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
CAPACITY: (id: string) => `/contracts/${id}/capacity`, CAPACITY: (id: string) => `/contracts/${id}/capacity`,
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking. // Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.

View File

@@ -59,6 +59,9 @@ export type BookingActionContext = Pick<
| "reference" | "reference"
| "schedulingStatus" | "schedulingStatus"
| "customsClearingEnabled" | "customsClearingEnabled"
// Set when this booking shares a wagon: the pairable staff decisions then
// apply to both halves at once rather than to this booking alone.
| "consolidationPartnerId"
>; >;
const ALLOCATABLE_SCHEDULING_STATUSES = new Set([ const ALLOCATABLE_SCHEDULING_STATUSES = new Set([

View File

@@ -102,6 +102,11 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
label: "Operation Changes", label: "Operation Changes",
color: "bg-orange-50 text-orange-700 border-orange-200", color: "bg-orange-50 text-orange-700 border-orange-200",
}, },
// Shared-wagon gate: held for a human decision before reaching Operations.
CONSOLIDATION_APPROVAL_PENDING: {
label: "Wagon Approval",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
OPERATION_PRICE_PENDING_CONFIRM: { OPERATION_PRICE_PENDING_CONFIRM: {
label: "Price Confirm", label: "Price Confirm",
color: "bg-amber-50 text-amber-700 border-amber-200", color: "bg-amber-50 text-amber-700 border-amber-200",
@@ -309,6 +314,9 @@ export const BOOKING_LIST_TABS = [
"OPERATION_REQUEST_PENDING", "OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED", "OPERATION_CHANGES_REQUESTED",
"OPERATION_PRICE_PENDING_CONFIRM", "OPERATION_PRICE_PENDING_CONFIRM",
// Held at the shared-wagon gate — still an ops-review-stage booking, it
// just needs the pairing signed off before Operations can act on it.
"CONSOLIDATION_APPROVAL_PENDING",
], ],
}, },
{ {

View File

@@ -121,7 +121,38 @@ export function useBookingMutations(bookingId: string) {
onError: (error) => toast.error(parseApiError(error, "Failed to cancel booking")), onError: (error) => toast.error(parseApiError(error, "Failed to cancel booking")),
}); });
/**
* One staff decision applied to both halves of a consolidated pair. Both
* bookings are invalidated on success so whichever tab is open reflects the
* new state immediately.
*/
const pairedDecision = useMutation({
mutationFn: (payload: {
decision: "accept" | "cancel" | "operationAccept" | "requestChanges";
reason?: string;
note?: string;
validityDays?: number;
}) => {
const { decision, ...options } = payload;
return bookingsService.pairedDecision(bookingId, decision, options);
},
onSuccess: (data) => {
toast.success("Applied to both bookings on the shared wagon");
void invalidateBookingDetail(qc, data.booking.id);
void invalidateBookingDetail(qc, data.partner.id);
},
onError: (error) => {
toast.error(
parseApiError(error, "Failed to apply the decision to both bookings"),
);
// Nothing should have committed (the server runs both halves in one
// transaction), but refetch so the UI never shows a stale guess.
void invalidateBookingDetail(qc, bookingId);
},
});
const isPending = const isPending =
pairedDecision.isPending ||
staffAccept.isPending || staffAccept.isPending ||
requestChanges.isPending || requestChanges.isPending ||
staffReject.isPending || staffReject.isPending ||
@@ -134,6 +165,7 @@ export function useBookingMutations(bookingId: string) {
cancel.isPending; cancel.isPending;
return { return {
pairedDecision,
staffAccept, staffAccept,
requestChanges, requestChanges,
staffReject, staffReject,

View File

@@ -61,6 +61,7 @@ export const FREIGHT_PERMS = {
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
wagonCancellationRebook: wagonCancellationRebook:
"edr_freight_app:bookings:wagon_cancellation_rebook", "edr_freight_app:bookings:wagon_cancellation_rebook",
approveConsolidation: "edr_freight_app:bookings:approve_consolidation",
}, },
contracts: { contracts: {
view: "edr_freight_app:contracts:view", view: "edr_freight_app:contracts:view",

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { queryClient } from "./queryClient";
/**
* The MutationCache seeds `meta.updates` entries into the cache and then skips
* exactly those keys when running `meta.invalidates`. Getting that skip wrong
* silently reintroduces the refetch it exists to avoid, so it is worth pinning.
*/
const runOnSuccess = (meta: Record<string, unknown>, data: unknown, variables: unknown) => {
const handler = (queryClient.getMutationCache() as unknown as {
config: {
onSuccess?: (
data: unknown,
variables: unknown,
context: unknown,
mutation: { meta?: Record<string, unknown> },
) => void;
};
}).config.onSuccess;
handler?.(data, variables, undefined, { meta });
};
describe("MutationCache updates/invalidates", () => {
it("writes the mutation response into the seeded key and leaves it fresh", () => {
const seededKey = ["train-builder", "composition", "t1"] as const;
const siblingKey = ["train-builder", "list", {}] as const;
queryClient.setQueryData(seededKey, { code: "STALE" });
queryClient.setQueryData(siblingKey, { items: [] });
const response = { code: "FRESH" };
runOnSuccess(
{
updates: (_v: unknown, d: unknown) => [[seededKey, d]],
invalidates: () => [["train-builder"]],
},
response,
{ id: "t1" },
);
// Seeded key holds the response, and was NOT invalidated back to stale.
expect(queryClient.getQueryData(seededKey)).toStrictEqual(response);
expect(queryClient.getQueryState(seededKey)?.isInvalidated).toBe(false);
// Its siblings under the same root still get invalidated.
expect(queryClient.getQueryState(siblingKey)?.isInvalidated).toBe(true);
});
it("invalidates everything when a mutation declares no updates", () => {
const key = ["train-builder", "composition", "t2"] as const;
queryClient.setQueryData(key, { code: "X" });
runOnSuccess({ invalidates: () => [["train-builder"]] }, undefined, undefined);
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true);
});
});

View File

@@ -1,6 +1,6 @@
import { MutationCache, QueryClient } from "@tanstack/react-query"; import { MutationCache, QueryClient } from "@tanstack/react-query";
import type { InvalidatesMeta } from "@/utils/endpoint"; import type { InvalidatesMeta, UpdatesMeta } from "@/utils/endpoint";
/** /**
* Single app-wide React Query client (do not nest additional providers). * Single app-wide React Query client (do not nest additional providers).
@@ -14,13 +14,37 @@ import type { InvalidatesMeta } from "@/utils/endpoint";
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
mutationCache: new MutationCache({ mutationCache: new MutationCache({
onSuccess: (data, variables, _context, mutation) => { onSuccess: (data, variables, _context, mutation) => {
// Seed first: endpoints that return the entity they just changed write it
// straight into its cache key, so the screen updates from the response
// instead of round-tripping for data it already holds.
const updates = mutation.meta?.updates as UpdatesMeta | undefined;
const seeded: readonly unknown[][] = [];
if (typeof updates === "function") {
for (const [queryKey, value] of updates(variables, data)) {
queryClient.setQueryData(queryKey, value);
seeded.push(queryKey as unknown[]);
}
}
const invalidates = mutation.meta?.invalidates as const invalidates = mutation.meta?.invalidates as
| InvalidatesMeta | InvalidatesMeta
| undefined; | undefined;
if (typeof invalidates !== "function") return; if (typeof invalidates !== "function") return;
for (const queryKey of invalidates(variables, data)) { for (const queryKey of invalidates(variables, data)) {
void queryClient.invalidateQueries({ queryKey }); void queryClient.invalidateQueries({
queryKey,
// A seeded key already holds the authoritative value from this very
// response — invalidating it would refetch it right back.
predicate: seeded.length
? (query) =>
!seeded.some(
(key) =>
key.length === query.queryKey.length &&
key.every((part, i) => Object.is(part, query.queryKey[i])),
)
: undefined,
});
} }
}, },
// Mutation failures are surfaced globally by the axios interceptor in // Mutation failures are surfaced globally by the axios interceptor in

View File

@@ -9,6 +9,7 @@ import {
FolderOpen, FolderOpen,
Layers, Layers,
LayoutGrid, LayoutGrid,
Link2,
Milestone, Milestone,
MoreHorizontal, MoreHorizontal,
Package, Package,
@@ -20,6 +21,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { import {
ActionIcon, ActionIcon,
Alert,
Badge, Badge,
Box, Box,
Button, Button,
@@ -47,6 +49,7 @@ import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge
import { NextStepBanner } from "@/components/bookings/NextStepBanner"; import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner"; import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
import { ConsolidationApprovalCard } from "@/components/bookings/detail/ConsolidationApprovalCard";
import { import {
detailStyles, detailStyles,
BookingRouteServiceCard, BookingRouteServiceCard,
@@ -79,14 +82,50 @@ export default function BookingRequestDetailPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
// Deep-link from a warehouse fee invoice → this booking's warehouse section. // Deep-link from a warehouse fee invoice → this booking's warehouse section.
useScrollToHash(); useScrollToHash();
// Consolidated pair: `?booking=<partnerId>` swaps the WHOLE page over to the
// other half of the shared wagon. Everything below — KPIs, stepper, the
// overview/orders/documents/trucks sub-tabs, the action toolbar — then reads
// from the selected booking, so each half gets its own complete detail page
// under a top-level tab. The URL id stays put so Back still works.
const selectedId = searchParams.get("booking") || id;
const { const {
data: booking, data: booking,
isLoading, isLoading,
isError, isError,
refetch, refetch,
isFetching, isFetching,
} = useBookingDetail(id); } = useBookingDetail(selectedId);
const mutations = useBookingMutations(id ?? ""); const mutations = useBookingMutations(selectedId ?? "");
// The pair is discovered from whichever half is on screen: each booking
// carries a reference to the other.
const routeBookingId = id ?? "";
const partnerId = booking?.consolidationPartnerId ?? null;
const isPaired = Boolean(partnerId);
const viewingPartner = selectedId !== routeBookingId;
// Tab identities: the booking named by the URL is always the first tab, the
// other half the second — regardless of which one is currently displayed.
const firstTabId = routeBookingId;
const secondTabId = viewingPartner ? selectedId : partnerId;
// Only for the tab label (reference + customer) — the displayed half is
// loaded above. Skipped entirely when the booking is not part of a pair.
const { data: otherBooking } = useBookingDetail(
secondTabId && secondTabId !== selectedId ? secondTabId : undefined,
);
const firstTabBooking = viewingPartner ? otherBooking : booking;
const secondTabBooking = viewingPartner ? booking : otherBooking;
const selectBooking = (bookingId: string) => {
const next = new URLSearchParams(searchParams);
if (bookingId === routeBookingId) next.delete("booking");
else next.set("booking", bookingId);
// Switching booking resets the sub-tab: the other half has its own content
// and may not even have the tab that was open (e.g. Orders).
next.delete("tab");
setSearchParams(next, { replace: true });
};
if (isLoading) { if (isLoading) {
return ( return (
@@ -349,6 +388,48 @@ export default function BookingRequestDetailPage() {
/> />
<Stack gap="lg"> <Stack gap="lg">
{/* Consolidated pair: one tab per booking, switching the ENTIRE page
below. The overview/orders/documents/trucks tabs further down are
sub-tabs of whichever booking is selected here. */}
{isPaired && secondTabId ? (
<Tabs
value={selectedId ?? undefined}
onChange={(value) => value && selectBooking(value)}
variant="pills"
radius="md"
>
<Tabs.List>
<Tabs.Tab value={firstTabId} leftSection={<Link2 size={15} />}>
<Stack gap={0} align="flex-start">
<Text fz={13} fw={700}>
{firstTabBooking?.reference ?? "Booking"}
</Text>
<Text fz={11} c="dimmed">
{firstTabBooking?.company?.name ?? "—"}
</Text>
</Stack>
</Tabs.Tab>
<Tabs.Tab value={secondTabId} leftSection={<Link2 size={15} />}>
<Stack gap={0} align="flex-start">
<Text fz={13} fw={700}>
{secondTabBooking?.reference ?? "Partner booking"}
</Text>
<Text fz={11} c="dimmed">
{secondTabBooking?.company?.name ?? "—"}
</Text>
</Stack>
</Tabs.Tab>
</Tabs.List>
</Tabs>
) : null}
{isPaired ? (
<Text size="xs" c="dimmed">
These two bookings share one wagon. Accepting or cancelling applies
to both; each is invoiced and paid separately.
</Text>
) : null}
<KpiStrip items={kpis} /> <KpiStrip items={kpis} />
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
@@ -381,6 +462,21 @@ export default function BookingRequestDetailPage() {
<ConsolidationWaitingBanner bookingId={booking.id} /> <ConsolidationWaitingBanner bookingId={booking.id} />
)} )}
{booking.status === "CONSOLIDATION_APPROVAL_PENDING" && (
<Alert
color="yellow"
radius="md"
icon={<Link2 size={18} />}
title="Waiting for shared-wagon approval"
>
<Text size="sm">
This booking shares a wagon with another customer&apos;s booking.
Both are held here until the pairing is approved neither reaches
Operations before then.
</Text>
</Alert>
)}
<Grid gap="lg"> <Grid gap="lg">
{/* LEFT — primary content, split into tabs to keep each view focused. {/* LEFT — primary content, split into tabs to keep each view focused.
The Documents tab is always present, so the tab bar always renders. */} The Documents tab is always present, so the tab bar always renders. */}
@@ -455,6 +551,8 @@ export default function BookingRequestDetailPage() {
that train and its clock must be readable before the approve that train and its clock must be readable before the approve
button. */} button. */}
<BookingSchedulingWindowCard booking={booking} /> <BookingSchedulingWindowCard booking={booking} />
{/* Renders itself only when this booking has a shared wagon. */}
<ConsolidationApprovalCard bookingId={booking.id} />
<BookingActionsToolbar <BookingActionsToolbar
booking={booking} booking={booking}
mutations={mutations} mutations={mutations}

View File

@@ -15,6 +15,7 @@ import {
CheckCircle2, CheckCircle2,
Clock, Clock,
LayoutList, LayoutList,
Link2,
Package, Package,
Plus, Plus,
RefreshCw, RefreshCw,
@@ -210,10 +211,28 @@ export default function BookingRequestsPage() {
// Search is applied server-side (via the `search` filter param) — no // Search is applied server-side (via the `search` filter param) — no
// client-side filtering here. // client-side filtering here.
const rows = useMemo( const rows = useMemo(() => {
() => (data?.items ?? []).map(toBookingListRow), const mapped = (data?.items ?? []).map(toBookingListRow);
[data?.items], // Consolidated pairs share one wagon and are decided together, so they show
); // as ONE row. Keep the half that appears first in the current sort and hang
// the other on it as `pairedWith`; the row renders both bookings' details
// and opens the detail page, where each half gets its own tab.
const byId = new Map(mapped.map((row) => [row.id, row]));
const absorbed = new Set<string>();
const merged: BookingListRow[] = [];
for (const row of mapped) {
if (absorbed.has(row.id)) continue;
const partnerId = row.consolidationPartnerId;
const partner = partnerId ? byId.get(partnerId) : undefined;
if (partner && !absorbed.has(partner.id)) {
absorbed.add(partner.id);
merged.push({ ...row, pairedWith: partner });
continue;
}
merged.push(row);
}
return merged;
}, [data?.items]);
const total = data?.total ?? 0; const total = data?.total ?? 0;
const hasSearch = controls.searchText.trim().length > 0; const hasSearch = controls.searchText.trim().length > 0;
@@ -331,6 +350,22 @@ export default function BookingRequestsPage() {
</Badge> </Badge>
) : null} ) : null}
</p> </p>
{/* Shared wagon: the second booking rides in the same row, so the
operator sees both customers before opening the pair. */}
{b.pairedWith ? (
<div className="mt-1.5 border-l-2 border-muted pl-2">
<div className="flex items-center gap-1.5">
<Link2 className="size-3 shrink-0 opacity-70" />
<p className="truncate text-xs font-medium text-foreground">
{b.pairedWith.reference}
</p>
</div>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{b.pairedWith.customerLabel}
</p>
</div>
) : null}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,284 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
Center,
Group,
Loader,
Modal,
Paper,
Stack,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { AlertCircle, Check, Clock, Link2, X } from "lucide-react";
import toast from "react-hot-toast";
import { PageContainer, PageHeader } from "@/components/page";
import {
bookingsService,
type ConsolidationApprovalRow,
} from "@/services/bookings.service";
import { formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
const QUEUE_KEY = ["consolidation-approvals", "queue"];
/**
* Review queue for shared-wagon pairings.
*
* A booking that fills its own wagons goes straight to Operations. A
* consolidated one waits here: two customers' cargo rides one physical wagon
* under two separate invoices, so a person signs off on the pairing first.
* Approving releases BOTH bookings to Operations; rejecting sends BOTH back to
* GL with the reason.
*/
export default function ConsolidationApprovalsPage() {
const qc = useQueryClient();
const [decision, setDecision] = useState<{
row: ConsolidationApprovalRow;
kind: "approve" | "reject";
} | null>(null);
const [note, setNote] = useState("");
const {
data: rows,
isLoading,
isError,
} = useQuery({
queryKey: QUEUE_KEY,
queryFn: () => bookingsService.consolidationApprovalQueue(),
});
const close = () => {
setDecision(null);
setNote("");
};
const decide = useMutation({
mutationFn: () => {
if (!decision) throw new Error("No pairing selected");
return decision.kind === "approve"
? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined)
: bookingsService.rejectConsolidation(decision.row.id, note.trim());
},
onSuccess: () => {
toast.success(
decision?.kind === "approve"
? "Shared wagon approved — both bookings sent to Operations"
: "Shared wagon rejected — both bookings returned to GL",
);
void qc.invalidateQueries({ queryKey: QUEUE_KEY });
close();
},
onError: (error) =>
toast.error(extractErrorMessage(error, "Could not record the decision")),
});
// A rejection has to tell GL what to fix, so the reason is mandatory there.
const confirmDisabled =
decide.isPending || (decision?.kind === "reject" && !note.trim());
return (
<PageContainer>
<PageHeader
title="Shared wagon approvals"
subtitle="Two customers' cargo on one wagon — review the pairing before it reaches Operations."
/>
{isLoading ? (
<Center py={80}>
<Loader color="edr-green" />
</Center>
) : isError ? (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
Could not load the approval queue.
</Alert>
) : !rows?.length ? (
<Alert color="gray" radius="md" icon={<Check size={16} />}>
Nothing waiting for approval.
</Alert>
) : (
<Stack gap="md">
{rows.map((row) => (
<Paper
key={row.id}
withBorder
radius="lg"
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap={8} align="center" mb={10}>
<ThemeIcon variant="light" color="blue" radius="md" size={30}>
<Link2 size={16} />
</ThemeIcon>
<Text fw={800} fz={15}>
Shared wagon
</Text>
<Badge color="yellow" variant="light" radius="sm">
Awaiting approval
</Badge>
</Group>
<Group gap="xl" wrap="wrap">
<BookingSide
id={row.bookingId}
reference={row.booking?.reference ?? row.bookingReference}
company={row.booking?.company?.name}
/>
<BookingSide
id={row.partnerBookingId}
reference={
row.partnerBooking?.reference ??
row.partnerBookingReference
}
company={row.partnerBooking?.company?.name}
/>
</Group>
<Group gap={6} mt={12} c="dimmed">
<Clock size={13} />
<Text fz={12}>
Requested {formatDateTime(row.requestedAt)}
{row.scheduledDate
? ` · ships ${formatDateTime(row.scheduledDate)}`
: ""}
</Text>
</Group>
</Box>
<Group gap="sm">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={15} />}
onClick={() => {
setDecision({ row, kind: "approve" });
setNote("");
}}
>
Approve
</Button>
<Button
color="red"
variant="light"
radius="md"
leftSection={<X size={15} />}
onClick={() => {
setDecision({ row, kind: "reject" });
setNote("");
}}
>
Reject
</Button>
</Group>
</Group>
</Paper>
))}
</Stack>
)}
<Modal
opened={Boolean(decision)}
onClose={() => {
if (!decide.isPending) close();
}}
centered
radius="lg"
title={
<Text fw={800} fz={16}>
{decision?.kind === "approve"
? "Approve this shared wagon?"
: "Reject this shared wagon?"}
</Text>
}
>
<Stack gap="md">
<Text fz="sm" c="dimmed">
{decision?.kind === "approve"
? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."
: "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."}
</Text>
<Textarea
label={
decision?.kind === "approve"
? "Note (optional)"
: "Reason (required)"
}
description={
decision?.kind === "approve"
? "Recorded with the approval for the audit trail."
: "GL sees this on both bookings — say what has to change."
}
placeholder={
decision?.kind === "approve"
? "Anything worth recording…"
: "e.g. the partner's cargo weights are unbalanced for one wagon"
}
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={3}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={close}
disabled={decide.isPending}
>
Cancel
</Button>
<Button
color={decision?.kind === "approve" ? "edr-green" : "red"}
radius="md"
loading={decide.isPending}
disabled={confirmDisabled}
onClick={() => decide.mutate()}
>
{decision?.kind === "approve" ? "Approve both" : "Reject both"}
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}
/** One half of the wagon: its reference (linked) and whose cargo it is. */
function BookingSide({
id,
reference,
company,
}: {
id: string;
reference?: string | null;
company?: string | null;
}) {
return (
<Box style={{ minWidth: 0 }}>
<Text
component={Link}
to={`/dashboard/booking-requests/${id}`}
fz={14}
fw={700}
c="blue.7"
style={{ textDecoration: "none" }}
>
{reference ?? "—"}
</Text>
<Text fz={12.5} c="dimmed">
{company ?? "—"}
</Text>
</Box>
);
}

View File

@@ -29,7 +29,7 @@ import {
Weight, Weight,
Wrench, Wrench,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel"; import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
@@ -98,7 +98,14 @@ export default function TrainBuilderDetailPage() {
const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband); const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband);
const compositionQuery = useQuery( const compositionQuery = useQuery(
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }), api.trainBuilder.composition.queryOptions({
input: { id },
enabled: Boolean(id),
// Mutations seed this key from their own response (see `seedComposition`
// in services/api.ts), so the cached consist is authoritative — the
// global staleTime of 0 would otherwise refetch it on every remount.
staleTime: 30_000,
}),
); );
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions()); const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions()); const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
@@ -111,6 +118,34 @@ export default function TrainBuilderDetailPage() {
const activate = useMutation(api.trainBuilder.activate.mutationOptions()); const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data; const composition = compositionQuery.data;
// The diagram memoizes off its `locomotives`/`wagons` props; building those
// arrays inline in JSX would hand it a new identity on every render and
// re-normalize + repaint every car for each keystroke or pending mutation.
const diagramLocomotives = useMemo(
() =>
(composition?.locomotives ?? []).map((loco) => ({
code: loco.code,
name: loco.name,
maxPullWeightTons: loco.maxPullWeightTons,
})),
[composition?.locomotives],
);
const diagramWagons = useMemo(
() =>
(composition?.wagons ?? []).map((wagon, index) => ({
sequenceNo: wagon.sequenceNumber ?? index + 1,
capacityTons: wagon.wagonType?.capacityTons ?? 0,
// No bookings at build time — wagons ride empty until allocation.
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
wagonTypeCode: wagon.wagonType?.code ?? null,
physicalWagonNumber: wagon.wagonNumber,
allocations: [],
})),
[composition?.wagons],
);
// Staff identify a train by its operational run numbers, not the internal // Staff identify a train by its operational run numbers, not the internal
// code — mirrors formatTrainRunLabel on the API, which writes the history note. // code — mirrors formatTrainRunLabel on the API, which writes the history note.
const trainRunLabel = const trainRunLabel =
@@ -130,17 +165,62 @@ export default function TrainBuilderDetailPage() {
maintenanceWagon.isPending || maintenanceWagon.isPending ||
reorderWagons.isPending; reorderWagons.isPending;
const withToast = async (action: () => Promise<unknown>, failTitle: string) => { const withToast = useCallback(
try { async (action: () => Promise<unknown>, failTitle: string) => {
await action(); try {
} catch (err) { await action();
toast({ } catch (err) {
title: failTitle, toast({
description: parseError(err, "Something went wrong"), title: failTitle,
variant: "destructive", description: parseError(err, "Something went wrong"),
}); variant: "destructive",
} });
}; }
},
[toast],
);
// Stable handlers: the consist list and wagon picker are memoized, so a new
// closure each render would defeat the memo and re-render every wagon row
// (and re-mount the drag context) on unrelated state changes.
// `trainId` is only absent before the composition loads, and these handlers
// are wired to controls that render after that — the guard keeps the promise
// rather than leaning on a non-null assertion.
const trainId = composition?.id;
const handleAssign = useCallback(
(wagonIds: string[]) => {
if (!trainId) return;
void withToast(
() => assignWagons.mutateAsync({ id: trainId, wagonIds }),
"Could not add wagons",
);
},
[withToast, assignWagons.mutateAsync, trainId],
);
const handleReorder = useCallback(
(wagonIds: string[]) => {
if (!trainId) return;
void withToast(
() => reorderWagons.mutateAsync({ id: trainId, wagonIds }),
"Could not reorder wagons",
);
},
[withToast, reorderWagons.mutateAsync, trainId],
);
const handleRemove = useCallback(
(wagonId: string) => {
if (!trainId) return;
void withToast(
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
"Could not detach wagon",
);
},
[withToast, removeWagon.mutateAsync, trainId],
);
const handleMaintenance = useCallback(
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
[],
);
if (compositionQuery.isLoading) { if (compositionQuery.isLoading) {
return ( return (
@@ -364,21 +444,8 @@ export default function TrainBuilderDetailPage() {
<Stack gap="sm"> <Stack gap="sm">
<TrainCompositionDiagram <TrainCompositionDiagram
locomotives={composition.locomotives.map((loco) => ({ locomotives={diagramLocomotives}
code: loco.code, wagons={diagramWagons}
name: loco.name,
maxPullWeightTons: loco.maxPullWeightTons,
}))}
wagons={composition.wagons.map((wagon, index) => ({
sequenceNo: wagon.sequenceNumber ?? index + 1,
capacityTons: wagon.wagonType?.capacityTons ?? 0,
// No bookings at build time — wagons ride empty until allocation.
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
wagonTypeCode: wagon.wagonType?.code ?? null,
physicalWagonNumber: wagon.wagonNumber,
allocations: [],
}))}
trainNumber={composition.code} trainNumber={composition.code}
totalLengthMeters={totals.totalLengthMeters} totalLengthMeters={totals.totalLengthMeters}
/> />
@@ -412,12 +479,7 @@ export default function TrainBuilderDetailPage() {
exportTrainNumber={composition.exportTrainNumber} exportTrainNumber={composition.exportTrainNumber}
importTrainNumber={composition.importTrainNumber} importTrainNumber={composition.importTrainNumber}
assigning={assignWagons.isPending} assigning={assignWagons.isPending}
onAssign={(wagonIds) => onAssign={handleAssign}
void withToast(
() => assignWagons.mutateAsync({ id: composition.id, wagonIds }),
"Could not add wagons",
)
}
/> />
</Stack> </Stack>
</Card> </Card>
@@ -434,19 +496,9 @@ export default function TrainBuilderDetailPage() {
wagons={composition.wagons} wagons={composition.wagons}
editable={composition.editable && canAssign} editable={composition.editable && canAssign}
busy={busy} busy={busy}
onReorder={(wagonIds) => onReorder={handleReorder}
void withToast( onRemove={handleRemove}
() => reorderWagons.mutateAsync({ id: composition.id, wagonIds }), onMaintenance={handleMaintenance}
"Could not reorder wagons",
)
}
onRemove={(wagonId) =>
void withToast(
() => removeWagon.mutateAsync({ id: composition.id, wagonId }),
"Could not detach wagon",
)
}
onMaintenance={(wagon) => setMaintenanceTarget(wagon)}
/> />
</Stack> </Stack>
</Card> </Card>

View File

@@ -284,6 +284,32 @@ const TRAIN_BUILDER_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
QUERY_KEYS.FLEET.ROOT, QUERY_KEYS.FLEET.ROOT,
]; ];
/**
* Coupling/uncoupling wagons moves wagons between the available pool and one
* train — it does not touch locomotives, so those roots stay valid. Trimming
* the set keeps a drag-reorder from refetching the whole fleet.
*/
const TRAIN_BUILDER_WAGON_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
QUERY_KEYS.TRAIN_BUILDER.ROOT,
QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
["wagons"],
];
/**
* Every train-builder mutation responds with the train's full, fresh
* composition — write it straight into the detail cache so the workspace
* repaints from the response instead of refetching what it was just handed.
*/
const seedComposition = (
input: { id: string } | string,
data: TrainComposition,
): ReadonlyArray<readonly [readonly unknown[], unknown]> => [
[
QUERY_KEYS.TRAIN_BUILDER.composition(typeof input === "string" ? input : input.id),
data,
],
];
export const api = { export const api = {
trainScheduling: { trainScheduling: {
// ── Queries ──────────────────────────────────────────────────────────── // ── Queries ────────────────────────────────────────────────────────────
@@ -2070,6 +2096,7 @@ export const api = {
trainBuilderService.setLocomotives(id, locomotiveIds).then((r) => r.data), trainBuilderService.setLocomotives(id, locomotiveIds).then((r) => r.data),
undefined, undefined,
() => TRAIN_BUILDER_INVALIDATIONS, () => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
), ),
setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>( setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>(
@@ -2079,6 +2106,7 @@ export const api = {
trainBuilderService.setYard(id, currentYardId).then((r) => r.data), trainBuilderService.setYard(id, currentYardId).then((r) => r.data),
undefined, undefined,
() => TRAIN_BUILDER_INVALIDATIONS, () => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
), ),
updateDetails: endpoint< updateDetails: endpoint<
@@ -2091,6 +2119,7 @@ export const api = {
trainBuilderService.updateDetails(id, payload).then((r) => r.data), trainBuilderService.updateDetails(id, payload).then((r) => r.data),
undefined, undefined,
() => TRAIN_BUILDER_INVALIDATIONS, () => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
), ),
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>( assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
@@ -2099,7 +2128,8 @@ export const api = {
({ id, wagonIds }) => ({ id, wagonIds }) =>
trainBuilderService.assignWagons(id, wagonIds).then((r) => r.data), trainBuilderService.assignWagons(id, wagonIds).then((r) => r.data),
undefined, undefined,
() => TRAIN_BUILDER_INVALIDATIONS, () => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
), ),
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>( removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
@@ -2108,7 +2138,8 @@ export const api = {
({ id, wagonId }) => ({ id, wagonId }) =>
trainBuilderService.removeWagon(id, wagonId).then((r) => r.data), trainBuilderService.removeWagon(id, wagonId).then((r) => r.data),
undefined, undefined,
() => TRAIN_BUILDER_INVALIDATIONS, () => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
), ),
sendWagonToMaintenance: endpoint< sendWagonToMaintenance: endpoint<
@@ -2120,7 +2151,8 @@ export const api = {
({ id, wagonId, note }) => ({ id, wagonId, note }) =>
trainBuilderService.sendWagonToMaintenance(id, wagonId, note).then((r) => r.data), trainBuilderService.sendWagonToMaintenance(id, wagonId, note).then((r) => r.data),
undefined, undefined,
() => TRAIN_BUILDER_INVALIDATIONS, () => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
), ),
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>( reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
@@ -2129,7 +2161,8 @@ export const api = {
({ id, wagonIds }) => ({ id, wagonIds }) =>
trainBuilderService.reorderWagons(id, wagonIds).then((r) => r.data), trainBuilderService.reorderWagons(id, wagonIds).then((r) => r.data),
undefined, undefined,
() => TRAIN_BUILDER_INVALIDATIONS, () => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
), ),
deactivate: endpoint<string, TrainComposition>( deactivate: endpoint<string, TrainComposition>(
@@ -2138,6 +2171,7 @@ export const api = {
(id) => trainBuilderService.deactivate(id).then((r) => r.data), (id) => trainBuilderService.deactivate(id).then((r) => r.data),
undefined, undefined,
() => TRAIN_BUILDER_INVALIDATIONS, () => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
), ),
activate: endpoint<string, TrainComposition>( activate: endpoint<string, TrainComposition>(
@@ -2146,6 +2180,7 @@ export const api = {
(id) => trainBuilderService.activate(id).then((r) => r.data), (id) => trainBuilderService.activate(id).then((r) => r.data),
undefined, undefined,
() => TRAIN_BUILDER_INVALIDATIONS, () => TRAIN_BUILDER_INVALIDATIONS,
seedComposition,
), ),
disband: endpoint<string, void>( disband: endpoint<string, void>(

View File

@@ -6,6 +6,35 @@ import type { Freight } from "@edr/types";
const B = URL_CONSTANTS.BOOKINGS; const B = URL_CONSTANTS.BOOKINGS;
/**
* One shared-wagon approval. Covers BOTH bookings on the wagon — the pair is
* decided as a unit, never one side at a time.
*/
export interface ConsolidationApprovalRow {
id: string;
bookingId: string;
partnerBookingId: string;
status: "PENDING" | "APPROVED" | "REJECTED";
requestedBy?: string | null;
requestedAt: string;
decidedBy?: string | null;
decidedAt?: string | null;
decisionNote?: string | null;
scheduledDate?: string | null;
bookingReference?: string | null;
partnerBookingReference?: string | null;
booking?: {
id: string;
reference?: string;
company?: { name?: string } | null;
} | null;
partnerBooking?: {
id: string;
reference?: string;
company?: { name?: string } | null;
} | null;
}
export interface BookingListFilter { export interface BookingListFilter {
status?: string; status?: string;
/** Comma-separated statuses for grouped tabs */ /** Comma-separated statuses for grouped tabs */
@@ -327,6 +356,60 @@ export const bookingsService = {
cancel: (id: string, reason: string) => cancel: (id: string, reason: string) =>
postBooking<BookingDetail>(B.CANCEL(id), { reason }), postBooking<BookingDetail>(B.CANCEL(id), { reason }),
// ── Shared-wagon approval gate ──────────────────────────────────────────
/** Pairings awaiting a decision, oldest first. */
consolidationApprovalQueue: async (): Promise<ConsolidationApprovalRow[]> => {
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE);
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
},
/** Decision history for one booking's shared wagon — who, when, and why. */
consolidationApprovalHistory: async (
bookingId: string,
): Promise<ConsolidationApprovalRow[]> => {
const response = await client.get(
B.CONSOLIDATION_APPROVAL_HISTORY(bookingId),
);
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
},
/** Approve: both bookings leave the gate and continue to Operations. */
approveConsolidation: async (approvalId: string, note?: string) => {
const response = await client.post(B.CONSOLIDATION_APPROVE(approvalId), {
note,
});
return unwrap(response.data);
},
/** Reject: both bookings go back to GL for changes with the reason. */
rejectConsolidation: async (approvalId: string, reason: string) => {
const response = await client.post(B.CONSOLIDATION_REJECT(approvalId), {
reason,
});
return unwrap(response.data);
},
/**
* Apply one staff decision to BOTH halves of a consolidated pair. The two
* bookings share a wagon, so they advance or cancel together — all-or-nothing
* on the server. Each half keeps its own invoice and payment.
*/
pairedDecision: async (
id: string,
decision: "accept" | "cancel" | "operationAccept" | "requestChanges",
options: { reason?: string; note?: string; validityDays?: number } = {},
): Promise<{ booking: BookingDetail; partner: BookingDetail }> => {
const response = await client.post(B.PAIRED_DECISION(id), {
decision,
...options,
});
return unwrap(response.data) as {
booking: BookingDetail;
partner: BookingDetail;
};
},
create: async (payload: Record<string, unknown>): Promise<BookingDetail> => { create: async (payload: Record<string, unknown>): Promise<BookingDetail> => {
const response = await client.post<{ booking: BookingDetail } | BookingDetail>( const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
B.BASE, B.BASE,

View File

@@ -73,6 +73,32 @@ export interface ShipmentValidation {
totalAmount?: number; totalAmount?: number;
} }
/**
* A booking GL may pick as the shared-wagon partner of an odd-20ft customs
* booking. `hasCargo` is false for a bare instance whose containers GL still
* enters on the split completion form.
*/
export interface ConsolidationCandidate {
id: string;
reference: string;
contractId: string | null;
companyName: string | null;
status: string;
tradeDirection: string | null;
originYardId: string | null;
destinationYardId: string | null;
scheduledDate: string | null;
ft20Quantity: number;
hasCargo: boolean;
}
/** Both halves of a shared-wagon completion, each with its own full payload. */
export interface CompleteConsolidatedPairPayload {
partnerBookingId: string;
booking: Freight.CreateBookingUnderContractDto;
partner: Freight.CreateBookingUnderContractDto;
}
export interface ContractListSummaryMetrics { export interface ContractListSummaryMetrics {
inQueue: number; inQueue: number;
needsAction: number; needsAction: number;
@@ -677,6 +703,46 @@ export const contractsService = {
}; };
}, },
/**
* Bookings GL may link to an odd-20ft customs booking as its shared-wagon
* partner (same route and direction, customs, odd 20ft, not already paired).
*/
listConsolidationCandidates: async (
id: string,
bookingId: string,
): Promise<ConsolidationCandidate[]> => {
const response = await client.get(
C.CONSOLIDATION_CANDIDATES(id, bookingId),
);
return (unwrap(response.data) ?? []) as ConsolidationCandidate[];
},
/**
* Complete an odd-20ft booking together with the partner booking sharing its
* wagon. All-or-nothing on the server: either both bookings complete and are
* linked, or neither does. Each booking keeps its own price and its own
* invoice — only the wagon is shared.
*/
completeConsolidatedPair: async (
id: string,
bookingId: string,
payload: CompleteConsolidatedPairPayload,
): Promise<{
booking: { id: string; reference: string };
partner: { id: string; reference: string };
warnings?: string[];
}> => {
const response = await client.post(
C.BOOKINGS_COMPLETE_CONSOLIDATED(id, bookingId),
payload,
);
return unwrap(response.data) as {
booking: { id: string; reference: string };
partner: { id: string; reference: string };
warnings?: string[];
};
},
/** /**
* Pre-create validation + authoritative price preview: the same * Pre-create validation + authoritative price preview: the same
* BookingPricingService pass that prices the booking on create (rail + * BookingPricingService pass that prices the booking on create (rail +

View File

@@ -296,6 +296,12 @@ export interface BookingListRow {
governmentInstitution?: string | null; governmentInstitution?: string | null;
consolidationPartnerId?: string | null; consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null; consolidationPartnerReference?: string | null;
/**
* The other half of a consolidated pair, folded into this row for display.
* Set client-side when both halves are present in the same page of results —
* the list shows one row per shared wagon, not one per booking.
*/
pairedWith?: BookingListRow | null;
customsClearingEnabled?: boolean; customsClearingEnabled?: boolean;
/** /**
* Derived booking kind for the list "Type" column. Mirrors the server's * Derived booking kind for the list "Type" column. Mirrors the server's

View File

@@ -33,6 +33,27 @@ export type InvalidatesMeta = (
data: unknown, data: unknown,
) => ReadonlyArray<readonly unknown[]>; ) => ReadonlyArray<readonly unknown[]>;
/**
* Cache entries a mutation can write DIRECTLY from its own response, skipping
* a refetch. Many endpoints already return the fresh entity they just changed
* (e.g. every train-builder mutation returns the whole `TrainComposition`), so
* re-fetching that same key is a wasted round-trip and a visible flicker.
*
* Returned pairs are written with `setQueryData` by the app-wide MutationCache
* BEFORE the `invalidates` keys are invalidated, and any key seeded here is
* skipped by that invalidation pass — the value just written IS the fresh one.
*/
export type UpdatesFn<TInput, TResponse> = (
input: TInput,
data: TResponse,
) => ReadonlyArray<readonly [readonly unknown[], unknown]>;
/** Shape stored in `mutation.meta.updates` and consumed by the MutationCache. */
export type UpdatesMeta = (
variables: unknown,
data: unknown,
) => ReadonlyArray<readonly [readonly unknown[], unknown]>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Endpoint interfaces // Endpoint interfaces
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -67,6 +88,7 @@ export function endpoint<TInput, TResponse>(
execute: (input: TInput) => Promise<TResponse>, execute: (input: TInput) => Promise<TResponse>,
queryKeyBuilder?: (input: TInput) => readonly unknown[], queryKeyBuilder?: (input: TInput) => readonly unknown[],
invalidates?: InvalidatesFn<TInput, TResponse>, invalidates?: InvalidatesFn<TInput, TResponse>,
updates?: UpdatesFn<TInput, TResponse>,
) { ) {
const buildKey = (input?: TInput): readonly unknown[] => { const buildKey = (input?: TInput): readonly unknown[] => {
if (queryKeyBuilder && input !== undefined) { if (queryKeyBuilder && input !== undefined) {
@@ -101,16 +123,30 @@ export function endpoint<TInput, TResponse>(
const mutationOptions = ( const mutationOptions = (
config?: Omit<UseMutationOptions<TResponse, Error, TInput>, "mutationFn">, config?: Omit<UseMutationOptions<TResponse, Error, TInput>, "mutationFn">,
): UseMutationOptions<TResponse, Error, TInput> => { ): UseMutationOptions<TResponse, Error, TInput> => {
const meta = invalidates const meta =
? { invalidates || updates
...config?.meta, ? {
invalidates: ((variables, data) => ...config?.meta,
invalidates( ...(invalidates
variables as TInput, ? {
data as TResponse, invalidates: ((variables, data) =>
)) satisfies InvalidatesMeta, invalidates(
} variables as TInput,
: config?.meta; data as TResponse,
)) satisfies InvalidatesMeta,
}
: {}),
...(updates
? {
updates: ((variables, data) =>
updates(
variables as TInput,
data as TResponse,
)) satisfies UpdatesMeta,
}
: {}),
}
: config?.meta;
return { return {
...config, ...config,

View File

@@ -163,11 +163,17 @@ export function Step8Review({
.join(", ") .join(", ")
: ""; : "";
// 20ft containers must pair up (two per wagon) — an odd total blocks submit. // 20ft containers must pair up (two per wagon). Without customs an odd total
// still blocks submit — nothing downstream can place the unpaired container.
// With customs it is allowed: Global Logistics completes the booking and links
// it to another customer's odd booking so the two share the wagon, so an odd
// count here is only a notice.
const { hasOddUnit: hasOdd20ft, ft20Wagons: twentyFtCount } = const { hasOddUnit: hasOdd20ft, ft20Wagons: twentyFtCount } =
values.cargoType === "container" values.cargoType === "container"
? calcWagons(values.containers ?? []) ? calcWagons(values.containers ?? [])
: { hasOddUnit: false, ft20Wagons: 0 }; : { hasOddUnit: false, ft20Wagons: 0 };
const oddPairsViaCustoms = hasOdd20ft && Boolean(values.customsClearingEnabled);
const oddBlocksSubmit = hasOdd20ft && !oddPairsViaCustoms;
const isGeneralContract = values.bookingType === "general_contract"; const isGeneralContract = values.bookingType === "general_contract";
// Both one-time and general contracts take the bulk amount from the cargo step // Both one-time and general contracts take the bulk amount from the cargo step
@@ -543,7 +549,7 @@ export function Step8Review({
</Paper> </Paper>
<Paper radius={20} p="lg" withBorder bg="white"> <Paper radius={20} p="lg" withBorder bg="white">
{hasOdd20ft ? ( {oddBlocksSubmit ? (
<Box mb="md"> <Box mb="md">
<AlertBox tone="error"> <AlertBox tone="error">
<p className="font-semibold"> <p className="font-semibold">
@@ -558,6 +564,20 @@ export function Step8Review({
</p> </p>
</AlertBox> </AlertBox>
</Box> </Box>
) : oddPairsViaCustoms ? (
<Box mb="md">
<AlertBox tone="info">
<p className="font-semibold">
Odd number of 20ft containers ({twentyFtCount})
</p>
<p className="mt-1 text-xs">
20ft containers travel two per wagon, so one of yours will
share a wagon with another shipment. Global Logistics
arranges the pairing when completing your booking you are
billed only for your own containers.
</p>
</AlertBox>
</Box>
) : noWagonForSelectedDay ? ( ) : noWagonForSelectedDay ? (
<Box mb="md"> <Box mb="md">
<AlertBox tone="error"> <AlertBox tone="error">
@@ -587,7 +607,7 @@ export function Step8Review({
leftSection={<Send size={16} />} leftSection={<Send size={16} />}
onClick={onSubmit} onClick={onSubmit}
loading={submitPending} loading={submitPending}
disabled={submitPending || hasOdd20ft || noWagonForSelectedDay} disabled={submitPending || oddBlocksSubmit || noWagonForSelectedDay}
> >
Submit Submit
</Button> </Button>

View File

@@ -106,14 +106,14 @@ export default function NewShipmentRequestPage() {
contract.cargoScope?.[0]; contract.cargoScope?.[0];
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM"; const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
// 20ft containers ride two per wagon, so an odd total can never be planned — // 20ft containers ride two per wagon. An odd total leaves one unpaired, which
// and GL's create-booking form blocks it too, so an odd request would only // is allowed here: on a customs contract GL completes the booking and links it
// dead-end there. Same even-number rule the booking forms apply. // to another customer's odd booking so the two share the wagon. The request is
// therefore informational only, not a block.
const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0; const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0;
const hasOdd20ft = ft20Requested % 2 === 1; const hasOdd20ft = ft20Requested % 2 === 1;
const handleSubmit = () => { const handleSubmit = () => {
if (hasOdd20ft) return;
const dto: Freight.CreateBookingRequestDto = { const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id, contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined, scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
@@ -220,17 +220,17 @@ export default function NewShipmentRequestPage() {
{hasOdd20ft ? ( {hasOdd20ft ? (
<Alert <Alert
color="red" color="blue"
variant="light" variant="light"
radius="md" radius="md"
icon={<AlertCircle size={16} />} icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Requested})`} title={`Odd number of 20ft containers (${ft20Requested})`}
> >
<Text fz={13}> <Text fz={13}>
20ft containers travel two per wagon, so they must be requested 20ft containers travel two per wagon, so one of yours will
in even numbers. Please add one more 20ft container or remove share a wagon with another shipment. Global Logistics arranges
one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "} the pairing when completing your booking you are billed only
instead of {ft20Requested}). for your own containers.
</Text> </Text>
</Alert> </Alert>
) : null} ) : null}
@@ -281,7 +281,6 @@ export default function NewShipmentRequestPage() {
leftSection={<Send size={16} />} leftSection={<Send size={16} />}
loading={submit.isPending} loading={submit.isPending}
onClick={handleSubmit} onClick={handleSubmit}
disabled={hasOdd20ft}
> >
Submit shipment request Submit shipment request
</Button> </Button>