mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 21:48:18 +00:00
Merge pull request #1337 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, [
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maker–checker: 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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 booking’s 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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 & book
|
Review price & 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
58
apps/edr-freight-web/backoffice/src/lib/queryClient.test.ts
Normal file
58
apps/edr-freight-web/backoffice/src/lib/queryClient.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 +
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user