mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: Implement consolidated booking functionality
- Added support for viewing and managing consolidated bookings in BookingRequestDetailPage. - Enhanced BookingRequestsPage to display paired bookings in a single row. - Introduced pairedDecision method in bookings service to handle decisions for both halves of a consolidated pair. - Updated contracts service to include methods for manual consolidation of odd-20ft bookings. - Created new components for selecting and editing consolidation partners. - Added tests for paired decision logic and manual consolidation scenarios. - Updated UI to reflect changes in booking handling and provide user feedback for odd container counts.
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
|
||||
@@ -59,6 +59,7 @@ import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import {
|
||||
AcceptIntakeDto,
|
||||
CancelBookingDto,
|
||||
PairedDecisionDto,
|
||||
RejectBookingDto,
|
||||
RequestChangesDto,
|
||||
ReviewDocumentDto,
|
||||
@@ -1541,6 +1542,31 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@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")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||
@ApiOperation({ summary: "Cancel booking" })
|
||||
|
||||
@@ -308,6 +308,72 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.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)
|
||||
* (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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
|
||||
@@ -125,3 +125,36 @@ export class OperationReviewDto {
|
||||
@IsString()
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
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
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
||||
import {
|
||||
CompleteConsolidatedPairDto,
|
||||
CreateBookingContainerLineDto,
|
||||
CreateBookingUnderContractDto,
|
||||
} from './dto/create-booking-under-contract.dto';
|
||||
@@ -62,6 +63,25 @@ export interface CreateBookingUnderContractResult {
|
||||
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
|
||||
* booking's pre-split snapshot MINUS everything currently booked. Container
|
||||
@@ -598,6 +618,134 @@ export class ContractBookingService {
|
||||
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,
|
||||
): 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 };
|
||||
});
|
||||
|
||||
// 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
|
||||
* finalized (CLEARANCE_READY) or operations returned it for changes
|
||||
@@ -826,10 +974,18 @@ export class ContractBookingService {
|
||||
// exactly like a drawdown created with cargo does. The shipment day is
|
||||
// stored first so the pairing event can resume straight into the
|
||||
// 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);
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
!dto.skipAutoConsolidation &&
|
||||
(await this.consolidationService.needsConsolidationFromBooking(withContainers))
|
||||
) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
|
||||
@@ -76,7 +76,10 @@ import {
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.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 {
|
||||
CreateBookingRequestDto,
|
||||
ReviewBookingRequestDto,
|
||||
@@ -1152,6 +1155,41 @@ export class ContractsController {
|
||||
// Customs (Path B) instances may only be completed by GL Ethiopia — the
|
||||
// service checks the actor's contracts:create_booking permission.
|
||||
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,
|
||||
bookingId,
|
||||
dto,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiHideProperty, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
@@ -219,4 +219,46 @@ export class CreateBookingUnderContractDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export function BookingActionsMenu({
|
||||
reference: row.reference,
|
||||
schedulingStatus: row.schedulingStatus,
|
||||
customsClearingEnabled: row.customsClearingEnabled,
|
||||
consolidationPartnerId: row.consolidationPartnerId,
|
||||
};
|
||||
|
||||
const flow = useBookingActionDialog(row.id, context);
|
||||
@@ -92,7 +93,13 @@ export function BookingActionsMenu({
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
|
||||
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
|
||||
<ActionDialog
|
||||
flow={flow}
|
||||
pendingAction={pendingAction}
|
||||
onSuppressRowClick={onSuppressRowClick}
|
||||
consolidationPartnerId={row.consolidationPartnerId}
|
||||
consolidationPartnerReference={row.consolidationPartnerReference}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -158,10 +171,14 @@ function ActionDialog({
|
||||
flow,
|
||||
pendingAction,
|
||||
onSuppressRowClick,
|
||||
consolidationPartnerId,
|
||||
consolidationPartnerReference,
|
||||
}: {
|
||||
flow: ReturnType<typeof useBookingActionDialog>;
|
||||
pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"];
|
||||
onSuppressRowClick?: () => void;
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartnerReference?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<BookingConfirmDialog
|
||||
@@ -182,6 +199,17 @@ function ActionDialog({
|
||||
}}
|
||||
isPending={flow.mutations.isPending || flow.detailLoading}
|
||||
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 { Link2 } from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Modal,
|
||||
Group,
|
||||
Stack,
|
||||
@@ -37,6 +39,12 @@ interface BookingConfirmDialogProps {
|
||||
isPending: boolean;
|
||||
confirmDisabled?: boolean;
|
||||
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({
|
||||
@@ -52,6 +60,7 @@ export function BookingConfirmDialog({
|
||||
isPending,
|
||||
confirmDisabled = false,
|
||||
extra,
|
||||
pairedWithReference = null,
|
||||
}: BookingConfirmDialogProps) {
|
||||
if (!action || !action.confirmTitle) return null;
|
||||
|
||||
@@ -125,6 +134,21 @@ export function BookingConfirmDialog({
|
||||
{action.confirmDescription}
|
||||
</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>
|
||||
|
||||
{/* Body */}
|
||||
|
||||
@@ -14,6 +14,19 @@ function isValidValidityDays(value: string): boolean {
|
||||
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(
|
||||
bookingId: string,
|
||||
context: BookingActionContext,
|
||||
@@ -52,6 +65,30 @@ export function useBookingActionDialog(
|
||||
|
||||
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) {
|
||||
case "accept": {
|
||||
const days = Number(inputValue.trim());
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
FileText,
|
||||
FileUp,
|
||||
Flame,
|
||||
Link2,
|
||||
MapPin,
|
||||
Package,
|
||||
Receipt,
|
||||
@@ -57,7 +59,10 @@ import {
|
||||
import { api } from "@/services/api";
|
||||
import { PageContainer } from "@/components/page";
|
||||
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 {
|
||||
useContractCapacity,
|
||||
@@ -80,6 +85,18 @@ import {
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
} 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. */
|
||||
const EAT_TZ = "Africa/Addis_Ababa";
|
||||
@@ -240,6 +257,14 @@ export default function GlCreateBookingForm() {
|
||||
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
|
||||
// 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.
|
||||
@@ -290,6 +315,18 @@ export default function GlCreateBookingForm() {
|
||||
const [withReturn, setWithReturn] = useState(false);
|
||||
const [prefilled, setPrefilled] = 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 returnSeededRef = useRef(false);
|
||||
|
||||
@@ -834,6 +871,48 @@ export default function GlCreateBookingForm() {
|
||||
}, [isContainer, containerLines]);
|
||||
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 bulkErrors = useMemo<BulkErrors>(() => {
|
||||
@@ -886,7 +965,64 @@ export default function GlCreateBookingForm() {
|
||||
!cargoDescriptionError
|
||||
: !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
|
||||
* authoritative price preview and the actual submit so what GL confirms is
|
||||
@@ -953,6 +1089,44 @@ export default function GlCreateBookingForm() {
|
||||
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
|
||||
// create): rail freight + first/last mile + overweight + every surcharge,
|
||||
// plus the hard-block checks (20ft pairing, max capacity, container numbers
|
||||
@@ -964,6 +1138,22 @@ export default function GlCreateBookingForm() {
|
||||
});
|
||||
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 items = validation?.lineItems;
|
||||
if (!items?.length) return null;
|
||||
@@ -1010,8 +1200,52 @@ export default function GlCreateBookingForm() {
|
||||
};
|
||||
}, [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 =
|
||||
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
|
||||
// estimate is display-only; booking on it would confirm an un-validated,
|
||||
@@ -1023,7 +1257,13 @@ export default function GlCreateBookingForm() {
|
||||
capacityErrors.length > 0 ||
|
||||
containerClashErrors.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 = () => {
|
||||
// Surface the per-field errors (portal-parity validation) instead of
|
||||
@@ -1039,6 +1279,15 @@ export default function GlCreateBookingForm() {
|
||||
validateShipmentMutation.reset();
|
||||
validateShipmentMutation.mutate(payload);
|
||||
}
|
||||
validatePartnerMutation.reset();
|
||||
const partnerPayload = buildPartnerPayload();
|
||||
if (partnerPayload && partner?.contractId) {
|
||||
validatePartnerMutation.mutate({
|
||||
contractId: partner.contractId,
|
||||
bookingId: partner.id,
|
||||
dto: partnerPayload,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
@@ -1054,6 +1303,25 @@ export default function GlCreateBookingForm() {
|
||||
const payload = buildPayload();
|
||||
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) {
|
||||
// Completion mode: cargo + day land on the already-cleared instance —
|
||||
// the request was linked and accepted at submission time.
|
||||
@@ -1347,6 +1615,18 @@ export default function GlCreateBookingForm() {
|
||||
maxRows={4}
|
||||
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 ? (
|
||||
<Text fz="sm" c="dimmed">
|
||||
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
|
||||
color="red"
|
||||
variant="light"
|
||||
@@ -1540,6 +1884,34 @@ export default function GlCreateBookingForm() {
|
||||
— the booking cannot be created with an unpaired 20ft container.
|
||||
</Alert>
|
||||
) : 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>
|
||||
</StepCard>
|
||||
) : (
|
||||
@@ -1806,12 +2178,31 @@ export default function GlCreateBookingForm() {
|
||||
>
|
||||
Fix the highlighted fields before reviewing the price.
|
||||
</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}
|
||||
<Group justify="flex-end">
|
||||
<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
|
||||
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,
|
||||
so the wrapper carries the hover target. */}
|
||||
@@ -1821,9 +2212,11 @@ export default function GlCreateBookingForm() {
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={openPriceModal}
|
||||
// Same hard block the customer portal applies at review time —
|
||||
// an unpaired 20ft can never be planned onto a wagon.
|
||||
disabled={hasOdd20ft}
|
||||
// An unpaired 20ft can never be planned onto a wagon — unless
|
||||
// a parent booking is linked to share it, which is what
|
||||
// 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
|
||||
</Button>
|
||||
@@ -1833,6 +2226,24 @@ export default function GlCreateBookingForm() {
|
||||
</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
|
||||
opened={priceOpen}
|
||||
onClose={() => {
|
||||
@@ -1984,6 +2395,18 @@ export default function GlCreateBookingForm() {
|
||||
)}
|
||||
|
||||
<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}>
|
||||
{displayTotal.lines.map((line, i) => (
|
||||
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
|
||||
@@ -2028,6 +2451,123 @@ export default function GlCreateBookingForm() {
|
||||
</Group>
|
||||
</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">
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -2046,7 +2586,11 @@ export default function GlCreateBookingForm() {
|
||||
disabled={confirmDisabled}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{completeBookingId ? "Confirm & complete" : "Confirm & book"}
|
||||
{consolidationActive && partner
|
||||
? "Confirm & book both"
|
||||
: completeBookingId
|
||||
? "Confirm & complete"
|
||||
: "Confirm & book"}
|
||||
</Button>
|
||||
</Group>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -191,6 +191,8 @@ export const URL_CONSTANTS = {
|
||||
BY_ID: (id: string) => `/bookings/${id}`,
|
||||
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
|
||||
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`,
|
||||
STAFF_REQUEST_CHANGES: (id: string) =>
|
||||
`/bookings/${id}/staff/request-changes`,
|
||||
STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
|
||||
@@ -317,6 +319,12 @@ export const URL_CONSTANTS = {
|
||||
AWAITING_SHIPMENT: "/contracts/awaiting-shipment",
|
||||
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
|
||||
`/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`,
|
||||
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
|
||||
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.
|
||||
|
||||
@@ -59,6 +59,9 @@ export type BookingActionContext = Pick<
|
||||
| "reference"
|
||||
| "schedulingStatus"
|
||||
| "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([
|
||||
|
||||
@@ -121,7 +121,38 @@ export function useBookingMutations(bookingId: string) {
|
||||
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 =
|
||||
pairedDecision.isPending ||
|
||||
staffAccept.isPending ||
|
||||
requestChanges.isPending ||
|
||||
staffReject.isPending ||
|
||||
@@ -134,6 +165,7 @@ export function useBookingMutations(bookingId: string) {
|
||||
cancel.isPending;
|
||||
|
||||
return {
|
||||
pairedDecision,
|
||||
staffAccept,
|
||||
requestChanges,
|
||||
staffReject,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FolderOpen,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Link2,
|
||||
Milestone,
|
||||
MoreHorizontal,
|
||||
Package,
|
||||
@@ -79,14 +80,50 @@ export default function BookingRequestDetailPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
|
||||
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 {
|
||||
data: booking,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useBookingDetail(id);
|
||||
const mutations = useBookingMutations(id ?? "");
|
||||
} = useBookingDetail(selectedId);
|
||||
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) {
|
||||
return (
|
||||
@@ -349,6 +386,48 @@ export default function BookingRequestDetailPage() {
|
||||
/>
|
||||
|
||||
<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} />
|
||||
|
||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
LayoutList,
|
||||
Link2,
|
||||
Package,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -200,10 +201,28 @@ export default function BookingRequestsPage() {
|
||||
|
||||
// Search is applied server-side (via the `search` filter param) — no
|
||||
// client-side filtering here.
|
||||
const rows = useMemo(
|
||||
() => (data?.items ?? []).map(toBookingListRow),
|
||||
[data?.items],
|
||||
);
|
||||
const rows = useMemo(() => {
|
||||
const mapped = (data?.items ?? []).map(toBookingListRow);
|
||||
// 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 hasSearch = controls.searchText.trim().length > 0;
|
||||
@@ -321,6 +340,22 @@ export default function BookingRequestsPage() {
|
||||
</Badge>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -323,6 +323,26 @@ export const bookingsService = {
|
||||
cancel: (id: string, reason: string) =>
|
||||
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
|
||||
|
||||
/**
|
||||
* 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> => {
|
||||
const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
|
||||
B.BASE,
|
||||
|
||||
@@ -71,6 +71,32 @@ export interface ShipmentValidation {
|
||||
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 {
|
||||
inQueue: number;
|
||||
needsAction: number;
|
||||
@@ -674,6 +700,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
|
||||
* BookingPricingService pass that prices the booking on create (rail +
|
||||
|
||||
@@ -296,6 +296,12 @@ export interface BookingListRow {
|
||||
governmentInstitution?: string | null;
|
||||
consolidationPartnerId?: 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;
|
||||
/**
|
||||
* Derived booking kind for the list "Type" column. Mirrors the server's
|
||||
|
||||
@@ -163,11 +163,17 @@ export function Step8Review({
|
||||
.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 } =
|
||||
values.cargoType === "container"
|
||||
? calcWagons(values.containers ?? [])
|
||||
: { hasOddUnit: false, ft20Wagons: 0 };
|
||||
const oddPairsViaCustoms = hasOdd20ft && Boolean(values.customsClearingEnabled);
|
||||
const oddBlocksSubmit = hasOdd20ft && !oddPairsViaCustoms;
|
||||
|
||||
const isGeneralContract = values.bookingType === "general_contract";
|
||||
// Both one-time and general contracts take the bulk amount from the cargo step
|
||||
@@ -543,7 +549,7 @@ export function Step8Review({
|
||||
</Paper>
|
||||
|
||||
<Paper radius={20} p="lg" withBorder bg="white">
|
||||
{hasOdd20ft ? (
|
||||
{oddBlocksSubmit ? (
|
||||
<Box mb="md">
|
||||
<AlertBox tone="error">
|
||||
<p className="font-semibold">
|
||||
@@ -558,6 +564,20 @@ export function Step8Review({
|
||||
</p>
|
||||
</AlertBox>
|
||||
</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 ? (
|
||||
<Box mb="md">
|
||||
<AlertBox tone="error">
|
||||
@@ -587,7 +607,7 @@ export function Step8Review({
|
||||
leftSection={<Send size={16} />}
|
||||
onClick={onSubmit}
|
||||
loading={submitPending}
|
||||
disabled={submitPending || hasOdd20ft || noWagonForSelectedDay}
|
||||
disabled={submitPending || oddBlocksSubmit || noWagonForSelectedDay}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
|
||||
@@ -106,14 +106,14 @@ export default function NewShipmentRequestPage() {
|
||||
contract.cargoScope?.[0];
|
||||
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
|
||||
|
||||
// 20ft containers ride two per wagon, so an odd total can never be planned —
|
||||
// and GL's create-booking form blocks it too, so an odd request would only
|
||||
// dead-end there. Same even-number rule the booking forms apply.
|
||||
// 20ft containers ride two per wagon. An odd total leaves one unpaired, which
|
||||
// is allowed here: on a customs contract GL completes the booking and links it
|
||||
// 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 hasOdd20ft = ft20Requested % 2 === 1;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (hasOdd20ft) return;
|
||||
const dto: Freight.CreateBookingRequestDto = {
|
||||
contractRouteId: route?.id,
|
||||
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
||||
@@ -220,17 +220,17 @@ export default function NewShipmentRequestPage() {
|
||||
|
||||
{hasOdd20ft ? (
|
||||
<Alert
|
||||
color="red"
|
||||
color="blue"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title={`Odd number of 20ft containers (${ft20Requested})`}
|
||||
>
|
||||
<Text fz={13}>
|
||||
20ft containers travel two per wagon, so they must be requested
|
||||
in even numbers. Please add one more 20ft container or remove
|
||||
one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "}
|
||||
instead of {ft20Requested}).
|
||||
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.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -281,7 +281,6 @@ export default function NewShipmentRequestPage() {
|
||||
leftSection={<Send size={16} />}
|
||||
loading={submit.isPending}
|
||||
onClick={handleSubmit}
|
||||
disabled={hasOdd20ft}
|
||||
>
|
||||
Submit shipment request
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user