mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 01:55:41 +00:00
Merge pull request #440 from Tria-plc/freight_feature/usermanagement
enhance booking windows section with pagination and improved UI
This commit is contained in:
@@ -431,7 +431,7 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
const lines: PriceLineItemDto[] = [];
|
const lines: PriceLineItemDto[] = [];
|
||||||
const usedRatesMap = new Map<string, Rate>();
|
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) {
|
for (const container of evalInput.containers) {
|
||||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
|
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
|
||||||
@@ -571,6 +571,23 @@ export class BookingPricingService {
|
|||||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
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". */
|
/** Friendly container-type label for the per-unit card; degrades to "Container". */
|
||||||
private async containerTypeLabel(containerTypeId: string): Promise<string> {
|
private async containerTypeLabel(containerTypeId: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1051,7 +1051,22 @@ export class BookingTransitionService {
|
|||||||
// paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
|
// paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
|
||||||
// only reserve once both partners are FULLY_EXECUTED (handled inside).
|
// only reserve once both partners are FULLY_EXECUTED (handled inside).
|
||||||
const fresh = await this.bookingsService.findById(booking.id);
|
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
|
// IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the
|
||||||
// batch runs after the window closes + staff document review, never at accept
|
// batch runs after the window closes + staff document review, never at accept
|
||||||
@@ -1073,23 +1088,59 @@ export class BookingTransitionService {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
> {
|
> {
|
||||||
const note = await this.bookingsRepository.findLatestReviewNote(
|
// This enrichment runs AFTER the transition has committed. A failure here
|
||||||
booking.id,
|
// must never 500 the response — the client would report "failed" for a
|
||||||
"CHANGES_REQUESTED",
|
// transition that actually succeeded (visible only after a refresh).
|
||||||
);
|
// Degrade each fragile field to null instead.
|
||||||
const summary =
|
let note: Awaited<
|
||||||
booking.contractSummary ??
|
ReturnType<typeof this.bookingsRepository.findLatestReviewNote>
|
||||||
this.contractService.buildContractSummary(booking);
|
> = null;
|
||||||
const nextPending =
|
try {
|
||||||
booking.status === "PENDING_APPROVAL" ||
|
note = await this.bookingsRepository.findLatestReviewNote(
|
||||||
booking.status === "APPROVED_PENDING_SIGNATURE"
|
booking.id,
|
||||||
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
"CHANGES_REQUESTED",
|
||||||
: null;
|
);
|
||||||
const nextStep = computeNextStep(booking, nextPending);
|
} catch (err) {
|
||||||
const activeBatchOffer =
|
this.logger.warn(
|
||||||
booking.status === "SELECTED_FOR_BATCH"
|
`enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||||
? await this.bookingBatchService.getOpenOfferSummary(booking.id)
|
);
|
||||||
: null;
|
}
|
||||||
|
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 {
|
return {
|
||||||
...booking,
|
...booking,
|
||||||
latestChangeRequestNote: note?.note ?? null,
|
latestChangeRequestNote: note?.note ?? null,
|
||||||
|
|||||||
@@ -587,6 +587,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
.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');
|
.where('booking.deleted_at IS NULL');
|
||||||
|
|
||||||
this.applyListFilters(qb, options);
|
this.applyListFilters(qb, options);
|
||||||
@@ -605,10 +609,24 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
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)
|
.skip((page - 1) * pageSize)
|
||||||
.take(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) {
|
if (items.length) {
|
||||||
const links = await this.dataSource.getRepository(TrainScheduleBooking).find({
|
const links = await this.dataSource.getRepository(TrainScheduleBooking).find({
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { ExchangeService } from '@edr/api-common';
|
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
|
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||||
@@ -66,7 +66,6 @@ export class ContractBookingService {
|
|||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly exchangeService: ExchangeService,
|
|
||||||
@Inject(forwardRef(() => TrainSchedulingService))
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
) {}
|
) {}
|
||||||
@@ -587,11 +586,14 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-create validation for the shipment form: run the overweight rule + the
|
* Pre-create validation + authoritative price preview for the shipment form:
|
||||||
* 20ft weight-pairing rule against the entered containers WITHOUT persisting a
|
* build an UNSAVED booking shaped exactly like {@link createUnderContract}
|
||||||
* booking. The portal calls this from the price-confirm modal so the customer
|
* would persist it and run the same BookingPricingService compute over it —
|
||||||
* sees the overweight warning (+ surcharge basis) and is blocked on an
|
* base rail freight, first/last-mile trucking, and every rule-engine surcharge
|
||||||
* un-pairable 20ft set before the booking is created.
|
* (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(
|
async validateShipment(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
@@ -606,22 +608,26 @@ export class ContractBookingService {
|
|||||||
overweightSurchargeAmount: number;
|
overweightSurchargeAmount: number;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
pairingErrors: string[];
|
pairingErrors: string[];
|
||||||
|
lineItems: PriceLineItemDto[];
|
||||||
|
totalAmount: number;
|
||||||
}> {
|
}> {
|
||||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||||
|
|
||||||
const lines = dto.containers ?? [];
|
const lines = dto.containers ?? [];
|
||||||
if (!lines.length) {
|
if (contract.freightType === 'CONTAINER' && !lines.length) {
|
||||||
return {
|
return {
|
||||||
overweightLines: [],
|
overweightLines: [],
|
||||||
overweightSurchargeAmount: 0,
|
overweightSurchargeAmount: 0,
|
||||||
currency: null,
|
currency: null,
|
||||||
pairingErrors: [],
|
pairingErrors: [],
|
||||||
|
lineItems: [],
|
||||||
|
totalAmount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve each line's container type + total VGM (sum of unit weights) so the
|
// Resolve each container line's type + total VGM (sum of unit weights) —
|
||||||
// rule engine can flag overweight per line (maxVgmTons × quantity vs total).
|
// mirrors persistContainers so the preview lines match the persisted ones.
|
||||||
const resolved = await Promise.all(
|
const resolved = await Promise.all(
|
||||||
lines.map(async (line) => {
|
lines.map(async (line) => {
|
||||||
const ct = await this.resolveContainerTypeForSize(
|
const ct = await this.resolveContainerTypeForSize(
|
||||||
@@ -636,46 +642,44 @@ export class ContractBookingService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const ruleResult = await this.ruleEngineService.evaluate({
|
// The unsaved twin of the booking createUnderContract would write: same
|
||||||
freightType: 'CONTAINER',
|
// denormalized contract fields, same container-line math. No id → the
|
||||||
cargoTypeId: null,
|
// pricing service derives wagon counts from the in-memory lines.
|
||||||
serviceTypeId: contract.serviceTypeId,
|
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||||
paymentCurrency: contract.paymentCurrency,
|
const previewBooking = Object.assign(new Booking(), {
|
||||||
|
freightType: contract.freightType,
|
||||||
tradeDirection: contract.tradeDirection,
|
tradeDirection: contract.tradeDirection,
|
||||||
isHazardous: false,
|
paymentCurrency: contract.paymentCurrency,
|
||||||
isReefer: contract.isReefer ?? false,
|
serviceTypeId: contract.serviceTypeId,
|
||||||
isGovernment: false,
|
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||||
allowConsolidation: false,
|
isHazardous: contract.isHazardous,
|
||||||
|
isReefer: contract.isReefer,
|
||||||
|
isGovernment: contract.isGovernment,
|
||||||
shippingLineId: null,
|
shippingLineId: null,
|
||||||
totalWagons: 0,
|
contractRouteId: route?.id ?? null,
|
||||||
bulkTons: 0,
|
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||||
containers: resolved.map((r) => ({
|
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||||
containerTypeId: r.ct.id,
|
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||||
quantity: r.line.quantity,
|
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
|
||||||
vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0,
|
Object.assign(new BookingContainer(), {
|
||||||
totalVgmTons: r.totalVgmTons,
|
containerTypeId: ct.id,
|
||||||
isReefer: r.ct.isReefer,
|
containerSize: line.containerSize,
|
||||||
})),
|
quantity: line.quantity,
|
||||||
} as never);
|
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<{
|
const computed = await this.bookingPricingService.computePriceForBooking(previewBooking);
|
||||||
containerTypeCode: string;
|
|
||||||
totalVgmTons: number;
|
// The overweight surcharge line is already currency-converted; surface its
|
||||||
maxAllowedTons: number;
|
// amount separately so the warning alert can reference the exact charge.
|
||||||
excessTons: number;
|
const overweightSurchargeAmount =
|
||||||
}> = [];
|
computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0;
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
|
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
|
||||||
const twentyFtUnits = resolved
|
const twentyFtUnits = resolved
|
||||||
@@ -691,27 +695,13 @@ export class ContractBookingService {
|
|||||||
(v) => v.message,
|
(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',
|
|
||||||
);
|
|
||||||
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 {
|
return {
|
||||||
overweightLines,
|
overweightLines: computed.overweightLines,
|
||||||
overweightSurchargeAmount,
|
overweightSurchargeAmount,
|
||||||
currency: overweightLines.length ? contract.paymentCurrency : null,
|
currency: computed.currency,
|
||||||
pairingErrors,
|
pairingErrors,
|
||||||
|
lineItems: computed.lineItems,
|
||||||
|
totalAmount: computed.totalAmount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -791,7 +791,7 @@ export class ContractsController {
|
|||||||
@Post(':id/validate-shipment')
|
@Post(':id/validate-shipment')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
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(
|
validateShipment(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.getBookingWindowsForContract(contractId);
|
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")
|
@Get("global-rules")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })
|
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })
|
||||||
|
|||||||
@@ -3220,6 +3220,50 @@ export class TrainSchedulingService {
|
|||||||
return rows.map((r) => this.mapBookingWindowRow(r));
|
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) {
|
private mapBookingWindowRow(r: BookingWindowRow) {
|
||||||
return {
|
return {
|
||||||
scheduleId: r.schedule_id,
|
scheduleId: r.schedule_id,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
useParams,
|
useParams,
|
||||||
useSearchParams,
|
useSearchParams,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -365,8 +366,11 @@ export default function GlCreateBookingForm() {
|
|||||||
(!needsRouteSelect || Boolean(contractRouteId)) &&
|
(!needsRouteSelect || Boolean(contractRouteId)) &&
|
||||||
(isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0);
|
(isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
/** The create-booking DTO from the current form state — shared by the
|
||||||
if (!scheduledDate || !contract || !windowOpen) return;
|
* 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 = {
|
const payload: Freight.CreateBookingUnderContractDto = {
|
||||||
scheduledDate,
|
scheduledDate,
|
||||||
@@ -408,6 +412,56 @@ 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 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;
|
||||||
|
const payload = buildPayload();
|
||||||
|
if (!payload) return;
|
||||||
|
|
||||||
mutations.createBooking.mutate(payload, {
|
mutations.createBooking.mutate(payload, {
|
||||||
onSuccess: async (booking) => {
|
onSuccess: async (booking) => {
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
@@ -819,7 +873,7 @@ export default function GlCreateBookingForm() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<Receipt size={16} />}
|
leftSection={<Receipt size={16} />}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
onClick={() => setPriceOpen(true)}
|
onClick={openPriceModal}
|
||||||
>
|
>
|
||||||
Review price & book
|
Review price & book
|
||||||
</Button>
|
</Button>
|
||||||
@@ -850,11 +904,67 @@ export default function GlCreateBookingForm() {
|
|||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{priceTotal ? (
|
{displayTotal ? (
|
||||||
<Stack gap="md">
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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" }}>
|
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
|
||||||
<Stack gap={10}>
|
<Stack gap={10}>
|
||||||
{priceTotal.lines.map((line, i) => (
|
{displayTotal.lines.map((line, i) => (
|
||||||
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
|
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
|
||||||
<Box style={{ minWidth: 0 }}>
|
<Box style={{ minWidth: 0 }}>
|
||||||
<Text fz="sm" fw={500}>
|
<Text fz="sm" fw={500}>
|
||||||
@@ -862,16 +972,16 @@ export default function GlCreateBookingForm() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed">
|
<Text fz="xs" c="dimmed">
|
||||||
{line.quantity.toLocaleString()} ×{" "}
|
{line.quantity.toLocaleString()} ×{" "}
|
||||||
{line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "}
|
{line.unitPrice.toLocaleString()} {displayTotal.currency} ·{" "}
|
||||||
{formatRateUnit(line.unit)}
|
{formatRateUnit(line.unit)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Text fz="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
|
<Text fz="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||||
{line.amount.toLocaleString()} {priceTotal.currency}
|
{line.amount.toLocaleString()} {displayTotal.currency}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
{priceTotal.lines.length === 0 && (
|
{displayTotal.lines.length === 0 && (
|
||||||
<Text fz="sm" c="dimmed">
|
<Text fz="sm" c="dimmed">
|
||||||
No priced lines — check the cargo details.
|
No priced lines — check the cargo details.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -889,9 +999,9 @@ export default function GlCreateBookingForm() {
|
|||||||
Total
|
Total
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={800} fz={28}>
|
<Text fw={800} fz={28}>
|
||||||
{priceTotal.total.toLocaleString()}{" "}
|
{displayTotal.total.toLocaleString()}{" "}
|
||||||
<Text span fz={16} fw={700} c="dimmed">
|
<Text span fz={16} fw={700} c="dimmed">
|
||||||
{priceTotal.currency}
|
{displayTotal.currency}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -912,6 +1022,9 @@ export default function GlCreateBookingForm() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<CheckCircle2 size={16} />}
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
loading={mutations.createBooking.isPending}
|
loading={mutations.createBooking.isPending}
|
||||||
|
disabled={
|
||||||
|
validateShipmentMutation.isPending || pairingErrors.length > 0
|
||||||
|
}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
Confirm & book
|
Confirm & book
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Badge, Box, Card, Group, ScrollArea, Skeleton, Stack, Text } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
SimpleGrid,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { CountdownTimer } from "@edr/ui-common";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
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. */
|
/** All window times are communicated in East Africa Time. */
|
||||||
const TZ = "Africa/Addis_Ababa";
|
const TZ = "Africa/Addis_Ababa";
|
||||||
|
/** Cards visible per carousel page. */
|
||||||
|
const PER_PAGE = 3;
|
||||||
|
|
||||||
function fmtDay(iso: string): string {
|
function fmtDay(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString("en-GB", {
|
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) {
|
if (w.windowOpensAt && w.windowClosesAt) {
|
||||||
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime(
|
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime(
|
||||||
w.windowClosesAt,
|
w.windowClosesAt,
|
||||||
@@ -42,36 +59,33 @@ function windowLabel(w: BatchBoardSchedule): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The countdown for whichever phase the window is currently in, mirroring the
|
* The countdown for whichever phase the window is currently in, mirroring the
|
||||||
* customer portal. Phases run pre-window (opens at windowOpensAt) → open (closes
|
* customer portal. `expiredText` names the NEXT step so a deadline that lapses
|
||||||
* at windowClosesAt) → document review (docReviewEndsAt) → payment
|
* between refetches announces what comes next rather than the bare "Expired".
|
||||||
* (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.
|
|
||||||
*/
|
*/
|
||||||
function phaseCountdown(
|
function phaseCountdown(
|
||||||
w: BatchBoardSchedule,
|
w: StaffBookingWindow,
|
||||||
): { label: string; deadline: string; expiredText: string } | null {
|
): { label: string; deadline: string; expiredText: string } | null {
|
||||||
switch (w.windowPhase) {
|
switch (w.windowPhase) {
|
||||||
case "PRE_WINDOW":
|
case "PRE_WINDOW":
|
||||||
return w.windowOpensAt
|
return w.windowOpensAt
|
||||||
? {
|
? {
|
||||||
label: "Booking opens in",
|
label: "Opens in",
|
||||||
deadline: w.windowOpensAt,
|
deadline: w.windowOpensAt,
|
||||||
expiredText: "Booking opening now…",
|
expiredText: "Opening now…",
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
case "OPEN":
|
case "OPEN":
|
||||||
return w.windowClosesAt
|
return w.windowClosesAt
|
||||||
? {
|
? {
|
||||||
label: "Window closes in",
|
label: "Closes in",
|
||||||
deadline: w.windowClosesAt,
|
deadline: w.windowClosesAt,
|
||||||
expiredText: "Document review starting…",
|
expiredText: "Review starting…",
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
case "DOC_REVIEW":
|
case "DOC_REVIEW":
|
||||||
return w.docReviewEndsAt
|
return w.docReviewEndsAt
|
||||||
? {
|
? {
|
||||||
label: "Document review ends in",
|
label: "Doc review ends in",
|
||||||
deadline: w.docReviewEndsAt,
|
deadline: w.docReviewEndsAt,
|
||||||
expiredText: "Payment starting…",
|
expiredText: "Payment starting…",
|
||||||
}
|
}
|
||||||
@@ -79,9 +93,9 @@ function phaseCountdown(
|
|||||||
case "PAYMENT":
|
case "PAYMENT":
|
||||||
return w.paymentPhaseEndsAt
|
return w.paymentPhaseEndsAt
|
||||||
? {
|
? {
|
||||||
label: "Payment window ends in",
|
label: "Payment ends in",
|
||||||
deadline: w.paymentPhaseEndsAt,
|
deadline: w.paymentPhaseEndsAt,
|
||||||
expiredText: "Payment window closing…",
|
expiredText: "Closing…",
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
default:
|
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. */
|
/** 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 now = Date.now();
|
||||||
const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null;
|
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).
|
// Still live while in a post-close staff phase (doc review / payment).
|
||||||
if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false;
|
if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false;
|
||||||
if (departs != null && departs <= now) return true;
|
if (departs != null && departs <= now) return true;
|
||||||
@@ -105,16 +115,121 @@ function isPast(w: BatchBoardSchedule): boolean {
|
|||||||
return false;
|
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
|
* All announced booking windows (import cycles + export FCFS) across every lane,
|
||||||
* ET on the clearance queue so they can see which lanes are accepting bookings
|
* shown to GL ET on the clearance queue as a paged carousel — three lanes per
|
||||||
* (mirrors the customer's portal "Booking Windows" card). Hidden when nothing is
|
* page, arrows to flip. Mirrors the customer's portal "Booking Windows" card.
|
||||||
* pending. Windows already past close/departure are dropped.
|
* Hidden when nothing is pending.
|
||||||
*/
|
*/
|
||||||
export function GlUpcomingWindowsSection() {
|
export function GlUpcomingWindowsSection() {
|
||||||
const { data, isLoading } = useQuery(
|
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 windows = useMemo(() => {
|
||||||
const rows = (data ?? []).filter(
|
const rows = (data ?? []).filter(
|
||||||
@@ -122,7 +237,7 @@ export function GlUpcomingWindowsSection() {
|
|||||||
);
|
);
|
||||||
// Open lanes first, then by opening time.
|
// Open lanes first, then by opening time.
|
||||||
return rows.sort((a, b) => {
|
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;
|
if (openDiff !== 0) return openDiff;
|
||||||
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
|
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
|
||||||
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
|
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
|
||||||
@@ -130,120 +245,87 @@ export function GlUpcomingWindowsSection() {
|
|||||||
});
|
});
|
||||||
}, [data]);
|
}, [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;
|
if (!isLoading && windows.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder shadow="sm" radius="lg" p="lg">
|
<Card withBorder shadow="sm" radius="lg" p="lg">
|
||||||
<Group gap={8} mb="md" wrap="nowrap">
|
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
|
||||||
<CalendarClock size={18} />
|
<Group gap={8} wrap="nowrap">
|
||||||
<Box>
|
<CalendarClock size={18} />
|
||||||
<Text fw={700} fz={16}>
|
<Box>
|
||||||
Booking windows
|
<Text fw={700} fz={16}>
|
||||||
</Text>
|
Booking windows
|
||||||
<Text fz={13} c="dimmed">
|
</Text>
|
||||||
Upcoming and open import booking windows across all lanes (EAT)
|
<Text fz={13} c="dimmed">
|
||||||
</Text>
|
Import and export booking windows across all lanes (EAT)
|
||||||
</Box>
|
</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>
|
</Group>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Stack gap={8}>
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
{[1, 2].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<Skeleton key={i} height={58} radius="md" />
|
<Skeleton key={i} height={150} radius="md" />
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</SimpleGrid>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea.Autosize mah={340} type="hover">
|
<SimpleGrid key={safePage} cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
<Stack gap={10} pr={4}>
|
{visible.map((w) => (
|
||||||
{windows.map((w) => {
|
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
|
||||||
const open = isOpenNow(w);
|
))}
|
||||||
const cd = phaseCountdown(w);
|
</SimpleGrid>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ export const URL_CONSTANTS = {
|
|||||||
CLEARANCE_HISTORY: "/contracts/clearance/history",
|
CLEARANCE_HISTORY: "/contracts/clearance/history",
|
||||||
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
|
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
|
||||||
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
||||||
|
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
|
||||||
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
|
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
|
||||||
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.
|
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.
|
||||||
BOOKING_REQUEST_QUEUE: "/contracts/booking-requests/queue",
|
BOOKING_REQUEST_QUEUE: "/contracts/booking-requests/queue",
|
||||||
@@ -295,6 +296,7 @@ export const URL_CONSTANTS = {
|
|||||||
MOVE_BOOKING_SCHEDULE: (bookingId: string) =>
|
MOVE_BOOKING_SCHEDULE: (bookingId: string) =>
|
||||||
`/train-scheduling/bookings/${bookingId}/move-schedule`,
|
`/train-scheduling/bookings/${bookingId}/move-schedule`,
|
||||||
GLOBAL_RULES: "/train-scheduling/global-rules",
|
GLOBAL_RULES: "/train-scheduling/global-rules",
|
||||||
|
BOOKING_WINDOWS: "/train-scheduling/booking-windows",
|
||||||
PREVIEW: "/train-scheduling/preview",
|
PREVIEW: "/train-scheduling/preview",
|
||||||
ASSIGN_BOOKINGS: (id: string) =>
|
ASSIGN_BOOKINGS: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/assign-bookings`,
|
`/train-scheduling/schedules/${id}/assign-bookings`,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
|||||||
return {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
reference: booking.reference,
|
reference: booking.reference,
|
||||||
|
contractReference: booking.contractReference ?? null,
|
||||||
approvalSteps: booking.approvalSteps,
|
approvalSteps: booking.approvalSteps,
|
||||||
customerLabel: booking.isGovernment
|
customerLabel: booking.isGovernment
|
||||||
? (booking.governmentInstitution ?? "Government")
|
? (booking.governmentInstitution ?? "Government")
|
||||||
|
|||||||
@@ -78,7 +78,13 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
note?: string;
|
note?: string;
|
||||||
}) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }),
|
}) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }),
|
||||||
onSuccess: (data) => onSuccess(data, "Operation request reviewed"),
|
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({
|
const approveStep = useMutation({
|
||||||
|
|||||||
@@ -149,7 +149,8 @@ export default function BookingRequestsPage() {
|
|||||||
return items.filter(
|
return items.filter(
|
||||||
(b) =>
|
(b) =>
|
||||||
b.reference.toLowerCase().includes(q) ||
|
b.reference.toLowerCase().includes(q) ||
|
||||||
b.customerLabel.toLowerCase().includes(q),
|
b.customerLabel.toLowerCase().includes(q) ||
|
||||||
|
(b.contractReference?.toLowerCase().includes(q) ?? false),
|
||||||
);
|
);
|
||||||
}, [data?.items, query]);
|
}, [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",
|
id: "route",
|
||||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||||
@@ -373,7 +390,7 @@ export default function BookingRequestsPage() {
|
|||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between" gap="md" wrap="wrap">
|
<Group justify="space-between" gap="md" wrap="wrap">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search reference or customer…"
|
placeholder="Search booking, contract or customer…"
|
||||||
leftSection={<Search size={18} />}
|
leftSection={<Search size={18} />}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import type {
|
|||||||
LocomotiveRecord,
|
LocomotiveRecord,
|
||||||
PinWagonsPayload,
|
PinWagonsPayload,
|
||||||
RecordCheckpointPayload,
|
RecordCheckpointPayload,
|
||||||
|
StaffBookingWindow,
|
||||||
TrainScheduleDetail,
|
TrainScheduleDetail,
|
||||||
TrainScheduleFilters,
|
TrainScheduleFilters,
|
||||||
TrainScheduleListItem,
|
TrainScheduleListItem,
|
||||||
@@ -223,6 +224,13 @@ export const api = {
|
|||||||
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
allBookingWindows: endpoint<void, StaffBookingWindow[]>(
|
||||||
|
"train-scheduling",
|
||||||
|
"all-booking-windows",
|
||||||
|
() => trainSchedulingService.getAllBookingWindows(),
|
||||||
|
() => ["train-scheduling", "all-booking-windows"],
|
||||||
|
),
|
||||||
|
|
||||||
batchBoardDetail: endpoint<
|
batchBoardDetail: endpoint<
|
||||||
{ scheduleId: string },
|
{ scheduleId: string },
|
||||||
BatchBoardScheduleDetail
|
BatchBoardScheduleDetail
|
||||||
|
|||||||
@@ -27,6 +27,39 @@ export interface PaginatedContracts {
|
|||||||
total: number;
|
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` 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[];
|
||||||
|
lineItems?: ShipmentPriceLine[];
|
||||||
|
totalAmount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContractListSummaryMetrics {
|
export interface ContractListSummaryMetrics {
|
||||||
inQueue: number;
|
inQueue: number;
|
||||||
needsAction: number;
|
needsAction: number;
|
||||||
@@ -471,6 +504,18 @@ export const contractsService = {
|
|||||||
payload: Freight.CreateBookingUnderContractDto,
|
payload: Freight.CreateBookingUnderContractDto,
|
||||||
) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload),
|
) => 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). */
|
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
|
||||||
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
|
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
|
||||||
const response = await client.get(C.CAPACITY(id));
|
const response = await client.get(C.CAPACITY(id));
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
LocomotiveRecord,
|
LocomotiveRecord,
|
||||||
PinWagonsPayload,
|
PinWagonsPayload,
|
||||||
RecordCheckpointPayload,
|
RecordCheckpointPayload,
|
||||||
|
StaffBookingWindow,
|
||||||
TrainScheduleDetail,
|
TrainScheduleDetail,
|
||||||
TrainScheduleFilters,
|
TrainScheduleFilters,
|
||||||
TrainScheduleListItem,
|
TrainScheduleListItem,
|
||||||
@@ -543,6 +544,13 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
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 (
|
updateGlobalRules: async (
|
||||||
payload: Partial<Omit<TrainSchedulingGlobalRules, "id">>,
|
payload: Partial<Omit<TrainSchedulingGlobalRules, "id">>,
|
||||||
): Promise<TrainSchedulingGlobalRules> => {
|
): Promise<TrainSchedulingGlobalRules> => {
|
||||||
|
|||||||
@@ -191,6 +191,9 @@ export interface BookingDetail {
|
|||||||
customsClearingEnabled?: boolean;
|
customsClearingEnabled?: boolean;
|
||||||
customsClearingAgent?: string | null;
|
customsClearingAgent?: string | null;
|
||||||
contractKind?: "ONE_TIME" | "GENERAL" | 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;
|
contractSummary?: string | null;
|
||||||
latestChangeRequestNote?: string | null;
|
latestChangeRequestNote?: string | null;
|
||||||
nextStep?: BookingNextStep | null;
|
nextStep?: BookingNextStep | null;
|
||||||
@@ -218,6 +221,7 @@ export interface BookingDetail {
|
|||||||
export interface BookingListRow {
|
export interface BookingListRow {
|
||||||
id: string;
|
id: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
|
contractReference?: string | null;
|
||||||
customerLabel: string;
|
customerLabel: string;
|
||||||
approvalSteps?: BookingApprovalStep[];
|
approvalSteps?: BookingApprovalStep[];
|
||||||
status: BookingStatus;
|
status: BookingStatus;
|
||||||
|
|||||||
@@ -226,6 +226,27 @@ export interface BatchBoardBooking {
|
|||||||
state: BatchBoardBookingState;
|
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 {
|
export interface BatchBoardSchedule {
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
trainNumber: string | null;
|
trainNumber: string | null;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||||
import { memo } from "react";
|
import { memo, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import {
|
||||||
import { ArrowRight, CalendarClock, PackagePlus } from "lucide-react";
|
ArrowRight,
|
||||||
|
CalendarClock,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
import { CountdownTimer } from "@edr/ui-common";
|
import { CountdownTimer } from "@edr/ui-common";
|
||||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
@@ -175,11 +179,32 @@ interface UpcomingWindowsSectionProps {
|
|||||||
* lane the customer has an active contract for carry a "Book now" action;
|
* 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.
|
* 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({
|
export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
||||||
windows,
|
windows,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: UpcomingWindowsSectionProps) {
|
}: 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.
|
// Nothing upcoming — keep the dashboard uncluttered.
|
||||||
if (!isLoading && windows.length === 0) return null;
|
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
|
Upcoming and open booking windows across all lanes
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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>
|
</Group>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
{[1, 2].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<Skeleton key={i} height={60} radius="md" />
|
<Skeleton key={i} height={60} radius="md" />
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={10}>
|
<Stack gap={10} key={safePage}>
|
||||||
{windows.map((w) => (
|
{visible.map((w) => (
|
||||||
<Group
|
<Group
|
||||||
key={`${w.scheduleId}-${w.bookingCycleNo}`}
|
key={`${w.scheduleId}-${w.bookingCycleNo}`}
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
@@ -249,31 +315,11 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
|||||||
})()}
|
})()}
|
||||||
</Box>
|
</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 }}>
|
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||||
<DirectionBadge direction={w.direction} />
|
<DirectionBadge direction={w.direction} />
|
||||||
<StatusBadge window={w} />
|
<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>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -218,8 +218,6 @@ function NewShipmentBookingForm({
|
|||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const isContainerContract = contract.freightType === "CONTAINER";
|
|
||||||
|
|
||||||
const submitMutation = useMutation({
|
const submitMutation = useMutation({
|
||||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||||
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
|
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
|
||||||
@@ -287,15 +285,14 @@ function NewShipmentBookingForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Submit validates the whole form, then opens the price modal for
|
// Submit validates the whole form, then opens the price modal for
|
||||||
// confirmation. For container contracts we also run the server-side shipment
|
// confirmation. The server-side shipment validation also returns the
|
||||||
// validation (overweight warnings + 20ft pairing hard-blocks) so the modal
|
// authoritative price breakdown (rail + first/last mile + every surcharge) —
|
||||||
// can surface them before the booking is created.
|
// 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) => {
|
const handleReview = form.handleSubmit((values) => {
|
||||||
setPendingValues(values);
|
setPendingValues(values);
|
||||||
if (isContainerContract) {
|
validateMutation.reset();
|
||||||
validateMutation.reset();
|
validateMutation.mutate(buildDto(values));
|
||||||
validateMutation.mutate(buildDto(values));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
@@ -449,12 +446,32 @@ function PriceConfirmModal({
|
|||||||
const hasPairingBlock = pairingErrors.length > 0;
|
const hasPairingBlock = pairingErrors.length > 0;
|
||||||
const confirmDisabled = loading || validationLoading || hasPairingBlock;
|
const confirmDisabled = loading || validationLoading || hasPairingBlock;
|
||||||
|
|
||||||
// The contract's frozen unit rates (computeShipmentTotal) don't carry an
|
// Authoritative server breakdown — the SAME BookingPricingService pass that
|
||||||
// overweight line — that surcharge only exists in the live rule engine. Fold
|
// prices the booking on create, so it carries every line the booking will be
|
||||||
// the real amount from validateShipment into the displayed total so the
|
// charged: rail freight, first/last mile trucking, overweight, hazard/reefer
|
||||||
// customer sees the actual charge the overweight warning refers to, not just
|
// and any other rule-engine surcharge.
|
||||||
// the warning text.
|
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(() => {
|
const total = useMemo(() => {
|
||||||
|
if (serverTotal) return serverTotal;
|
||||||
if (!baseTotal) return null;
|
if (!baseTotal) return null;
|
||||||
if (!(overweightSurchargeAmount > 0)) return baseTotal;
|
if (!(overweightSurchargeAmount > 0)) return baseTotal;
|
||||||
return {
|
return {
|
||||||
@@ -471,7 +488,7 @@ function PriceConfirmModal({
|
|||||||
],
|
],
|
||||||
total: baseTotal.total + overweightSurchargeAmount,
|
total: baseTotal.total + overweightSurchargeAmount,
|
||||||
};
|
};
|
||||||
}, [baseTotal, overweightSurchargeAmount]);
|
}, [serverTotal, baseTotal, overweightSurchargeAmount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -505,7 +522,8 @@ function PriceConfirmModal({
|
|||||||
<Group gap={8} c="dimmed">
|
<Group gap={8} c="dimmed">
|
||||||
<Loader size="xs" color="edr-green" />
|
<Loader size="xs" color="edr-green" />
|
||||||
<Text fz="sm" c="dimmed">
|
<Text fz="sm" c="dimmed">
|
||||||
Checking container weights and wagon pairing…
|
Computing the final price breakdown and checking container
|
||||||
|
weights…
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -42,18 +42,37 @@ 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
|
* `overweightLines` are WARNINGS only (an overweight surcharge applies — the
|
||||||
* customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers
|
* customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers
|
||||||
* that cannot be balanced onto wagons) and must prevent booking.
|
* that cannot be balanced onto wagons) and must prevent booking.
|
||||||
* `overweightSurchargeAmount` is the real overweight charge (same rate the
|
* `lineItems`/`totalAmount` are the full server-computed breakdown — the same
|
||||||
* booking is billed at on submit) so the confirm-modal total can include it.
|
* 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 {
|
export interface ShipmentValidation {
|
||||||
overweightLines: OverweightLine[];
|
overweightLines: OverweightLine[];
|
||||||
overweightSurchargeAmount: number;
|
overweightSurchargeAmount: number;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
pairingErrors: string[];
|
pairingErrors: string[];
|
||||||
|
lineItems?: ShipmentPriceLine[];
|
||||||
|
totalAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContractListFilter {
|
export interface ContractListFilter {
|
||||||
|
|||||||
Reference in New Issue
Block a user