Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice

This commit is contained in:
natib21
2026-07-04 01:15:08 +00:00
33 changed files with 1140 additions and 476 deletions

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add a hard per-unit weight ceiling to weight limit rules.
*
* maxVgmTons stays the soft "overweight" threshold (surcharge + warning);
* max_capacity_tons is the absolute ceiling above which a booking cannot be
* created at all. Null means no ceiling (existing behavior).
*/
export class AddMaxCapacityToWeightLimitRules1930000000000
implements MigrationInterface
{
name = "AddMaxCapacityToWeightLimitRules1930000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
ADD COLUMN IF NOT EXISTS max_capacity_tons numeric(8, 3);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
DROP COLUMN IF EXISTS max_capacity_tons;
`);
}
}

View File

@@ -431,7 +431,7 @@ export class BookingPricingService {
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
const wagonCount = await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
@@ -571,6 +571,23 @@ export class BookingPricingService {
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
/**
* Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate;
* an unsaved preview booking (no id) sums the wagonsRequired already computed
* on its in-memory container lines — same math, no DB row needed.
*/
private async resolveWagonCount(booking: Booking): Promise<number> {
if (!booking.id) {
return Math.ceil(
(booking.bookingContainers ?? []).reduce(
(sum, bc) => sum + Number(bc.wagonsRequired ?? 0),
0,
),
);
}
return this.bookingsRepository.calculateWagonCount(booking.id);
}
/** Friendly container-type label for the per-unit card; degrades to "Container". */
private async containerTypeLabel(containerTypeId: string): Promise<string> {
try {

View File

@@ -1051,7 +1051,22 @@ export class BookingTransitionService {
// paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
// only reserve once both partners are FULLY_EXECUTED (handled inside).
const fresh = await this.bookingsService.findById(booking.id);
await this.bookingBatchService.acceptExportBooking(fresh);
try {
await this.bookingBatchService.acceptExportBooking(fresh);
} catch (err) {
// The status update above already committed. Without compensation the
// client gets an error for a booking that reads as accepted after a
// refresh — half-applied state. Put the request back so staff can retry.
await this.bookingsRepository.update(booking.id, {
status: "OPERATION_REQUEST_PENDING",
fullyExecutedAt: null,
lockedAt: booking.lockedAt ?? null,
} as never);
this.logger.warn(
`Export accept failed post-commit for ${booking.reference}:${booking.id}; reverted to OPERATION_REQUEST_PENDING: ${(err as Error).message}`,
);
throw err;
}
}
// IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the
// batch runs after the window closes + staff document review, never at accept
@@ -1073,23 +1088,59 @@ export class BookingTransitionService {
} | null;
}
> {
const note = await this.bookingsRepository.findLatestReviewNote(
booking.id,
"CHANGES_REQUESTED",
);
const summary =
booking.contractSummary ??
this.contractService.buildContractSummary(booking);
const nextPending =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE"
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
const nextStep = computeNextStep(booking, nextPending);
const activeBatchOffer =
booking.status === "SELECTED_FOR_BATCH"
? await this.bookingBatchService.getOpenOfferSummary(booking.id)
: null;
// This enrichment runs AFTER the transition has committed. A failure here
// must never 500 the response — the client would report "failed" for a
// transition that actually succeeded (visible only after a refresh).
// Degrade each fragile field to null instead.
let note: Awaited<
ReturnType<typeof this.bookingsRepository.findLatestReviewNote>
> = null;
try {
note = await this.bookingsRepository.findLatestReviewNote(
booking.id,
"CHANGES_REQUESTED",
);
} catch (err) {
this.logger.warn(
`enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
let summary: string | null = booking.contractSummary ?? null;
try {
summary =
booking.contractSummary ??
this.contractService.buildContractSummary(booking);
} catch (err) {
this.logger.warn(
`enrichBookingResponse: contract summary failed for ${booking.id}: ${(err as Error).message}`,
);
}
let nextStep: BookingNextStep | null = null;
try {
const nextPending =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE"
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
nextStep = computeNextStep(booking, nextPending);
} catch (err) {
this.logger.warn(
`enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
let activeBatchOffer: Awaited<
ReturnType<typeof this.bookingBatchService.getOpenOfferSummary>
> = null;
try {
activeBatchOffer =
booking.status === "SELECTED_FOR_BATCH"
? await this.bookingBatchService.getOpenOfferSummary(booking.id)
: null;
} catch (err) {
this.logger.warn(
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
return {
...booking,
latestChangeRequestNote: note?.note ?? null,

View File

@@ -587,6 +587,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
// Contract reference for the list column + search (no entity relation on
// Booking → contract, so join by id and select just the reference).
.leftJoin('freight.contracts', 'contract', 'contract.id = booking.contract_id')
.addSelect('contract.reference', 'contract_reference')
.where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options);
@@ -605,10 +609,24 @@ export class BookingsRepository extends BaseRepository<Booking> {
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
}
const [items, total] = await qb
const total = await qb.getCount();
const { entities: items, raw } = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
.getRawAndEntities();
// The joined contract.reference comes back on the raw rows only (entity has no
// contract relation) — map it onto each booking by position.
const contractRefByBooking = new Map<string, string | null>();
for (const row of raw as Array<{ booking_id: string; contract_reference: string | null }>) {
if (row.booking_id && !contractRefByBooking.has(row.booking_id)) {
contractRefByBooking.set(row.booking_id, row.contract_reference ?? null);
}
}
for (const item of items) {
(item as Booking & { contractReference?: string | null }).contractReference =
contractRefByBooking.get(item.id) ?? null;
}
if (items.length) {
const links = await this.dataSource.getRepository(TrainScheduleBooking).find({
@@ -1059,7 +1077,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
company: true,
originYard: true,
destinationYard: true,
bookingContainers: { containerType: true },
// units carry the real per-container numbers entered at booking time —
// the wagon plan shows those instead of generated placeholders.
bookingContainers: { containerType: true, units: true },
cargoType: true,
},
order: { priorityScore: 'DESC', createdAt: 'ASC' },

View File

@@ -8,13 +8,13 @@ import {
forwardRef,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { ExchangeService } from '@edr/api-common';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
@@ -66,7 +66,6 @@ export class ContractBookingService {
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly dataSource: DataSource,
private readonly exchangeService: ExchangeService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
@@ -134,6 +133,13 @@ export class ContractBookingService {
});
}
// Hard capacity gate: a container line whose total weight exceeds the
// container type's max capacity can never be booked — no surcharge path,
// no override. Checked before any row is written.
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
const booking = await this.bookingsRepository.create({
reference,
@@ -587,11 +593,14 @@ export class ContractBookingService {
}
/**
* Pre-create validation for the shipment form: run the overweight rule + the
* 20ft weight-pairing rule against the entered containers WITHOUT persisting a
* booking. The portal calls this from the price-confirm modal so the customer
* sees the overweight warning (+ surcharge basis) and is blocked on an
* un-pairable 20ft set before the booking is created.
* Pre-create validation + authoritative price preview for the shipment form:
* build an UNSAVED booking shaped exactly like {@link createUnderContract}
* would persist it and run the same BookingPricingService compute over it —
* base rail freight, first/last-mile trucking, and every rule-engine surcharge
* (overweight, hazard, reefer, consolidation, …). The portal and the GL
* backoffice form call this from the price-confirm modal, so the breakdown the
* user confirms is line-for-line what the booking will be charged. Also runs
* the 20ft weight-pairing rule, which hard-blocks creation.
*/
async validateShipment(
contractId: string,
@@ -606,22 +615,28 @@ export class ContractBookingService {
overweightSurchargeAmount: number;
currency: string | null;
pairingErrors: string[];
capacityErrors: string[];
lineItems: PriceLineItemDto[];
totalAmount: number;
}> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const lines = dto.containers ?? [];
if (!lines.length) {
if (contract.freightType === 'CONTAINER' && !lines.length) {
return {
overweightLines: [],
overweightSurchargeAmount: 0,
currency: null,
pairingErrors: [],
capacityErrors: [],
lineItems: [],
totalAmount: 0,
};
}
// Resolve each line's container type + total VGM (sum of unit weights) so the
// rule engine can flag overweight per line (maxVgmTons × quantity vs total).
// Resolve each container line's type + total VGM (sum of unit weights)
// mirrors persistContainers so the preview lines match the persisted ones.
const resolved = await Promise.all(
lines.map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
@@ -636,46 +651,44 @@ export class ContractBookingService {
}),
);
const ruleResult = await this.ruleEngineService.evaluate({
freightType: 'CONTAINER',
cargoTypeId: null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
// The unsaved twin of the booking createUnderContract would write: same
// denormalized contract fields, same container-line math. No id → the
// pricing service derives wagon counts from the in-memory lines.
const route = await this.resolveRoute(contract, dto.contractRouteId);
const previewBooking = Object.assign(new Booking(), {
freightType: contract.freightType,
tradeDirection: contract.tradeDirection,
isHazardous: false,
isReefer: contract.isReefer ?? false,
isGovernment: false,
allowConsolidation: false,
paymentCurrency: contract.paymentCurrency,
serviceTypeId: contract.serviceTypeId,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
isGovernment: contract.isGovernment,
shippingLineId: null,
totalWagons: 0,
bulkTons: 0,
containers: resolved.map((r) => ({
containerTypeId: r.ct.id,
quantity: r.line.quantity,
vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0,
totalVgmTons: r.totalVgmTons,
isReefer: r.ct.isReefer,
})),
} as never);
contractRouteId: route?.id ?? null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
Object.assign(new BookingContainer(), {
containerTypeId: ct.id,
containerSize: line.containerSize,
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons,
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
}),
),
}) as Booking;
const overweightLines: Array<{
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}> = [];
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
const wr = ruleResult.containerWeightResults[i];
if (!wr?.isOverweight) continue;
const r = resolved[i];
const excessTons = Number(wr.overweightExcessTons ?? 0);
overweightLines.push({
containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '',
totalVgmTons: r?.totalVgmTons ?? 0,
maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons),
excessTons,
});
}
const computed = await this.bookingPricingService.computePriceForBooking(previewBooking);
// The overweight surcharge line is already currency-converted; surface its
// amount separately so the warning alert can reference the exact charge.
const overweightSurchargeAmount =
computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0;
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
const twentyFtUnits = resolved
@@ -691,30 +704,63 @@ export class ContractBookingService {
(v) => v.message,
);
// Real overweight surcharge (same rate the rule engine bills at booking-create
// time) so the confirm-modal total isn't missing the charge the warning refers to.
// Rates are stored in USD; convert to the contract's payment currency the same
// way BookingPricingService does so this preview matches the eventual booking total.
const overweightModifier = ruleResult.appliedModifiers.find(
(m) => m.surchargeCode === 'OVERWEIGHT_PER_TON',
// Hard capacity ceiling — a non-empty result means the create call will be
// rejected, so the form can block submit up front.
const capacityErrors = await this.ruleEngineService.capacityViolations(
resolved.map(({ line, ct, totalVgmTons }) => ({
containerTypeId: ct.id,
quantity: line.quantity,
totalVgmTons,
})),
contract.tradeDirection,
);
let overweightSurchargeAmount = 0;
if (overweightModifier) {
const isEtb = contract.paymentCurrency === 'ETB';
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
overweightSurchargeAmount = isEtb
? Math.round(overweightModifier.calculatedAmount * usdToEtb)
: overweightModifier.calculatedAmount;
}
return {
overweightLines,
overweightLines: computed.overweightLines,
overweightSurchargeAmount,
currency: overweightLines.length ? contract.paymentCurrency : null,
currency: computed.currency,
pairingErrors,
capacityErrors,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
};
}
/**
* Throws when any container line's total weight exceeds the hard capacity
* ceiling of its weight limit rule. Mirrors validateShipment's line
* resolution so the gate matches what the form preview reported.
*/
private async assertWithinMaxCapacity(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const lines = dto.containers ?? [];
if (!lines.length) return;
const containers = await Promise.all(
lines.map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const totalVgmTons = (line.units ?? []).reduce(
(s, u) => s + Number(u.vgmTons ?? 0),
0,
);
return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons };
}),
);
const violations = await this.ruleEngineService.capacityViolations(
containers,
contract.tradeDirection,
);
if (violations.length) {
throw new BadRequestException(violations.join('; '));
}
}
private async max20ftPairDiffTons(): Promise<number> {
const row = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)

View File

@@ -791,7 +791,7 @@ export class ContractsController {
@Post(':id/validate-shipment')
@ApiOperation({
summary:
'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).',
'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).',
})
validateShipment(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsUUID, Min } from 'class-validator';
import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
@@ -21,4 +21,15 @@ export class CreateWeightLimitRuleDto {
@Min(0)
@Transform(({ value }) => Number(value))
maxVgmTons!: number;
@ApiPropertyOptional({
description:
'Hard per-unit weight ceiling in tons — above this the booking cannot be created. Null/omitted = no ceiling.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => (value === null || value === undefined || value === '' ? null : Number(value)))
maxCapacityTons?: number | null;
}

View File

@@ -18,4 +18,12 @@ export class WeightLimitRule extends BaseEntity {
@Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
maxVgmTons!: number;
/**
* Absolute per-unit weight ceiling in tons. Weight above maxVgmTons but at or
* below this is "overweight" (surcharge + warning); weight above this hard-
* blocks booking creation entirely. Null = no ceiling (overweight only).
*/
@Column({ name: 'max_capacity_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
maxCapacityTons!: number | null;
}

View File

@@ -136,6 +136,10 @@ export class RuleEngineService {
}
}
hardBlocked.push(
...(await this.capacityViolations(input.containers, input.tradeDirection)),
);
for (const container of input.containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
@@ -296,6 +300,40 @@ export class RuleEngineService {
};
}
/**
* Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
* must not be created at all. Overweight (above maxVgmTons but within
* capacity) is NOT reported here — that is a surcharge, not a block.
*/
async capacityViolations(
containers: Array<{
containerTypeId: string;
quantity: number;
totalVgmTons: number;
}>,
tradeDirection: string,
): Promise<string[]> {
const violations: string[] = [];
for (const container of containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
tradeDirection,
);
const rule = rules[0];
if (!rule || rule.maxCapacityTons == null) continue;
const perUnit = Number(rule.maxCapacityTons);
const maxTotal = perUnit * container.quantity;
if (container.totalVgmTons > maxTotal) {
const label = rule.containerType?.code ?? container.containerTypeId;
violations.push(
`${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`,
);
}
}
return violations;
}
/**
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
*/

View File

@@ -1,4 +1,10 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
@@ -62,13 +68,31 @@ export class WeightLimitRulesService {
}
}
/**
* Capacity is the hard ceiling; the VGM limit is the soft overweight
* threshold. A ceiling below the threshold would make every overweight
* booking impossible to create, which is never what the operator means.
*/
private assertCapacityAboveVgmLimit(
maxVgmTons: number,
maxCapacityTons: number | null | undefined,
): void {
if (maxCapacityTons != null && Number(maxCapacityTons) < Number(maxVgmTons)) {
throw new BadRequestException(
'Max capacity must be greater than or equal to the max VGM limit.',
);
}
}
/** Create a new weight limit rule. */
async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> {
await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection);
this.assertCapacityAboveVgmLimit(dto.maxVgmTons, dto.maxCapacityTons);
return this.repository.create({
containerTypeId: dto.containerTypeId,
tradeDirection: dto.tradeDirection,
maxVgmTons: dto.maxVgmTons,
maxCapacityTons: dto.maxCapacityTons ?? null,
});
}
@@ -79,6 +103,12 @@ export class WeightLimitRulesService {
if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId;
if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection;
if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons;
if (dto.maxCapacityTons !== undefined) patch.maxCapacityTons = dto.maxCapacityTons;
this.assertCapacityAboveVgmLimit(
patch.maxVgmTons ?? Number(existing.maxVgmTons),
patch.maxCapacityTons !== undefined ? patch.maxCapacityTons : existing.maxCapacityTons,
);
// Re-check uniqueness when the identity (container/direction) changes.
if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) {

View File

@@ -83,6 +83,16 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getBookingWindowsForContract(contractId);
}
@Get("booking-windows")
@TrainSchedulingView()
@ApiOperation({
summary:
"All announced booking windows across lanes (import cycle + export FCFS), for staff dashboards",
})
listBookingWindows() {
return this.trainSchedulingService.listAllBookingWindows();
}
@Get("global-rules")
@TrainSchedulingView()
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })

View File

@@ -605,16 +605,22 @@ export class TrainSchedulingService {
);
if (!validation.valid) {
// Put the violation detail in the message itself — global exception
// filters flatten the body, and "Booking validation failed" alone tells
// staff nothing (e.g. which wagon type is missing at the yard).
throw new BadRequestException({
message: 'Booking validation failed',
message: `Booking validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations,
warnings: validation.warnings,
});
}
if (!validation.bookings.length) {
const shortfall = validation.deferredBookings
.map((d) => `${d.reference}: ${d.reason}`)
.join('; ');
throw new BadRequestException({
message: 'No bookings fit on available fleet wagons',
message: `No wagons available for the selected bookings${shortfall ? `${shortfall}` : ''}`,
violations: ['Insufficient fleet wagons for the selected bookings'],
warnings: validation.warnings,
deferredBookings: validation.deferredBookings,
@@ -628,12 +634,14 @@ export class TrainSchedulingService {
if (!limitLoco) {
throw new BadRequestException('Schedule train set has no locomotives');
}
if (limitLoco.maxPullWeightTons < totalWeightTons) {
// forceAssign lets staff overload the locomotive set knowingly — the
// validator has already surfaced it as a warning in that case.
if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) {
throw new BadRequestException(
`Train set locomotives cannot pull ${totalWeightTons}T`,
);
}
if (limitLoco.maxTrainLengthMeters < totalLengthMeters) {
if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) {
throw new BadRequestException(
`Train set locomotives cannot support ${totalLengthMeters}m`,
);
@@ -2251,9 +2259,16 @@ export class TrainSchedulingService {
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
};
// With forceAssign, capacity-shaped rules (train limits, total weight,
// locomotive capability) become warnings — staff owns the override. Physical
// impossibilities (no wagon of the required type at the yard, wrong route,
// wrong status) can never be forced and stay violations.
const pushLimit = (issues: string[]) =>
forceAssign ? warnings.push(...issues) : violations.push(...issues);
if (resolvedMode === 'MIXED') {
violations.push(
...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits),
pushLimit(
validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits),
);
if (requireContainerPlacements) {
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
@@ -2270,7 +2285,7 @@ export class TrainSchedulingService {
);
}
} else {
violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits));
pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits));
if (requireContainerPlacements && resolvedMode === 'CONTAINER') {
violations.push(
@@ -2293,8 +2308,8 @@ export class TrainSchedulingService {
);
if (totalWeightTons > trainLimits.maxWeightTons) {
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`;
if (!violations.includes(message)) {
violations.push(message);
if (!violations.includes(message) && !warnings.includes(message)) {
pushLimit([message]);
}
}
@@ -2321,9 +2336,9 @@ export class TrainSchedulingService {
(setLimits.maxPullWeightTons < totalWeightTons ||
setLimits.maxTrainLengthMeters < totalLengthMeters)
) {
violations.push(
pushLimit([
'Assigned locomotives cannot support the total train weight and length',
);
]);
}
} else {
const inServiceLocomotives = await this.locomotivesRepository.findAll({
@@ -2341,7 +2356,7 @@ export class TrainSchedulingService {
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
)
) {
violations.push('No locomotive can support the total train weight and length');
pushLimit(['No locomotive can support the total train weight and length']);
}
}
@@ -3220,6 +3235,50 @@ export class TrainSchedulingService {
return rows.map((r) => this.mapBookingWindowRow(r));
}
/**
* All announced booking windows across every lane — import window cycles AND
* export FCFS lead windows — for staff dashboards (GL clearance queue). Same
* phase filter as the customer-facing lists, no contract scoping.
*/
async listAllBookingWindows() {
const rows: Array<
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'> & {
train_number: string | null;
}
> = await this.dataSource.query(
`SELECT ts.id AS schedule_id,
ts.train_number,
ts.direction,
ts.window_phase,
ts.window_opens_at,
ts.window_closes_at,
ts.doc_review_ends_at,
ts.payment_phase_ends_at,
ts.booking_window_status,
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.window_opens_at ASC NULLS LAST`,
);
return rows.map((r) => ({
...this.mapBookingWindowRow({
...r,
contract_id: null,
contract_kind: null,
}),
trainNumber: r.train_number,
}));
}
private mapBookingWindowRow(r: BookingWindowRow) {
return {
scheduleId: r.schedule_id,
@@ -3696,7 +3755,7 @@ export class TrainSchedulingService {
if (!validation.valid) {
throw new BadRequestException({
message: 'Booking validation failed',
message: `Booking validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations,
warnings: validation.warnings,
});

View File

@@ -210,7 +210,14 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5));
const perWagon = containersPerWagonFromType(wagonsPerUnit);
const teuSlots = teuSlotsForSizeFt(sizeFt);
// The REAL per-container numbers/weights entered at booking time. Unit i of
// the line maps to units[i] (sortOrder order); the line-level number is only
// a legacy fallback — never invent numbers here.
const units = [...(line.units ?? [])].sort(
(a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0),
);
for (let i = 0; i < qty; i += 1) {
const unit = units[i];
rows.push({
bookingId: booking.id,
bookingReference: booking.reference,
@@ -219,12 +226,13 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
containerTypeId: line.containerTypeId ?? '',
containerTypeCode: code,
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
grossWeightTons: Number(line.vgmPerUnitTons),
grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
sizeFt,
wagonsPerUnit,
containersPerWagon: perWagon,
teuSlots,
containerNumber: line.containerNumber ?? null,
containerNumber:
unit?.containerNumber?.trim() || line.containerNumber || null,
});
}
}

View File

@@ -4,7 +4,7 @@ import {
useParams,
useSearchParams,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
@@ -25,6 +25,7 @@ import {
} from "@mantine/core";
import {
AlertCircle,
AlertTriangle,
CalendarDays,
CheckCircle2,
ChevronLeft,
@@ -365,8 +366,11 @@ export default function GlCreateBookingForm() {
(!needsRouteSelect || Boolean(contractRouteId)) &&
(isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0);
const handleSubmit = () => {
if (!scheduledDate || !contract || !windowOpen) return;
/** The create-booking DTO from the current form state — shared by the
* authoritative price preview and the actual submit so what GL confirms is
* exactly what gets booked. */
const buildPayload = (): Freight.CreateBookingUnderContractDto | null => {
if (!scheduledDate || !contract) return null;
const payload: Freight.CreateBookingUnderContractDto = {
scheduledDate,
@@ -408,6 +412,59 @@ export default function GlCreateBookingForm() {
}));
}
return payload;
};
// Authoritative price preview (same pricing pass the booking persists at
// create): rail freight + first/last mile + overweight + every surcharge.
// Fired when the price modal opens; the modal falls back to the contract
// unit-rate estimate while it loads.
const validateShipmentMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
contractsService.validateShipment(id ?? "", dto),
});
const validation = validateShipmentMutation.data ?? null;
const serverTotal = useMemo(() => {
const items = validation?.lineItems;
if (!items?.length) return null;
return {
currency: validation?.currency ?? priceTotal?.currency ?? "ETB",
lines: items.map((li) => ({
label: li.description,
unitPrice: li.unitAmount,
unit: li.unit.toLowerCase(),
quantity: li.quantity,
amount: li.amount,
})),
total:
validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
};
}, [validation, priceTotal]);
const displayTotal = serverTotal ?? priceTotal;
const pairingErrors = validation?.pairingErrors ?? [];
const capacityErrors = validation?.capacityErrors ?? [];
const overweightLines = validation?.overweightLines ?? [];
const openPriceModal = () => {
setPriceOpen(true);
const payload = buildPayload();
if (payload) {
validateShipmentMutation.reset();
validateShipmentMutation.mutate(payload);
}
};
const handleSubmit = () => {
if (!contract || !windowOpen) return;
// Never book past unresolved 20ft pairing hard-blocks.
if (pairingErrors.length > 0) return;
// A line above the container type's max capacity can never book.
if (capacityErrors.length > 0) return;
const payload = buildPayload();
if (!payload) return;
mutations.createBooking.mutate(payload, {
onSuccess: async (booking) => {
if (requestId) {
@@ -819,7 +876,7 @@ export default function GlCreateBookingForm() {
radius="md"
leftSection={<Receipt size={16} />}
disabled={!canSubmit}
onClick={() => setPriceOpen(true)}
onClick={openPriceModal}
>
Review price &amp; book
</Button>
@@ -850,11 +907,89 @@ export default function GlCreateBookingForm() {
</Group>
}
>
{priceTotal ? (
{displayTotal ? (
<Stack gap="md">
{validateShipmentMutation.isPending && (
<Group gap={8} c="dimmed">
<Loader size="xs" color="edr-green" />
<Text fz="sm" c="dimmed">
Computing the final price breakdown and checking container
weights
</Text>
</Group>
)}
{pairingErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot create booking — 20ft wagon pairing"
>
<Stack gap={6}>
{pairingErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Adjust the 20ft container weights or quantities so pairs
differ by no more than 10 tons.
</Text>
</Stack>
</Alert>
)}
{capacityErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot create booking — over maximum capacity"
>
<Stack gap={6}>
{capacityErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Reduce the cargo weight or split it across more containers
to book this shipment.
</Text>
</Stack>
</Alert>
)}
{overweightLines.length > 0 && (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertTriangle size={16} />}
title="Overweight containers"
>
<Stack gap={6}>
{overweightLines.map((line, i) => (
<Text key={i} fz="sm" c="#9A5B00">
{line.containerTypeCode}: {line.totalVgmTons}t exceeds
limit {line.maxAllowedTons}t (+{line.excessTons}t
overweight)
</Text>
))}
<Text fz="xs" c="#9A5B00" mt={2}>
An overweight surcharge applies (included in the total
below).
</Text>
</Stack>
</Alert>
)}
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
<Stack gap={10}>
{priceTotal.lines.map((line, i) => (
{displayTotal.lines.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ minWidth: 0 }}>
<Text fz="sm" fw={500}>
@@ -862,16 +997,16 @@ export default function GlCreateBookingForm() {
</Text>
<Text fz="xs" c="dimmed">
{line.quantity.toLocaleString()} ×{" "}
{line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "}
{line.unitPrice.toLocaleString()} {displayTotal.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
<Text fz="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
{line.amount.toLocaleString()} {priceTotal.currency}
{line.amount.toLocaleString()} {displayTotal.currency}
</Text>
</Group>
))}
{priceTotal.lines.length === 0 && (
{displayTotal.lines.length === 0 && (
<Text fz="sm" c="dimmed">
No priced lines check the cargo details.
</Text>
@@ -889,9 +1024,9 @@ export default function GlCreateBookingForm() {
Total
</Text>
<Text fw={800} fz={28}>
{priceTotal.total.toLocaleString()}{" "}
{displayTotal.total.toLocaleString()}{" "}
<Text span fz={16} fw={700} c="dimmed">
{priceTotal.currency}
{displayTotal.currency}
</Text>
</Text>
</Group>
@@ -912,6 +1047,11 @@ export default function GlCreateBookingForm() {
radius="md"
leftSection={<CheckCircle2 size={16} />}
loading={mutations.createBooking.isPending}
disabled={
validateShipmentMutation.isPending ||
pairingErrors.length > 0 ||
capacityErrors.length > 0
}
onClick={handleSubmit}
>
Confirm &amp; book

View File

@@ -1,14 +1,31 @@
import { useMemo } from "react";
import { Badge, Box, Card, Group, ScrollArea, Skeleton, Stack, Text } from "@mantine/core";
import { useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Box,
Card,
Group,
SimpleGrid,
Skeleton,
Stack,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, CalendarClock } from "lucide-react";
import {
ArrowRight,
CalendarClock,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { api } from "@/services/api";
import type { BatchBoardSchedule } from "@/types/trainScheduling";
import type { StaffBookingWindow } from "@/types/trainScheduling";
/** All window times are communicated in East Africa Time. */
const TZ = "Africa/Addis_Ababa";
/** Cards visible per carousel page. */
const PER_PAGE = 3;
function fmtDay(iso: string): string {
return new Date(iso).toLocaleDateString("en-GB", {
@@ -28,7 +45,7 @@ function fmtTime(iso: string): string {
});
}
function windowLabel(w: BatchBoardSchedule): string {
function windowLabel(w: StaffBookingWindow): string {
if (w.windowOpensAt && w.windowClosesAt) {
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} ${fmtTime(
w.windowClosesAt,
@@ -42,36 +59,33 @@ function windowLabel(w: BatchBoardSchedule): string {
/**
* The countdown for whichever phase the window is currently in, mirroring the
* customer portal. Phases run pre-window (opens at windowOpensAt) → open (closes
* at windowClosesAt) → document review (docReviewEndsAt) → payment
* (paymentPhaseEndsAt). `expiredText` names the NEXT step so a deadline that
* lapses between the 60s refetches announces what comes next rather than the
* bare word "Expired". Returns null when no phase is timing down.
* customer portal. `expiredText` names the NEXT step so a deadline that lapses
* between refetches announces what comes next rather than the bare "Expired".
*/
function phaseCountdown(
w: BatchBoardSchedule,
w: StaffBookingWindow,
): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) {
case "PRE_WINDOW":
return w.windowOpensAt
? {
label: "Booking opens in",
label: "Opens in",
deadline: w.windowOpensAt,
expiredText: "Booking opening now…",
expiredText: "Opening now…",
}
: null;
case "OPEN":
return w.windowClosesAt
? {
label: "Window closes in",
label: "Closes in",
deadline: w.windowClosesAt,
expiredText: "Document review starting…",
expiredText: "Review starting…",
}
: null;
case "DOC_REVIEW":
return w.docReviewEndsAt
? {
label: "Document review ends in",
label: "Doc review ends in",
deadline: w.docReviewEndsAt,
expiredText: "Payment starting…",
}
@@ -79,9 +93,9 @@ function phaseCountdown(
case "PAYMENT":
return w.paymentPhaseEndsAt
? {
label: "Payment window ends in",
label: "Payment ends in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Payment window closing…",
expiredText: "Closing…",
}
: null;
default:
@@ -89,15 +103,11 @@ function phaseCountdown(
}
}
function isOpenNow(w: BatchBoardSchedule): boolean {
return w.windowPhase === "OPEN" && w.bookingWindowStatus === "OPEN";
}
/** Drop windows whose booking window (or the train itself) has already passed. */
function isPast(w: BatchBoardSchedule): boolean {
function isPast(w: StaffBookingWindow): boolean {
const now = Date.now();
const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null;
const departs = w.scheduleDate ? new Date(w.scheduleDate).getTime() : null;
const departs = w.departureDate ? new Date(w.departureDate).getTime() : null;
// Still live while in a post-close staff phase (doc review / payment).
if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false;
if (departs != null && departs <= now) return true;
@@ -105,16 +115,121 @@ function isPast(w: BatchBoardSchedule): boolean {
return false;
}
function WindowCard({ w }: { w: StaffBookingWindow }) {
const cd = phaseCountdown(w);
const open = w.isOpenNow;
const isImport = w.direction === "IMPORT";
return (
<Box
p="md"
style={{
borderRadius: 14,
height: "100%",
border: `1px solid ${
open
? "var(--mantine-color-edr-green-3)"
: "var(--mantine-color-gray-2)"
}`,
background: open
? "linear-gradient(160deg, var(--mantine-color-edr-green-0) 0%, #ffffff 85%)"
: "var(--mantine-color-body)",
boxShadow: open ? "0 2px 10px rgba(10,111,77,0.10)" : "none",
transition: "border-color 150ms ease, box-shadow 150ms ease",
}}
>
<Stack gap={8} h="100%" justify="space-between">
<Box>
<Group justify="space-between" wrap="nowrap" gap={8}>
{w.direction ? (
<Badge
variant="light"
color={isImport ? "blue" : "teal"}
radius="sm"
size="sm"
>
{isImport ? "Import" : "Export"}
</Badge>
) : (
<span />
)}
<Badge
variant={open ? "filled" : "light"}
color={open ? "edr-green" : "gray"}
radius="sm"
size="sm"
>
{open
? "Open now"
: (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
</Badge>
</Group>
<Group gap={6} wrap="nowrap" mt={10}>
<Text fz={15} fw={700} truncate>
{w.origin ?? "—"}
</Text>
<ArrowRight size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
<Text fz={15} fw={700} truncate>
{w.destination ?? "—"}
</Text>
</Group>
{w.trainNumber ? (
<Text fz={12} c="dimmed" truncate>
Train {w.trainNumber}
</Text>
) : null}
<Group gap={6} wrap="nowrap" mt={8}>
<CalendarClock size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
<Text fz={12} c="dimmed" truncate>
{windowLabel(w)}
</Text>
</Group>
{w.departureDate ? (
<Text fz={12} c="dimmed">
Departs {fmtDay(w.departureDate)}
</Text>
) : null}
</Box>
{cd ? (
<Box
px={10}
py={6}
style={{
borderRadius: 10,
background: open
? "rgba(10,111,77,0.08)"
: "var(--mantine-color-gray-0)",
}}
>
<CountdownTimer
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
size="xs"
/>
</Box>
) : null}
</Stack>
</Box>
);
}
/**
* Upcoming / open import booking windows across all train schedules, shown to GL
* ET on the clearance queue so they can see which lanes are accepting bookings
* (mirrors the customer's portal "Booking Windows" card). Hidden when nothing is
* pending. Windows already past close/departure are dropped.
* All announced booking windows (import cycles + export FCFS) across every lane,
* shown to GL ET on the clearance queue as a paged carousel — three lanes per
* page, arrows to flip. Mirrors the customer's portal "Booking Windows" card.
* Hidden when nothing is pending.
*/
export function GlUpcomingWindowsSection() {
const { data, isLoading } = useQuery(
api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 60_000 }),
api.trainScheduling.allBookingWindows.queryOptions({
refetchInterval: 60_000,
}),
);
const [page, setPage] = useState(0);
const windows = useMemo(() => {
const rows = (data ?? []).filter(
@@ -122,7 +237,7 @@ export function GlUpcomingWindowsSection() {
);
// Open lanes first, then by opening time.
return rows.sort((a, b) => {
const openDiff = Number(isOpenNow(b)) - Number(isOpenNow(a));
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
if (openDiff !== 0) return openDiff;
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
@@ -130,120 +245,87 @@ export function GlUpcomingWindowsSection() {
});
}, [data]);
const pageCount = Math.max(1, Math.ceil(windows.length / PER_PAGE));
const safePage = Math.min(page, pageCount - 1);
const visible = windows.slice(
safePage * PER_PAGE,
safePage * PER_PAGE + PER_PAGE,
);
if (!isLoading && windows.length === 0) return null;
return (
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group gap={8} mb="md" wrap="nowrap">
<CalendarClock size={18} />
<Box>
<Text fw={700} fz={16}>
Booking windows
</Text>
<Text fz={13} c="dimmed">
Upcoming and open import booking windows across all lanes (EAT)
</Text>
</Box>
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<CalendarClock size={18} />
<Box>
<Text fw={700} fz={16}>
Booking windows
</Text>
<Text fz={13} c="dimmed">
Import and export booking windows across all lanes (EAT)
</Text>
</Box>
</Group>
{pageCount > 1 ? (
<Group gap={8} wrap="nowrap">
<ActionIcon
variant="default"
radius="xl"
size="lg"
aria-label="Previous windows"
disabled={safePage <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeft size={18} />
</ActionIcon>
<Group gap={5} wrap="nowrap">
{Array.from({ length: pageCount }, (_, i) => (
<Box
key={i}
onClick={() => setPage(i)}
style={{
width: i === safePage ? 18 : 7,
height: 7,
borderRadius: 999,
cursor: "pointer",
background:
i === safePage
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-gray-3)",
transition: "width 200ms ease, background 200ms ease",
}}
/>
))}
</Group>
<ActionIcon
variant="default"
radius="xl"
size="lg"
aria-label="Next windows"
disabled={safePage >= pageCount - 1}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
<ChevronRight size={18} />
</ActionIcon>
</Group>
) : null}
</Group>
{isLoading ? (
<Stack gap={8}>
{[1, 2].map((i) => (
<Skeleton key={i} height={58} radius="md" />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{[1, 2, 3].map((i) => (
<Skeleton key={i} height={150} radius="md" />
))}
</Stack>
</SimpleGrid>
) : (
<ScrollArea.Autosize mah={340} type="hover">
<Stack gap={10} pr={4}>
{windows.map((w) => {
const open = isOpenNow(w);
const cd = phaseCountdown(w);
return (
<Group
key={`${w.scheduleId}-${w.bookingCycleNo}`}
justify="space-between"
wrap="nowrap"
gap={12}
p="sm"
style={{
borderRadius: 12,
border: `1px solid ${
open
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-gray-2)"
}`,
backgroundColor: open
? "var(--mantine-color-edr-green-0)"
: undefined,
}}
>
<Box style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fz={14} fw={700} truncate>
{w.origin ?? "—"}
</Text>
<ArrowRight size={13} style={{ flexShrink: 0 }} />
<Text fz={14} fw={700} truncate>
{w.destination ?? "—"}
</Text>
{w.trainNumber ? (
<Text fz={12} c="dimmed" truncate>
· {w.trainNumber}
</Text>
) : null}
</Group>
<Text fz={12} c="dimmed" truncate mt={2}>
{windowLabel(w)}
{w.scheduleDate ? ` · Departs ${fmtDay(w.scheduleDate)}` : ""}
</Text>
{cd ? (
<Box mt={4}>
<CountdownTimer
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
size="xs"
/>
</Box>
) : null}
</Box>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
{w.direction ? (
<Badge
variant="light"
color={w.direction === "IMPORT" ? "blue" : "teal"}
radius="sm"
>
{w.direction === "IMPORT" ? "Import" : "Export"}
</Badge>
) : null}
<Badge
variant={open ? "filled" : "light"}
color={
open
? "edr-green"
: w.windowPhase === "PRE_WINDOW"
? "yellow"
: "gray"
}
radius="sm"
>
{open
? "Open now"
: w.windowPhase === "PRE_WINDOW" && w.windowOpensAt
? `Opens ${fmtTime(w.windowOpensAt)} EAT`
: (w.windowPhase ?? w.bookingWindowStatus).replace(
/_/g,
" ",
)}
</Badge>
</Group>
</Group>
);
})}
</Stack>
</ScrollArea.Autosize>
<SimpleGrid key={safePage} cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{visible.map((w) => (
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
))}
</SimpleGrid>
)}
</Card>
);

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { isAxiosError } from "axios";
import {
Badge,
Box,
@@ -47,6 +48,18 @@ interface ScheduleWorkspacePanelProps {
const GREEN = "var(--mantine-color-edr-green-6)";
/** Pull the API's violation detail out of an error (e.g. "No CW3 wagon available…"). */
function apiErrorMessage(error: unknown, fallback: string): string {
if (isAxiosError(error)) {
const data = error.response?.data as Record<string, unknown> | undefined;
const violations = data?.violations;
if (Array.isArray(violations) && violations.length) return violations.join(", ");
if (typeof data?.message === "string") return data.message;
if (Array.isArray(data?.message)) return (data.message as string[]).join(", ");
}
return fallback;
}
/**
* Deadline + label for the window phase this schedule is currently in.
* Phases run: window open (windowClosesAt) → document review (docReviewEndsAt)
@@ -198,8 +211,12 @@ export function ScheduleWorkspacePanel({
onChanged();
void poolQuery.refetch();
})
.catch(() =>
toast({ title: "Could not add booking", variant: "destructive" }),
.catch((error) =>
toast({
title: "Could not add booking",
description: apiErrorMessage(error, "Validation failed — check capacity and status."),
variant: "destructive",
}),
);
};
@@ -211,8 +228,12 @@ export function ScheduleWorkspacePanel({
onChanged();
void poolQuery.refetch();
})
.catch(() =>
toast({ title: "Could not remove booking", variant: "destructive" }),
.catch((error) =>
toast({
title: "Could not remove booking",
description: apiErrorMessage(error, "Please try again."),
variant: "destructive",
}),
);
};
@@ -226,8 +247,12 @@ export function ScheduleWorkspacePanel({
onChanged();
void poolQuery.refetch();
})
.catch(() =>
toast({ title: "Could not reassign booking", variant: "destructive" }),
.catch((error) =>
toast({
title: "Could not reassign booking",
description: apiErrorMessage(error, "Target train may be closed or full."),
variant: "destructive",
}),
);
};

View File

@@ -201,6 +201,7 @@ export const URL_CONSTANTS = {
CLEARANCE_HISTORY: "/contracts/clearance/history",
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.
BOOKING_REQUEST_QUEUE: "/contracts/booking-requests/queue",
@@ -295,6 +296,7 @@ export const URL_CONSTANTS = {
MOVE_BOOKING_SCHEDULE: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/move-schedule`,
GLOBAL_RULES: "/train-scheduling/global-rules",
BOOKING_WINDOWS: "/train-scheduling/booking-windows",
PREVIEW: "/train-scheduling/preview",
ASSIGN_BOOKINGS: (id: string) =>
`/train-scheduling/schedules/${id}/assign-bookings`,

View File

@@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
return {
id: booking.id,
reference: booking.reference,
contractReference: booking.contractReference ?? null,
approvalSteps: booking.approvalSteps,
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")

View File

@@ -78,7 +78,13 @@ export function useBookingMutations(bookingId: string) {
note?: string;
}) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }),
onSuccess: (data) => onSuccess(data, "Operation request reviewed"),
onError: () => toast.error("Failed to review operation request"),
onError: (error) => {
toast.error(parseApiError(error, "Failed to review operation request"));
// The transition may have committed even when the response errored (e.g.
// a post-accept step failed). Refetch so the UI shows the true state
// instead of requiring a manual refresh.
void invalidateBookingDetail(qc, bookingId);
},
});
const approveStep = useMutation({

View File

@@ -149,7 +149,8 @@ export default function BookingRequestsPage() {
return items.filter(
(b) =>
b.reference.toLowerCase().includes(q) ||
b.customerLabel.toLowerCase().includes(q),
b.customerLabel.toLowerCase().includes(q) ||
(b.contractReference?.toLowerCase().includes(q) ?? false),
);
}, [data?.items, query]);
@@ -196,6 +197,22 @@ export default function BookingRequestsPage() {
);
},
},
{
id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => {
const ref = row.original.contractReference;
return (
<div className="py-1">
{ref ? (
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
@@ -373,7 +390,7 @@ export default function BookingRequestsPage() {
<Stack gap="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
placeholder="Search booking, contract or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}

View File

@@ -341,6 +341,10 @@ const RuleEngineResourcePage = () => {
} else if (config.slug === "priority-configs") {
// Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.now()) };
} else if (config.slug === "weight-limit-rules") {
// Empty max capacity means "no ceiling" — send null explicitly so an
// edit can clear a previously-set ceiling (omitting the key keeps it).
payload = { ...values, maxCapacityTons: values.maxCapacityTons ?? null };
}
if (editing?.id) {

View File

@@ -374,6 +374,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
},
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
{
id: "maxCapacityTons",
header: "Max capacity (t)",
accessorKey: "maxCapacityTons",
format: "number",
},
],
formFields: [
{
@@ -391,6 +397,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
options: TRADE_DIRECTIONS,
},
{ name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true },
{
name: "maxCapacityTons",
label: "Max capacity (tons)",
type: "number",
optional: true,
description:
"Hard ceiling — a booking whose line weight exceeds this cannot be created at all. Leave empty for no ceiling (overweight surcharge only).",
},
],
},
{

View File

@@ -54,6 +54,7 @@ import type {
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
StaffBookingWindow,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
@@ -223,6 +224,13 @@ export const api = {
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
),
allBookingWindows: endpoint<void, StaffBookingWindow[]>(
"train-scheduling",
"all-booking-windows",
() => trainSchedulingService.getAllBookingWindows(),
() => ["train-scheduling", "all-booking-windows"],
),
batchBoardDetail: endpoint<
{ scheduleId: string },
BatchBoardScheduleDetail

View File

@@ -27,6 +27,41 @@ export interface PaginatedContracts {
total: number;
}
/** One line of the server-priced booking breakdown (mirrors PriceLineItemDto). */
export interface ShipmentPriceLine {
code: string;
description: string;
amount: number;
unitAmount: number;
/** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */
unit: string;
quantity: number;
currency: string;
}
/**
* Pre-create validation + authoritative price preview for a booking under a
* contract. `lineItems`/`totalAmount` are the full server-computed breakdown —
* the same pricing pass the booking persists at create (rail freight,
* first/last mile, overweight and every other surcharge). `pairingErrors` and
* `capacityErrors` are HARD BLOCKS; `overweightLines` are warnings.
*/
export interface ShipmentValidation {
overweightLines: Array<{
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}>;
overweightSurchargeAmount: number;
currency: string | null;
pairingErrors: string[];
/** Lines above the container type's hard max capacity — booking cannot be created. */
capacityErrors?: string[];
lineItems?: ShipmentPriceLine[];
totalAmount?: number;
}
export interface ContractListSummaryMetrics {
inQueue: number;
needsAction: number;
@@ -471,6 +506,18 @@ export const contractsService = {
payload: Freight.CreateBookingUnderContractDto,
) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload),
/**
* Pre-create validation + authoritative price preview: the same
* BookingPricingService pass that prices the booking on create (rail +
* first/last mile + every surcharge), plus overweight warnings and 20ft
* pairing hard-blocks. Shown in the GL price-confirm modal.
*/
validateShipment: (
id: string,
payload: Freight.CreateBookingUnderContractDto,
) =>
postContract<ShipmentValidation>(C.VALIDATE_SHIPMENT(id), payload),
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
const response = await client.get(C.CAPACITY(id));

View File

@@ -21,6 +21,7 @@ import type {
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
StaffBookingWindow,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
@@ -543,6 +544,13 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getAllBookingWindows: async (): Promise<StaffBookingWindow[]> => {
const response = await client.get<StaffBookingWindow[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WINDOWS,
);
return unwrap(response.data);
},
updateGlobalRules: async (
payload: Partial<Omit<TrainSchedulingGlobalRules, "id">>,
): Promise<TrainSchedulingGlobalRules> => {

View File

@@ -191,6 +191,9 @@ export interface BookingDetail {
customsClearingEnabled?: boolean;
customsClearingAgent?: string | null;
contractKind?: "ONE_TIME" | "GENERAL" | null;
contractId?: string | null;
/** Reference of the contract this booking was created under (list column + search). */
contractReference?: string | null;
contractSummary?: string | null;
latestChangeRequestNote?: string | null;
nextStep?: BookingNextStep | null;
@@ -218,6 +221,7 @@ export interface BookingDetail {
export interface BookingListRow {
id: string;
reference: string;
contractReference?: string | null;
customerLabel: string;
approvalSteps?: BookingApprovalStep[];
status: BookingStatus;

View File

@@ -226,6 +226,27 @@ export interface BatchBoardBooking {
state: BatchBoardBookingState;
}
/**
* An announced booking window on any lane (import cycle or export FCFS), for
* staff dashboards. Mirrors the customer portal's MyBookingWindow.
*/
export interface StaffBookingWindow {
scheduleId: string;
trainNumber: string | null;
direction: "IMPORT" | "EXPORT" | null;
windowPhase: BookingWindowPhase | string | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
origin: string | null;
destination: string | null;
}
export interface BatchBoardSchedule {
scheduleId: string;
trainNumber: string | null;

View File

@@ -1,7 +1,11 @@
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, CalendarClock, PackagePlus } from "lucide-react";
import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core";
import { memo, useMemo, useState } from "react";
import {
ArrowRight,
CalendarClock,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import { Card } from "./Card";
@@ -175,11 +179,32 @@ interface UpcomingWindowsSectionProps {
* lane the customer has an active contract for carry a "Book now" action;
* others route to the contract list. Hidden entirely when nothing is announced.
*/
/** Rows shown per carousel page. */
const PER_PAGE = 3;
export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
windows,
isLoading,
}: UpcomingWindowsSectionProps) {
const navigate = useNavigate();
const [page, setPage] = useState(0);
// Open lanes first, then by opening time — the ones the customer can act on
// lead the carousel.
const sorted = useMemo(
() =>
[...windows].sort((a, b) => {
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
if (openDiff !== 0) return openDiff;
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
return at - bt;
}),
[windows],
);
const pageCount = Math.max(1, Math.ceil(sorted.length / PER_PAGE));
const safePage = Math.min(page, pageCount - 1);
const visible = sorted.slice(safePage * PER_PAGE, safePage * PER_PAGE + PER_PAGE);
// Nothing upcoming — keep the dashboard uncluttered.
if (!isLoading && windows.length === 0) return null;
@@ -195,17 +220,58 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
Upcoming and open booking windows across all lanes
</Text>
</Box>
{pageCount > 1 ? (
<Group gap={8} wrap="nowrap">
<ActionIcon
variant="default"
radius="xl"
size="lg"
aria-label="Previous windows"
disabled={safePage <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeft size={18} />
</ActionIcon>
<Group gap={5} wrap="nowrap">
{Array.from({ length: pageCount }, (_, i) => (
<Box
key={i}
onClick={() => setPage(i)}
style={{
width: i === safePage ? 18 : 7,
height: 7,
borderRadius: 999,
cursor: "pointer",
background: i === safePage ? "#0A6F4D" : "#D8E2EB",
transition: "width 200ms ease, background 200ms ease",
}}
/>
))}
</Group>
<ActionIcon
variant="default"
radius="xl"
size="lg"
aria-label="Next windows"
disabled={safePage >= pageCount - 1}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
<ChevronRight size={18} />
</ActionIcon>
</Group>
) : null}
</Group>
{isLoading ? (
<Stack gap={6}>
{[1, 2].map((i) => (
{[1, 2, 3].map((i) => (
<Skeleton key={i} height={60} radius="md" />
))}
</Stack>
) : (
<Stack gap={10}>
{windows.map((w) => (
<Stack gap={10} key={safePage}>
{visible.map((w) => (
<Group
key={`${w.scheduleId}-${w.bookingCycleNo}`}
justify="space-between"
@@ -249,31 +315,11 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
})()}
</Box>
{/* Windows are informational here — booking is done from the
contract page while a window is open, not via a home CTA. */}
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<DirectionBadge direction={w.direction} />
<StatusBadge window={w} />
{/* ONE_TIME contracts book via their own single-shipment flow,
not window drawdown — show the window + countdown but no
"Book now" entry. */}
{w.isOpenNow && w.contractKind !== "ONE_TIME" && (
<Button
size="xs"
radius="md"
color="edr-green"
leftSection={<PackagePlus size={14} />}
// Book straight against the row's contract when it carries
// one; otherwise fall back to the contract list to pick.
onClick={() =>
navigate(
w.contractId
? `/contracts/${w.contractId}/bookings/new`
: "/contracts",
)
}
>
Book now
</Button>
)}
</Group>
</Group>
))}

View File

@@ -524,11 +524,10 @@ export default function NewBookingPage() {
}
: { customsClearingEnabled: false }),
...(cargoFreeText ? { cargoFreeText } : {}),
// Multi-route general contracts: routes are pure origin→destination lanes
// the contract covers — they carry NO quantity. Route #1 is the primary
// origin/destination; the rest come from the extra-routes step. The
// contracted quantity lives in a single shared pool (the container
// quantities / bulk total), drawn down per order against a chosen lane.
// A general contract covers exactly ONE route — the same single
// origin→destination pair as a one-time booking (multi-route on bookings
// was dropped). The contracted quantity lives in a single shared pool
// (container quantities / bulk total), drawn down per order.
...(isContract
? {
routes: [
@@ -536,12 +535,6 @@ export default function NewBookingPage() {
originYardId: data.originYard,
destinationYardId: data.destinationYard,
},
...(data.extraRoutes ?? [])
.filter((r) => r.originYard && r.destinationYard)
.map((r) => ({
originYardId: r.originYard,
destinationYardId: r.destinationYard,
})),
],
}
: {}),

View File

@@ -144,10 +144,9 @@ export const bookingFormSchema = z
// The contracted quantity now comes from the cargo step (cargoWeight), the
// same as a one-time booking, so per-route quantity is no longer entered.
primaryRouteQuantity: z.string().default(""),
// Additional routes for a GENERAL contract (the primary origin/destination
// above is route #1). Each route is just an (origin, destination) pair —
// identical to the one-time route — so a contract can cover several routes.
// Ignored for one-time bookings. quantity/km kept for payload back-compat.
// LEGACY — multi-route general contracts were dropped; a contract booking
// now covers exactly one route, like a one-time booking. Field retained only
// so previously saved drafts still hydrate; never collected or sent anymore.
extraRoutes: z
.array(
z.object({
@@ -434,7 +433,6 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"originYard",
"destinationYard",
"primaryRouteQuantity",
"extraRoutes",
// Estimated shipment date now lives in the Route step (one-time bookings only).
"scheduledDate",
],

View File

@@ -1,26 +1,9 @@
import type { Freight } from "@edr/types";
import {
Box,
Button,
Group,
Skeleton,
Stack,
Text,
} from "@mantine/core";
import { Box, Skeleton, Stack } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import {
CalendarDays,
MapPin,
Plus,
Route as RouteIcon,
Trash2,
} from "lucide-react";
import { CalendarDays, MapPin, Route as RouteIcon } from "lucide-react";
import { useCallback, useEffect, useMemo } from "react";
import {
Controller,
useFieldArray,
type UseFormReturn,
} from "react-hook-form";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
BookingFormInputValues,
type BookingFormValues,
@@ -70,16 +53,6 @@ export function Step4Route({
}
}, [operationType]);
const {
fields: extraRoutes,
append: appendRoute,
remove: removeRoute,
} = useFieldArray({ control: form.control, name: "extraRoutes" });
// useFieldArray's `fields` don't re-render on value change, so watch the live
// route values to filter each row's yard options by what it has selected.
const watchedExtraRoutes = form.watch("extraRoutes") ?? [];
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
@@ -129,25 +102,6 @@ export function Step4Route({
}
}, [destinationCountry, dest, form]);
// Same cleanup for the extra contract routes: when the operation type changes,
// clear any extra-route yard whose country no longer matches the required side
// so an added route can't contradict the operation either.
useEffect(() => {
watchedExtraRoutes.forEach((route, i) => {
const ro = referenceData?.yard.find((y) => y.id === route?.originYard);
if (originCountry && ro && ro.country !== originCountry) {
form.setValue(`extraRoutes.${i}.originYard`, "");
}
const rd = referenceData?.yard.find(
(y) => y.id === route?.destinationYard,
);
if (destinationCountry && rd && rd.country !== destinationCountry) {
form.setValue(`extraRoutes.${i}.destinationYard`, "");
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [originCountry, destinationCountry, referenceData, form]);
const directionStyle: Record<string, string> = {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
@@ -161,11 +115,10 @@ export function Step4Route({
const stationSelectDisabled = yardOptions.length === 0;
// A general contract can cover several routes, but each route is just an
// (origin, destination) pair — the same shape as the one-time route. The
// contracted quantity comes from the cargo step, so no per-route quantity or
// distance is collected here. Cargo handling (hazardous / refrigerated) also
// lives in the Cargo step now, not here.
// A general contract covers exactly ONE route — the same single
// origin/destination pair as a one-time booking. (Multi-route contracts were
// dropped; the multi-lane concept lives on the contracts module, not on
// bookings.) The contracted quantity comes from the cargo step.
// Earliest selectable shipment date (today, local) for the date input's `min`.
const todayISODate = useMemo(() => {
@@ -256,103 +209,6 @@ export function Step4Route({
</div>
)}
{isGeneralContract && !isLoading && (
<Box mt={18}>
<Group justify="space-between" align="center" mb={8}>
<StepLabel>Additional contract routes</StepLabel>
<Button
variant="light"
color="edr-green"
size="xs"
radius="md"
leftSection={<Plus size={14} />}
disabled={stationSelectDisabled}
onClick={() =>
appendRoute({
originYard: "",
destinationYard: "",
quantity: "",
km: "",
})
}
>
Add route
</Button>
</Group>
<Text fz={12} c="#6B7C8E" mb={12}>
A general contract can cover several routes. The route above is your
primary route; add more origindestination routes the contract should
cover.
</Text>
<Stack gap={12}>
{extraRoutes.map((rf, i) => {
// Each extra route is constrained by the SAME operation type as the
// primary route: its origin must sit in originCountry and its
// destination in destinationCountry. Watch this row's current values
// so each side also excludes the yard picked on the other side.
const rowOrigin = watchedExtraRoutes[i]?.originYard ?? "";
const rowDestination =
watchedExtraRoutes[i]?.destinationYard ?? "";
const rowOriginData = yardsForSide(originCountry, rowDestination);
const rowDestData = yardsForSide(destinationCountry, rowOrigin);
return (
<Group
key={rf.id}
gap={10}
align="flex-start"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.originYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Origin"
placeholder="Origin..."
disabled={stationSelectDisabled}
data={rowOriginData}
/>
)}
/>
</Box>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.destinationYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Destination"
placeholder="Destination..."
disabled={stationSelectDisabled}
data={rowDestData}
/>
)}
/>
</Box>
<Button
variant="subtle"
color="red"
size="xs"
mt={24}
px={6}
onClick={() => removeRoute(i)}
>
<Trash2 size={16} />
</Button>
</Group>
);
})}
</Stack>
</Box>
)}
</StepCard>
);
}

View File

@@ -218,8 +218,6 @@ function NewShipmentBookingForm({
mode: "onChange",
});
const isContainerContract = contract.freightType === "CONTAINER";
const submitMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
@@ -287,21 +285,22 @@ function NewShipmentBookingForm({
}
// Submit validates the whole form, then opens the price modal for
// confirmation. For container contracts we also run the server-side shipment
// validation (overweight warnings + 20ft pairing hard-blocks) so the modal
// can surface them before the booking is created.
// confirmation. The server-side shipment validation also returns the
// authoritative price breakdown (rail + first/last mile + every surcharge) —
// run it for every freight type; container contracts additionally get
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
const handleReview = form.handleSubmit((values) => {
setPendingValues(values);
if (isContainerContract) {
validateMutation.reset();
validateMutation.mutate(buildDto(values));
}
validateMutation.reset();
validateMutation.mutate(buildDto(values));
});
const handleConfirm = () => {
if (!pendingValues) return;
// Guard: never let a booking with unresolved 20ft pairing errors submit.
if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return;
// Guard: a line above the container type's max capacity can never book.
if ((validateMutation.data?.capacityErrors?.length ?? 0) > 0) return;
submitMutation.mutate(buildDto(pendingValues));
};
@@ -447,14 +446,37 @@ function PriceConfirmModal({
const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0;
const pairingErrors = validation?.pairingErrors ?? [];
const hasPairingBlock = pairingErrors.length > 0;
const confirmDisabled = loading || validationLoading || hasPairingBlock;
const capacityErrors = validation?.capacityErrors ?? [];
const hasCapacityBlock = capacityErrors.length > 0;
const confirmDisabled =
loading || validationLoading || hasPairingBlock || hasCapacityBlock;
// The contract's frozen unit rates (computeShipmentTotal) don't carry an
// overweight line — that surcharge only exists in the live rule engine. Fold
// the real amount from validateShipment into the displayed total so the
// customer sees the actual charge the overweight warning refers to, not just
// the warning text.
// Authoritative server breakdown — the SAME BookingPricingService pass that
// prices the booking on create, so it carries every line the booking will be
// charged: rail freight, first/last mile trucking, overweight, hazard/reefer
// and any other rule-engine surcharge.
const serverTotal = useMemo(() => {
const items = validation?.lineItems;
if (!items?.length) return null;
return {
currency: validation?.currency ?? baseTotal?.currency ?? "ETB",
lines: items.map((li) => ({
label: li.description,
unitPrice: li.unitAmount,
unit: li.unit.toLowerCase(),
quantity: li.quantity,
amount: li.amount,
})),
total:
validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
};
}, [validation, baseTotal]);
// Fallback while the server preview loads: the contract's frozen unit rates
// (container/bulk + hazard/reefer only) with the overweight surcharge folded
// in. Replaced by the full server breakdown the moment it arrives.
const total = useMemo(() => {
if (serverTotal) return serverTotal;
if (!baseTotal) return null;
if (!(overweightSurchargeAmount > 0)) return baseTotal;
return {
@@ -471,7 +493,7 @@ function PriceConfirmModal({
],
total: baseTotal.total + overweightSurchargeAmount,
};
}, [baseTotal, overweightSurchargeAmount]);
}, [serverTotal, baseTotal, overweightSurchargeAmount]);
return (
<Modal
@@ -505,7 +527,8 @@ function PriceConfirmModal({
<Group gap={8} c="dimmed">
<Loader size="xs" color="edr-green" />
<Text fz="sm" c="dimmed">
Checking container weights and wagon pairing
Computing the final price breakdown and checking container
weights
</Text>
</Group>
)}
@@ -532,6 +555,28 @@ function PriceConfirmModal({
</Alert>
)}
{hasCapacityBlock && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot create booking — over maximum capacity"
>
<Stack gap={6}>
{capacityErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Reduce the cargo weight or split it across more containers to
book this shipment.
</Text>
</Stack>
</Alert>
)}
{overweightLines.length > 0 && (
<Alert
color="yellow"

View File

@@ -42,18 +42,39 @@ export interface OverweightLine {
}
/**
* Pre-submit validation for a shipment booking under a CONTAINER contract.
* One line of the server-priced booking breakdown — the exact line the booking
* will persist at create time (rail freight, first/last mile, surcharges…).
*/
export interface ShipmentPriceLine {
code: string;
description: string;
amount: number;
unitAmount: number;
/** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */
unit: string;
quantity: number;
currency: string;
}
/**
* Pre-submit validation + authoritative price preview for a shipment booking.
* `overweightLines` are WARNINGS only (an overweight surcharge applies — the
* customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers
* that cannot be balanced onto wagons) and must prevent booking.
* `overweightSurchargeAmount` is the real overweight charge (same rate the
* booking is billed at on submit) so the confirm-modal total can include it.
* `lineItems`/`totalAmount` are the full server-computed breakdown — the same
* BookingPricingService pass that prices the booking on create, so the confirm
* modal shows first/last mile, overweight, and every surcharge, not just the
* container estimate.
*/
export interface ShipmentValidation {
overweightLines: OverweightLine[];
overweightSurchargeAmount: number;
currency: string | null;
pairingErrors: string[];
/** Lines above the container type's hard max capacity — booking cannot be created. */
capacityErrors?: string[];
lineItems?: ShipmentPriceLine[];
totalAmount?: number;
}
export interface ContractListFilter {