Merge pull request #288 from Tria-plc/freight_feature/profile

Freight feature/profile
This commit is contained in:
marshal
2026-06-25 10:42:42 +03:00
committed by GitHub
96 changed files with 6934 additions and 2361 deletions

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Multi-locomotive train sets: a train set is now pulled by 2+ locomotives.
*
* Adds the `freight.train_set_locomotives` link table (train set ⇄ locomotive,
* with an order index) and backfills one row per existing train set from its
* current `locomotive_id`, so existing read paths keep resolving locomotives.
* The `train_sets.locomotive_id` column is retained as the "primary" locomotive.
*
* NOTE: the shared dev DB has no applied migration history, so this is also
* hand-applied there. IF NOT EXISTS keeps that idempotent.
*/
export class AddTrainSetLocomotives1820000000011 implements MigrationInterface {
name = 'AddTrainSetLocomotives1820000000011';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_set_locomotives (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
train_set_id uuid NOT NULL,
locomotive_id uuid NOT NULL,
sequence_no int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT "PK_train_set_locomotives" PRIMARY KEY (id),
CONSTRAINT "FK_train_set_locomotives_train_set" FOREIGN KEY (train_set_id)
REFERENCES freight.train_sets (id) ON DELETE CASCADE,
CONSTRAINT "FK_train_set_locomotives_locomotive" FOREIGN KEY (locomotive_id)
REFERENCES freight.locomotives (id)
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_train_set_locomotives_set_loco"
ON freight.train_set_locomotives (train_set_id, locomotive_id);
`);
// Backfill: one link row per existing train set, from its current primary loco.
await queryRunner.query(`
INSERT INTO freight.train_set_locomotives (train_set_id, locomotive_id, sequence_no)
SELECT ts.id, ts.locomotive_id, 0
FROM freight.train_sets ts
WHERE ts.locomotive_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM freight.train_set_locomotives tsl
WHERE tsl.train_set_id = ts.id AND tsl.locomotive_id = ts.locomotive_id
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."UQ_train_set_locomotives_set_loco";`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_locomotives;`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* The booking wizard now captures a NON-BINDING estimated shipment date instead
* of the binding scheduledDate. The binding scheduledDate (validated against
* open train departures) is set later, at the operation-request step.
*/
export class AddEstimatedShipmentDate1820000000012
implements MigrationInterface
{
name = 'AddEstimatedShipmentDate1820000000012';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS estimated_shipment_date timestamptz NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS estimated_shipment_date;
`);
}
}

View File

@@ -41,12 +41,54 @@ export class BookingOrdersService {
) {}
/** Orders placed against a contract, with their lines and child booking. */
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
return this.ordersRepository.findByContract(contractBookingId);
async listByContract(contractBookingId: string): Promise<BookingOrder[]> {
const orders = await this.ordersRepository.findByContract(contractBookingId);
await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
return orders;
}
findById(id: string): Promise<BookingOrder | null> {
return this.ordersRepository.findById(id);
async findById(id: string): Promise<BookingOrder | null> {
const order = await this.ordersRepository.findById(id);
if (order) await this.syncOrderFromChild(order);
return order;
}
/**
* The order is a ledger row; the spawned child ONE_TIME booking is what
* actually moves through the workflow (clearance → marketing/ops accept →
* pay → allocate), exactly like a one-time booking. Nothing writes the order
* row after creation, so its stored status would stay 'PENDING' forever.
*
* Mirror the child onto the order whenever it is read: copy the child's
* status, schedulingStatus and trainScheduleId onto the order (mutating the
* in-memory instance the caller gets back), and persist that snapshot when it
* has drifted so list/detail views and any stored reporting stay in sync.
*/
private async syncOrderFromChild(order: BookingOrder): Promise<void> {
const child = order.booking;
if (!child) return;
const nextStatus = child.status;
const nextScheduling = child.schedulingStatus;
const nextTrainScheduleId = child.trainScheduleId ?? null;
const drifted =
order.status !== nextStatus ||
order.schedulingStatus !== nextScheduling ||
(order.trainScheduleId ?? null) !== nextTrainScheduleId;
// Reflect the child onto the instance returned to the caller.
order.status = nextStatus;
order.schedulingStatus = nextScheduling;
order.trainScheduleId = nextTrainScheduleId;
if (drifted) {
await this.ordersRepository.update(order.id, {
status: nextStatus,
schedulingStatus: nextScheduling,
trainScheduleId: nextTrainScheduleId,
});
}
}
/**
@@ -125,7 +167,6 @@ export class BookingOrdersService {
}
const isContainer = contract.freightType === 'CONTAINER';
const orderTotal = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
// Hazardous/reefer counts the customer entered cannot exceed the line they
// belong to. Validated for every order regardless of routing.
@@ -142,43 +183,31 @@ export class BookingOrdersService {
}
}
if (routeLineId) {
// Multi-route: validate against the chosen route line's remaining pool.
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
// The contract has a single shared drawdown pool (per container type for
// CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route
// only fixed origin/destination/km above — so every order, routed or not,
// validates each line against the same shared pool.
const poolLines = await this.generalContractService.getQuantityLines(
contract.id,
);
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
const chosen = routeLines.find((r) => r.routeLineId === routeLineId)!;
if (orderTotal > chosen.remainingQuantity) {
const key = isContainer ? (line.containerTypeId ?? '') : '';
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
if (!poolLine) {
throw new BadRequestException(
`Requested ${orderTotal} exceeds remaining ${chosen.remainingQuantity} for this route`,
isContainer
? `Container type ${line.containerTypeId} is not part of this contract`
: 'This contract has no matching quantity pool',
);
}
} else {
// Single-route: validate each line against the per-container-type pool.
const poolLines = await this.generalContractService.getQuantityLines(
contract.id,
);
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
const key = isContainer ? (line.containerTypeId ?? '') : '';
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
if (!poolLine) {
throw new BadRequestException(
isContainer
? `Container type ${line.containerTypeId} is not part of this contract`
: 'This contract has no matching quantity pool',
);
}
if (line.quantity > poolLine.remainingQuantity) {
throw new BadRequestException(
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
);
}
if (line.quantity > poolLine.remainingQuantity) {
throw new BadRequestException(
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
);
}
}

View File

@@ -22,7 +22,13 @@ export class ContractQuantityLineView {
remainingQuantity!: number;
}
/** A contracted/ordered/remaining pool line for one route of a general contract. */
/**
* A contracted route (lane) of a general contract. Routes are pure
* origin→destination lanes the contract covers; they carry NO quantity. The
* contract has a single shared drawdown pool (see {@link ContractQuantityLineView}),
* and an order picks one lane (for scheduling/billing) while drawing from that
* shared pool.
*/
export class ContractRouteLineView {
@ApiProperty({ description: 'Contract route line id' })
routeLineId!: string;
@@ -39,21 +45,6 @@ export class ContractRouteLineView {
@ApiProperty({ nullable: true })
destinationYardName!: string | null;
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
containerTypeId!: string | null;
@ApiProperty({ nullable: true })
containerTypeName!: string | null;
@ApiProperty()
contractedQuantity!: number;
@ApiProperty()
orderedQuantity!: number;
@ApiProperty()
remainingQuantity!: number;
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
km!: number | null;
}

View File

@@ -3,6 +3,16 @@ import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { BookingOrder } from './booking-order.entity';
/**
* Postgres `numeric` columns are serialized to JS strings by the driver. This
* transformer hydrates them back into real numbers so consumers (and the
* `quantity: number` API type) don't have to coerce on every read.
*/
const numericColumn = {
to: (value: number) => value,
from: (value: string | null) => (value == null ? value : Number(value)),
};
/**
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
@@ -25,7 +35,7 @@ export class BookingOrderLine extends BaseEntity {
containerType?: ContainerType | null;
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, transformer: numericColumn })
quantity!: number;
/**
@@ -33,9 +43,23 @@ export class BookingOrderLine extends BaseEntity {
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
*/
@Column({ name: 'hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
@Column({
name: 'hazardous_quantity',
type: 'numeric',
precision: 12,
scale: 3,
default: 0,
transformer: numericColumn,
})
hazardousQuantity!: number;
@Column({ name: 'reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
@Column({
name: 'reefer_quantity',
type: 'numeric',
precision: 12,
scale: 3,
default: 0,
transformer: numericColumn,
})
reeferQuantity!: number;
}

View File

@@ -125,10 +125,12 @@ export class GeneralContractService {
}
/**
* Per-route drawdown pool for a multi-route general contract: contracted vs.
* ordered vs. remaining, one entry per contracted route line. Returns [] for
* single-route contracts (no route lines) — callers fall back to
* {@link getQuantityLines}.
* The contracted routes (lanes) of a multi-route general contract — pure
* origin→destination pairs the contract covers. Routes carry NO quantity; the
* contract draws from a single shared pool ({@link getQuantityLines}). An order
* picks one lane (for scheduling + road billing) and draws from that pool.
* Returns [] for single-route contracts (no route lines) — callers then use the
* contract's own origin/destination.
*/
async getRouteLines(
contractBookingId: string,
@@ -140,52 +142,18 @@ export class GeneralContractService {
relations: {
originYard: true,
destinationYard: true,
containerType: true,
},
order: { createdAt: 'ASC' },
});
if (routeLines.length === 0) return [];
const ordered = await this.orderedByRouteLine(contractBookingId);
return routeLines.map((rl) => {
const orderedQty = ordered.get(rl.id) ?? 0;
const contracted = Number(rl.quantity);
return {
routeLineId: rl.id,
originYardId: rl.originYardId,
originYardName: rl.originYard?.label ?? null,
destinationYardId: rl.destinationYardId,
destinationYardName: rl.destinationYard?.label ?? null,
containerTypeId: rl.containerTypeId ?? null,
containerTypeName: rl.containerType?.label ?? null,
contractedQuantity: contracted,
orderedQuantity: orderedQty,
remainingQuantity: Math.max(0, contracted - orderedQty),
km: rl.km != null ? Number(rl.km) : null,
};
});
}
/** Sum of non-cancelled order quantities, keyed by route_line_id. */
private async orderedByRouteLine(
contractBookingId: string,
): Promise<Map<string, number>> {
const rows = await this.dataSource
.getRepository(BookingOrder)
.createQueryBuilder('o')
.innerJoin('o.lines', 'line')
.select('o.route_line_id', 'key')
.addSelect('SUM(line.quantity)', 'total')
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
.andWhere('o.route_line_id IS NOT NULL')
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
.groupBy('o.route_line_id')
.getRawMany<{ key: string; total: string }>();
const map = new Map<string, number>();
for (const row of rows) if (row.key) map.set(row.key, Number(row.total));
return map;
return routeLines.map((rl) => ({
routeLineId: rl.id,
originYardId: rl.originYardId,
originYardName: rl.originYard?.label ?? null,
destinationYardId: rl.destinationYardId,
destinationYardName: rl.destinationYard?.label ?? null,
km: rl.km != null ? Number(rl.km) : null,
}));
}
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
@@ -221,14 +189,12 @@ export class GeneralContractService {
return line?.remainingQuantity ?? 0;
}
/** True once every contracted line is fully drawn down. */
/**
* True once the contract's shared pool is fully drawn down. Routes are pure
* lanes with no quantity, so exhaustion is purely a function of the shared
* per-container-type (or bulk) pool, regardless of how many routes exist.
*/
async isExhausted(contractBookingId: string): Promise<boolean> {
// Multi-route contracts are exhausted when every route line is drawn down;
// single-route contracts fall back to the per-container-type pool.
const routeLines = await this.getRouteLines(contractBookingId);
if (routeLines.length > 0) {
return routeLines.every((l) => l.remainingQuantity <= 0);
}
const lines = await this.getQuantityLines(contractBookingId);
return lines.every((l) => l.remainingQuantity <= 0);
}

View File

@@ -276,6 +276,12 @@ export class BookingPricingService {
allowConsolidation,
shippingLineId: booking.shippingLineId,
totalWagons,
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
// Container freight carries 0 here — its surcharges scale by container count.
bulkTons:
booking.freightType === 'BULK'
? Number(booking.cargoTotalWeightVgm ?? 0)
: 0,
containers,
};
}

View File

@@ -63,7 +63,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
);
});
it('moves to CLEARANCE_READY when all required documents are APPROVED', async () => {
it('moves to CLEARANCE_READY when all required documents are APPROVED (non-customs, no output set)', async () => {
const { service, bookingsRepository } = makeService([
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
{ settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' },
@@ -75,3 +75,165 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
);
});
});
/**
* Customs bookings additionally require the GL output documents before
* finalizing — they are cleared by Global Logistics, not the customer alone.
*/
describe('BookingTransitionService — finalizeClearance customs output gate', () => {
const customsBooking = {
id: 'b-2',
status: 'DOCUMENTS_UNDER_REVIEW',
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
serviceType: { includesCustoms: true }, // input + output sets apply
};
const inputSetting = {
code: 'clearance_import_container_with_customs',
fields: [{ fileKey: 'commercial_invoice', isRequired: true }],
};
const outputSetting = {
code: 'clearance_output_import_container',
fields: [{ fileKey: 'im4', fileLabel: 'IM4 declaration', isRequired: true }],
};
function makeCustomsService(uploadedOutputCodes: string[]) {
const bookingsRepository = {
findDocumentReviews: jest.fn().mockResolvedValue([
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
]),
update: jest.fn().mockResolvedValue({ id: 'b-2' }),
};
const bookingsService = { findById: jest.fn().mockResolvedValue(customsBooking) };
const fileUploadSettingsService = {
getByCode: jest.fn((code: string) =>
Promise.resolve(code === outputSetting.code ? outputSetting : inputSetting),
),
};
const filesService = {
findByResource: jest
.fn()
.mockResolvedValue(uploadedOutputCodes.map((code) => ({ code }))),
};
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never,
{} as never,
{} as never,
filesService as never,
fileUploadSettingsService as never,
{} as never,
bookingsService as never,
);
return { service, bookingsRepository };
}
it('rejects when required customs output documents are missing', async () => {
const { service } = makeCustomsService([]); // no output uploaded
await expect(service.finalizeClearance('b-2')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('moves to CLEARANCE_READY when input is approved and output docs are present', async () => {
const { service, bookingsRepository } = makeCustomsService(['im4']);
await service.finalizeClearance('b-2');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-2',
expect.objectContaining({ status: 'CLEARANCE_READY' }),
);
});
});
/**
* The first clearance submission (AWAITING_DOCUMENTS) must include every
* required input document; subsequent re-uploads during review only need the
* specific files being fixed, so already-uploaded required docs stay in place.
*/
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
const inputSetting = {
code: 'clearance_import_container_without_customs',
fields: [
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
],
};
function makeService(status: string, existingCodes: string[]) {
const bookingsRepository = {
upsertDocumentReviewPending: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue({ id: 'b-3' }),
};
const booking = {
id: 'b-3',
status,
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
serviceType: { includesCustoms: false },
};
const bookingsService = { findById: jest.fn().mockResolvedValue(booking) };
const fileUploadSettingsService = {
getByCode: jest.fn().mockResolvedValue(inputSetting),
};
const filesService = {
findByResource: jest
.fn()
.mockResolvedValue(existingCodes.map((code) => ({ code }))),
upsertByCode: jest.fn().mockResolvedValue({ id: 'file-rec' }),
};
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never,
{} as never,
{} as never,
filesService as never,
fileUploadSettingsService as never,
{} as never,
bookingsService as never,
);
return { service, bookingsRepository, filesService };
}
function fakeFile(fieldname: string): Express.Multer.File {
return { fieldname, originalname: `${fieldname}.pdf` } as Express.Multer.File;
}
it('rejects the first submission when a required document is missing', async () => {
const { service } = makeService('AWAITING_DOCUMENTS', []);
await expect(
service.submitClearanceDocuments('b-3', [fakeFile('commercial_invoice')]),
).rejects.toBeInstanceOf(BadRequestException);
});
it('accepts the first submission when every required document is provided', async () => {
const { service, bookingsRepository } = makeService('AWAITING_DOCUMENTS', []);
await service.submitClearanceDocuments('b-3', [
fakeFile('commercial_invoice'),
fakeFile('packing_list'),
]);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-3',
expect.objectContaining({ status: 'DOCUMENTS_UNDER_REVIEW' }),
);
});
it('allows re-uploading a single queried document during review without re-sending the rest', async () => {
// packing_list was already uploaded in the first round; the customer is now
// only re-uploading the queried commercial_invoice.
const { service, bookingsRepository } = makeService(
'DOCUMENTS_UNDER_REVIEW',
['packing_list'],
);
await service.submitClearanceDocuments('b-3', [
fakeFile('commercial_invoice'),
]);
// Only the re-uploaded doc is touched — no full re-gate, no rework on the rest.
expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledTimes(1);
expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledWith(
expect.objectContaining({ fileKey: 'commercial_invoice' }),
);
});
});

View File

@@ -645,6 +645,14 @@ export class BookingTransitionService {
throw new BadRequestException('No documents uploaded');
}
// First submission (nothing in review yet): every required input field must
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
// is only fixing queried/pending docs, so the already-uploaded required docs
// stay in place and we don't re-gate on the full required set.
if (booking.status === 'AWAITING_DOCUMENTS') {
await this.assertRequiredInputsPresent(bookingId, inputCode, files);
}
for (const file of files) {
const record = await this.filesService.upsertByCode({
resourceId: bookingId,
@@ -670,6 +678,41 @@ export class BookingTransitionService {
return this.bookingsService.findById(bookingId);
}
/**
* Guard for the first clearance submission: every required field of the
* booking's customer-input set must be covered, either by a file already on
* the booking or by one in this upload batch. Keeps the customer from starting
* review with required documents missing.
*/
private async assertRequiredInputsPresent(
bookingId: string,
inputCode: string,
files: Express.Multer.File[],
): Promise<void> {
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(inputCode);
} catch {
return; // setting not seeded — nothing to enforce
}
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return;
const existing = await this.filesService.findByResource(bookingId, 'bookings');
const presentKeys = new Set<string>([
...existing.map((f) => f.code),
...files.map((f) => f.fieldname),
]);
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
if (missing.length > 0) {
const labels = missing.map((f) => f.fileLabel).join(', ');
throw new BadRequestException(
`Please upload all required documents before submitting: ${labels}`,
);
}
}
/** GL reviews a single document: APPROVED or QUERIED (with a note). */
async reviewDocument(
bookingId: string,
@@ -795,6 +838,20 @@ export class BookingTransitionService {
throw new BadRequestException('A valid schedule date is required');
}
// The binding shipment day must have at least one OPEN departure on the
// route — only schedule-backed days are selectable. The batch engine
// assigns the specific train within that (route, day) pool later.
const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
booking.originYardId,
booking.destinationYardId,
eatDay(date),
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_REQUEST_PENDING',
scheduledDate: date,

View File

@@ -134,6 +134,12 @@ export class BookingsController {
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
return this.bookingsService.findAll(filter);
}
// Global Logistics has clearance:view but NOT bookings:view — it is scoped
// to the customs document-clearance queue only and never sees the general
// booking-request list.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)) {
return this.bookingsService.findClearanceQueue(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');
const companyId =
@@ -236,8 +242,12 @@ export class BookingsController {
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
// Staff see any booking; Global Logistics (clearance:view) may inspect any
// booking for the clearance gate; customers only their own company's.
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,

View File

@@ -39,6 +39,7 @@ export interface BookingListFilterOptions {
paymentCurrency?: string;
paymentStatus?: string;
excludePaymentStatus?: string;
customsClearingEnabled?: boolean;
createdFrom?: string;
createdTo?: string;
consolidationPaired?: string;
@@ -728,6 +729,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
excludePaymentStatus: options.excludePaymentStatus,
});
}
if (options.customsClearingEnabled !== undefined) {
qb.andWhere('booking.customs_clearing_enabled = :customsClearingEnabled', {
customsClearingEnabled: options.customsClearingEnabled,
});
}
if (options.consolidationPaired === 'true') {
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
} else if (options.consolidationPaired === 'false') {

View File

@@ -26,6 +26,7 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
@@ -119,6 +120,18 @@ export class BookingsService {
}
/** Build evaluation input from booking freight shape. */
/**
* Whether a service type bundles customs clearance. This is the single source
* of truth for a booking's `customsClearingEnabled` — the customer cannot
* diverge from it, and it decides who clears the documents (GL vs Marketing).
*/
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
const serviceType = await this.dataSource
.getRepository(ServiceType)
.findOne({ where: { id: serviceTypeId } });
return serviceType?.includesCustoms ?? false;
}
private async buildEvalInput(dto: {
freightType: FreightType;
cargoTypeId?: string | null;
@@ -126,8 +139,10 @@ export class BookingsService {
paymentCurrency: string;
tradeDirection: string;
isHazardous?: boolean;
isReefer?: boolean;
isGovernment?: boolean;
shippingLineId?: string | null;
bulkTons?: number;
containers: CreateBookingContainerDto[];
}): Promise<BookingEvaluationInput> {
const containerLines =
@@ -166,10 +181,14 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
// Bulk reefer comes from the customer toggle; container reefer is derived
// from the container type and ORed in by the engine.
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
isGovernment: dto.isGovernment ?? false,
allowConsolidation,
shippingLineId: dto.shippingLineId,
totalWagons,
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
containers,
};
}
@@ -317,12 +336,14 @@ export class BookingsService {
) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
} else if (!isGeneralContract) {
// Day-level pool: the customer picked a DAY — require that the route has at
// least one OPEN departure on that EAT day. The batch engine assigns the
// train later. General contracts skip this — they have no shipment date at
// creation; each drawdown order validates its own day.
const day = eatDay(new Date(dto.scheduledDate!));
} else if (dto.scheduledDate) {
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
// directly). Require that the route has at least one OPEN departure on
// that EAT day. The booking wizard does NOT send scheduledDate at creation
// — it captures a non-binding estimatedShipmentDate instead, and the
// binding day is chosen later at the operation-request step. General
// contracts also skip this (each drawdown order validates its own day).
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
dto.originYardId,
@@ -396,8 +417,10 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
tradeDirection,
isHazardous: dto.isHazardous,
isReefer: dto.isReefer,
isGovernment,
shippingLineId: dto.shippingLineId,
bulkTons: dto.cargoTotalWeightVgm,
containers,
});
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
@@ -405,6 +428,11 @@ export class BookingsService {
warnings.push(...ruleResult.warnings);
// Customs clearing is owned by the service type, not the customer: when the
// service includes customs, EDR/GL clears it (no external agent); otherwise
// the customer clears it themselves and may name their broker.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const booking = await this.bookingsRepository.create({
reference,
companyId: companyId ?? null,
@@ -422,8 +450,8 @@ export class BookingsService {
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
customsClearingEnabled: dto.customsClearingEnabled ?? false,
customsClearingAgent: dto.customsClearingAgent ?? null,
customsClearingEnabled: includesCustoms,
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
@@ -434,11 +462,18 @@ export class BookingsService {
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
isHazardous: dto.isHazardous ?? false,
// Bulk reefer is the customer's toggle; container reefer is derived from
// the container type at pricing time, so the booking-level flag stays off
// for container freight to avoid double-counting.
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms,
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
estimatedShipmentDate: dto.estimatedShipmentDate
? new Date(dto.estimatedShipmentDate)
: null,
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: 'DRAFT',
@@ -461,8 +496,11 @@ export class BookingsService {
warnings.push(`Estimated wagons required: ${wagonCount}`);
}
// Multi-route general contracts: persist the contracted routes + quantities.
// Each drawdown order later draws from one of these route lines.
// Multi-route general contracts: persist the contracted routes (lanes). Routes
// carry NO quantity — the contract has a single shared pool (the cargo-step
// total / container quantities). Each drawdown order picks one lane for
// scheduling + road billing and draws from that shared pool. `quantity` on the
// route line is retained for legacy rows but is no longer meaningful (0).
if (isGeneralContract && dto.routes?.length) {
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
await routeRepo.save(
@@ -471,9 +509,8 @@ export class BookingsService {
contractBookingId: booking.id,
originYardId: r.originYardId,
destinationYardId: r.destinationYardId,
containerTypeId:
dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
quantity: r.quantity,
containerTypeId: null,
quantity: 0,
km: r.km ?? null,
}),
),
@@ -491,7 +528,11 @@ export class BookingsService {
// Reuse the booking profile's onboarding documents instead of asking the
// customer to re-upload. Snapshot them onto the booking now (by reference),
// so a later active-profile switch never changes this booking's documents.
if (companyProfileId) {
//
// Skip this when the customer uploaded documents for this booking — those
// per-booking files take precedence, so auto-attaching the profile snapshots
// would create duplicates.
if (companyProfileId && files.length === 0) {
try {
const onboardingFiles =
await this.companiesService.getProfileOnboardingFiles(companyProfileId);
@@ -588,7 +629,9 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous,
isReefer: dto.isReefer ?? existing.isReefer,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
containers,
});
@@ -608,6 +651,12 @@ export class BookingsService {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
// Booking-level reefer is only meaningful for bulk; container reefer is
// derived from the container type at pricing time.
isReefer:
freightType === 'BULK'
? (dto.isReefer ?? existing.isReefer ?? false)
: false,
priorityScore: ruleResult.priorityScore,
tradeDirection,
};
@@ -628,10 +677,22 @@ export class BookingsService {
);
}
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
if (dto.estimatedShipmentDate)
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);
if (dto.endDate) updates.endDate = new Date(dto.endDate);
delete updates.containers;
// Customs clearing always mirrors the (possibly changed) service type — never
// the client payload — so it can't diverge from the service's customs scope.
const includesCustoms = await this.resolveIncludesCustoms(
dto.serviceTypeId ?? existing.serviceTypeId,
);
updates.customsClearingEnabled = includesCustoms;
updates.customsClearingAgent = includesCustoms
? null
: (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null);
await this.bookingsRepository.update(id, updates);
if (freightType === 'CONTAINER' && dto.containers) {
@@ -703,6 +764,23 @@ export class BookingsService {
}
/** Return a paginated list of bookings matching the filter. */
/**
* Whether a route has at least one OPEN train departure on the given EAT day.
* Used to validate the binding shipment day chosen at the operation-request
* step (only days with a schedule are selectable).
*/
async hasOpenDepartureOnDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<boolean> {
return this.trainSchedulingService.existsOpenScheduleOnRouteDay(
originYardId,
destinationYardId,
day,
);
}
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
@@ -749,6 +827,48 @@ export class BookingsService {
'AWAITING_PAYMENT',
];
/**
* Booking statuses that belong to the customs document-clearance queue. The
* Global Logistics role is scoped to ONLY these — it never sees the general
* booking-request list.
*/
private static readonly CLEARANCE_STATUSES = [
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
];
/**
* List bookings in the customs document-clearance queue. Used by Global
* Logistics (clearance:view) which has no general bookings:view — so the
* status set is force-scoped to clearance statuses and can't be widened to
* arbitrary bookings by a caller-supplied status filter.
*/
async findClearanceQueue(
filter: FilterBookingDto,
): Promise<PaginatedBookings> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 100;
// Honour a caller status filter only if it's within the clearance set;
// otherwise fall back to the full clearance status list.
const requested = filter.status;
const statuses =
requested && BookingsService.CLEARANCE_STATUSES.includes(requested)
? [requested]
: BookingsService.CLEARANCE_STATUSES;
return this.bookingsRepository.findAllPaginated({
page,
pageSize,
statuses,
// Global Logistics only clears customs bookings; non-customs clearance is
// reviewed by Marketing from the booking detail, not this queue.
customsClearingEnabled: true,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/**
* List the current customer's bookings that are ready for payment:
* payable status AND not yet PAID. Company scope is derived from the

View File

@@ -55,6 +55,12 @@ export class CreateBookingContainerDto {
vgmPerUnitTons!: number;
}
/**
* A contracted route (lane) of a general contract — a pure origin→destination
* pair the contract covers. Routes carry NO quantity; the contract draws from a
* single shared pool (the container quantities / bulk total on the booking). An
* order picks one lane (for scheduling + road billing) and draws from that pool.
*/
export class CreateContractRouteDto {
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
@IsUUID()
@@ -64,20 +70,6 @@ export class CreateContractRouteDto {
@IsUUID()
destinationYardId!: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Container type for CONTAINER contracts; omit for BULK',
})
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiProperty({ description: 'Contracted quantity for this route', minimum: 1 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiPropertyOptional({
description: 'Road distance (km) for this route; used to bill road orders.',
minimum: 0,
@@ -155,14 +147,24 @@ export class CreateBookingDto {
bookingType?: string;
/**
* The day the customer wants to ship (the pool day key). Required for one-time
* bookings; omitted for general contracts, which pick the date per order.
* The BINDING shipment day (the pool day key), validated against open train
* departures. Set later at the operation-request step — NOT at booking
* creation. Optional here; staff may still pin it directly.
*/
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
@ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT')
@IsOptional()
@IsDateString()
scheduledDate?: string;
/**
* Non-binding shipment-date estimate captured in the booking wizard. Purely
* informational — NOT validated against train departures.
*/
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
@IsOptional()
@IsDateString()
estimatedShipmentDate?: string;
@ApiProperty({ enum: CONTRACT_TYPES })
@IsIn([...CONTRACT_TYPES])
contractType!: string;
@@ -296,6 +298,17 @@ export class CreateBookingDto {
@Transform(({ value }) => value === 'true' || value === true)
isHazardous?: boolean;
/**
* Booking-level refrigerated flag. For bulk freight this is the customer's
* reefer choice (containers derive reefer from the container type instead).
* ORed with per-container reefer when the REEFER surcharge is evaluated.
*/
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isReefer?: boolean;
@ApiProperty({ enum: PAYMENT_CURRENCIES })
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency!: string;

View File

@@ -155,10 +155,23 @@ export class Booking extends BaseEntity {
/**
* Nullable: general contracts have no shipment date at creation — the date is
* chosen per drawdown order. One-time bookings always set this (the pool day key).
*
* NOTE: this is the BINDING shipment day, validated against actual open train
* departures. It is set later, when the customer requests the operation — NOT
* at booking creation. See estimatedShipmentDate for the non-binding estimate
* captured in the booking wizard.
*/
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
scheduledDate?: Date | null;
/**
* Non-binding shipment-date estimate captured in the booking wizard. Purely
* informational — NOT validated against train departures. The binding
* scheduledDate is chosen later at the operation-request step.
*/
@Column({ name: 'estimated_shipment_date', type: 'timestamptz', nullable: true })
estimatedShipmentDate?: Date | null;
/**
* General contracts only: when the ordering window closes, computed from the
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time

View File

@@ -1,4 +1,4 @@
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
@@ -25,10 +25,14 @@ import { PaymentRefundEntity } from "./entities/payment-refund.entity";
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
function rabbitMQImport(): DynamicModule[] {
if (!process.env.PAYMENT_RABBITMQ_URL) return [];
return [
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
RabbitMQModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
@@ -49,18 +53,6 @@ function rabbitMQImport(): DynamicModule[] {
connectionInitOptions: { wait: false },
}),
}),
];
}
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
...rabbitMQImport(),
],
providers: [
PaymentRepository,
@@ -72,4 +64,4 @@ function rabbitMQImport(): DynamicModule[] {
controllers: [PaymentController, InternalPaymentController],
exports: [PaymentService],
})
export class PaymentModule { }
export class PaymentModule { }

View File

@@ -57,6 +57,12 @@ export interface BookingEvaluationInput {
allowConsolidation?: boolean;
shippingLineId?: string | null;
totalWagons: number;
/**
* Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale
* PER_TON surcharges (e.g. the bulk reefer surcharge). 0/undefined for
* container freight, which is scaled by container count instead.
*/
bulkTons?: number;
containers: BookingContainerEvalInput[];
}
@@ -224,16 +230,46 @@ export class RuleEngineService {
});
if (!triggered) continue;
let triggerValue: number | null = null;
let calculatedAmount = Number(rate.rateValue);
// Surcharges scale by their own rateUnit, so the same trigger can bill the
// right way per freight shape — e.g. a PER_TON reefer rate multiplies the
// bulk tonnage, while a PER_CONTAINER reefer rate multiplies the container
// count. triggerValue records the quantity billed (shown on the breakdown).
const rateValue = Number(rate.rateValue);
const containerCount = input.containers.reduce(
(sum, c) => sum + Number(c.quantity || 0),
0,
);
const overweightExcessTons = containerWeightResults.reduce(
(sum, r) => sum + (r.overweightExcessTons ?? 0),
0,
);
// Per-ton surcharges (typically OVERWEIGHT) bill against the excess tons.
if (rate.rateUnit === 'PER_TON' && rate.trigger === 'OVERWEIGHT') {
triggerValue = containerWeightResults.reduce(
(sum, r) => sum + (r.overweightExcessTons ?? 0),
0,
);
calculatedAmount = triggerValue * Number(rate.rateValue);
let triggerValue: number | null = null;
let calculatedAmount: number;
switch (rate.rateUnit) {
case 'PER_TON':
// OVERWEIGHT bills the excess tons; every other PER_TON surcharge
// (e.g. bulk reefer) bills the full bulk tonnage.
triggerValue =
rate.trigger === 'OVERWEIGHT'
? overweightExcessTons
: Number(input.bulkTons ?? 0);
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_CONTAINER':
triggerValue = containerCount;
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_WAGON':
triggerValue = input.totalWagons;
calculatedAmount = triggerValue * rateValue;
break;
case 'FLAT':
default:
// FLAT (and any unknown unit) bills once.
calculatedAmount = rateValue;
break;
}
// Safety guard: never include a surcharge with a non-positive amount (a

View File

@@ -25,6 +25,7 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
route: true,
trainSet: {
locomotive: true,
locomotives: { locomotive: true },
wagons: {
wagonType: true,
physicalWagon: true,

View File

@@ -1,6 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsUUID,
Min,
} from 'class-validator';
export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@@ -11,9 +20,15 @@ export class CreateContainerTrainScheduleDto {
@IsDateString()
scheduleDate!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
locomotiveId!: string;
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Locomotives pulling the train (minimum 2 — front and back)',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
@IsOptional()

View File

@@ -69,6 +69,25 @@ export function deriveTrainCapacityFromLocomotive(
export const MAX_FALLBACK_WEIGHT = 3500;
export const MAX_FALLBACK_LENGTH = 760;
/**
* Effective pull limits for a train set with multiple locomotives: the weakest
* locomotive caps the train, so take the minimum pull weight and minimum length
* across all assigned locomotives. Returns null when no locomotives are given.
*/
export function minLocomotiveLimits(
locomotives: Array<Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'>>,
): LocomotiveLimits | null {
if (!locomotives.length) return null;
return {
maxPullWeightTons: Math.min(
...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity),
),
maxTrainLengthMeters: Math.min(
...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity),
),
};
}
/** Per-booking train length from wagon count and freight-specific wagon type length. */
export function bookingTrainLengthMeters(
freightType: string | null | undefined,

View File

@@ -0,0 +1,52 @@
import {
BULK_IMPORT_NUMBERS,
CONTAINER_EXPORT_NUMBERS,
CONTAINER_IMPORT_NUMBERS,
pickLowestFreeNumber,
pickTrainNumberPool,
} from './train-number.util';
describe('train-number.util', () => {
describe('pickTrainNumberPool', () => {
it('picks container export (odd) when container wagons dominate and direction is EXPORT', () => {
const pool = pickTrainNumberPool(5, 2, 'EXPORT');
expect(pool.cargo).toBe('CONTAINER');
expect(pool.direction).toBe('EXPORT');
expect(pool.numbers).toEqual(CONTAINER_EXPORT_NUMBERS);
});
it('picks container import (even) when container wagons dominate and direction is IMPORT', () => {
const pool = pickTrainNumberPool(5, 2, 'IMPORT');
expect(pool.numbers).toEqual(CONTAINER_IMPORT_NUMBERS);
});
it('picks bulk when bulk wagons dominate', () => {
const pool = pickTrainNumberPool(1, 9, 'IMPORT');
expect(pool.cargo).toBe('BULK');
expect(pool.numbers).toEqual(BULK_IMPORT_NUMBERS);
});
it('treats a tie as container', () => {
expect(pickTrainNumberPool(3, 3, 'EXPORT').cargo).toBe('CONTAINER');
});
it('defaults DOMESTIC to the export/odd pool', () => {
expect(pickTrainNumberPool(5, 0, 'DOMESTIC').direction).toBe('EXPORT');
expect(pickTrainNumberPool(5, 0, null).direction).toBe('EXPORT');
});
});
describe('pickLowestFreeNumber', () => {
it('returns the lowest unused number', () => {
expect(pickLowestFreeNumber(CONTAINER_EXPORT_NUMBERS, ['8001'])).toBe('8101');
});
it('returns the first number when none are used', () => {
expect(pickLowestFreeNumber(CONTAINER_EXPORT_NUMBERS, [])).toBe('8001');
});
it('returns null when the pool is exhausted', () => {
expect(pickLowestFreeNumber(BULK_IMPORT_NUMBERS, [...BULK_IMPORT_NUMBERS])).toBeNull();
});
});
});

View File

@@ -0,0 +1,68 @@
/**
* Fixed train-number pools assigned to a train on dispatch.
*
* The prefix encodes cargo type (8 = container, 1 = bulk) and the parity encodes
* trade direction (odd = export, even = import). Numbers are finite and recycle:
* a number is "in use" only while its train is DISPATCHED and not yet ARRIVED.
*/
export const CONTAINER_EXPORT_NUMBERS = [
'8001', '8101', '8201', '8301', '8401', '8501', '8601', '8701', '8801', '8901',
] as const;
export const CONTAINER_IMPORT_NUMBERS = [
'8002', '8102', '8202', '8302', '8402', '8502', '8602', '8702', '8802', '8902',
] as const;
export const BULK_EXPORT_NUMBERS = ['1101', '1103', '1105', '1107'] as const;
export const BULK_IMPORT_NUMBERS = ['1002', '1004', '1006', '1008'] as const;
export type CargoKind = 'CONTAINER' | 'BULK';
export type PoolDirection = 'IMPORT' | 'EXPORT';
export interface TrainNumberPool {
cargo: CargoKind;
/** EXPORT = odd numbers, IMPORT = even numbers. */
direction: PoolDirection;
numbers: readonly string[];
}
/**
* Resolve which fixed pool a train draws from.
*
* - Cargo: container vs bulk by dominant wagon count; ties resolve to container.
* - Direction: EXPORT → odd pool, IMPORT → even pool. DOMESTIC (neither end is
* Djibouti) has no dedicated pool, so it defaults to the export/odd pool.
*/
export function pickTrainNumberPool(
containerWagons: number,
bulkWagons: number,
direction: 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null | undefined,
): TrainNumberPool {
const cargo: CargoKind = bulkWagons > containerWagons ? 'BULK' : 'CONTAINER';
const poolDirection: PoolDirection = direction === 'IMPORT' ? 'IMPORT' : 'EXPORT';
const numbers =
cargo === 'CONTAINER'
? poolDirection === 'IMPORT'
? CONTAINER_IMPORT_NUMBERS
: CONTAINER_EXPORT_NUMBERS
: poolDirection === 'IMPORT'
? BULK_IMPORT_NUMBERS
: BULK_EXPORT_NUMBERS;
return { cargo, direction: poolDirection, numbers };
}
/** Lowest pool number not currently in use, or null when the pool is exhausted. */
export function pickLowestFreeNumber(
pool: readonly string[],
usedNumbers: Iterable<string>,
): string | null {
const used = new Set(usedNumbers);
for (const number of pool) {
if (!used.has(number)) return number;
}
return null;
}

View File

@@ -7,6 +7,7 @@ import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Route } from '../routes/entities/route.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
@@ -30,6 +31,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
WagonType,
TrainSet,
TrainSetWagon,
TrainSetLocomotive,
Route,
Wagon,
Container,

View File

@@ -389,8 +389,12 @@ describe('TrainSchedulingService', () => {
isActive: true,
};
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
const lockedLocomotiveRepo = {
findOne: jest.fn().mockResolvedValue(locomotive),
findOne: jest
.fn()
.mockResolvedValueOnce(locomotive)
.mockResolvedValueOnce(locomotive2),
update: jest.fn().mockResolvedValue(undefined),
};
const trainScheduleRepo = {
@@ -401,6 +405,10 @@ describe('TrainSchedulingService', () => {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
};
const trainSetLocomotiveRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
switch (entity?.name) {
@@ -410,13 +418,14 @@ describe('TrainSchedulingService', () => {
return trainScheduleRepo;
case 'TrainSet':
return trainSetRepo;
case 'TrainSetLocomotive':
return trainSetLocomotiveRepo;
default:
throw new Error(`Unexpected transaction repository ${entity?.name}`);
}
}),
};
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') {
return { findOne: jest.fn().mockResolvedValue(route) };
@@ -437,12 +446,16 @@ describe('TrainSchedulingService', () => {
const result = await service.createContainerTrainSchedule({
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
locomotiveId: 'loc-1',
locomotiveIds: ['loc-1', 'loc-2'],
});
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith(
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) },
{ status: 'ASSIGNED' },
);
expect(result.id).toBe('schedule-1');
});
@@ -508,7 +521,6 @@ describe('TrainSchedulingService', () => {
})),
};
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
return {
@@ -531,7 +543,7 @@ describe('TrainSchedulingService', () => {
service.createContainerTrainSchedule({
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
locomotiveId: 'loc-1',
locomotiveIds: ['loc-1', 'loc-2'],
}),
).rejects.toBeInstanceOf(ConflictException);
});

View File

@@ -22,6 +22,7 @@ import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
import { Route } from '../routes/entities/route.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
@@ -79,8 +80,10 @@ import {
pickBulkWagonType,
} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
deriveTrainCapacityFromLocomotive,
minLocomotiveLimits,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import {
@@ -288,31 +291,42 @@ export class TrainSchedulingService {
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const route = await this.getActiveRoute(dto.routeId);
const locomotive = await this.selectOrValidateLocomotive(dto.locomotiveId, 0, 0);
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
}
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
const lockedLocomotive = await manager.getRepository(Locomotive).findOne({
where: { id: locomotive.id },
lock: { mode: 'pessimistic_write' },
});
if (!lockedLocomotive) {
throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
}
if (lockedLocomotive.status !== 'AVAILABLE') {
throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`);
// Lock and validate every locomotive: all must be AVAILABLE and at the origin yard.
const lockedLocomotives: Locomotive[] = [];
for (const locomotiveId of locomotiveIds) {
const locked = await manager.getRepository(Locomotive).findOne({
where: { id: locomotiveId },
lock: { mode: 'pessimistic_write' },
});
if (!locked) {
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
}
if (locked.status !== 'AVAILABLE') {
throw new ConflictException(`Locomotive ${locked.code} is not available`);
}
if (locked.currentYardId !== route.originYardId) {
throw new ConflictException(
`Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`,
);
}
lockedLocomotives.push(locked);
}
const direction = deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
);
if (lockedLocomotive.currentYardId !== route.originYardId) {
throw new ConflictException(
`Locomotive ${lockedLocomotive.code} is at yard ${lockedLocomotive.currentYardId} but schedule originates from ${route.originYardId}`,
);
}
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive);
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
// Effective capacity is capped by the weakest locomotive in the set.
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
routeId: route.id,
@@ -322,11 +336,14 @@ export class TrainSchedulingService {
status: TrainScheduleStatusEnum.Draft,
direction,
maxWagons: (
await this.resolveTrainLimitConfig(dto, lockedLocomotive)
await this.resolveTrainLimitConfig(dto, limitLoco)
).maxWagonsPerTrain,
});
const saved = await manager.getRepository(TrainSchedule).save(schedule);
await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' });
await manager.getRepository(Locomotive).update(
{ id: In(lockedLocomotives.map((l) => l.id)) },
{ status: 'ASSIGNED' },
);
return saved.id;
});
@@ -375,8 +392,9 @@ export class TrainSchedulingService {
maxWagonsPerTrain: dto.maxWagonsPerTrain,
};
const locomotive = schedule.trainSet.locomotive;
const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined);
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
const validation = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
@@ -408,17 +426,17 @@ export class TrainSchedulingService {
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
if (!locomotive) {
throw new BadRequestException('Schedule train set has no locomotive');
if (!limitLoco) {
throw new BadRequestException('Schedule train set has no locomotives');
}
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
if (limitLoco.maxPullWeightTons < totalWeightTons) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`,
`Train set locomotives cannot pull ${totalWeightTons}T`,
);
}
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
if (limitLoco.maxTrainLengthMeters < totalLengthMeters) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
`Train set locomotives cannot support ${totalLengthMeters}m`,
);
}
@@ -681,10 +699,12 @@ export class TrainSchedulingService {
const now = new Date();
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Dispatched,
{ actualDepartureAt: now },
{ actualDepartureAt: now, trainNumber },
manager,
);
if (schedule.trainSetId) {
@@ -718,6 +738,60 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
/**
* Assign a fixed train number on dispatch. The number is drawn from the pool
* for the train's dominant cargo type (container vs bulk) and trade direction
* (export = odd, import = even). Numbers recycle once a train ARRIVES, so the
* "used" set is every still-DISPATCHED schedule's number. Locked FOR UPDATE so
* concurrent dispatches can't grab the same number. Throws when the pool is
* exhausted. Idempotent: returns the existing number if already assigned.
*/
private async assignTrainNumber(
manager: EntityManager,
schedule: TrainSchedule,
): Promise<string> {
if (schedule.trainNumber) return schedule.trainNumber;
// Count container vs bulk wagons from the planned allocations.
let containerWagons = 0;
let bulkWagons = 0;
for (const wagon of schedule.trainSet?.wagons ?? []) {
const isBulk = (wagon.allocations ?? []).some((a) => a.loadType === 'BULK');
if (isBulk) bulkWagons += 1;
else containerWagons += 1;
}
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
const pool = pickTrainNumberPool(containerWagons, bulkWagons, direction);
// Lock the set of currently-active numbered schedules so two concurrent
// dispatches serialize and can't both claim the same lowest-free number.
const activeNumbered = await manager
.getRepository(TrainSchedule)
.createQueryBuilder('schedule')
.setLock('pessimistic_write')
.where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
.andWhere('schedule.train_number IS NOT NULL')
.getMany();
const usedNumbers = activeNumbered
.map((s) => s.trainNumber)
.filter((n): n is string => Boolean(n));
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
if (!number) {
throw new ConflictException(
`No free ${pool.cargo.toLowerCase()} ${pool.direction.toLowerCase()} train number available; a train must arrive to free one`,
);
}
return number;
}
/** Open or close a schedule's booking window (staff override). */
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
await this.dataSource
@@ -995,7 +1069,7 @@ export class TrainSchedulingService {
async getContainerTrainSchedules() {
const schedules = await this.trainSchedulesRepository.findAll({
relations: {
trainSet: { locomotive: true },
trainSet: { locomotive: true, locomotives: { locomotive: true } },
route: true,
originStation: true,
destinationStation: true,
@@ -1026,10 +1100,11 @@ export class TrainSchedulingService {
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
}
if (schedule.trainSet?.locomotiveId) {
await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, {
status: 'AVAILABLE',
});
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
if (cancelledLocoIds.length) {
await manager
.getRepository(Locomotive)
.update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' });
}
for (const wagon of schedule.trainSet?.wagons ?? []) {
if (wagon.physicalWagonId) {
@@ -1264,24 +1339,29 @@ export class TrainSchedulingService {
}
}
let assignedLocomotive: Locomotive | null = null;
let assignedLocomotives: Locomotive[] = [];
if (targetScheduleId) {
const targetSchedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null;
assignedLocomotives = this.locomotivesOfTrainSet(targetSchedule?.trainSet);
}
if (assignedLocomotive) {
if (assignedLocomotive.currentYardId !== originYardId) {
if (assignedLocomotives.length) {
// Every locomotive of the set must sit at the origin yard, and the weakest
// one must still be able to pull the train (min limits across the set).
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
const setLimits = minLocomotiveLimits(assignedLocomotives);
if (offYard) {
violations.push(
`Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`,
`Locomotive ${offYard.code} is not at the schedule origin yard`,
);
} else if (
Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons ||
Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters
setLimits &&
(setLimits.maxPullWeightTons < totalWeightTons ||
setLimits.maxTrainLengthMeters < totalLengthMeters)
) {
violations.push(
'Assigned locomotive cannot support the total train weight and length',
'Assigned locomotives cannot support the total train weight and length',
);
}
} else {
@@ -1831,6 +1911,22 @@ export class TrainSchedulingService {
}
}
/**
* All locomotives attached to a loaded train set. Prefers the `locomotives`
* link rows; falls back to the legacy single `locomotive` for train sets
* created before multi-loco support.
*/
private locomotivesOfTrainSet(
trainSet: TrainSet | null | undefined,
): Locomotive[] {
if (!trainSet) return [];
const linked = (trainSet.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco));
if (linked.length) return linked;
return trainSet.locomotive ? [trainSet.locomotive] : [];
}
async selectOrValidateLocomotive(
locomotiveId: string,
totalWeightTons: number,
@@ -1854,15 +1950,28 @@ export class TrainSchedulingService {
return locomotive;
}
private async buildEmptyTrainSet(manager: EntityManager, locomotive: Locomotive) {
private async buildEmptyTrainSet(manager: EntityManager, locomotives: Locomotive[]) {
const [primary] = locomotives;
const trainSet = manager.getRepository(TrainSet).create({
locomotiveId: locomotive.id,
// `locomotiveId` retained as the primary locomotive for single-loco read paths.
locomotiveId: primary.id,
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
return manager.getRepository(TrainSet).save(trainSet);
const saved = await manager.getRepository(TrainSet).save(trainSet);
const links = locomotives.map((loco, index) =>
manager.getRepository(TrainSetLocomotive).create({
trainSetId: saved.id,
locomotiveId: loco.id,
sequenceNo: index,
}),
);
await manager.getRepository(TrainSetLocomotive).save(links);
return saved;
}
private async getActiveRoute(routeId: string) {
@@ -1928,6 +2037,12 @@ export class TrainSchedulingService {
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
}
: null,
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
id: loco.id,
code: loco.code,
name: loco.name ?? null,
currentYardId: loco.currentYardId ?? null,
})),
wagonCount: schedule.trainSet?.wagonCount ?? 0,
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
@@ -1965,7 +2080,7 @@ export class TrainSchedulingService {
bookingWindowStatus: 'OPEN',
},
relations: {
trainSet: { locomotive: true },
trainSet: { locomotive: true, locomotives: { locomotive: true } },
route: { milestones: true },
originStation: true,
destinationStation: true,
@@ -2112,6 +2227,15 @@ export class TrainSchedulingService {
),
}
: null,
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
id: loco.id,
code: loco.code,
name: loco.name ?? null,
status: loco.status,
currentYardId: loco.currentYardId ?? null,
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
})),
wagons: [...(schedule.trainSet.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((wagon) => ({

View File

@@ -0,0 +1,31 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { TrainSet } from './train-set.entity';
/**
* Link row joining a train set to one of its locomotives. A train set must be
* pulled by at least two locomotives (front + back); `sequenceNo` is a plain
* order index — no front/rear semantics are modelled yet.
*/
@Entity({ schema: 'freight', name: 'train_set_locomotives' })
@Index(['trainSetId', 'locomotiveId'], { unique: true })
export class TrainSetLocomotive extends BaseEntity {
@Column({ name: 'train_set_id', type: 'uuid' })
trainSetId!: string;
@ManyToOne(() => TrainSet, (trainSet) => trainSet.locomotives, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet;
@Column({ name: 'locomotive_id', type: 'uuid' })
locomotiveId!: string;
@ManyToOne(() => Locomotive)
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
@Column({ name: 'sequence_no', type: 'int', default: 0 })
sequenceNo!: number;
}

View File

@@ -3,6 +3,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } fro
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { TrainSetLocomotive } from './train-set-locomotive.entity';
import { TrainSetWagon } from './train-set-wagon.entity';
export const TRAIN_SET_STATUSES = [
@@ -19,6 +20,7 @@ export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number];
@Index(['locomotiveId'])
@Index(['status'])
export class TrainSet extends BaseEntity {
/** Primary locomotive (first of the set). Kept for back-compat with single-loco read paths. */
@Column({ name: 'locomotive_id', type: 'uuid' })
locomotiveId!: string;
@@ -26,6 +28,10 @@ export class TrainSet extends BaseEntity {
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
/** All locomotives pulling this train set (minimum 2). */
@OneToMany(() => TrainSetLocomotive, (link) => link.trainSet)
locomotives?: TrainSetLocomotive[];
@Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 })
totalWeightTons!: number;

View File

@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainSet } from './entities/train-set.entity';
import { TrainSetLocomotive } from './entities/train-set-locomotive.entity';
import { TrainSetWagon } from './entities/train-set-wagon.entity';
import { TrainSetWagonsRepository } from './train-set-wagons.repository';
import { TrainSetsRepository } from './train-sets.repository';
@Module({
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])],
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon, TrainSetLocomotive])],
providers: [TrainSetsRepository, TrainSetWagonsRepository],
exports: [TrainSetsRepository, TrainSetWagonsRepository],
})

View File

@@ -51,6 +51,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'),
perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'),
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
perm('a1000001-0001-4000-8000-000000000023', 'edr_freight_app:bookings:clearance_view', 'View customs-clearance queue'),
perm('a1000001-0001-4000-8000-000000000020', 'edr_freight_app:bookings:review_documents', 'Review clearance documents'),
perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'),
perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'),
@@ -97,6 +98,7 @@ export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIO
export const FREIGHT_PERMS = {
bookings: {
view: 'edr_freight_app:bookings:view',
clearanceView: 'edr_freight_app:bookings:clearance_view',
staffAccept: 'edr_freight_app:bookings:staff_accept',
requestChanges: 'edr_freight_app:bookings:request_changes',
reject: 'edr_freight_app:bookings:reject',
@@ -171,15 +173,20 @@ export const ROLE_PERMISSION_PRESETS = {
...allRuleEngineViewKeys(),
],
finance: [FREIGHT_PERMS.bookings.view],
// Global Logistics: reviews post-counter-sign clearance documents, uploads
// customs output documents, and finalizes the clearance gate.
// Global Logistics: manages ONLY the customs-clearance queue. Scoped out of
// the general booking-request list (no bookings:view) — instead a dedicated
// clearance:view permission lists the clearance bookings. Reviews customer
// clearance documents, uploads customs output documents, and finalizes the
// clearance gate.
globalLogistics: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.bookings.reviewDocuments,
FREIGHT_PERMS.bookings.uploadClearanceOutput,
FREIGHT_PERMS.bookings.finalizeClearance,
],
// Marketing handles intake through contract (same as line staff here).
// Marketing handles intake through contract (same as line staff here) and,
// for non-customs bookings, reviews/finalizes the customer's clearance
// documents from the booking detail (customs bookings go to Global Logistics).
marketing: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.staffAccept,
@@ -190,6 +197,8 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.bookings.generateContract,
FREIGHT_PERMS.bookings.signStaff,
FREIGHT_PERMS.bookings.reviewDocuments,
FREIGHT_PERMS.bookings.finalizeClearance,
],
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
} as const;

View File

@@ -32,7 +32,7 @@ export class PricingDataSeeder {
const prRepo = manager.getRepository(PriorityConfig);
const rRepo = manager.getRepository(Rate);
await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo);
await this.upsertReferenceData(manager, ctRepo, yRepo, slRepo);
await this.seedDomesticRoute(manager, yRepo);
await this.seedWeightLimits(wlRepo, ctRepo);
await this.seedPriorityConfigs(prRepo);
@@ -71,7 +71,6 @@ export class PricingDataSeeder {
private async upsertReferenceData(
manager: any,
ctRepo: any,
stRepo: any,
yRepo: any,
slRepo: any,
): Promise<void> {
@@ -155,47 +154,7 @@ export class PricingDataSeeder {
{ conflictPaths: { code: true } },
);
await stRepo.upsert(
[
{
code: "RAIL_CONTAINER",
serviceName: "Rail Container Service",
description: "Standard rail container transport",
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 1,
},
{
code: "RAIL_FORWARDING",
serviceName: "Rail Forwarding Service",
description: "Rail transport with first/last mile and customs",
canBeBookedAlone: true,
includesFirstMile: true,
includesLastMile: true,
includesCustoms: true,
priorityBonusPoints: 15,
isActive: true,
displayOrder: 2,
},
{
code: "RAIL_BULK",
serviceName: "Rail Bulk Transport",
description: "Bulk commodity rail transport",
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 10,
isActive: true,
displayOrder: 3,
},
],
{ conflictPaths: { code: true } },
);
await slRepo.upsert(
[
@@ -238,59 +197,86 @@ export class PricingDataSeeder {
{ conflictPaths: { code: true } },
);
await manager.getRepository(CargoType).upsert(
await this.seedCargoTypes(manager);
}
/**
* Cargo types are a fixed two-level tree: two top-level groups — Bulk and
* Break Bulk — each with a set of commodity children. The groups are the
* stable parents the booking wizard renders; children carry the
* unit_of_measure used when reserving quantity (PER_TON for bulk commodities,
* PER_ITEM for break-bulk items like vehicles/machinery).
*
* Parents are upserted first, then re-read by code to resolve their ids so the
* children can be linked via parent_group_id (upsert doesn't return ids).
*/
private async seedCargoTypes(manager: any): Promise<void> {
const repo = manager.getRepository(CargoType);
const groups = [
{ code: "BULK", cargoTypeName: "Bulk", displayOrder: 1 },
{ code: "BREAK_BULK", cargoTypeName: "Break Bulk", displayOrder: 2 },
];
await repo.upsert(
groups.map((g) => ({ ...g, isActive: true })),
{ conflictPaths: { code: true } },
);
const bulk = await repo.findOneBy({ code: "BULK" });
const breakBulk = await repo.findOneBy({ code: "BREAK_BULK" });
if (!bulk || !breakBulk) return;
// Bulk commodities — measured by tonnage (PER_TON).
const bulkChildren = [
{ code: "SUGAR", cargoTypeName: "Sugar" },
{ code: "GRAIN", cargoTypeName: "Grain / Cereals" },
{ code: "WHEAT", cargoTypeName: "Wheat" },
{ code: "FERTILIZER", cargoTypeName: "Fertilizer" },
{ code: "CEMENT", cargoTypeName: "Cement / Clinker" },
{ code: "COAL", cargoTypeName: "Coal" },
];
// Break-bulk items — counted as whole units (PER_ITEM).
const breakBulkChildren = [
{ code: "CARS", cargoTypeName: "Cars / Vehicles" },
{
code: "MACHINERY",
cargoTypeName: "Heavy Machinery",
requiresDirectorApproval: true,
},
{
code: "STEEL",
cargoTypeName: "Steel / Rebar",
requiresDirectorApproval: true,
},
{ code: "PIPES", cargoTypeName: "Pipes" },
{ code: "TIMBER", cargoTypeName: "Timber" },
];
await repo.upsert(
[
{
code: "GRAIN",
cargoTypeName: "Grain / Cereals",
showFreeTextBox: false,
requiresDirectorApproval: false,
...bulkChildren.map((c, i) => ({
...c,
parentGroupId: bulk.id,
unitOfMeasure: "PER_TON",
isActive: true,
displayOrder: 1,
},
{
code: "FERTILIZER",
cargoTypeName: "Fertilizer",
showFreeTextBox: false,
requiresDirectorApproval: false,
displayOrder: i + 1,
})),
...breakBulkChildren.map((c, i) => ({
...c,
parentGroupId: breakBulk.id,
unitOfMeasure: "PER_ITEM",
isActive: true,
displayOrder: 2,
},
{
code: "CEMENT",
cargoTypeName: "Cement / Clinker",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 3,
},
{
code: "STEEL",
cargoTypeName: "Steel / Rebar",
showFreeTextBox: false,
requiresDirectorApproval: true,
isActive: true,
displayOrder: 4,
},
{
code: "MACHINERY",
cargoTypeName: "Heavy Machinery",
showFreeTextBox: false,
requiresDirectorApproval: true,
isActive: true,
displayOrder: 5,
},
{
code: "OTHER_BULK",
cargoTypeName: "Other Bulk Cargo",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 6,
},
displayOrder: i + 1,
})),
],
{ conflictPaths: { code: true } },
);
// Retire the old flat "Other Bulk Cargo" top-level type from earlier seeds so
// it no longer shows alongside the Bulk / Break Bulk groups. No-op on a fresh
// DB where it was never seeded.
await repo.update({ code: "OTHER_BULK" }, { isActive: false });
}
private async seedDomesticRoute(manager: any, yRepo: any): Promise<void> {
@@ -448,7 +434,13 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
// ── Surcharges (trigger-based) ──────────────────────────────────────
{ appliesTo: "OTHER", trigger: "OVERWEIGHT", rateType: "OVERWEIGHT_PER_TON", rateValue: 25, rateUnit: "PER_TON" },
{ appliesTo: "OTHER", trigger: "HAZARDOUS", rateType: "HAZARD_SURCHARGE", rateValue: 150, rateUnit: "FLAT" },
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 200, rateUnit: "FLAT" },
// Reefer surcharge scales with the freight shape: container bookings bill
// per reefer container, bulk bookings bill per ton. The engine now honors
// each rate's unit, so both rows can coexist — only the matching one
// produces a non-zero line (the other multiplies by 0 and is dropped).
// Small test values (< 20) so the surcharge stays a minor add for now.
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 15, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" },
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
];

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ import { useAuth } from "@/auth/useAuth";
import {
getNextPendingApprovalStep,
isAllocateAction,
isClearanceNavAction,
isContractNavAction,
listRowHasActions,
type BookingActionContext,
@@ -38,6 +39,7 @@ export function BookingActionsMenu({
reference: row.reference,
approvalSteps: row.approvalSteps,
schedulingStatus: row.schedulingStatus,
customsClearingEnabled: row.customsClearingEnabled,
};
const flow = useBookingActionDialog(row.id, context);
@@ -46,10 +48,15 @@ export function BookingActionsMenu({
const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`);
const goToClearanceTab = () =>
navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`);
const handleAction = (action: (typeof actions)[number]) => {
onSuppressRowClick?.();
if (isContractNavAction(action.id)) {
goToContract();
} else if (isClearanceNavAction(action.id)) {
goToClearanceTab();
} else if (isAllocateAction(action.id)) {
onAllocateBooking?.();
} else {

View File

@@ -2,9 +2,11 @@ import { Badge, ScrollArea, Tabs } from "@mantine/core";
import {
CheckCircle,
ClipboardCheck,
ClipboardList,
FileSignature,
Inbox,
LayoutGrid,
ShieldCheck,
Train,
Wallet,
XCircle,
@@ -21,7 +23,9 @@ const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
intake: <Inbox size={17} strokeWidth={1.85} />,
in_approval: <ClipboardCheck size={17} strokeWidth={1.85} />,
approved_contract: <FileSignature size={17} strokeWidth={1.85} />,
clearance: <ShieldCheck size={17} strokeWidth={1.85} />,
payment: <Wallet size={17} strokeWidth={1.85} />,
ops_review: <ClipboardList size={17} strokeWidth={1.85} />,
operations: <Train size={17} strokeWidth={1.85} />,
completed: <CheckCircle size={17} strokeWidth={1.85} />,
closed: <XCircle size={17} strokeWidth={1.85} />,

View File

@@ -1,4 +1,4 @@
import { Train, MapPin, ArrowRight } from "lucide-react";
import { Train, MapPin, ArrowRight, FileText } from "lucide-react";
import { Group, Stack, Text, Badge, Box, SimpleGrid } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
@@ -44,6 +44,8 @@ export function BookingRouteServiceCard({
const serviceLabel =
booking.serviceType?.label ?? booking.serviceType?.code ?? "Rail service";
const includesCustoms = booking.serviceType?.includesCustoms;
const metrics = [
{ label: "Trade direction", value: booking.tradeDirection },
{ label: "Freight type", value: booking.freightType },
@@ -96,6 +98,47 @@ export function BookingRouteServiceCard({
<MetricTile key={m.label} label={m.label} value={m.value} />
))}
</SimpleGrid>
{includesCustoms ? (
<Box
mt="md"
px={14}
py={10}
style={{
borderRadius: 10,
border: "1.5px solid #CDEBDD",
background: "#F6FBF8",
}}
>
<Group gap={10} align="center">
<FileText size={15} color="#0A6F4D" />
<Text fz={13} fw={600} c="#0A6F4D">
Customs clearing included automatically
</Text>
</Group>
</Box>
) : booking.customsClearingAgent ? (
<Box
mt="md"
px={14}
py={10}
style={{
borderRadius: 10,
border: "1.5px solid #E6ECF2",
background: "#F8FAFC",
}}
>
<Group gap={10} align="center">
<FileText size={15} color="#64748B" />
<Text fz={13} fw={500} c="#374151">
Customs clearing agent:{" "}
<Text component="span" fw={700} c="#10202F">
{booking.customsClearingAgent}
</Text>
</Text>
</Group>
</Box>
) : null}
</SectionCard>
);
}

View File

@@ -5,15 +5,13 @@ import {
Badge,
Box,
Button,
Card,
FileButton,
Group,
Loader,
Paper,
Progress,
ScrollArea,
Stack,
Text,
TextInput,
Textarea,
ThemeIcon,
Tooltip,
@@ -21,257 +19,63 @@ import {
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
ExternalLink,
FileCheck2,
FileText,
Inbox,
MessageSquareWarning,
Search,
ShieldCheck,
Upload,
X,
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "./SectionCard";
import { bookingsService } from "@/services/bookings.service";
const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
export default function GlClearancePage() {
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState("");
// Bookings currently awaiting GL document review.
const { data: list, isLoading } = useQuery({
queryKey: ["gl-clearance", "list"],
queryFn: () =>
bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }),
});
const bookings = list?.items ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return bookings;
return bookings.filter(
(b) =>
b.reference?.toLowerCase().includes(q) ||
b.tradeDirection?.toLowerCase().includes(q) ||
b.freightType?.toLowerCase().includes(q),
);
}, [bookings, search]);
const activeId =
selectedId && filtered.some((b) => b.id === selectedId)
? selectedId
: (filtered[0]?.id ?? null);
return (
<PageContainer>
<PageHeader
title="Document Clearance"
subtitle="Review customer documents, approve or raise a query, and finalize clearance."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{bookings.length} awaiting review
</Badge>
}
/>
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
{/* ── Review queue ─────────────────────────────────────────────── */}
<Card
withBorder
shadow="sm"
radius="lg"
p="sm"
className="w-full shrink-0 lg:w-[320px]"
>
<Group justify="space-between" align="center" mb="xs" px={4}>
<Text fz="13px" fw={700} c="edr-text">
Review queue
</Text>
<Badge size="sm" variant="default" radius="sm">
{filtered.length}
</Badge>
</Group>
<TextInput
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
placeholder="Search reference…"
size="xs"
radius="md"
mb="xs"
leftSection={<Search size={14} />}
rightSection={
search ? (
<X
size={14}
style={{ cursor: "pointer" }}
onClick={() => setSearch("")}
/>
) : null
}
/>
{isLoading ? (
<Group justify="center" py="lg" gap={8}>
<Loader size="xs" color="edr-green" />
<Text fz="13px" c="dimmed">
Loading
</Text>
</Group>
) : filtered.length === 0 ? (
<Stack align="center" gap={6} py="xl">
<ThemeIcon variant="light" color="gray" radius="xl" size={40}>
<Inbox size={20} />
</ThemeIcon>
<Text fz="13px" c="dimmed" ta="center">
{search
? "No bookings match your search."
: "Nothing awaiting document review."}
</Text>
</Stack>
) : (
<ScrollArea.Autosize mah={620} type="hover" offsetScrollbars>
<Stack gap={6}>
{filtered.map((b) => (
<QueueItem
key={b.id}
booking={b}
active={b.id === activeId}
onSelect={() => setSelectedId(b.id)}
/>
))}
</Stack>
</ScrollArea.Autosize>
)}
</Card>
{/* ── Review panel ─────────────────────────────────────────────── */}
<Box style={{ flex: 1, minWidth: 0 }}>
{activeId ? (
<ClearanceReviewPanel
key={activeId}
bookingId={activeId}
onChanged={() =>
qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] })
}
/>
) : (
<EmptyPanel />
)}
</Box>
</div>
</PageContainer>
);
export interface ClearanceReviewSectionProps {
bookingId: string;
/** Called after any review/finalize mutation so the parent can refetch. */
onChanged?: () => void;
/** Hide the inline progress summary (e.g. when the parent renders its own). */
hideSummary?: boolean;
}
/** A single booking row in the left-hand review queue. */
function QueueItem({
booking,
active,
onSelect,
}: {
booking: Freight.IBooking;
active: boolean;
onSelect: () => void;
}) {
return (
<Box
component="button"
type="button"
onClick={onSelect}
ta="left"
p="xs"
style={{
cursor: "pointer",
borderRadius: 12,
border: "1px solid",
borderColor: active
? "var(--mantine-color-edr-green-5)"
: "var(--mantine-color-edr-border-6)",
background: active
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-edr-card-6)",
transition: "all 120ms ease",
}}
>
<Group justify="space-between" wrap="nowrap" gap={8}>
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={700} c="edr-text" truncate>
{booking.reference}
</Text>
<Group gap={6} mt={3} wrap="nowrap">
<Badge
size="xs"
variant="light"
radius="sm"
color={
booking.tradeDirection === "IMPORT" ? "edr-blue" : "edr-accent"
}
>
{booking.tradeDirection}
</Badge>
<Text fz="11px" c="edr-muted" truncate>
{booking.freightType}
</Text>
</Group>
</Box>
</Group>
</Box>
);
}
const STATUS_META: Record<
Freight.DocumentReviewStatus,
{ label: string; color: string }
> = {
APPROVED: { label: "Approved", color: "edr-green" },
QUERIED: { label: "Queried", color: "red" },
PENDING: { label: "Pending", color: "gray" },
};
function EmptyPanel() {
return (
<Card withBorder shadow="sm" radius="lg" p={48}>
<Stack align="center" gap={10}>
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
<ShieldCheck size={28} />
</ThemeIcon>
<Text fw={700} c="edr-text">
No booking selected
</Text>
<Text fz="13px" c="dimmed" ta="center" maw={320}>
Pick a booking from the review queue to inspect its customer documents
and start clearance.
</Text>
</Stack>
</Card>
);
}
function ClearanceReviewPanel({
/**
* Staff-facing clearance document review: approve / query each customer
* document, upload customs output documents (customs bookings only) and
* finalize once every required document is approved. Shared by the Global
* Logistics clearance detail page (customs) and the Marketing booking detail
* (non-customs) the only difference is the output-docs block, which renders
* only when the booking has a customs output set.
*/
export function ClearanceReviewSection({
bookingId,
onChanged,
}: {
bookingId: string;
onChanged: () => void;
}) {
hideSummary,
}: ClearanceReviewSectionProps) {
const qc = useQueryClient();
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
const { data: clearance, isLoading } = useQuery({
queryKey: ["gl-clearance", bookingId],
queryKey: ["clearance", bookingId],
queryFn: () => bookingsService.getClearance(bookingId),
});
const refresh = () => {
qc.invalidateQueries({ queryKey: ["gl-clearance", bookingId] });
onChanged();
qc.invalidateQueries({ queryKey: ["clearance", bookingId] });
qc.invalidateQueries({ queryKey: ["clearance", "list"] });
onChanged?.();
};
const reviewMutation = useMutation({
@@ -292,8 +96,7 @@ function ClearanceReviewPanel({
});
const outputMutation = useMutation({
mutationFn: () =>
bookingsService.uploadClearanceOutput(bookingId, outputFiles),
mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles),
onSuccess: () => {
toast.success("Output documents uploaded");
setOutputFiles({});
@@ -323,7 +126,6 @@ function ClearanceReviewPanel({
[clearance],
);
// Review progress across the customer documents — drives the summary bar.
const stats = useMemo(() => {
const total = customerDocs.length;
const approved = customerDocs.filter(
@@ -333,120 +135,97 @@ function ClearanceReviewPanel({
(d) => d.reviewStatus === "QUERIED",
).length;
const pending = total - approved - queried;
return { total, approved, queried, pending };
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
return { total, approved, queried, pending, pct };
}, [customerDocs]);
if (isLoading || !clearance) {
return (
<Card withBorder shadow="sm" radius="lg" p={48}>
<Group justify="center" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
</Card>
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
);
}
const progressPct =
stats.total === 0 ? 0 : Math.round((stats.approved / stats.total) * 100);
return (
<Stack gap="md">
{/* ── Progress summary ───────────────────────────────────────────── */}
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group justify="space-between" align="flex-start" mb="md">
<Box>
<Text fw={700} fz="15px" c="edr-text">
Customer documents
</Text>
<Text fz="12.5px" c="dimmed" mt={2}>
Approve each document, or open a query to tell the customer what to
fix.
</Text>
</Box>
{clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
size="lg"
leftSection={<CheckCircle2 size={14} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="edr-blue"
radius="sm"
size="lg"
leftSection={<Clock size={14} />}
>
Review pending
</Badge>
)}
</Group>
<Progress
value={progressPct}
color="edr-green"
radius="xl"
size="sm"
mb="sm"
/>
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="edr-slate" label="Pending" value={stats.pending} />
<Text fz="12.5px" c="dimmed" ml="auto">
<Stack gap="lg">
<SectionCard
icon={FileText}
title="Customer documents"
subtitle="Approve each document, or open a query to tell the customer what to fix."
extra={
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
</Group>
</Card>
{/* ── Document review list ───────────────────────────────────────── */}
<Stack gap={12}>
{customerDocs.map((doc) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) => setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))}
onApprove={() =>
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
}
onQuery={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: queryNotes[doc.fileKey],
})
}
busy={reviewMutation.isPending}
/>
))}
</Stack>
{/* ── Customs output documents (GL-supplied) ─────────────────────── */}
{clearance.outputCode && (
<Card withBorder shadow="sm" radius="lg" p="lg">
<Group gap={8} mb="md">
<ThemeIcon variant="light" color="edr-blue" radius="md" size={28}>
<Upload size={15} />
</ThemeIcon>
<Text fw={700} c="edr-text">
Customs output documents
}
>
<Stack gap={12}>
{!hideSummary && stats.total > 0 && (
<Box>
<Progress
value={stats.pct}
color="edr-green"
radius="xl"
size="sm"
mb={6}
/>
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="gray" label="Pending" value={stats.pending} />
</Group>
</Box>
)}
{customerDocs.length === 0 ? (
<Text size="sm" c="dimmed">
No customer documents are required for this booking.
</Text>
</Group>
) : (
customerDocs.map((doc) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) =>
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
}
onApprove={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "APPROVED",
})
}
onQuery={() =>
reviewMutation.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: queryNotes[doc.fileKey],
})
}
busy={reviewMutation.isPending}
/>
))
)}
</Stack>
</SectionCard>
{clearance.outputCode && (
<SectionCard
icon={Upload}
title="Customs output documents"
subtitle="Upload the cleared/customs paperwork to hand back to the customer."
accent="edr-green"
>
<Stack gap={10}>
{glDocs.map((doc) => (
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="var(--mantine-color-edr-blue-6)" />
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
<Text fz="13px" c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
@@ -460,7 +239,7 @@ function ClearanceReviewPanel({
href={doc.file.url}
target="_blank"
rel="noreferrer"
c="edr-blue"
c="edr-green"
style={{ display: "flex" }}
>
<Download size={15} />
@@ -506,7 +285,7 @@ function ClearanceReviewPanel({
Upload output documents
</Button>
</Group>
</Card>
</SectionCard>
)}
{finalizeMutation.isError && (
@@ -517,14 +296,23 @@ function ClearanceReviewPanel({
</Alert>
)}
{/* ── Finalize bar ───────────────────────────────────────────────── */}
<Card withBorder shadow="sm" radius="lg" p="md">
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved. You can finalize clearance."
: "Approve every required document to unlock finalization."}
</Text>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
<FileCheck2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved — you can finalize."
: "Approve every required document to unlock finalization."}
</Text>
</Group>
<Button
color="edr-green"
radius="md"
@@ -536,7 +324,7 @@ function ClearanceReviewPanel({
Finalize clearance
</Button>
</Group>
</Card>
</Paper>
</Stack>
);
}
@@ -570,16 +358,6 @@ function StatPill({
);
}
/** Visual treatment for each document review state. */
const STATUS_META: Record<
Freight.DocumentReviewStatus,
{ label: string; color: string }
> = {
APPROVED: { label: "Approved", color: "edr-green" },
QUERIED: { label: "Queried", color: "red" },
PENDING: { label: "Pending", color: "edr-slate" },
};
function DocReviewCard({
doc,
note,
@@ -604,10 +382,9 @@ function DocReviewCard({
const hasFile = !!doc.file;
return (
<Card
<Paper
withBorder
shadow="sm"
radius="lg"
radius="md"
p="md"
style={{
borderColor:
@@ -622,7 +399,7 @@ function DocReviewCard({
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={hasFile ? "edr-blue" : "gray"}
color={hasFile ? "edr-green" : "gray"}
radius="md"
size={40}
>
@@ -662,7 +439,6 @@ function DocReviewCard({
</Group>
</Group>
{/* Previously raised query — visible so staff see what was asked. */}
{status === "QUERIED" && doc.note && (
<Alert
mt="sm"
@@ -678,7 +454,6 @@ function DocReviewCard({
</Alert>
)}
{/* Action row — only when the customer actually uploaded a file. */}
{hasFile && (
<Box mt="sm">
{!queryOpen ? (
@@ -760,6 +535,6 @@ function DocReviewCard({
)}
</Box>
)}
</Card>
</Paper>
);
}

View File

@@ -0,0 +1,260 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
Badge,
Box,
Card,
Center,
Group,
Loader,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { ChevronRight, Inbox, PackageCheck } from "lucide-react";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import {
useContractOrders,
useContractPool,
} from "@/hooks/bookings/useContractOrders";
import type { Freight } from "@edr/types";
export interface ContractOrdersPanelProps {
/** The general-contract booking whose drawdown orders are listed. */
contractBookingId: string;
/** Whether the contract is container-based (affects quantity labels). */
isContainer: boolean;
}
/** Format a contracted/remaining quantity with its unit. */
function formatQuantity(
qty: number,
unit: Freight.ContractQuantityLine["unitOfMeasure"],
isContainerLine: boolean,
): string {
const rounded = Number.isInteger(qty) ? qty : Number(qty.toFixed(2));
if (isContainerLine) return `${rounded} containers`;
if (unit === "PER_ITEM") return `${rounded} items`;
return `${rounded} tons`;
}
/** Summarise an order's lines, e.g. "2 20FT, 1 40FT" or "15". */
function summariseLines(lines: Freight.IBookingOrderLine[]): string {
return lines
.map((l) => {
const qty = Number(l.quantity);
const label = Number.isInteger(qty) ? `${qty}` : qty.toFixed(2);
return `${label}${l.containerTypeName ? ` ${l.containerTypeName}` : ""}`;
})
.join(", ");
}
/**
* Backoffice "Orders" tab for a general contract: shows the drawdown pool and
* lists each order placed against the contract. Each order links to its child
* booking's detail page, where staff approve it and review clearance/customer
* documents independently (same screen as a one-time booking).
*/
export function ContractOrdersPanel({
contractBookingId,
isContainer,
}: ContractOrdersPanelProps) {
const navigate = useNavigate();
const { data: orders, isLoading: ordersLoading } =
useContractOrders(contractBookingId);
const { data: pool, isLoading: poolLoading } =
useContractPool(contractBookingId);
const poolLines = pool ?? [];
const totals = useMemo(() => {
const contracted = poolLines.reduce(
(s, l) => s + (l.contractedQuantity || 0),
0,
);
const ordered = poolLines.reduce((s, l) => s + (l.orderedQuantity || 0), 0);
const pct = contracted > 0 ? Math.round((ordered / contracted) * 100) : 0;
return { contracted, ordered, pct };
}, [poolLines]);
if (ordersLoading || poolLoading) {
return (
<Center mih={240}>
<Loader color="gray" />
</Center>
);
}
return (
<Stack gap="lg">
{/* Drawdown pool */}
<Card withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="lg">
<Box>
<Text fw={700} fz={16}>
Contracted quantity
</Text>
<Text fz={13} c="dimmed" mt={2}>
How much of this contract has been ordered versus what remains.
</Text>
</Box>
{totals.contracted > 0 && (
<RingProgress
size={72}
thickness={7}
roundCaps
sections={[{ value: totals.pct, color: "edr-green" }]}
label={
<Text ta="center" fz={13} fw={800}>
{totals.pct}%
</Text>
}
/>
)}
</Group>
<Stack gap="lg">
{poolLines.length === 0 && (
<Text fz={13} c="dimmed">
No quantity pool available.
</Text>
)}
{poolLines.map((line, i) => {
const pct =
line.contractedQuantity > 0
? Math.min(
100,
(line.orderedQuantity / line.contractedQuantity) * 100,
)
: 0;
const label = isContainer
? (line.containerTypeName ?? "Containers")
: line.unitOfMeasure === "PER_ITEM"
? "Items"
: "Tons";
const depleted = line.remainingQuantity <= 0;
return (
<div key={line.containerTypeId ?? `bulk-${i}`}>
<Group justify="space-between" mb={6}>
<Group gap={8} align="center">
<Text fz={14} fw={600}>
{label}
</Text>
{depleted && (
<Badge size="xs" variant="light" color="gray" radius="sm">
Fully ordered
</Badge>
)}
</Group>
<Text fz={13} c="dimmed">
<Text span fw={700} c={depleted ? "dimmed" : "edr-green"}>
{formatQuantity(
line.remainingQuantity,
line.unitOfMeasure,
isContainer,
)}
</Text>{" "}
remaining of{" "}
{formatQuantity(
line.contractedQuantity,
line.unitOfMeasure,
isContainer,
)}
</Text>
</Group>
<Progress
value={pct}
color={depleted ? "gray" : "edr-green"}
size="md"
radius="xl"
/>
</div>
);
})}
</Stack>
</Card>
{/* Orders */}
<Card withBorder radius="md" p="lg">
<Group justify="space-between" align="center" mb="md">
<Text fw={700} fz={16}>
Orders
</Text>
<Badge variant="light" color="violet" radius="sm">
{orders?.length ?? 0}
</Badge>
</Group>
{!orders || orders.length === 0 ? (
<Stack align="center" gap={8} py="xl">
<ThemeIcon size={48} radius="xl" variant="light" color="gray">
<Inbox size={22} />
</ThemeIcon>
<Text fz={13} c="dimmed" ta="center" maw={360}>
No orders have been placed against this contract yet.
</Text>
</Stack>
) : (
<Stack gap={10}>
{orders.map((order) => {
const childId = order.bookingId;
const clickable = Boolean(childId);
return (
<Group
key={order.id}
justify="space-between"
wrap="nowrap"
p="sm"
style={{
borderRadius: 12,
border: "1px solid var(--mantine-color-gray-3)",
cursor: clickable ? "pointer" : "default",
}}
onClick={
clickable
? () =>
navigate(`/dashboard/booking-requests/${childId}`)
: undefined
}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
size={38}
radius="md"
variant="light"
color="violet"
>
<PackageCheck size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} truncate>
{order.reference}
</Text>
<Text fz={12} c="dimmed" truncate>
Ship{" "}
{new Date(order.scheduledDate).toLocaleDateString()}
{order.lines.length > 0
? ` · ${summariseLines(order.lines)}`
: ""}
</Text>
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<BookingStatusBadge status={order.status} />
{clickable && (
<ChevronRight
size={16}
color="var(--mantine-color-gray-5)"
/>
)}
</Group>
</Group>
);
})}
</Stack>
)}
</Card>
</Stack>
);
}

View File

@@ -1,5 +1,7 @@
export * from "./booking-detail.styles";
export * from "./SectionCard";
export * from "./ClearanceReviewSection";
export * from "./ContractOrdersPanel";
export * from "./MetricTile";
export * from "./BookingDetailToolbar";
export * from "./BookingDetailHeader";

View File

@@ -8,6 +8,7 @@ import {
Checkbox,
Group,
Modal,
MultiSelect,
Paper,
Radio,
RingProgress,
@@ -107,7 +108,7 @@ export function AllocateBookingWizard({
const [selectedScheduleId, setSelectedScheduleId] = useState<string | null>(null);
const [routeId, setRouteId] = useState("");
const scheduleDate = booking.scheduledDate;
const [locomotiveId, setLocomotiveId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const [extraBookingIds, setExtraBookingIds] = useState<string[]>([]);
const [forceAssign, setForceAssign] = useState(false);
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
@@ -152,7 +153,7 @@ export function AllocateBookingWizard({
useEffect(() => {
if (scheduleMode === "new") {
setLocomotiveId("");
setLocomotiveIds([]);
}
}, [routeId, scheduleMode]);
@@ -264,11 +265,11 @@ export function AllocateBookingWizard({
const ensureSchedule = async (): Promise<string> => {
if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId;
if (!routeId || !scheduleDate || !locomotiveId) {
throw new Error("Select route, date, and locomotive");
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
throw new Error("Select route, date, and at least two locomotives");
}
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveId },
payload: { routeId, scheduleDate, locomotiveIds },
});
setSelectedScheduleId(created.id);
return created.id;
@@ -526,17 +527,25 @@ export function AllocateBookingWizard({
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
<Select
label="Locomotive"
placeholder={routeId ? "Select locomotive" : "Select a route first"}
<MultiSelect
label="Locomotives"
description="At least two (front and back)"
placeholder={
routeId ? "Select at least two locomotives" : "Select a route first"
}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable
disabled={!routeId}
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
}
nothingFoundMessage={
routeId ? "No available locomotives for this corridor" : "Select a route first"
}

View File

@@ -45,6 +45,14 @@ export const QUERY_KEYS = {
byId: (id: string) => ["bookings", "detail", id] as const,
},
BOOKING_ORDERS: {
ROOT: ["booking-orders"] as const,
byContract: (contractBookingId: string) =>
["booking-orders", "by-contract", contractBookingId] as const,
pool: (contractBookingId: string) =>
["booking-orders", "pool", contractBookingId] as const,
},
TRAIN_SCHEDULING: {
ROOT: ["train-scheduling"] as const,
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>

View File

@@ -32,6 +32,7 @@ export type BookingActionId =
| "rejectApproval"
| "viewContract"
| "signContractStaff"
| "reviewClearance"
| "allocateBooking"
| "startTransit"
| "complete"
@@ -69,6 +70,7 @@ export type BookingActionContext = Pick<
| "approvalSteps"
| "reference"
| "schedulingStatus"
| "customsClearingEnabled"
>;
const ALLOCATABLE_SCHEDULING_STATUSES = new Set([
@@ -250,6 +252,20 @@ const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
primary: true,
};
// Opens the booking detail straight on the Clearance tab so Marketing can
// review the customer's clearance documents (non-customs bookings only).
const REVIEW_CLEARANCE_ACTION: BookingActionDef = {
id: "reviewClearance",
label: "Review clearance",
shortLabel: "Clearance",
description: "Approve or query the customer's clearance documents",
confirmTitle: "",
confirmDescription: "",
variant: "default",
icon: ShieldCheck,
primary: true,
};
function withCancel(actions: BookingActionDef[]): BookingActionDef[] {
return [...actions, CANCEL_ACTION];
}
@@ -261,6 +277,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
viewContract: FREIGHT_PERMS.bookings.view,
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
reviewClearance: FREIGHT_PERMS.bookings.reviewDocuments,
startTransit: FREIGHT_PERMS.bookings.operations,
complete: FREIGHT_PERMS.bookings.operations,
operationAccept: FREIGHT_PERMS.bookings.operations,
@@ -359,6 +376,14 @@ export function getBookingActions(
},
];
break;
case "AWAITING_DOCUMENTS":
case "DOCUMENTS_UNDER_REVIEW":
// Marketing reviews non-customs clearance here; customs bookings are
// handled in the Global Logistics clearance queue, not the booking list.
actions = ctx.customsClearingEnabled
? [CANCEL_ACTION]
: withCancel([REVIEW_CLEARANCE_ACTION]);
break;
case "OPERATION_REQUEST_PENDING":
actions = withCancel(OPERATION_REVIEW_ACTIONS);
break;
@@ -446,11 +471,17 @@ export function isAllocateAction(id: BookingActionId): boolean {
return id === "allocateBooking";
}
/** Opens the booking detail on the Clearance tab without a confirm dialog. */
export function isClearanceNavAction(id: BookingActionId): boolean {
return id === "reviewClearance";
}
export function listRowHasActions(
row: {
status: BookingStatus;
paymentCurrency: string;
approvalSteps?: BookingApprovalStep[] | null;
customsClearingEnabled?: boolean;
},
user?: AuthUser | null,
): boolean {
@@ -461,6 +492,7 @@ export function listRowHasActions(
reference: "",
approvalSteps: row.approvalSteps ?? undefined,
schedulingStatus: row.status,
customsClearingEnabled: row.customsClearingEnabled,
},
user,
);

View File

@@ -262,6 +262,11 @@ export const BOOKING_LIST_TABS = [
"FULLY_EXECUTED",
],
},
{
key: "clearance",
label: "Clearance",
statuses: ["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"],
},
{
key: "payment",
label: "Payment",

View File

@@ -44,6 +44,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
governmentInstitution: booking.governmentInstitution ?? null,
consolidationPartnerId: booking.consolidationPartnerId ?? null,
consolidationPartnerReference: booking.consolidationPartner?.reference ?? null,
customsClearingEnabled: booking.customsClearingEnabled ?? false,
createdAt: booking.createdAt,
};
}

View File

@@ -0,0 +1,25 @@
import type { LucideIcon } from "lucide-react";
import { Layers, ShipWheel, Truck } from "lucide-react";
/**
* The Global Logistics clearance queue holds only customs bookings
* (`DOCUMENTS_UNDER_REVIEW` + customsClearingEnabled); non-customs clearance is
* reviewed by Marketing on the booking detail. Since every row here is a customs
* booking, the tabs slice by trade direction rather than customs scope.
*/
export type ClearanceTabKey = "all" | "import" | "export";
export interface ClearanceTab {
key: ClearanceTabKey;
label: string;
icon: LucideIcon;
}
export const CLEARANCE_TABS: ClearanceTab[] = [
{ key: "all", label: "All", icon: Layers },
{ key: "import", label: "Import", icon: Truck },
{ key: "export", label: "Export", icon: ShipWheel },
];
/** The backend booking status that places a booking in the clearance queue. */
export const CLEARANCE_REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";

View File

@@ -0,0 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { bookingOrdersService } from "@/services/booking-orders.service";
/** Orders placed against a general contract (id = the contract booking id). */
export function useContractOrders(
contractBookingId: string | undefined,
enabled = true,
) {
return useQuery({
queryKey: QUERY_KEYS.BOOKING_ORDERS.byContract(contractBookingId ?? ""),
queryFn: () => bookingOrdersService.listByContract(contractBookingId!),
enabled: Boolean(contractBookingId) && enabled,
});
}
/** Contracted / ordered / remaining drawdown pool for a general contract. */
export function useContractPool(
contractBookingId: string | undefined,
enabled = true,
) {
return useQuery({
queryKey: QUERY_KEYS.BOOKING_ORDERS.pool(contractBookingId ?? ""),
queryFn: () => bookingOrdersService.pool(contractBookingId!),
enabled: Boolean(contractBookingId) && enabled,
});
}

View File

@@ -4,6 +4,7 @@ import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export const FREIGHT_PERMS = {
bookings: {
view: "edr_freight_app:bookings:view",
clearanceView: "edr_freight_app:bookings:clearance_view",
staffAccept: "edr_freight_app:bookings:staff_accept",
requestChanges: "edr_freight_app:bookings:request_changes",
reject: "edr_freight_app:bookings:reject",
@@ -82,6 +83,11 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}
/** Can see/manage the customs document-clearance queue (Global Logistics). */
export function canViewClearance(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.reviewDocuments);
}
export function canViewScheduling(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
}

View File

@@ -1,11 +1,19 @@
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, FileSignature, Package } from "lucide-react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
FileSignature,
Layers,
LayoutGrid,
Package,
ShieldCheck,
} from "lucide-react";
import {
Container,
Stack,
Grid,
Center,
Loader,
Tabs,
Text,
Paper,
Button,
@@ -28,11 +36,14 @@ import {
BookingCompanyCard,
BookingContractSummaryCard,
BookingDocumentsCard,
ClearanceReviewSection,
ContractOrdersPanel,
type BookingFileView,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import type { BookingDetail } from "@/types/booking";
import { downloadBookingFile } from "@/services/files.service";
import {
useBookingDetail,
@@ -52,6 +63,7 @@ const SIGNATURE_FILE_CODES = new Set([
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const {
data: booking,
isLoading,
@@ -142,6 +154,31 @@ export default function BookingRequestDetailPage() {
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE";
// Non-customs clearance is reviewed here by Marketing in its own tab; customs
// bookings are handled in the Global Logistics clearance queue instead.
const showClearanceTab =
!booking.customsClearingEnabled &&
["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"].includes(
booking.status,
);
// A general contract drives an "Orders" tab: each drawdown order spawns a
// child booking that staff manage (clearance/approval) independently.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
const showTabs = showClearanceTab || isGeneralContract;
const requestedTab = searchParams.get("tab");
const activeTab =
requestedTab === "clearance" && showClearanceTab
? "clearance"
: requestedTab === "orders" && isGeneralContract
? "orders"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
else next.delete("tab");
setSearchParams(next, { replace: true });
};
return (
<PageContainer>
<Breadcrumbs
@@ -172,26 +209,69 @@ export default function BookingRequestDetailPage() {
)}
<Grid gap="lg">
{/* LEFT — primary content */}
{/* LEFT — primary content, split into tabs to keep each view focused */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<BookingRouteServiceCard
booking={booking}
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
{showTabs ? (
<Tabs
value={activeTab}
onChange={setActiveTab}
variant="pills"
color="edr-blue"
keepMounted={false}
>
<Tabs.List mb="lg">
<Tabs.Tab
value="overview"
leftSection={<LayoutGrid size={16} />}
>
Overview
</Tabs.Tab>
{isGeneralContract && (
<Tabs.Tab value="orders" leftSection={<Layers size={16} />}>
Orders
</Tabs.Tab>
)}
{showClearanceTab && (
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Customer clearance
</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
</Tabs.Panel>
{isGeneralContract && (
<Tabs.Panel value="orders">
<ContractOrdersPanel
contractBookingId={booking.id}
isContainer={booking.freightType === "CONTAINER"}
/>
</Tabs.Panel>
)}
{showClearanceTab && (
<Tabs.Panel value="clearance">
<ClearanceReviewSection
bookingId={booking.id}
onChanged={() => refetch()}
/>
</Tabs.Panel>
)}
</Tabs>
) : (
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
</Stack>
)}
</Grid.Col>
{/* RIGHT — sticky action / summary rail */}
@@ -233,3 +313,35 @@ export default function BookingRequestDetailPage() {
</PageContainer>
);
}
/** The booking's primary detail cards — route, services, cargo, contract, docs. */
function OverviewPanel({
booking,
row,
onDownload,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
onDownload: (file: BookingFileView) => void;
}) {
return (
<Stack gap="lg">
<BookingRouteServiceCard
booking={booking}
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)}
onDownload={onDownload}
/>
</Stack>
);
}

View File

@@ -0,0 +1,291 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import {
Alert,
Badge,
Box,
Grid,
Group,
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
ArrowRight,
CheckCircle2,
Clock,
PackageCheck,
ShieldCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { bookingsService } from "@/services/bookings.service";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
export default function DocumentClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: booking } = useBookingDetail(id);
const {
data: clearance,
isLoading,
isError,
} = useQuery({
queryKey: ["clearance", id],
queryFn: () => bookingsService.getClearance(id!),
enabled: Boolean(id),
});
const stats = useMemo(() => {
const docs = (clearance?.documents ?? []).filter(
(d) => d.uploadedBy === "customer",
);
const total = docs.length;
const approved = docs.filter((d) => d.reviewStatus === "APPROVED").length;
const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length;
const pending = total - approved - queried;
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
return { total, approved, queried, pending, pct };
}, [clearance]);
const reference = booking?.reference ?? "Clearance";
if (isLoading) {
return (
<PageContainer>
<Group justify="center" py={80} gap={10}>
<Loader color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
</PageContainer>
);
}
if (isError || !clearance) {
return (
<PageContainer>
<PageHeader
title="Clearance not found"
backTo="/dashboard/clearance"
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/clearance" },
{ label: "Not found" },
]}
/>
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
We couldnt load this bookings clearance.
</Alert>
</PageContainer>
);
}
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={reference}
backTo="/dashboard/clearance"
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/clearance" },
{ label: reference },
]}
meta={
clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)
}
/>
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />
<Grid gap="lg">
{/* LEFT — document review (shared with the Marketing booking detail) */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<ClearanceReviewSection bookingId={id!} hideSummary />
</Grid.Col>
{/* RIGHT — sticky progress gauge */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-green"
label="Approved"
value={stats.approved}
/>
<ProgressStat
color="red"
label="Queried"
value={stats.queried}
/>
<ProgressStat
color="gray"
label="Pending"
value={stats.pending}
/>
</Group>
</Stack>
</SectionCard>
</Box>
</Grid.Col>
</Grid>
</Stack>
</PageContainer>
);
}
function ClearanceHero({
booking,
clearance,
stats,
}: {
booking: ReturnType<typeof useBookingDetail>["data"];
clearance: Freight.ClearanceView;
stats: { pct: number; approved: number; total: number };
}) {
const direction = booking?.tradeDirection ?? "—";
const origin =
booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
const destination =
booking?.destinationYard?.label ??
booking?.destinationYard?.code ??
"Destination";
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
<ShieldCheck size={26} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={800} fz={20} c="edr-text" truncate>
{booking?.reference ?? "Clearance"}
</Text>
<Badge
size="sm"
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{direction}
</Badge>
{clearance.includesCustoms ? (
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={12} />}
>
Customs
</Badge>
) : null}
</Group>
<Group gap={8} mt={6} wrap="nowrap">
<Text size="sm" fw={600} truncate maw={160}>
{origin}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={160}>
{destination}
</Text>
</Group>
</Box>
</Group>
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed" fw={600}>
Document review
</Text>
<Text size="xs" c="dimmed">
{stats.approved}/{stats.total}
</Text>
</Group>
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
</Box>
</Group>
</Paper>
);
}
function ProgressStat({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Stack gap={2} align="center">
<Text fw={700} fz={18} c="edr-text">
{value}
</Text>
<Group gap={4} wrap="nowrap">
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="11px" c="dimmed">
{label}
</Text>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,600 @@
import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
Badge,
Box,
Card,
Group,
ScrollArea,
SegmentedControl,
SimpleGrid,
Stack,
Tabs,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
ArrowRight,
Calendar,
ChevronRight,
Inbox,
LayoutGrid,
RefreshCw,
Search,
ShieldCheck,
ShipWheel,
Table as TableIcon,
Truck,
User,
X,
} from "lucide-react";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { bookingsService } from "@/services/bookings.service";
import type { BookingDetail } from "@/types/booking";
import {
CLEARANCE_REVIEW_STATUS,
CLEARANCE_TABS,
type ClearanceTabKey,
} from "@/features/clearance/clearance-tabs.config";
type ViewMode = "table" | "cards";
interface ClearanceRow {
id: string;
reference: string;
customerLabel: string;
tradeDirection: string;
freightType: string;
originLabel: string;
destinationLabel: string;
scheduledDate: string;
hasCustoms: boolean;
}
function labelFromRef(
ref?: { companyName?: string; label?: string; name?: string; code?: string },
fallback = "—",
): string {
if (!ref) return fallback;
return ref.companyName ?? ref.label ?? ref.name ?? ref.code ?? fallback;
}
function toClearanceRow(booking: BookingDetail): ClearanceRow {
return {
id: booking.id,
reference: booking.reference,
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
tradeDirection: booking.tradeDirection ?? "—",
freightType: booking.freightType ?? "—",
originLabel: labelFromRef(booking.originYard),
destinationLabel: labelFromRef(booking.destinationYard),
scheduledDate: booking.scheduledDate,
hasCustoms: Boolean(
booking.customsClearingEnabled ?? booking.serviceType?.includesCustoms,
),
};
}
function formatDate(iso?: string): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString(undefined, {
day: "2-digit",
month: "short",
year: "numeric",
});
}
/**
* Icon-only chip for a booking's trade direction — Truck for import, ShipWheel
* for export — on a light background, matching the "awaiting review" badge
* styling. Keeps the cards within the white / light-gray / green palette and
* drops the text label in favour of a tooltip.
*/
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
return (
<Tooltip label={label} withArrow>
<ThemeIcon
variant="light"
color={isImport ? "edr-green" : "gray"}
radius="md"
size={28}
aria-label={label}
>
<Icon size={15} strokeWidth={1.9} />
</ThemeIcon>
</Tooltip>
);
}
export default function DocumentClearanceListPage() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { data, isLoading, isError, isFetching, refetch } = useQuery({
queryKey: ["clearance", "list"],
queryFn: () =>
bookingsService.list({ status: CLEARANCE_REVIEW_STATUS, pageSize: 200 }),
});
// GL clears customs bookings only; non-customs clearance is reviewed by
// Marketing on the booking detail. Scope the queue defensively so a staff or
// marketing user opening this page still sees the customs queue.
const allRows = useMemo(
() => (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms),
[data?.items],
);
// Per-tab counts drive the badge on each tab.
const tabCounts = useMemo(() => {
return {
all: allRows.length,
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
} satisfies Record<ClearanceTabKey, number>;
}, [allRows]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return allRows.filter((r) => {
if (activeTab === "import" && r.tradeDirection !== "IMPORT") return false;
if (activeTab === "export" && r.tradeDirection !== "EXPORT") return false;
if (!q) return true;
return (
r.reference.toLowerCase().includes(q) ||
r.customerLabel.toLowerCase().includes(q) ||
r.originLabel.toLowerCase().includes(q) ||
r.destinationLabel.toLowerCase().includes(q)
);
});
}, [allRows, activeTab, query]);
const total = rows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const pagedRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return rows.slice(start, start + pagination.pageSize);
}, [rows, pagination.pageIndex, pagination.pageSize]);
const openDetail = useCallback(
(id: string) => navigate(`/dashboard/clearance/${id}`),
[navigate],
);
const columns: ColumnDef<ClearanceRow>[] = useMemo(
() => [
{
id: "booking",
header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<ShieldCheck className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{r.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{r.customerLabel}
</p>
</div>
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={500} truncate maw={120}>
{r.originLabel}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500} truncate maw={120}>
{r.destinationLabel}
</Text>
</Group>
<Group gap={8} align="center">
<DirectionIcon direction={r.tradeDirection} />
<Badge size="xs" variant="default" radius="sm">
{r.freightType}
</Badge>
</Group>
</Stack>
);
},
},
{
id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
{formatDate(row.original.scheduledDate)}
</span>
),
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: () => (
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Under review
</Badge>
),
},
{
id: "go",
size: 56,
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[],
);
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Document Clearance"
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{tabCounts.all} awaiting review
</Badge>
}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<KpiStrip
loading={isLoading}
items={[
{
label: "Awaiting review",
value: tabCounts.all,
icon: Inbox,
color: "edr-green",
},
{
label: "Import",
value: tabCounts.import,
icon: Truck,
color: "edr-green",
},
{
label: "Export",
value: tabCounts.export,
icon: ShipWheel,
color: "gray",
},
]}
/>
<Tabs
value={activeTab}
onChange={(v) => {
setActiveTab((v as ClearanceTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
variant="pills"
color="edr-green"
>
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
<Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
{CLEARANCE_TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.key;
return (
<Tabs.Tab
key={tab.key}
value={tab.key}
leftSection={<Icon size={15} />}
rightSection={
<Badge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "edr-green" : "gray"}
>
{tabCounts[tab.key]}
</Badge>
}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</ScrollArea>
</Tabs>
<Card p={0} withBorder shadow="sm" radius="lg">
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference, customer, or route…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
radius="lg"
style={{ flex: 1, minWidth: 220 }}
/>
<Group gap="sm" wrap="nowrap">
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<SegmentedControl
size="sm"
radius="md"
value={view}
onChange={(v) => setView(v as ViewMode)}
data={[
{
value: "table",
label: (
<Group gap={6} wrap="nowrap">
<TableIcon size={15} />
<Box visibleFrom="sm">Table</Box>
</Group>
),
},
{
value: "cards",
label: (
<Group gap={6} wrap="nowrap">
<LayoutGrid size={15} />
<Box visibleFrom="sm">Cards</Box>
</Group>
),
},
]}
/>
</Group>
</Group>
</Box>
{view === "table" ? (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<DataTable<ClearanceRow, unknown>
columns={columns}
data={pagedRows}
status={
isLoading ? "loading" : isError ? "error" : "success"
}
onRowClick={(row) => openDetail(row.id)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
) : (
<ClearanceCardGrid
rows={pagedRows}
loading={isLoading}
onOpen={openDetail}
/>
)}
</Stack>
</Card>
</Stack>
</PageContainer>
);
}
function ClearanceCardGrid({
rows,
loading,
onOpen,
}: {
rows: ClearanceRow[];
loading: boolean;
onOpen: (id: string) => void;
}) {
if (loading) {
return (
<Box px="md" py="xl">
<Text c="dimmed" ta="center">
Loading
</Text>
</Box>
);
}
if (rows.length === 0) {
return (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">No bookings match this view.</Text>
</Stack>
);
}
return (
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" p="md">
{rows.map((r) => (
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} />
))}
</SimpleGrid>
);
}
function ClearanceCard({
row,
onOpen,
}: {
row: ClearanceRow;
onOpen: () => void;
}) {
return (
<Card
withBorder
shadow="sm"
radius="lg"
p="md"
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
style={{ cursor: "pointer", transition: "all 120ms ease" }}
className="hover:border-edr-green-4 hover:shadow-md"
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={40}>
<ShieldCheck size={19} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} size="sm" c="edr-text" truncate>
{row.reference}
</Text>
<Group gap={4} wrap="nowrap">
<User size={11} className="shrink-0 opacity-70" />
<Text size="xs" c="dimmed" truncate>
{row.customerLabel}
</Text>
</Group>
</Box>
</Group>
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Under review
</Badge>
</Group>
<Box
mt="md"
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-edr-card-6)",
border: "1px solid var(--mantine-color-edr-border-6)",
}}
>
<Group gap={8} wrap="nowrap" justify="center">
<Text size="sm" fw={600} truncate maw={130}>
{row.originLabel}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={130}>
{row.destinationLabel}
</Text>
</Group>
</Box>
<Group justify="space-between" mt="md" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<DirectionIcon direction={row.tradeDirection} />
<Badge size="xs" variant="default" radius="sm">
{row.freightType}
</Badge>
{row.hasCustoms ? (
<Tooltip label="Customs clearance" withArrow>
<ThemeIcon
variant="light"
color="edr-green"
radius="md"
size={28}
aria-label="Customs clearance"
>
<ShieldCheck size={15} strokeWidth={1.9} />
</ThemeIcon>
</Tooltip>
) : null}
</Group>
<Group gap={4} wrap="nowrap">
<Calendar size={13} className="text-muted-foreground" />
<Text size="xs" c="dimmed">
{formatDate(row.scheduledDate)}
</Text>
</Group>
</Group>
</Card>
);
}

View File

@@ -408,7 +408,13 @@ export default function RoutesPage() {
<TextInput
label="Name"
value={form.name}
onChange={(e) => setForm((current) => ({ ...current, name: e.currentTarget.value }))}
onChange={(e) => {
// Capture the value before the state updater runs — React may
// recycle the synthetic event, nulling currentTarget by the time
// the updater executes ("Cannot read properties of null").
const name = e.currentTarget.value;
setForm((current) => ({ ...current, name }));
}}
/>
<Group justify="space-between">
<Text size="sm" fw={500}>

View File

@@ -291,6 +291,14 @@ export default function TrainScheduleV2DetailPage() {
);
}
// All locomotives pulling the train (≥2), falling back to the legacy single loco.
const locomotives =
schedule.trainSet?.locomotives && schedule.trainSet.locomotives.length > 0
? schedule.trainSet.locomotives
: schedule.trainSet?.locomotive
? [schedule.trainSet.locomotive]
: [];
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
const canDispatch = schedule.status === "SCHEDULED";
@@ -811,13 +819,13 @@ export default function TrainScheduleV2DetailPage() {
<KpiStrip
items={[
{
label: "Locomotive",
value: schedule.trainSet?.locomotive?.code ?? "—",
hint: schedule.trainSet?.locomotive?.currentYardId
? schedule.trainSet.locomotive.currentYardId === schedule.originStation?.id
? `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? "origin yard"}`
: "Not at schedule origin yard"
: "No current yard set",
label: locomotives.length > 1 ? "Locomotives" : "Locomotive",
value: locomotives.length
? locomotives.map((l) => l.code).join(" + ")
: "—",
hint: locomotives.length
? `${locomotives.length} locomotive${locomotives.length > 1 ? "s" : ""}`
: "No locomotives assigned",
icon: Train,
},
{

View File

@@ -7,6 +7,7 @@ import {
Group,
Menu,
Modal,
MultiSelect,
Select,
SimpleGrid,
Stack,
@@ -82,7 +83,7 @@ export default function TrainScheduleV2ListPage() {
const [createOpen, setCreateOpen] = useState(false);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveId, setLocomotiveId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
@@ -113,7 +114,7 @@ export default function TrainScheduleV2ListPage() {
}, [selectedRoute]);
useEffect(() => {
setLocomotiveId("");
setLocomotiveIds([]);
}, [routeId]);
const allSchedules = schedulesQuery.data ?? [];
@@ -147,6 +148,7 @@ export default function TrainScheduleV2ListPage() {
s.origin,
s.destination,
s.locomotive?.code,
...(s.locomotives ?? []).map((l) => l.code),
s.freightType,
s.status,
]
@@ -229,21 +231,32 @@ export default function TrainScheduleV2ListPage() {
},
{
id: "loco",
header: "Locomotive",
header: "Locomotives",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
row.original.locomotive?.code ? (
cell: ({ row }) => {
const locos =
row.original.locomotives && row.original.locomotives.length > 0
? row.original.locomotives
: row.original.locomotive
? [row.original.locomotive]
: [];
if (!locos.length) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{row.original.locomotive.code}
{locos[0].code}
{locos.length > 1 ? ` +${locos.length - 1}` : ""}
</Text>
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
),
);
},
},
{
id: "metrics",
@@ -331,13 +344,16 @@ export default function TrainScheduleV2ListPage() {
}, [navigate, cancel.isPending, cancel, toast]);
const handleCreate = async () => {
if (!routeId || !scheduleDate || !locomotiveId) {
toast({ title: "Select route, date, and locomotive", variant: "destructive" });
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
toast({
title: "Select route, date, and at least two locomotives",
variant: "destructive",
});
return;
}
try {
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveId },
payload: { routeId, scheduleDate, locomotiveIds },
});
toast({ title: "Train schedule created" });
setCreateOpen(false);
@@ -532,17 +548,25 @@ export default function TrainScheduleV2ListPage() {
setScheduleDate(raw ? new Date(raw).toISOString() : "");
}}
/>
<Select
label="Locomotive"
placeholder={routeId ? "Select locomotive" : "Select a route first"}
<MultiSelect
label="Locomotives"
description="A train must be pulled by at least two locomotives (front and back)"
placeholder={
routeId ? "Select at least two locomotives" : "Select a route first"
}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable
disabled={!routeId}
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
}
nothingFoundMessage={
routeId ? "No available locomotives for this corridor" : "Select a route first"
}

View File

@@ -0,0 +1,30 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import type { Freight } from "@edr/types";
/**
* Drawdown orders placed against a general contract (a booking with
* bookingType = GENERAL_CONTRACT). Each order spawns a child ONE_TIME booking
* that carries its own clearance/approval — managed on the child's detail page.
*/
export const bookingOrdersService = {
/** Orders placed against a general contract, with their lines + child status. */
listByContract: async (
contractBookingId: string,
): Promise<Freight.IBookingOrder[]> => {
const response = await client.get("/booking-orders", {
params: { contractBookingId },
});
return unwrap(response.data) as Freight.IBookingOrder[];
},
/** Contracted / ordered / remaining quantities for a general contract. */
pool: async (
contractBookingId: string,
): Promise<Freight.ContractQuantityLine[]> => {
const response = await client.get(
`/booking-orders/contract/${contractBookingId}/pool`,
);
return unwrap(response.data) as Freight.ContractQuantityLine[];
},
};

View File

@@ -2,6 +2,7 @@
export const BOOKING_STATUSES = [
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
@@ -12,6 +13,8 @@ export const BOOKING_STATUSES = [
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"SELECTED_FOR_BATCH",
"EXPIRED",
"PNR_GENERATED",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"PAID",
@@ -21,6 +24,18 @@ export const BOOKING_STATUSES = [
"CANCELLED",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
"CONTRACT_ACTIVE",
"CONTRACT_CLOSED",
// Post counter-sign document-clearance gate.
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
"ROAD_DISPATCH_PENDING",
"OPERATION_REQUESTED",
// Operations review gate.
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
"OPERATION_PRICE_PENDING_CONFIRM",
] as const;
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
@@ -117,6 +132,10 @@ export interface BookingDetail {
isGovernment?: boolean;
governmentInstitution?: string | null;
status: BookingStatus;
/** ONE_TIME shipment vs an umbrella GENERAL_CONTRACT drawn down by orders. */
bookingType?: "ONE_TIME" | "GENERAL_CONTRACT";
/** General contracts only: when the ordering window closes. */
expiresAt?: string | null;
scheduledDate: string;
totalAmount: number;
adjustedTotalAmount?: number | null;
@@ -157,6 +176,8 @@ export interface BookingDetail {
firstMilePickupAddress?: string | null;
lastMileDeliveryAddress?: string | null;
equipmentReturn?: string;
customsClearingEnabled?: boolean;
customsClearingAgent?: string | null;
contractSummary?: string | null;
latestChangeRequestNote?: string | null;
nextStep?: BookingNextStep | null;
@@ -167,7 +188,7 @@ export interface BookingDetail {
company?: BookingNamedRef & Partial<BookingCompany>;
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number };
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean };
cargoType?: BookingNamedRef;
shippingLine?: BookingNamedRef;
bookingContainers?: BookingContainerLine[];
@@ -204,5 +225,6 @@ export interface BookingListRow {
governmentInstitution?: string | null;
consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null;
customsClearingEnabled?: boolean;
createdAt: string;
}

View File

@@ -146,12 +146,21 @@ export interface TrainScheduleListItem {
origin: string | null;
destination: string | null;
freightType?: FreightType | null;
locomotive: {
locomotive:
| {
id: string;
code: string;
name?: string | null;
currentYardId?: string | null;
}
| null;
/** All locomotives pulling this train (≥2). Falls back to `locomotive` for legacy rows. */
locomotives?: Array<{
id: string;
code: string;
name?: string | null;
currentYardId?: string | null;
} | null;
}>;
wagonCount: number;
totalWeightTons: number;
totalLengthMeters: number;
@@ -351,6 +360,16 @@ export interface TrainScheduleDetail {
maxPullWeightTons: number;
maxTrainLengthMeters?: number;
} | null;
/** All locomotives pulling this train (≥2). Falls back to `locomotive` for legacy rows. */
locomotives?: Array<{
id: string;
code: string;
name?: string | null;
status: string;
currentYardId?: string | null;
maxPullWeightTons: number;
maxTrainLengthMeters?: number;
}>;
wagons: Array<{
id: string;
sequenceNo: number;
@@ -460,7 +479,8 @@ export interface ReschedulePlan {
export interface CreateTrainSchedulePayload {
routeId: string;
scheduleDate: string;
locomotiveId: string;
/** Locomotives pulling the train (minimum 2 — front and back). */
locomotiveIds: string[];
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;

View File

@@ -53,7 +53,12 @@ export interface AppLayoutProps {
title?: string;
sidebarItems: SidebarItem[];
activeHref?: string;
onNavigate?: (href: string) => void;
/**
* Navigate to a route. Accepts an optional options object (e.g. `{ state }`)
* forwarded to the router — used to pass navigation state like `fresh: true`
* to the new-booking wizard. Compatible with react-router's `navigate`.
*/
onNavigate?: (href: string, options?: { state?: unknown }) => void;
enableThemeToggle?: boolean;
userName?: string;
userEmail?: string;
@@ -157,7 +162,8 @@ export function AppLayout({
const primaryDarkColor = theme.colors["edr-green"][7];
const activePath = activeHref.toLowerCase();
const navigate = (href: string) => onNavigate?.(href);
const navigate = (href: string, options?: { state?: unknown }) =>
onNavigate?.(href, options);
const toggleTheme = () => {
setColorScheme(computedColorScheme === "dark" ? "light" : "dark");
@@ -478,7 +484,7 @@ export function AppLayout({
<Menu.Item
leftSection={<Plus size={15} />}
color="edr-green"
onClick={() => navigate("/bookings/new")}
onClick={() => navigate("/bookings/new", { state: { fresh: true } })}
>
New Booking
</Menu.Item>

View File

@@ -3,6 +3,12 @@ import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction";
import {
ContractSignButton,
bookingIsSignable,
} from "@/pages/bookings/contract/ContractSignButton";
interface BookingRowProps {
booking: any;
@@ -23,6 +29,12 @@ export const BookingRow = memo(function BookingRow({
// instead of navigating to the detail page.
const canPay =
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
// Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking);
// Contract ready for signature → "View & sign" jumps straight to the
// full-page contract viewer where the signature flow lives.
const canSign = bookingIsSignable(booking);
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -85,6 +97,10 @@ export const BookingRow = memo(function BookingRow({
</Group>
{canPay ? (
<PayNowButton booking={booking} size="sm" />
) : canSign ? (
<ContractSignButton booking={booking} size="sm" />
) : hasInlineAction ? (
<BookingActionButton booking={booking} size="sm" />
) : (
<Group
gap={5}

View File

@@ -26,7 +26,7 @@ export const HelloSection = memo(function HelloSection({
</Group>
</Box>
<Link to="/bookings/new">
<Link to="/bookings/new" state={{ fresh: true }}>
<Group
gap={14}
align="center"

View File

@@ -1,10 +1,13 @@
import {
ArrowRight,
CalendarClock,
CheckCircle2,
Clock3,
FileCheck2,
FilePen,
FileUp,
MapPin,
ShieldCheck,
Truck,
Wallet,
type LucideIcon,
@@ -161,6 +164,110 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
AWAITING_DOCUMENTS: {
stage: 3,
icon: FileUp,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Clearance documents needed",
step: "edr-accent",
badgeLabel: "Docs needed",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Upload documents", kind: "amber", icon: ArrowRight },
},
DOCUMENTS_UNDER_REVIEW: {
stage: 3,
icon: ShieldCheck,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Clearance under review · re-upload any queried docs",
step: "edr-blue-dot",
badgeLabel: "In review",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "Review documents", kind: "outline" },
},
CLEARANCE_READY: {
stage: 3,
icon: CalendarClock,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Cleared · choose a shipment day to proceed",
step: "edr-green.5",
badgeLabel: "Cleared",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "Schedule & proceed", kind: "amber", icon: ArrowRight },
},
OPERATION_REQUEST_PENDING: {
stage: 3,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Operation request under review by operations",
step: "edr-blue-dot",
badgeLabel: "Op. review",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
OPERATION_REQUESTED: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Operation requested · operator taking it forward",
step: "edr-green.5",
badgeLabel: "Operation requested",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
OPERATION_CHANGES_REQUESTED: {
stage: 3,
icon: FilePen,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Operations requested changes · please review",
step: "edr-accent",
badgeLabel: "Revise",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Review", kind: "dark" },
},
OPERATION_PRICE_PENDING_CONFIRM: {
stage: 3,
icon: Wallet,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Price adjusted · confirm to proceed",
step: "edr-accent",
badgeLabel: "Confirm price",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Confirm", kind: "amber", icon: ArrowRight },
},
ROAD_DISPATCH_PENDING: {
stage: 3,
icon: Truck,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Accepted · awaiting truck dispatch",
step: "edr-blue-dot",
badgeLabel: "Awaiting dispatch",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
PNR_GENERATED: {
stage: 3,
icon: FileCheck2,
@@ -317,6 +424,84 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
PRICE_CHANGED_PENDING_CONFIRM: {
stage: 1,
icon: Wallet,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Price changed · confirm to continue",
step: "edr-accent",
badgeLabel: "Confirm price",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Confirm", kind: "amber", icon: ArrowRight },
},
READY_FOR_ASSIGNMENT: {
stage: 2,
icon: FileCheck2,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Approved · awaiting wagon assignment",
step: "edr-blue-dot",
badgeLabel: "Assigning",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
WAGON_ASSIGNED: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Wagon assigned · preparing for loading",
step: "edr-green.5",
badgeLabel: "Wagon assigned",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
INVOICED: {
stage: 3,
icon: Wallet,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Invoice issued · awaiting payment",
step: "edr-blue-dot",
badgeLabel: "Invoiced",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "View", kind: "outline" },
},
CONTRACT_ACTIVE: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Contract active · accepting orders",
step: "edr-green.5",
badgeLabel: "Active",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
CONTRACT_CLOSED: {
stage: 4,
icon: CheckCircle2,
iconColor: "edr-slate",
tile: "edr-slate-soft2",
hint: "Contract closed · quantity used or window elapsed",
step: "edr-step",
badgeLabel: "Closed",
badgeBg: "edr-slate-soft2",
badgeText: "edr-slate",
badgeDot: "edr-step",
action: { label: "View", kind: "outline" },
},
};
export const ACTION_PROPS: Record<

View File

@@ -0,0 +1,205 @@
import {
Alert,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertCircle, Send, XCircle } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { PriceChangeModal } from "@/pages/bookings/resubmit/PriceChangeModal";
import { ResubmitDocuments } from "@/pages/bookings/resubmit/ResubmitDocuments";
import { useResubmitFlow } from "@/pages/bookings/resubmit/useResubmitFlow";
import { CardTitle, PageShell, SectionCard } from "./components/layout";
import { BodyGrid } from "./components/layout";
import { ActionRequiredBanner, MutationErrors } from "./components/Notices";
import { PageHeader } from "./components/PageHeader";
import { EstimateCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
/**
* Detail-page view for a booking staff returned with CHANGES_REQUESTED.
*
* Unlike a fresh draft, this booking already went through submission, so the
* documents shown are exactly the files the customer submitted (`booking.files`)
* — not a fixed required-document list. The customer reviews the staff note,
* replaces any document they need to update, and resubmits in place.
*/
export function ChangesRequestedView({
booking,
onBookingUpdated,
}: {
booking: Freight.IBooking;
onBookingUpdated: () => void;
}) {
const navigate = useNavigate();
const flow = useResubmitFlow(booking, { onResubmitted: onBookingUpdated });
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const { data: generatedPricing } = useQuery(
api.bookings.generatePrice.queryOptions({
input: { id: booking.id },
enabled: !booking.pricingBreakdown,
}),
);
const pricing = (booking.pricingBreakdown ??
generatedPricing ??
null) as Freight.PricingBreakdown | null;
const cancelMutation = useMutation({
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: booking.id, reason }),
onSuccess: () => {
setCancelDialogOpen(false);
onBookingUpdated();
},
});
return (
<PageShell>
<PageHeader
booking={booking}
menuActions={{
onCancel: () => setCancelDialogOpen(true),
onSupport: () => navigate("/support"),
}}
/>
<MutationErrors mutations={[...flow.mutations, cancelMutation]} />
<StatusHero booking={booking}>
{booking.latestChangeRequestNote ? (
<ActionRequiredBanner title="Review the requested changes, then resubmit.">
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
</StatusHero>
<BodyGrid
left={
<>
<ShipmentDetailsCard booking={booking} />
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Your documents</CardTitle>
</Group>
<Text fz="12.5px" c="#6B7C8E" mb="sm">
Update the documents for this booking, then resubmit for review.
Replace any that changed and attach any that are still required.
</Text>
<ResubmitDocuments flow={flow} />
{flow.validationError && (
<Alert
color="red"
radius="md"
icon={<AlertCircle size={16} />}
mt="md"
>
{flow.validationError}
</Alert>
)}
<Button
fullWidth
mt="lg"
radius={10}
color="#0C1A2B"
leftSection={<Send size={16} />}
onClick={flow.resubmit}
loading={flow.isBusy}
disabled={flow.isBusy}
styles={{
root: { height: 46 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{flow.isBusy ? "Resubmitting…" : "Resubmit for review"}
</Button>
</SectionCard>
</>
}
right={
<>
<EstimateCard
pricing={pricing}
title="Estimated Cost"
chip="Not invoiced"
/>
<ScheduleCard booking={booking} title="Schedule & Service" />
<SupportCard onCancel={() => setCancelDialogOpen(true)} />
</>
}
/>
<PriceChangeModal
data={flow.priceChange}
onClose={flow.clearPriceChange}
onConfirm={flow.confirmSubmit}
confirmPending={flow.confirmSubmitPending}
/>
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
title={<Text fw={700}>Cancel booking</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Are you sure you want to cancel <strong>{booking.reference}</strong>?
This action cannot be undone.
</Text>
<TextInput
label="Reason for cancellation (optional)"
placeholder="e.g. Change of plans, duplicate booking…"
value={cancelReason}
onChange={(e) => setCancelReason(e.currentTarget.value)}
radius="md"
data-autofocus
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setCancelDialogOpen(false)}
>
Keep booking
</Button>
<Button
color="red"
radius="md"
onClick={() =>
cancelMutation.mutate(cancelReason.trim() || "Cancelled by customer")
}
disabled={cancelMutation.isPending}
loading={cancelMutation.isPending}
leftSection={
!cancelMutation.isPending ? <XCircle size={15} /> : undefined
}
>
Yes, cancel
</Button>
</Group>
</Stack>
</Modal>
</PageShell>
);
}

View File

@@ -31,11 +31,7 @@ import { CardTitle, PageShell, SectionCard } from "./components/layout";
import { CountChip, DocRow, IconSquare } from "./components/Documents";
import { EstimateCard } from "./components/pricing";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import {
ActionRequiredBanner,
MutationErrors,
NoticeBanner,
} from "./components/Notices";
import { MutationErrors, NoticeBanner } from "./components/Notices";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero";
@@ -77,9 +73,7 @@ export function DraftBookingView({
const { data: generatedPricing } = useQuery(
api.bookings.generatePrice.queryOptions({
input: { id: booking.id },
enabled:
(booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") &&
!booking.pricingBreakdown,
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
}),
);
const pricing = (booking.pricingBreakdown ??
@@ -87,17 +81,8 @@ export function DraftBookingView({
null) as Freight.PricingBreakdown | null;
const uploadMutation = useMutation({
mutationFn: async (files: Record<string, File | File[] | null>) => {
if (booking.status === "CHANGES_REQUESTED") {
const result = await api.bookings.update.call({
id: booking.id,
dto: {},
documents: files,
});
return result.booking;
}
return api.bookings.uploadDocuments.call({ id: booking.id, files });
},
mutationFn: (files: Record<string, File | File[] | null>) =>
api.bookings.uploadDocuments.call({ id: booking.id, files }),
onSuccess: () => {
setSelectedFiles({});
setDocError("");
@@ -190,19 +175,7 @@ export function DraftBookingView({
]}
/>
<StatusHero booking={booking}>
{booking.status === "CHANGES_REQUESTED" &&
booking.latestChangeRequestNote ? (
<ActionRequiredBanner
title="Review the requested changes, then resubmit."
onAction={() =>
navigate(`/bookings/${booking.id}/edit?section=documents`)
}
>
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
</StatusHero>
<StatusHero booking={booking} />
<BodyGrid
left={
@@ -294,8 +267,9 @@ export function DraftBookingView({
const isUploaded = uploadedCodes.has(doc.key);
const selected = selectedFiles[doc.key];
const file = booking.files?.find((f) => f.code === doc.key);
const allowReplace =
!isUploaded || booking.status === "CHANGES_REQUESTED";
// In a draft, an already-uploaded doc can still be replaced
// before first submit.
const allowReplace = !isUploaded;
return (
<DocRow
key={doc.key}

View File

@@ -93,7 +93,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
}
menuActions={{
onViewContract: booking.signedByCeoAt ? () => {} : undefined,
onRebook: () => navigate("/bookings/new"),
onRebook: () => navigate("/bookings/new", { state: { fresh: true } }),
onSupport: () => navigate("/support"),
}}
/>
@@ -110,14 +110,14 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
: "This booking process has been terminated."
}
reason={booking.latestChangeRequestNote}
onRebook={() => navigate("/bookings/new")}
onRebook={() => navigate("/bookings/new", { state: { fresh: true } })}
/>
) : isExpired ? (
<CancelledBanner
pillLabel="Expired"
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
onRebook={() => navigate("/bookings/new")}
onRebook={() => navigate("/bookings/new", { state: { fresh: true } })}
/>
) : isPendingConsolidation ? (
<ConsolidationWaitingBanner

View File

@@ -1,127 +1,28 @@
import {
Alert,
Box,
Button,
FileButton,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
FileText,
Plus,
Upload,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Alert, Button, Group } from "@mantine/core";
import { CheckCircle2, Upload } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { ClearanceFlow } from "@/pages/bookings/clearance/ClearanceFlow";
import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow";
import { CardTitle, SectionCard } from "./layout";
import { IconSquare } from "./Documents";
const GREEN = "#0A6F4D";
function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) {
if (doc.reviewStatus === "APPROVED") {
return (
<Group gap={6} c={GREEN}>
<CheckCircle2 size={15} />
<Text fz="12px" fw={600} c={GREEN}>
Approved
</Text>
</Group>
);
}
if (doc.reviewStatus === "QUERIED") {
return (
<Group gap={6} c="#C0392B">
<AlertCircle size={15} />
<Text fz="12px" fw={600} c="#C0392B">
Queried
</Text>
</Group>
);
}
if (doc.file) {
return (
<Group gap={6} c="#2E5B96">
<Clock size={15} />
<Text fz="12px" fw={600} c="#2E5B96">
Pending review
</Text>
</Group>
);
}
return (
<Text fz="12px" fw={600} c="#9AA8B5">
Not uploaded
</Text>
);
}
/**
* Customer-facing clearance section: shows the resolved document grid, lets the
* customer (re)upload pending/queried documents plus ad-hoc named documents, and
* proceed to operation once Global Logistics marks the booking CLEARANCE_READY.
* Customer-facing clearance section on the booking detail page: shows the
* resolved document grid, lets the customer (re)upload pending/queried documents
* plus ad-hoc named documents, and proceed to operation once Global Logistics
* marks the booking CLEARANCE_READY.
*
* The flow body, calendar, and mutations are shared with the home-page action
* modal via `useClearanceFlow` / `ClearanceFlow`.
*/
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const status = booking.status as string;
const flow = useClearanceFlow(booking);
const { data: clearance, isLoading } = useQuery(
api.bookings.getClearance.queryOptions({ input: { id: booking.id } }),
);
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<Array<{ name: string; file: File | null }>>(
[],
);
const refresh = () => {
queryClient.invalidateQueries({
queryKey: api.bookings.getClearance.queryKey({ id: booking.id }),
});
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: booking.id }),
});
};
const uploadMutation = useMutation({
...api.bookings.submitClearanceDocuments.mutationOptions(),
onSuccess: () => {
setPending({});
setAdHoc([]);
refresh();
},
});
const proceedMutation = useMutation({
...api.bookings.proceedToOperation.mutationOptions(),
onSuccess: () => refresh(),
});
// Only the customer-input documents are uploadable here; GL output docs are
// shown read-only.
const customerDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
);
const glDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
[clearance],
);
if (status === "OPERATION_REQUESTED") {
if (flow.status === "OPERATION_REQUESTED") {
return (
<SectionCard>
<CardTitle>Operation</CardTitle>
@@ -132,246 +33,56 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
);
}
if (isLoading || !clearance) {
if (flow.isLoading || !flow.clearance) {
return (
<SectionCard>
<CardTitle>Clearance documents</CardTitle>
<Text fz="13px" c="dimmed" mt="sm">
Loading clearance
</Text>
</SectionCard>
);
}
const isReady = status === "CLEARANCE_READY";
const canUpload =
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
function handleSubmit() {
const files: Record<string, File | null> = { ...pending };
adHoc.forEach((row, i) => {
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
});
if (Object.keys(files).length === 0) return;
uploadMutation.mutate({ id: booking.id, files });
}
return (
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Clearance documents</CardTitle>
{clearance.includesCustoms && (
<Text fz="12px" fw={600} c="#9AA8B5">
Customs clearance
</Text>
)}
</Group>
{isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
Clearance is ready. You can now proceed to operation.
</Alert>
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
Global Logistics is reviewing your documents. Queried documents below
need to be re-uploaded.
</Alert>
) : (
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
Upload the documents below to start the clearance review.
</Alert>
)}
<Stack gap={10}>
{customerDocs.map((doc) => (
<Box
key={doc.fileKey}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box c="#2E5B96">
<FileText size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={600} c="#10202F" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
{doc.file && (
<Text fz="12px" c="dimmed" truncate>
{doc.file.name}
</Text>
)}
</Box>
</Group>
<Group gap={10} wrap="nowrap">
<StatusPill doc={doc} />
{doc.file && (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
)}
{canUpload && doc.reviewStatus !== "APPROVED" && (
<FileButton
onChange={(f) =>
f && setPending((p) => ({ ...p, [doc.fileKey]: f }))
}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{pending[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
{doc.reviewStatus === "QUERIED" && doc.note && (
<Text fz="12px" c="#C0392B" mt={6}>
Query: {doc.note}
</Text>
)}
{pending[doc.fileKey] && (
<Text fz="12px" c={GREEN} mt={6}>
Ready to upload: {pending[doc.fileKey].name}
</Text>
)}
</Box>
))}
</Stack>
{/* GL output documents (read-only to the customer). */}
{glDocs.length > 0 && (
<>
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
Customs output documents
</Text>
<Stack gap={8}>
{glDocs.map((doc) => (
<Group
key={doc.fileKey}
justify="space-between"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 10 }}
<ClearanceFlow
booking={booking}
flow={flow}
footer={
<Group justify="flex-end" mt="lg" gap="sm">
{flow.canUpload && (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => flow.submitDocuments()}
loading={flow.uploadMutation.isPending}
disabled={!flow.canSubmit}
>
<Text fz="13px" c="#10202F" truncate>
{doc.label}
</Text>
{doc.file ? (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
) : (
<Text fz="12px" c="#9AA8B5">
Pending
</Text>
)}
</Group>
))}
</Stack>
</>
)}
{/* Ad-hoc / additional documents. */}
{canUpload && (
<Box mt="lg">
<Group justify="space-between" align="center" mb={8}>
<Text fz="12.5px" fw={700} c="#10202F">
Additional documents
</Text>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Plus size={13} />}
onClick={() => setAdHoc((r) => [...r, { name: "", file: null }])}
>
Add document
</Button>
Submit documents
</Button>
)}
{flow.isReady && (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={() =>
flow.proceedToOperation({
onSuccess: () => navigate(`/bookings/${booking.id}`),
})
}
loading={flow.proceedMutation.isPending}
disabled={!flow.scheduledDate}
>
Proceed to operation
</Button>
)}
</Group>
<Stack gap={8}>
{adHoc.map((row, i) => (
<Group key={i} gap={8} wrap="nowrap">
<TextInput
placeholder="Document name"
value={row.name}
onChange={(e) =>
setAdHoc((rows) =>
rows.map((r, j) =>
j === i ? { ...r, name: e.currentTarget.value } : r,
),
)
}
style={{ flex: 1 }}
radius="md"
/>
<FileButton
onChange={(f) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
)
}
accept="application/pdf,image/*"
>
{(props) => (
<Button {...props} variant="default" radius="md">
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
</Button>
)}
</FileButton>
</Group>
))}
</Stack>
</Box>
)}
{uploadMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{uploadMutation.error instanceof Error
? uploadMutation.error.message
: "Upload failed. Please try again."}
</Alert>
)}
<Group justify="flex-end" mt="lg" gap="sm">
{canUpload && (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={handleSubmit}
loading={uploadMutation.isPending}
disabled={
Object.keys(pending).length === 0 &&
!adHoc.some((r) => r.file)
}
>
Submit documents
</Button>
)}
{isReady && (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={() =>
proceedMutation.mutate(
{ id: booking.id },
{ onSuccess: () => navigate(`/bookings/${booking.id}`) },
)
}
loading={proceedMutation.isPending}
>
Proceed to operation
</Button>
)}
</Group>
}
/>
</SectionCard>
);
}

View File

@@ -177,6 +177,61 @@ export const STATUS_MAP: Record<
description: "Cargo has been consolidated with a partner shipment.",
stage: 5,
},
AWAITING_DOCUMENTS: {
title: "Clearance documents needed",
description:
"Upload the required clearance documents so your shipment can be reviewed.",
stage: 5,
},
DOCUMENTS_UNDER_REVIEW: {
title: "Documents under review",
description:
"Your clearance documents are being reviewed. Re-upload any queried documents to proceed.",
stage: 5,
},
CLEARANCE_READY: {
title: "Cleared — choose a shipment day",
description:
"Clearance is complete. Pick a shipment day and proceed to operation.",
stage: 5,
},
OPERATION_REQUESTED: {
title: "Operation requested",
description: "Operation requested. An operator will take your shipment forward.",
stage: 5,
},
CONTRACT_ACTIVE: {
title: "Contract active",
description: "This general contract is active and accepting drawdown orders.",
stage: 5,
},
CONTRACT_CLOSED: {
title: "Contract closed",
description:
"This general contract is closed — its reserved quantity has been used or its window has elapsed.",
stage: 7,
},
PRICE_CHANGED_PENDING_CONFIRM: {
title: "Price changed — confirm to proceed",
description:
"The price for this booking changed. Confirm the new price to continue.",
stage: 1,
},
READY_FOR_ASSIGNMENT: {
title: "Awaiting wagon assignment",
description: "Approved and queued for wagon assignment.",
stage: 2,
},
WAGON_ASSIGNED: {
title: "Wagon assigned",
description: "A wagon has been assigned and your cargo is being prepared for loading.",
stage: 5,
},
INVOICED: {
title: "Invoice issued",
description: "An invoice has been issued for this booking.",
stage: 5,
},
COMPLETED: {
title: "Service complete",
description: "Cargo delivered and service successfully terminated.",

View File

@@ -5,6 +5,7 @@ import { useParams } from "react-router-dom";
import { api } from "@/services/api";
import { ChangesRequestedView } from "./ChangesRequestedView";
import { DraftBookingView } from "./DraftBookingView";
import { PageShell, SectionCard } from "./components/layout";
import { ReadonlyBookingView } from "./ReadonlyBookingView";
@@ -77,6 +78,17 @@ export default function BookingDetailPage() {
);
}
// Staff returned the booking for changes: resubmit-with-updated-documents
// flow, driven by the files the customer actually submitted.
if (booking.status === "CHANGES_REQUESTED") {
return (
<ChangesRequestedView
booking={booking}
onBookingUpdated={refetchBooking}
/>
);
}
// Brand-new draft: collect the required documents before first submit.
if (isDraftLike(booking.status)) {
return (
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
@@ -25,6 +25,7 @@ import {
LayoutList,
MoreVertical,
Package,
Plus,
Search,
Train,
Wallet,
@@ -33,7 +34,8 @@ import {
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { NewBookingButton } from "@/components/NewBookingButton";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
BookingTypeBadge,
CargoModeCell,
@@ -215,20 +217,10 @@ function PrimaryAction({
</Button>
);
}
if (status === "CHANGES_REQUESTED") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
color="orange"
rightSection={<ArrowRight size={14} />}
onClick={() => onNavigate(`/bookings/${id}/edit?section=documents`)}
>
Review changes
</Button>
);
// CHANGES_REQUESTED + clearance/operation steps are handled in place by a
// modal (update & resubmit, upload clearance docs, schedule & proceed).
if (bookingHasInlineAction(booking)) {
return <BookingActionButton booking={booking} size="xs" />;
}
const payableStatus = isGeneralContract
? "FULLY_EXECUTED"
@@ -668,7 +660,16 @@ export default function MyBookings() {
Track every cargo booking from draft to delivery.
</Text>
</Box>
<NewBookingButton label="New booking" />
<Button
component={Link}
to="/bookings/new"
state={{ fresh: true }}
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
>
New booking
</Button>
</Group>
{/* ── Summary stat cards ──────────────────────────────────────── */}
@@ -828,8 +829,10 @@ export default function MyBookings() {
: "Create your first booking to get started."}
</Text>
{!query && (
<NewBookingButton
label="Create first booking"
<Button
component={Link}
to="/bookings/new"
state={{ fresh: true }}
size="sm"
mt="md"
/>

View File

@@ -29,7 +29,7 @@ import {
} from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { Navigate, useNavigate } from "react-router-dom";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth";
import {
BookingFormInputValues,
@@ -39,12 +39,17 @@ import {
getRouteDirection,
initialBookingFormValues,
operationToProfileType,
operationToTradeDirection,
stepFields,
type BookingDocuments,
type BookingFormValues,
type OperationType,
} from "./new-booking-form/schema";
import { StepIndicator } from "./new-booking-form/StepIndicator";
import {
clearBookingDraft,
useBookingDraft,
} from "./new-booking-form/useBookingDraft";
import {
Step0OperationType,
Step1ContractType,
@@ -53,11 +58,22 @@ import {
Step5CargoDetails,
Step8Review,
StepDocuments,
StepScheduling,
} from "./new-booking-form/steps";
type PriceModalMode = "submit" | "draft";
/** Human-readable label for a rate's charge unit (e.g. "per container"). */
function formatPriceUnit(unit: string): string {
const map: Record<string, string> = {
PER_CONTAINER: "per container",
PER_TON: "per ton",
PER_WAGON: "per wagon",
PER_KM: "per km",
FLAT: "flat",
};
return map[unit] ?? unit.replace(/_/g, " ").toLowerCase();
}
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -182,6 +198,8 @@ export default function NewBookingPage() {
setPriceChangeResult(result);
return;
}
// Booking submitted — the saved wizard draft is no longer needed.
clearBookingDraft();
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
// A partial-wagon booking is parked until a partner is found — explain the
@@ -200,6 +218,8 @@ export default function NewBookingPage() {
return api.bookings.confirmSubmit.call({ id: priceBookingId });
},
onSuccess: (result) => {
// Booking submitted — the saved wizard draft is no longer needed.
clearBookingDraft();
setPriceChangeResult(null);
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
@@ -219,6 +239,8 @@ export default function NewBookingPage() {
return api.bookings.reject.call({ id: priceBookingId });
},
onSuccess: () => {
// The customer rejected this booking and will start over — drop the draft.
clearBookingDraft();
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate("/bookings");
@@ -242,17 +264,26 @@ export default function NewBookingPage() {
mode: "onChange",
});
// Persist the in-progress wizard to localStorage so a refresh doesn't lose it.
// The explicit "New Booking" entry points navigate with state.fresh = true to
// force a clean start; a plain refresh (no state) resumes the saved draft.
const location = useLocation();
const startFresh = (location.state as { fresh?: boolean } | null)?.fresh === true;
useBookingDraft({
form,
step,
setStep,
fresh: startFresh,
});
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const bookingType = form.watch("bookingType");
const isGeneralContract = bookingType === "general_contract";
const operationType = form.watch("operationType");
// General contracts have no shipment date at creation — the Schedule step
// (id 5) is skipped; the date is chosen per order against the contract later.
const visibleSteps = useMemo(
() => STEPS.filter((s) => !(isGeneralContract && s.id === 5)),
[isGeneralContract],
);
// The estimated shipment date lives in the Route step now; for general
// contracts that date field is simply hidden there (the date is chosen per
// order against the contract later). No dedicated schedule step remains.
const visibleSteps = useMemo(() => STEPS, []);
const visibleStepIds = useMemo<number[]>(
() => visibleSteps.map((s) => s.id),
[visibleSteps],
@@ -275,9 +306,16 @@ export default function NewBookingPage() {
(y) => y.id === destinationYard,
);
const route = getRouteDirection(origin, destination);
return route;
}, [originYard, destinationYard]);
// Country-based derivation is authoritative when both yards are tagged, but
// returns null if a yard is unselected or lacks a country (e.g. intercity
// yards with no country set → DOMESTIC). The operation chosen in step 0 is
// the user's explicit intent, so fall back to it to guarantee a valid value
// and avoid posting tradeDirection: null (which the API rejects with @IsIn).
return (
getRouteDirection(origin, destination) ??
(operationType ? operationToTradeDirection(operationType) : null)
);
}, [originYard, destinationYard, operationType, referenceData]);
// The company's onboarded profile types — drives which operations are offered
// and which profile each operation stamps the booking to.
@@ -371,13 +409,10 @@ export default function NewBookingPage() {
const isPerItem =
bulkChild?.unit_of_measure === Freight.CargoUnitOfMeasure.PerItem;
const isContract = data.bookingType === "general_contract";
// For bulk general contracts the contracted quantity is entered against the
// primary route in the route step; one-time bookings use the cargo-step
// amount. Item counts are rounded since fractional items are meaningless.
const bulkAmountRaw =
isContract && data.cargoType === "bulk"
? data.primaryRouteQuantity
: data.cargoWeight;
// Both one-time and general contracts take the bulk amount from the cargo
// step (cargoWeight) — general contracts no longer collect a per-route
// quantity. Item counts are rounded since fractional items are meaningless.
const bulkAmountRaw = data.cargoWeight;
const totalWeight =
data.cargoType === "container"
? 0
@@ -399,13 +434,14 @@ export default function NewBookingPage() {
bookingType: isContract
? Freight.BookingType.GeneralContract
: Freight.BookingType.OneTime,
// General contracts omit the shipment date — chosen per order later.
...(isContract
// The wizard captures a NON-BINDING estimate only — never the binding
// scheduledDate (that is chosen later at the operation-request step and
// validated against open departures). General contracts omit even the
// estimate; the date is chosen per order later.
...(isContract || !data.scheduledDate
? {}
: {
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: new Date().toISOString(),
estimatedShipmentDate: new Date(data.scheduledDate).toISOString(),
}),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
@@ -423,6 +459,9 @@ export default function NewBookingPage() {
// engine assigns the train, so no trainScheduleId is sent.
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
// Reefer is a customer choice for bulk only; container reefer is decided by
// the container type on the backend, so never send it for containers.
isReefer: data.cargoType === "bulk" ? data.isRefrigerated : false,
freightType:
data.cargoType === "container"
? ("CONTAINER" as const)
@@ -465,34 +504,23 @@ export default function NewBookingPage() {
}
: { customsClearingEnabled: false }),
...(cargoFreeText ? { cargoFreeText } : {}),
// Multi-route general contracts: route #1 is the primary origin/destination
// carrying the full contracted quantity; each extra route reserves its own.
// Multi-route general contracts: routes are pure origindestination 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.
...(isContract
? {
routes: [
{
originYardId: data.originYard,
destinationYardId: data.destinationYard,
quantity:
data.cargoType === "container"
? data.containers.reduce(
(sum, c) => sum + Number(c.qty || 0),
0,
)
: totalWeight,
},
...(data.extraRoutes ?? [])
.filter(
(r) =>
r.originYard &&
r.destinationYard &&
Number(r.quantity) > 0,
)
.filter((r) => r.originYard && r.destinationYard)
.map((r) => ({
originYardId: r.originYard,
destinationYardId: r.destinationYard,
quantity: Number(r.quantity),
...(r.km && Number(r.km) > 0 ? { km: Number(r.km) } : {}),
})),
],
}
@@ -639,10 +667,7 @@ export default function NewBookingPage() {
isLoading={refDataLoading}
/>
)}
{step === 5 && (
<StepScheduling form={form} referenceData={referenceData} />
)}
{step === 6 && <StepDocuments documents={onboardingDocs} />}
{step === 6 && <StepDocuments form={form} />}
{step === 7 && (
<Step8Review
form={form}
@@ -734,9 +759,65 @@ export default function NewBookingPage() {
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceModalMode === "submit"
? "Review your total price below. Confirm to submit for EDR staff review, or reject to discard this booking."
? "Review your total price below. Confirm to submit for EDR staff review, edit & regenerate to change details and re-price, or reject to discard this booking."
: "Your booking has been saved as a draft. Here is your estimated total price."}
</Text>
{pricingData.lineItems.length > 0 && (
<Box
p="md"
style={{
borderRadius: 16,
border: "1px solid var(--mantine-color-edr-border-0)",
background: "#fff",
}}
>
<Text
size="xs"
fw={700}
tt="uppercase"
c="edr-muted"
mb="xs"
style={{ letterSpacing: "0.06em" }}
>
Price breakdown
</Text>
<Stack gap={10}>
{pricingData.lineItems.map((item) => {
const hasUnit =
item.unitAmount != null &&
item.quantity != null &&
item.quantity > 0;
return (
<Group
key={item.code}
justify="space-between"
align="flex-start"
wrap="nowrap"
gap="sm"
>
<Box style={{ minWidth: 0 }}>
<Text size="sm" c="#10202F" fw={500}>
{item.description}
</Text>
{hasUnit && (
<Text size="xs" c="dimmed">
{item.quantity!.toLocaleString()} ×{" "}
{item.unitAmount!.toLocaleString()} {item.currency}
{item.unit
? ` · ${formatPriceUnit(item.unit)}`
: ""}
</Text>
)}
</Box>
<Text size="sm" fw={600} c="#10202F" style={{ whiteSpace: "nowrap" }}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
);
})}
</Stack>
</Box>
)}
<Box
p="lg"
style={{
@@ -775,6 +856,21 @@ export default function NewBookingPage() {
>
Reject
</Button>
{/* Go back to the wizard to change details, then Submit again to
regenerate the price. The draft booking is kept (priceBookingId
stays set), so re-submitting updates it instead of creating a
new one. */}
<Button
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => setPriceModalMode(null)}
disabled={
rejectMutation.isPending || confirmMutation.isPending
}
>
Edit &amp; regenerate
</Button>
<Button
color="edr-green"
radius="md"
@@ -783,7 +879,7 @@ export default function NewBookingPage() {
loading={confirmMutation.isPending}
disabled={rejectMutation.isPending}
>
Confirm & submit
Confirm &amp; submit
</Button>
</>
) : (
@@ -827,6 +923,69 @@ export default function NewBookingPage() {
{priceChangeResult.currency}
</Text>
</Group>
{priceChangeResult.lineItems &&
priceChangeResult.lineItems.length > 0 && (
<Box
p="md"
style={{
borderRadius: 16,
border: "1px solid var(--mantine-color-edr-border-0)",
background: "#fff",
}}
>
<Text
size="xs"
fw={700}
tt="uppercase"
c="edr-muted"
mb="xs"
style={{ letterSpacing: "0.06em" }}
>
Price breakdown
</Text>
<Stack gap={10}>
{priceChangeResult.lineItems.map((item) => {
const hasUnit =
item.unitAmount != null &&
item.quantity != null &&
item.quantity > 0;
return (
<Group
key={item.code}
justify="space-between"
align="flex-start"
wrap="nowrap"
gap="sm"
>
<Box style={{ minWidth: 0 }}>
<Text size="sm" c="#10202F" fw={500}>
{item.description}
</Text>
{hasUnit && (
<Text size="xs" c="dimmed">
{item.quantity!.toLocaleString()} ×{" "}
{item.unitAmount!.toLocaleString()}{" "}
{item.currency}
{item.unit
? ` · ${formatPriceUnit(item.unit)}`
: ""}
</Text>
)}
</Box>
<Text
size="sm"
fw={600}
c="#10202F"
style={{ whiteSpace: "nowrap" }}
>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
);
})}
</Stack>
</Box>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"

View File

@@ -0,0 +1,85 @@
import { Box, Button } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { AlertCircle, ArrowRight, PencilLine, Upload } from "lucide-react";
import type { Freight } from "@edr/types";
import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal";
import { BookingActionModal } from "./BookingActionModal";
import {
type BookingActionKind,
getBookingNextAction,
} from "./bookingNextAction";
const ICON_BY_KIND: Record<
BookingActionKind,
typeof Upload
> = {
UPLOAD_DOCUMENTS: Upload,
FIX_DOCUMENTS: AlertCircle,
SCHEDULE_OPERATION: ArrowRight,
};
interface BookingActionButtonProps {
booking: Freight.IBooking;
size?: "xs" | "sm";
}
/**
* Self-contained next-action trigger for a My Shipments row. Renders nothing
* when the booking has no customer-actionable clearance/operation step;
* otherwise shows a button that opens the in-place {@link BookingActionModal}.
*
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
* propagation so it never triggers the row's navigation handler.
*/
export function BookingActionButton({
booking,
size = "sm",
}: BookingActionButtonProps) {
const [opened, { open, close }] = useDisclosure(false);
// Staff returned the booking for changes — let the customer update the docs
// they submitted and resubmit, in place.
const isChangesRequested = booking.status === "CHANGES_REQUESTED";
const action = isChangesRequested ? null : getBookingNextAction(booking);
if (!isChangesRequested && !action) return null;
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
const label = action ? action.label : "Update & resubmit";
return (
// Mantine modals portal to <body>, but React events still bubble through
// the React tree to this button's ancestors — including the clickable list
// row. Stop click propagation here so interacting with the modal never
// triggers the row's navigate-to-detail handler.
<Box component="span" onClick={(e) => e.stopPropagation()}>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
leftSection={<Icon size={14} />}
onClick={(e) => {
e.stopPropagation();
open();
}}
>
{label}
</Button>
{isChangesRequested ? (
<ResubmitBookingModal
booking={booking}
opened={opened}
onClose={close}
/>
) : (
<BookingActionModal booking={booking} opened={opened} onClose={close} />
)}
</Box>
);
}

View File

@@ -0,0 +1,112 @@
import { Box, Button, Group, Modal, Text } from "@mantine/core";
import { CheckCircle2, Upload } from "lucide-react";
import type { Freight } from "@edr/types";
import { ClearanceFlow } from "./ClearanceFlow";
import { getBookingNextAction } from "./bookingNextAction";
import { useClearanceFlow } from "./useClearanceFlow";
interface BookingActionModalProps {
booking: Freight.IBooking;
opened: boolean;
onClose: () => void;
}
/**
* Home-page action modal: runs the full clearance / operation flow for a single
* booking without leaving the My Shipments list. The customer can upload the
* required documents, re-upload queried ones, then pick a shipment day and
* proceed to operation — all in place.
*
* Mounted only while `opened` so the clearance grid is fetched lazily and the
* staged-upload state resets every time the customer reopens it.
*/
export function BookingActionModal({
booking,
opened,
onClose,
}: BookingActionModalProps) {
if (!opened) return null;
return <BookingActionModalBody booking={booking} onClose={onClose} />;
}
function BookingActionModalBody({
booking,
onClose,
}: {
booking: Freight.IBooking;
onClose: () => void;
}) {
const action = getBookingNextAction(booking);
const flow = useClearanceFlow(booking);
const reference = booking.reference;
const handleSubmit = () => flow.submitDocuments();
const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose });
return (
<Modal
opened
onClose={onClose}
centered
size={560}
radius={16}
padding={24}
title={
<Box>
<Text fz={16} fw={800} c="#10202F">
{action?.title ?? "Booking"}
</Text>
<Text fz={12} c="dimmed" ff="monospace">
{reference}
</Text>
</Box>
}
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
styles={{ body: { paddingTop: 8 } }}
>
{flow.isLoading || !flow.clearance ? (
<Text fz="13px" c="dimmed" py="md">
Loading clearance
</Text>
) : (
<ClearanceFlow
booking={booking}
flow={flow}
footer={
<Group justify="flex-end" mt="xl" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Close
</Button>
{flow.canUpload && (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={handleSubmit}
loading={flow.uploadMutation.isPending}
disabled={!flow.canSubmit}
>
Submit documents
</Button>
)}
{flow.isReady && (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={handleProceed}
loading={flow.proceedMutation.isPending}
disabled={!flow.scheduledDate}
>
Proceed to operation
</Button>
)}
</Group>
}
/>
)}
</Modal>
);
}

View File

@@ -0,0 +1,315 @@
import {
Alert,
Box,
Button,
FileButton,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
FileText,
Plus,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { IconSquare } from "../BookingDetailPage/components/Documents";
import { OperationDatePicker } from "./OperationDatePicker";
import type { ClearanceFlowController } from "./useClearanceFlow";
const GREEN = "#0A6F4D";
function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) {
if (doc.reviewStatus === "APPROVED") {
return (
<Group gap={6} c={GREEN}>
<CheckCircle2 size={15} />
<Text fz="12px" fw={600} c={GREEN}>
Approved
</Text>
</Group>
);
}
if (doc.reviewStatus === "QUERIED") {
return (
<Group gap={6} c="#C0392B">
<AlertCircle size={15} />
<Text fz="12px" fw={600} c="#C0392B">
Queried
</Text>
</Group>
);
}
if (doc.file) {
return (
<Group gap={6} c="#2E5B96">
<Clock size={15} />
<Text fz="12px" fw={600} c="#2E5B96">
Pending review
</Text>
</Group>
);
}
return (
<Text fz="12px" fw={600} c="#9AA8B5">
Not uploaded
</Text>
);
}
interface ClearanceFlowProps {
booking: Freight.IBooking;
flow: ClearanceFlowController;
/**
* Rendered at the bottom of the flow (the submit / proceed buttons). Host
* supplies this so the detail card and the modal can place actions in their
* own footer chrome.
*/
footer?: React.ReactNode;
}
/**
* Presentational body of the customer clearance/operation flow: the required
* document grid (with re-upload of pending/queried docs), GL output documents,
* ad-hoc documents, and the shipment-day picker once CLEARANCE_READY.
*
* All state lives in the `flow` controller (see `useClearanceFlow`) so this can
* be dropped into either the booking detail card or the home-page action modal.
*/
export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
const {
clearance,
customerDocs,
glDocs,
isReady,
canUpload,
isInitialUpload,
status,
pending,
adHoc,
missingRequired,
stagePending,
addAdHocRow,
setAdHocName,
setAdHocFile,
scheduledDate,
setScheduledDate,
uploadMutation,
proceedMutation,
} = flow;
if (!clearance) return null;
return (
<Stack gap={0}>
{isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{clearance.includesCustoms
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
: "Clearance is ready. You can now proceed to operation."}
</Alert>
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
{clearance.includesCustoms
? "Global Logistics is reviewing your documents and will clear your shipment. Only re-upload the documents flagged with a query below — approved documents stay as they are."
: "Our team is reviewing your documents. Only re-upload the documents flagged with a query below — approved documents stay as they are."}
</Alert>
) : (
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
{clearance.includesCustoms
? "Upload every required document customs needs (marked *) to start the review. Global Logistics will clear your shipment and return the cleared documents here."
: "Upload every required clearance document (marked *) below to start the review."}
</Alert>
)}
{isInitialUpload && missingRequired.length > 0 && (
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
<Text fz="12px" c="#9A5B00">
Still required:{" "}
{missingRequired.map((d) => d.label).join(", ")}
</Text>
</Alert>
)}
<Stack gap={10}>
{customerDocs.map((doc) => (
<Box
key={doc.fileKey}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box c="#2E5B96">
<FileText size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={600} c="#10202F" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
{doc.file && (
<Text fz="12px" c="dimmed" truncate>
{doc.file.name}
</Text>
)}
</Box>
</Group>
<Group gap={10} wrap="nowrap">
<StatusPill doc={doc} />
{doc.file && (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
)}
{canUpload && doc.reviewStatus !== "APPROVED" && (
<FileButton
onChange={(f) => f && stagePending(doc.fileKey, f)}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{pending[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
{doc.reviewStatus === "QUERIED" && doc.note && (
<Text fz="12px" c="#C0392B" mt={6}>
Query: {doc.note}
</Text>
)}
{pending[doc.fileKey] && (
<Text fz="12px" c={GREEN} mt={6}>
Ready to upload: {pending[doc.fileKey].name}
</Text>
)}
</Box>
))}
</Stack>
{/* GL output documents (read-only to the customer). */}
{glDocs.length > 0 && (
<>
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
Customs output documents
</Text>
<Stack gap={8}>
{glDocs.map((doc) => (
<Group
key={doc.fileKey}
justify="space-between"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 10 }}
>
<Text fz="13px" c="#10202F" truncate>
{doc.label}
</Text>
{doc.file ? (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
) : (
<Text fz="12px" c="#9AA8B5">
Pending
</Text>
)}
</Group>
))}
</Stack>
</>
)}
{/* Ad-hoc / additional documents. */}
{canUpload && (
<Box mt="lg">
<Group justify="space-between" align="center" mb={8}>
<Text fz="12.5px" fw={700} c="#10202F">
Additional documents
</Text>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Plus size={13} />}
onClick={addAdHocRow}
>
Add document
</Button>
</Group>
<Stack gap={8}>
{adHoc.map((row, i) => (
<Group key={i} gap={8} wrap="nowrap">
<TextInput
placeholder="Document name"
value={row.name}
onChange={(e) => setAdHocName(i, e.currentTarget.value)}
style={{ flex: 1 }}
radius="md"
/>
<FileButton
onChange={(f) => setAdHocFile(i, f)}
accept="application/pdf,image/*"
>
{(props) => (
<Button {...props} variant="default" radius="md">
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
</Button>
)}
</FileButton>
</Group>
))}
</Stack>
</Box>
)}
{uploadMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{uploadMutation.error instanceof Error
? uploadMutation.error.message
: "Upload failed. Please try again."}
</Alert>
)}
{isReady && (
<Box mt="lg">
<Text fz="13px" fw={700} c="#10202F" mb={6}>
Choose your shipment day
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Only days with a scheduled departure on your route can be selected.
The operations team assigns the specific train for that day.
</Text>
<OperationDatePicker
originYardId={booking.originYard?.id}
destinationYardId={booking.destinationYard?.id}
value={scheduledDate}
onChange={setScheduledDate}
/>
</Box>
)}
{proceedMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{proceedMutation.error instanceof Error
? proceedMutation.error.message
: "Could not request the operation. Please try again."}
</Alert>
)}
{footer}
</Stack>
);
}

View File

@@ -0,0 +1,219 @@
import { Box, Button, Group, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameMonth,
isToday,
startOfMonth,
startOfWeek,
} from "date-fns";
import {
Calendar as CalendarIcon,
Check,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
interface OperationDatePickerProps {
originYardId?: string;
destinationYardId?: string;
value: string;
onChange: (date: string) => void;
}
/**
* Compact month calendar for picking the binding shipment day at the
* operation-request step. Only days that have an OPEN scheduled departure on the
* booking route are selectable; all other days are disabled.
*
* Shared by the booking detail clearance card and the home-page action modal.
*/
export function OperationDatePicker({
originYardId,
destinationYardId,
value,
onChange,
}: OperationDatePickerProps) {
const [month, setMonth] = useState(() => startOfMonth(new Date()));
const { data: availableDays, isLoading } = useQuery(
api.bookings.getAvailableDays.queryOptions({
input: { originYardId, destinationYardId },
enabled: !!originYardId && !!destinationYardId,
}),
);
const departureDays = useMemo(
() => new Set(availableDays ?? []),
[availableDays],
);
const cells = useMemo(() => {
const start = startOfWeek(startOfMonth(month), { weekStartsOn: 1 });
const end = endOfWeek(endOfMonth(month), { weekStartsOn: 1 });
return eachDayOfInterval({ start, end }).map((date) => {
const dateString = format(date, "yyyy-MM-dd");
return {
date,
dateString,
day: date.getDate(),
inMonth: isSameMonth(date, month),
today: isToday(date),
selected: value === dateString,
hasDeparture: departureDays.has(dateString),
};
});
}, [month, departureDays, value]);
return (
<Box
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
padding: 14,
maxWidth: 340,
}}
>
<Group justify="space-between" align="center" mb="sm">
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, -1))}
>
<ChevronLeft size={15} />
</Button>
<Text fz="13px" fw={700} c="#10202F">
{format(month, "MMMM yyyy")}
</Text>
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, 1))}
>
<ChevronRight size={15} />
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="md" gap={8}>
<CalendarIcon size={15} color="#9AA8B5" />
<Text fz="12px" c="dimmed">
Loading available days
</Text>
</Group>
) : (
<>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
marginBottom: 6,
}}
>
{["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
<Text key={i} ta="center" fz="10px" fw={700} c="#9AA8B5">
{d}
</Text>
))}
</Box>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
}}
>
{cells.map((c) => {
const clickable = c.hasDeparture && c.inMonth;
return (
<button
key={c.dateString}
type="button"
disabled={!clickable}
onClick={() => clickable && onChange(c.dateString)}
style={{
position: "relative",
height: 34,
borderRadius: 8,
fontSize: 12.5,
fontWeight: c.selected ? 800 : 600,
cursor: clickable ? "pointer" : "default",
border: c.selected
? "1.5px solid #12B981"
: clickable
? "1px solid #CDEBDD"
: "1px solid transparent",
background: c.selected
? "#12B981"
: clickable
? "#F4FBF7"
: "transparent",
color: c.selected
? "#fff"
: !c.inMonth
? "#CBD5E1"
: clickable
? "#0A6F4D"
: "#C4CDD6",
transition: "all 120ms ease",
}}
>
{c.day}
{c.hasDeparture && c.inMonth && !c.selected && (
<span
style={{
position: "absolute",
bottom: 4,
left: "50%",
transform: "translateX(-50%)",
width: 4,
height: 4,
borderRadius: "50%",
background: "#12B981",
}}
/>
)}
{c.selected && (
<Check
size={11}
color="#fff"
strokeWidth={3}
style={{
position: "absolute",
bottom: 3,
left: "50%",
transform: "translateX(-50%)",
}}
/>
)}
</button>
);
})}
</Box>
{value && (
<Text fz="12px" c="#0A6F4D" fw={600} mt="sm">
Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")}
</Text>
)}
{!isLoading && departureDays.size === 0 && (
<Text fz="12px" c="orange.7" mt="sm">
No scheduled departures found for this route yet.
</Text>
)}
</>
)}
</Box>
);
}

View File

@@ -0,0 +1,68 @@
import type { Freight } from "@edr/types";
/**
* The customer-actionable clearance/operation steps a booking can be sitting on.
* These are the statuses where the *customer* must do something next — upload
* documents, re-upload a queried document, or pick a shipment day and proceed
* to operation.
*/
export type BookingActionKind =
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
| "SCHEDULE_OPERATION"; // CLEARANCE_READY — pick a day and proceed to operation
export interface BookingNextAction {
kind: BookingActionKind;
/** Button label shown on the My Shipments row. */
label: string;
/** Modal title. */
title: string;
}
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
AWAITING_DOCUMENTS: {
kind: "UPLOAD_DOCUMENTS",
label: "Upload documents",
title: "Upload clearance documents",
},
DOCUMENTS_UNDER_REVIEW: {
kind: "FIX_DOCUMENTS",
label: "Review documents",
title: "Clearance documents",
},
CLEARANCE_READY: {
kind: "SCHEDULE_OPERATION",
label: "Schedule & proceed",
title: "Schedule your shipment",
},
};
/**
* Resolve the customer's next clearance/operation action for a booking, or
* `null` when there's nothing for them to do at this stage. Pure + cheap so it
* can be called inline while rendering a list row.
*
* Note: `DOCUMENTS_UNDER_REVIEW` always surfaces an action because the customer
* may need to re-upload a queried document; the modal itself shows a read-only
* "under review" state when nothing is actually queried.
*/
export function getBookingNextAction(
booking: Pick<Freight.IBooking, "status">,
): BookingNextAction | null {
return ACTION_BY_STATUS[booking.status as string] ?? null;
}
/**
* Whether a booking has an in-place action the customer can take from a list
* row via a modal — either a clearance/operation step, or a CHANGES_REQUESTED
* booking that needs documents updated and resubmitting. Used to decide whether
* to render {@link BookingActionButton}.
*/
export function bookingHasInlineAction(
booking: Pick<Freight.IBooking, "status">,
): boolean {
return (
booking.status === "CHANGES_REQUESTED" ||
getBookingNextAction(booking) !== null
);
}

View File

@@ -0,0 +1,14 @@
export { BookingActionButton } from "./BookingActionButton";
export { BookingActionModal } from "./BookingActionModal";
export { ClearanceFlow } from "./ClearanceFlow";
export { OperationDatePicker } from "./OperationDatePicker";
export {
getBookingNextAction,
type BookingActionKind,
type BookingNextAction,
} from "./bookingNextAction";
export {
useClearanceFlow,
type AdHocDoc,
type ClearanceFlowController,
} from "./useClearanceFlow";

View File

@@ -0,0 +1,164 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
export type AdHocDoc = { name: string; file: File | null };
/**
* Encapsulates everything the customer-facing clearance/operation flow needs:
* the clearance grid query, the staged uploads (keyed pending + ad-hoc docs),
* the chosen shipment day, and the submit / proceed mutations.
*
* Both the booking detail clearance card and the home-page action modal drive
* their UI off this single hook so the behaviour stays in lock-step.
*/
export function useClearanceFlow(booking: Freight.IBooking) {
const queryClient = useQueryClient();
const status = booking.status as string;
const clearanceQuery = useQuery(
api.bookings.getClearance.queryOptions({ input: { id: booking.id } }),
);
const clearance = clearanceQuery.data;
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
const [scheduledDate, setScheduledDate] = useState<string>("");
const refresh = () => {
queryClient.invalidateQueries({
queryKey: api.bookings.getClearance.queryKey({ id: booking.id }),
});
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: booking.id }),
});
queryClient.invalidateQueries({
queryKey: api.bookings.list.queryKey(),
});
};
const uploadMutation = useMutation({
...api.bookings.submitClearanceDocuments.mutationOptions(),
onSuccess: () => {
setPending({});
setAdHoc([]);
refresh();
},
});
const proceedMutation = useMutation({
...api.bookings.proceedToOperation.mutationOptions(),
onSuccess: () => refresh(),
});
// Only the customer-input documents are uploadable here; GL output docs are
// shown read-only.
const customerDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
);
const glDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
[clearance],
);
const isReady = status === "CLEARANCE_READY";
const canUpload =
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
// The very first upload (nothing in review yet). Here every required document
// must be provided. Once GL has started reviewing (DOCUMENTS_UNDER_REVIEW) the
// customer is only re-uploading queried/pending docs, so we don't re-gate on
// the full required set.
const isInitialUpload = status === "AWAITING_DOCUMENTS";
const hasStagedFiles =
Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
// A required customer document is satisfied when it already has an uploaded
// file or the customer has just staged one for this submission.
const missingRequired = useMemo(
() =>
customerDocs.filter(
(d) => d.required && !d.file && !pending[d.fileKey],
),
[customerDocs, pending],
);
// Initial upload: block submit until every required field has a file (and at
// least one file is actually staged to send). Re-upload rounds only need at
// least one staged file — the customer fixes the specific queried documents.
const canSubmit = isInitialUpload
? hasStagedFiles && missingRequired.length === 0
: hasStagedFiles;
// --- staged-upload mutators ----------------------------------------------
const stagePending = (fileKey: string, file: File) =>
setPending((p) => ({ ...p, [fileKey]: file }));
const addAdHocRow = () => setAdHoc((r) => [...r, { name: "", file: null }]);
const setAdHocName = (index: number, name: string) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === index ? { ...r, name } : r)),
);
const setAdHocFile = (index: number, file: File | null) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === index ? { ...r, file } : r)),
);
// --- actions --------------------------------------------------------------
const submitDocuments = (opts?: { onSuccess?: () => void }) => {
const files: Record<string, File | null> = { ...pending };
adHoc.forEach((row, i) => {
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
});
if (Object.keys(files).length === 0) return;
uploadMutation.mutate({ id: booking.id, files }, { onSuccess: opts?.onSuccess });
};
const proceedToOperation = (opts?: { onSuccess?: () => void }) => {
if (!scheduledDate) return;
proceedMutation.mutate(
{ id: booking.id, scheduledDate },
{ onSuccess: opts?.onSuccess },
);
};
return {
status,
clearance,
isLoading: clearanceQuery.isLoading,
customerDocs,
glDocs,
isReady,
canUpload,
isInitialUpload,
// staged upload state
pending,
adHoc,
hasStagedFiles,
missingRequired,
canSubmit,
stagePending,
addAdHocRow,
setAdHocName,
setAdHocFile,
// schedule
scheduledDate,
setScheduledDate,
// mutations
uploadMutation,
proceedMutation,
submitDocuments,
proceedToOperation,
};
}
export type ClearanceFlowController = ReturnType<typeof useClearanceFlow>;

View File

@@ -0,0 +1,59 @@
import { Button } from "@mantine/core";
import { FileSignature } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
/**
* Statuses where the contract is ready for the customer to review and sign.
* These are the only states where {@link ContractSignButton} renders — once the
* customer has signed, the row falls back to its normal "View" action.
*/
const SIGNABLE_STATUSES = ["CONTRACT_READY", "APPROVED_PENDING_SIGNATURE"];
export function bookingIsSignable(
booking: Pick<Freight.IBooking, "status">,
): boolean {
return SIGNABLE_STATUSES.includes(booking.status as string);
}
interface ContractSignButtonProps {
booking: Freight.IBooking;
size?: "xs" | "sm";
}
/**
* Self-contained "View & Sign" trigger for a My Shipments row. Renders nothing
* unless the booking's contract is ready to be signed; otherwise shows a button
* that navigates to the full-page contract viewer ({@link BookingContractPage})
* where the signature flow lives.
*
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
* propagation so it never triggers the row's navigation handler.
*/
export function ContractSignButton({
booking,
size = "sm",
}: ContractSignButtonProps) {
const navigate = useNavigate();
if (!bookingIsSignable(booking)) return null;
return (
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
leftSection={<FileSignature size={14} />}
onClick={(e) => {
// Don't let the surrounding row-click handler fire.
e.stopPropagation();
navigate(`/bookings/${booking.id}/contract`);
}}
>
View &amp; sign
</Button>
);
}

View File

@@ -57,7 +57,13 @@ export function StepIndicator({
}),
}}
>
{done ? <Check style={{ width: 15, height: 15 }} strokeWidth={3} /> : item.id}
{done ? (
<Check style={{ width: 15, height: 15 }} strokeWidth={3} />
) : (
// Display the 1-based position, not the raw step id — ids can
// be non-contiguous (e.g. the schedule step was removed).
index + 1
)}
</div>
<span
style={{

View File

@@ -8,7 +8,6 @@ export const STEPS = [
{ id: 2, label: "Service Type & Mile", short: "Service" },
{ id: 3, label: "Cargo Details", short: "Cargo" },
{ id: 4, label: "Route", short: "Route" },
{ id: 5, label: "Estimated Date", short: "Schedule" },
{ id: 6, label: "Documents", short: "Documents" },
{ id: 7, label: "Review & Submit", short: "Submit" },
] as const;
@@ -141,22 +140,20 @@ export const bookingFormSchema = z
customsClearingAgent: z.string().default(""),
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
// Quantity reserved on the PRIMARY route of a GENERAL contract, in the unit
// of the selected commodity (items vs tons). Customers enter it explicitly in
// the route step so the primary route reads consistently with the extra
// routes below. Ignored for one-time bookings; for containers the value is
// derived from the container count instead (see buildApiPayload).
// Retained for payload/back-compat only — no longer collected in the UI.
// 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 adds another (origin, destination, quantity) pool.
// Ignored for one-time bookings.
// 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.
extraRoutes: z
.array(
z.object({
originYard: z.string(),
destinationYard: z.string(),
quantity: z.string(),
// Road distance for this route; used to bill road (truck) orders.
quantity: z.string().default(""),
km: z.string().default(""),
}),
)
@@ -222,11 +219,10 @@ export const bookingFormSchema = z
)
.refine(
(data) => {
// General contracts capture bulk quantity per route (primaryRouteQuantity),
// not via the cargo-step cargoWeight — so only validate it for one-time
// bulk bookings.
// Both one-time and general contracts capture the bulk amount in the cargo
// step (cargoWeight). General contracts no longer collect a per-route
// quantity, so the cargo amount is the single source for the contract total.
if (data.cargoType !== "bulk") return true;
if (data.bookingType === "general_contract") return true;
const quantity = Number(data.cargoWeight);
return !!data.cargoWeight && !Number.isNaN(quantity) && quantity > 0;
},
@@ -245,14 +241,10 @@ export const bookingFormSchema = z
message: "Select a shipment date.",
});
}
// Customs clearing agent is required once the customs service is enabled.
if (data.customsClearingEnabled && !data.customsClearingAgent.trim()) {
ctx.addIssue({
code: "custom",
path: ["customsClearingAgent"],
message: "Enter the customs clearing agent.",
});
}
// The customs clearing agent is only the customer's own broker, named when
// the service does NOT bundle customs (EDR/GL handles it otherwise). It is
// never required: when the service includes customs the agent is left blank
// on purpose, so requiring it would silently block submission.
if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) {
ctx.addIssue({
@@ -262,19 +254,6 @@ export const bookingFormSchema = z
});
}
}
// General contracts reserve quantity per route. The primary route's quantity
// is entered in the route step; containers derive it from the container
// count, so only bulk cargo requires it here.
if (data.bookingType === "general_contract" && data.cargoType === "bulk") {
const qty = Number(data.primaryRouteQuantity);
if (!data.primaryRouteQuantity || Number.isNaN(qty) || qty <= 0) {
ctx.addIssue({
code: "custom",
path: ["primaryRouteQuantity"],
message: "Enter a quantity greater than 0.",
});
}
}
if (data.cargoType === "container") {
data.containers.forEach((c, i) => {
if (!c.qty || +c.qty < 1) {
@@ -349,8 +328,9 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"extraRoutes",
"isHazardous",
"isRefrigerated",
// Estimated shipment date now lives in the Route step (one-time bookings only).
"scheduledDate",
],
5: ["scheduledDate"],
6: ["documents"],
7: ["notes"],
};

View File

@@ -296,7 +296,11 @@ export function SelectField({
placeholder={placeholder}
disabled={disabled}
data={data}
value={String(field.value) || null}
value={
field.value === undefined || field.value === null || field.value === ""
? null
: String(field.value)
}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
error={error?.message}

View File

@@ -1,16 +1,32 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { Box, Group, Loader, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, FileText, FileUp } from "lucide-react";
import { SmartFileInput } from "@edr/ui-common";
import { type UseFormReturn } from "react-hook-form";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import {
type BookingDocuments,
type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
import { StepCard, StepHeader } from "./shared";
export interface OnboardingDoc {
name: string;
url: string;
size: number;
mimeType?: string;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
/** Onboarding document setting code for the company's nationality. */
function documentSettingCode(nationality: string | null | undefined): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
function formatSize(bytes: number): string {
function formatSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
@@ -18,57 +34,58 @@ function formatSize(bytes: number): string {
}
/**
* Read-only documents step: lists the documents the company uploaded during
* onboarding for the active operational profile. These are attached to the
* booking automatically at submission — the customer is never asked to re-upload.
* Editable documents step. Mirrors the company onboarding documents (TIN,
* passport, investment/commercial license, national ID, …) and lets the customer
* attach or replace them FOR THIS BOOKING. Selections are stored on the form's
* `documents` field and saved against the specific booking on submit — editing
* here never touches the company profile.
*
* The documents already on file from onboarding are shown as a reference so the
* customer can see what EDR already has; they only need to upload here if they
* want to override a document for this booking.
*/
export function StepDocuments({ documents }: { documents: OnboardingDoc[] }) {
const total = documents.length;
export function StepDocuments({ form }: { form: BookingForm }) {
const auth = useAuth();
const nationality = auth.company?.company?.nationality as
| string
| null
| undefined;
const docSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(nationality) },
}),
);
// Documents already on file from onboarding (read-only reference).
const onboardingDocs = (() => {
const profiles = auth.company?.company?.companyProfiles ?? [];
const active =
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
return active?.licenseFiles ?? [];
})();
const documents = (form.watch("documents") ?? {}) as BookingDocuments;
const setDocuments = (next: Record<string, File | File[] | null>) => {
form.setValue("documents", next, { shouldDirty: true });
};
return (
<StepCard>
<StepHeader
icon={<FileUp size={22} />}
title="Documents"
description="The documents from your onboarding will be attached to this booking automatically. No re-upload is needed."
description="Attach the documents for this booking. They default to what you uploaded during onboarding — upload here only to override a document for this specific booking."
/>
<Group
gap={10}
align="center"
wrap="nowrap"
className="rounded-xl"
style={{
border: "1px solid var(--mantine-color-edr-border-0)",
backgroundColor: "var(--mantine-color-gray-0)",
padding: "12px 16px",
}}
>
<Box
style={{
flexShrink: 0,
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
backgroundColor: total > 0 ? "#ECF6F1" : "#FBECEC",
color: total > 0 ? "#0A6F4D" : "#B42318",
}}
>
{total > 0 ? <CheckCircle2 size={16} /> : <FileUp size={16} />}
</Box>
<Text size="sm" c="dimmed">
{total > 0
? `${total} onboarding ${total === 1 ? "document" : "documents"} will be attached to this booking.`
: "No onboarding documents found on your active profile. You can add documents later from the booking page."}
</Text>
</Group>
{total > 0 && (
<Stack gap={10} mt={4}>
{documents.map((doc, i) => (
{onboardingDocs.length > 0 && (
<Stack gap={10} mb="lg">
<Text fz={13} fw={700} c="#10202F">
On file from your onboarding
</Text>
{onboardingDocs.map((doc, i) => (
<Group
key={`${doc.url}-${i}`}
gap={12}
@@ -116,13 +133,35 @@ export function StepDocuments({ documents }: { documents: OnboardingDoc[] }) {
>
<CheckCircle2 size={15} />
<Text size="xs" fw={600} c="#0A6F4D">
Uploaded
On file
</Text>
</Box>
</Group>
))}
</Stack>
)}
<Text fz={13} fw={700} c="#10202F" mb="sm">
Documents for this booking
</Text>
{docSettingQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
</Group>
) : docSettingQuery.data ? (
<SmartFileInput
file={docSettingQuery.data}
value={documents}
onChange={setDocuments}
/>
) : (
<Text size="sm" c="dimmed">
No document requirements are configured for your account. The documents
on file from your onboarding will be attached to this booking
automatically.
</Text>
)}
</StepCard>
);
}

View File

@@ -1,12 +1,11 @@
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
import type { ReactNode } from "react";
import { FileText, Layers, Train, Truck } from "lucide-react";
import { Check, FileText, Info, Layers, Train, Truck } from "lucide-react";
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
import {
fieldStyles,
OptionCard,
OptionFieldError,
StepCard,
StepHeader,
@@ -40,7 +39,6 @@ export function Step2ServiceType({
serviceType ?? {};
const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled");
const customsClearingEnabled = form.watch("customsClearingEnabled");
const prevServiceType = useRef(serviceType);
useEffect(() => {
@@ -65,12 +63,17 @@ export function Step2ServiceType({
if (!prev || prev === serviceType) return;
if (!includesCustoms)
if (includesCustoms) {
form.setValue("customsClearingEnabled", true, { shouldDirty: true });
form.setValue("customsClearingAgent", "", { shouldDirty: true });
} else {
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
form.setValue("customsClearingAgent", "", { shouldDirty: true });
}
}, [serviceTypeId, form]);
const showServiceSections =
includesCustoms || includesFirstMile || includesLastMile;
serviceType != null || includesFirstMile || includesLastMile;
return (
<StepCard>
<StepHeader
@@ -84,17 +87,14 @@ export function Step2ServiceType({
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-2">
{referenceData?.service
.filter((s) => s.canBeBookedAlone)
.map((s) => (
<OptionCard
<ServiceTypeCard
key={s.id}
selected={field.value === s.id}
onClick={() => field.onChange(s.id)}
icon={<Train className="h-5 w-5" />}
iconBg="#EEF0FB"
iconColor="#4F46E5"
title={s.serviceName}
description={s.description}
/>
@@ -263,43 +263,95 @@ export function Step2ServiceType({
)}
{/* Customs Clearing */}
{includesCustoms && (
<Controller
name="customsClearingEnabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<FileText size={18} />}
title="Customs Clearing Service"
description="EDR handles customs documentation and clearance on your behalf."
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("customsClearingAgent", "", {
shouldDirty: true,
shouldValidate: true,
});
}
{includesCustoms ? (
<Box
px={16}
py={14}
style={{
borderRadius: 14,
border: "1.5px solid #CDEBDD",
background: "#F6FBF8",
}}
>
<Group gap={13} align="flex-start" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#ECF6F1",
color: "#0A6F4D",
}}
>
{customsClearingEnabled && (
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Customs clearing agent *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
</ServiceToggle>
<FileText size={18} />
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
Customs Clearing Service
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
Customs documentation and clearance is included automatically with this service.
</Text>
</Box>
<Box style={{ flexShrink: 0, marginLeft: "auto" }}>
<Group gap={6} align="center">
<Info size={14} color="#0A6F4D" />
<Text fz={12} fw={600} c="#0A6F4D">Included</Text>
</Group>
</Box>
</Group>
</Box>
) : (
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field, fieldState }) => (
<Box
px={16}
py={14}
style={{
borderRadius: 14,
border: "1.5px solid #E6ECF2",
background: "#fff",
}}
>
<Group gap={13} align="flex-start" wrap="nowrap" mb="sm">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#F1F4F7",
color: "#64748B",
}}
>
<FileText size={18} />
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
Customs Clearing Agent
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
Enter the name of the customs clearing agent for this shipment.
</Text>
</Box>
</Group>
<TextInput
{...field}
placeholder="Customs clearing agent name"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
</Box>
)}
/>
)}
@@ -309,6 +361,92 @@ export function Step2ServiceType({
);
}
/**
* Compact service-type selection card. A single horizontal row (icon · text ·
* radio) — deliberately smaller than the shared OptionCard so the service list
* stays scannable.
*/
function ServiceTypeCard({
selected,
onClick,
title,
description,
}: {
selected: boolean;
onClick: () => void;
title?: ReactNode;
description?: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
width: "100%",
textAlign: "left",
cursor: "pointer",
borderRadius: 12,
padding: "12px 14px",
transition: "all 140ms ease",
border: `1.5px solid ${selected ? "#12B981" : "#E6ECF2"}`,
background: selected ? "#F4FBF7" : "#fff",
boxShadow: selected
? "0 0 0 1px #12B981, 0 4px 12px rgba(14,163,113,0.10)"
: "0 1px 2px rgba(16,24,40,0.04)",
}}
onMouseEnter={(e) => {
if (!selected) e.currentTarget.style.borderColor = "#BFE3D2";
}}
onMouseLeave={(e) => {
if (!selected) e.currentTarget.style.borderColor = "#E6ECF2";
}}
>
<Group gap={11} align="center" wrap="nowrap">
<Box
style={{
width: 34,
height: 34,
flexShrink: 0,
borderRadius: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: selected ? "#E3F4EC" : "#EEF0FB",
color: selected ? "#0A6F4D" : "#4F46E5",
}}
>
<Train size={17} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz={13.5} fw={700} c="#10202F" truncate>
{title}
</Text>
{description && (
<Text fz={11.5} c="#6B7C8E" truncate style={{ lineHeight: 1.35 }}>
{description}
</Text>
)}
</Box>
<Box
style={{
width: 18,
height: 18,
flexShrink: 0,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: selected ? "none" : "1.5px solid #CBD5E1",
background: selected ? "#12B981" : "transparent",
}}
>
{selected && <Check size={11} color="#fff" strokeWidth={3} />}
</Box>
</Group>
</button>
);
}
function ServiceToggle({
icon,
title,

View File

@@ -4,13 +4,14 @@ import {
Button,
Divider,
Group,
NumberInput,
Skeleton,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import {
CalendarDays,
Flame,
MapPin,
Plus,
@@ -18,7 +19,7 @@ import {
Snowflake,
Trash2,
} from "lucide-react";
import { useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import {
Controller,
useFieldArray,
@@ -48,42 +49,109 @@ export function Step4Route({
}) {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const operationType = form.watch("operationType");
const isGeneralContract = form.watch("bookingType") === "general_contract";
// The operation chosen in step 0 fixes which end of the route is inside
// Ethiopia. Djibouti yards stand in for "outside Ethiopia" (the port),
// mirroring getRouteDirection / the backend's deriveTradeDirection:
// import → origin outside (Djibouti), destination Ethiopia
// export → origin Ethiopia, destination outside (Djibouti)
// intercity → both Ethiopia (domestic)
// _ff variants share the trade direction of their base operation.
const { originCountry, destinationCountry } = useMemo(() => {
switch (operationType) {
case "import":
case "import_ff":
return { originCountry: "Djibouti", destinationCountry: "Ethiopia" };
case "export":
case "export_ff":
return { originCountry: "Ethiopia", destinationCountry: "Djibouti" };
case "intercity":
return { originCountry: "Ethiopia", destinationCountry: "Ethiopia" };
default:
return { originCountry: null, destinationCountry: null };
}
}, [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 }));
}, [referenceData]);
const originData = useMemo(() => {
return yardOptions
.filter((o) => o.value !== destinationYard)
.filter((o) => {
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
if (!dest) return true;
const origin = referenceData?.yard.find((y) => y.id === o.value);
// Filter the yard list to one side of a route: the yards in `country` (the
// operation type fixes which country each end must be in — see originCountry /
// destinationCountry above), excluding the yard already chosen on the other
// end of the same route so origin and destination can never match.
const yardsForSide = useCallback(
(country: string | null, excludeYardId: string) =>
yardOptions
.filter((o) => o.value !== excludeYardId)
.filter((o) => {
if (!country) return true;
const yard = referenceData?.yard.find((y) => y.id === o.value);
return yard?.country === country;
}),
[yardOptions, referenceData],
);
// can't go from Djibouti to Djibouti
if (dest?.country === "Djibouti" && origin?.country == "Djibouti")
return false;
return true;
});
}, [yardOptions, destinationYard]);
const destData = useMemo(() => {
return yardOptions.filter((o) => o.value !== originYard);
}, [yardOptions, originYard]);
const originData = useMemo(
() => yardsForSide(originCountry, destinationYard),
[yardsForSide, originCountry, destinationYard],
);
const destData = useMemo(
() => yardsForSide(destinationCountry, originYard),
[yardsForSide, destinationCountry, originYard],
);
const origin = referenceData?.yard.find((y) => y.id === originYard);
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
const direction = getRouteDirection(origin, dest);
// Changing the operation type (step 0) can invalidate a yard already chosen
// here — e.g. switching import→export flips which end must be in Ethiopia.
// Clear any selection that no longer matches the operation's required country
// so the customer can't submit a route that contradicts the operation.
useEffect(() => {
if (originCountry && origin && origin.country !== originCountry) {
form.setValue("originYard", "");
}
}, [originCountry, origin, form]);
useEffect(() => {
if (destinationCountry && dest && dest.country !== destinationCountry) {
form.setValue("destinationYard", "");
}
}, [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",
@@ -97,25 +165,26 @@ export function Step4Route({
const stationSelectDisabled = yardOptions.length === 0;
// General contracts reserve quantity per route. The unit (items vs tons) comes
// from the commodity picked in the cargo step, mirroring step5-cargo-details:
// PER_ITEM → a whole item count; otherwise an estimated tonnage. Container
// contracts reserve quantity by container count instead, so no quantity input
// is shown for them here.
// 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.
const cargoType = form.watch("cargoType");
const cargoTypePath = form.watch("cargoTypePath") ?? [];
const isContainer = cargoType === "container";
const selectedCommodity = useMemo(() => {
const parentId = cargoTypePath[0];
const childId = cargoTypePath[1];
if (!referenceData?.cargo_type || !parentId || !childId) return null;
const group = referenceData.cargo_type.find((g) => g.id === parentId);
return group?.children?.find((c) => c.id === childId) ?? null;
}, [referenceData, cargoTypePath]);
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
const quantityLabel = isPerItem ? "Quantity (Items)" : "Quantity (Tons)";
const quantityStep = isPerItem ? 1 : 0.01;
const showRouteQuantity = isGeneralContract && !isContainer;
// The reefer toggle only exists for bulk; if the customer switches to
// containers, drop any reefer flag they set so it can't ride along unseen.
useEffect(() => {
if (cargoType !== "bulk" && form.getValues("isRefrigerated")) {
form.setValue("isRefrigerated", false);
}
}, [cargoType, form]);
// Earliest selectable shipment date (today, local) for the date input's `min`.
const todayISODate = useMemo(() => {
const now = new Date();
const tz = now.getTimezoneOffset() * 60000;
return new Date(now.getTime() - tz).toISOString().slice(0, 10);
}, []);
return (
<StepCard>
@@ -168,21 +237,23 @@ export function Step4Route({
{directionLabel[direction]}
</div>
)}
{showRouteQuantity && (
<Box style={{ maxWidth: 220 }}>
{/* Estimated shipment date — one-time bookings only. General contracts
pick the date per order drawn against the contract later. */}
{!isGeneralContract && (
<Box style={{ maxWidth: 280 }}>
<Controller
name="primaryRouteQuantity"
name="scheduledDate"
control={form.control}
render={({ field, fieldState }) => (
<NumberInput
label={`${quantityLabel} *`}
placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"}
description="Quantity reserved on the primary route."
min={0}
step={quantityStep}
<TextInput
type="date"
label="Estimated shipment date *"
description="A planning estimate. You'll confirm the actual date when you request the operation."
min={todayISODate}
leftSection={<CalendarDays size={16} />}
error={fieldState.error?.message}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
value={field.value ?? ""}
onChange={(e) => field.onChange(e.currentTarget.value)}
radius="md"
/>
)}
@@ -216,96 +287,75 @@ export function Step4Route({
</Button>
</Group>
<Text fz={12} c="#6B7C8E" mb={12}>
A general contract can reserve quantity across several routes. The
route above is your primary route; add more routes and set the
quantity reserved for each.
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) => (
<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..."
data={yardOptions}
/>
)}
/>
</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..."
data={yardOptions}
/>
)}
/>
</Box>
<Box style={{ width: 140 }}>
<Controller
name={`extraRoutes.${i}.quantity`}
control={form.control}
render={({ field }) => (
<NumberInput
label={quantityLabel}
placeholder="0"
min={0}
step={quantityStep}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
radius="md"
/>
)}
/>
</Box>
<Box style={{ width: 110 }}>
<Controller
name={`extraRoutes.${i}.km`}
control={form.control}
render={({ field }) => (
<NumberInput
label="Distance (km)"
placeholder="0"
min={0}
step={1}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
radius="md"
/>
)}
/>
</Box>
<Button
variant="subtle"
color="red"
size="xs"
mt={24}
px={6}
onClick={() => removeRoute(i)}
{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 }}
>
<Trash2 size={16} />
</Button>
</Group>
))}
<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>
)}
@@ -329,21 +379,26 @@ export function Step4Route({
/>
)}
/>
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a refrigeration surcharge."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
{/* Reefer is a customer choice for bulk freight only. For containers the
reefer surcharge is driven by the container type, so the toggle is
hidden there to avoid a control that doesn't affect the price. */}
{cargoType === "bulk" && (
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a refrigeration surcharge."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
)}
</Stack>
</StepCard>
);

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { Package, Plus, Trash2, Weight } from "lucide-react";
import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core";
@@ -56,11 +56,25 @@ export function Step5CargoDetails({
);
}, [referenceData]);
// Reset the chosen commodity ONLY when the parent group actually changes to a
// different one. The previous version reset on every render where parentId was
// truthy, which wiped a valid commodity whenever the step re-rendered or was
// revisited (e.g. navigating Back/Next or restoring a saved draft) — making the
// commodity selection appear not to stick. Tracking the previous parent lets us
// clear the child on a real parent switch while leaving an existing selection
// intact on mount/re-render.
const prevParentIdRef = useRef<string | undefined>(parentId);
useEffect(() => {
if (parentId) {
form.setValue("cargoTypePath", [parentId, ""], { shouldDirty: true });
if (prevParentIdRef.current === parentId) return;
const switchedToAnotherParent =
!!prevParentIdRef.current && !!parentId;
prevParentIdRef.current = parentId;
if (switchedToAnotherParent) {
form.setValue("cargoTypePath", [parentId as string, ""], {
shouldDirty: true,
});
}
}, [parentId]);
}, [parentId, form]);
const selectedCommodity = useMemo(() => {
if (!referenceData?.cargo_type || !parentId || !childId) return null;

View File

@@ -35,7 +35,8 @@ export const REVIEW_STEP_TARGETS = {
service: 2,
cargo: 3,
route: 4,
schedule: 5,
// The estimated shipment date now lives in the Route step.
schedule: 4,
documents: 6,
} as const;
@@ -159,14 +160,10 @@ export function Step8Review({
.join(", ")
: "";
// For bulk general contracts the quantity is reserved per route (the primary
// route's amount lives in primaryRouteQuantity); one-time bookings use the
// cargo-step cargoWeight.
const isGeneralContract = values.bookingType === "general_contract";
const bulkAmount =
isGeneralContract && values.cargoType === "bulk"
? Number(values.primaryRouteQuantity || 0)
: Number(values.cargoWeight || 0);
// Both one-time and general contracts take the bulk amount from the cargo step
// (cargoWeight); general contracts no longer collect a per-route quantity.
const bulkAmount = Number(values.cargoWeight || 0);
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(
@@ -175,8 +172,18 @@ export function Step8Review({
)
: bulkAmount;
// Documents are reused from onboarding (read-only) and attached on submit.
const onboardingDocsCount = onboardingDocs.length;
// Documents the customer attached for THIS booking (keyed by document field).
// Onboarding docs on the active profile are still listed as a fallback so the
// customer can see what is already on file.
const attachedDocs = Object.entries(
(values.documents ?? {}) as Record<string, File | File[] | null>,
)
.filter(([, v]) => (Array.isArray(v) ? v.length > 0 : Boolean(v)))
.map(([key, v]) => ({
name: Array.isArray(v) ? (v[0]?.name ?? key) : ((v as File).name ?? key),
}));
const onboardingDocsCount = attachedDocs.length || onboardingDocs.length;
const docsToShow = attachedDocs.length > 0 ? attachedDocs : onboardingDocs;
const selectedCommodity = (() => {
if (values.cargoType !== "bulk" || !referenceData) return null;
@@ -347,13 +354,15 @@ export function Step8Review({
/>
</OverviewSection>
<OverviewSection
icon={<Calendar size={18} />}
title="Schedule"
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Estimated shipment date" value={scheduleLabel} />
</OverviewSection>
{!isGeneralContract && (
<OverviewSection
icon={<Calendar size={18} />}
title="Schedule"
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Estimated shipment date" value={scheduleLabel} />
</OverviewSection>
)}
<OverviewSection
icon={<Package size={18} />}
@@ -400,7 +409,7 @@ export function Step8Review({
>
<Stack gap="xs">
{onboardingDocsCount > 0 ? (
onboardingDocs.map((doc, i) => (
docsToShow.map((doc, i) => (
<Group
key={`${doc.name}-${i}`}
justify="space-between"
@@ -413,7 +422,7 @@ export function Step8Review({
</Text>
</Group>
<Text size="xs" c="dimmed">
Uploaded
Attached
</Text>
</Group>
))
@@ -421,13 +430,13 @@ export function Step8Review({
<Group gap="xs" wrap="nowrap">
<Circle size={16} className="text-gray-300 shrink-0" />
<Text size="sm" c="dimmed">
No onboarding documents found on your active profile.
No documents attached yet.
</Text>
</Group>
)}
</Stack>
<Text size="xs" c="dimmed" mt="sm">
Documents from your onboarding will be attached to this booking.
These documents will be attached to this booking.
</Text>
</OverviewSection>
@@ -463,10 +472,12 @@ export function Step8Review({
done={Boolean(values.originYard && values.destinationYard)}
label="Route selected"
/>
<ReadinessItem
done={Boolean(values.scheduledDate)}
label="Shipment day selected"
/>
{!isGeneralContract && (
<ReadinessItem
done={Boolean(values.scheduledDate)}
label="Shipment date selected"
/>
)}
<ReadinessItem
done={
values.cargoType === "container"
@@ -477,7 +488,7 @@ export function Step8Review({
/>
<ReadinessItem
done={onboardingDocsCount > 0}
label="Onboarding documents attached"
label="Documents attached"
/>
</Stack>
</Paper>

View File

@@ -0,0 +1,141 @@
import { useEffect, useRef } from "react";
import type { UseFormReturn } from "react-hook-form";
import type { BookingFormInputValues, BookingFormValues } from "./schema";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
const STORAGE_KEY = "edr.freight.bookingDraft.v1";
// Debounce writes so we don't hit localStorage on every keystroke.
const WRITE_DELAY_MS = 400;
interface BookingDraftSnapshot {
step: number;
values: Partial<BookingFormInputValues>;
savedAt: number;
}
/**
* Uploaded files can't be serialized to localStorage, so the documents map is
* stripped before persisting. The customer re-attaches files when they resume —
* everything else (operation, route, cargo, etc.) survives a refresh.
*/
function stripUnserializable(
values: BookingFormInputValues,
): Partial<BookingFormInputValues> {
const { documents: _documents, ...rest } = values;
return rest;
}
function readDraft(): BookingDraftSnapshot | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as BookingDraftSnapshot;
if (!parsed || typeof parsed !== "object" || !parsed.values) return null;
return parsed;
} catch {
// Corrupt or unavailable storage — treat as no draft.
return null;
}
}
export function clearBookingDraft(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore storage errors (private mode, quota, etc.).
}
}
/**
* Persists the in-progress booking wizard to localStorage so a refresh (or
* accidental navigation) doesn't lose the customer's work, and restores it on
* the next visit.
*
* `fresh` is set by the explicit "New Booking" entry points (they navigate with
* `state: { fresh: true }`). A plain refresh has no such state, so:
* - fresh === true → discard any saved draft and start clean.
* - fresh !== true → restore the saved draft and resume where they left off.
*
* Returns `clearDraft` so the page can wipe the draft once the booking is
* actually submitted.
*/
export function useBookingDraft({
form,
step,
setStep,
fresh,
}: {
form: BookingForm;
step: number;
setStep: (step: number) => void;
fresh: boolean;
}): { clearDraft: () => void } {
// Restore (or clear) exactly once on mount.
const restoredRef = useRef(false);
useEffect(() => {
if (restoredRef.current) return;
restoredRef.current = true;
if (fresh) {
clearBookingDraft();
return;
}
const draft = readDraft();
if (!draft) return;
// Merge over current defaults so any new schema fields keep their defaults.
form.reset(
{ ...form.getValues(), ...draft.values },
{ keepDefaultValues: true },
);
if (typeof draft.step === "number") setStep(draft.step);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Persist on every form change (debounced) and whenever the step changes.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const stepRef = useRef(step);
stepRef.current = step;
const write = () => {
try {
const snapshot: BookingDraftSnapshot = {
step: stepRef.current,
values: stripUnserializable(form.getValues()),
savedAt: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
// Ignore storage errors (private mode, quota, etc.).
}
};
useEffect(() => {
// Don't persist until the initial restore/clear has run.
if (!restoredRef.current) return;
const sub = form.watch(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(write, WRITE_DELAY_MS);
});
return () => {
sub.unsubscribe();
if (timerRef.current) clearTimeout(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
// Step changes are immediate (no debounce) so a refresh lands on the right step.
useEffect(() => {
if (!restoredRef.current) return;
write();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
return { clearDraft: clearBookingDraft };
}

View File

@@ -0,0 +1,84 @@
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import type { SubmitBookingResponse } from "@/services/bookings.service";
interface PriceChangeModalProps {
data: SubmitBookingResponse | null;
onClose: () => void;
onConfirm: () => void;
confirmPending: boolean;
}
/**
* Shown when submitting a booking returns a changed price: the customer must
* confirm the new total before the submit completes. Shared by the detail-page
* resubmit flow and the home-page resubmit modal.
*/
export function PriceChangeModal({
data,
onClose,
onConfirm,
confirmPending,
}: PriceChangeModalProps) {
return (
<Modal
opened={data !== null}
onClose={onClose}
title={<Text fw={700}>Price has changed</Text>}
radius="lg"
centered
>
{data && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{data.message ??
"The booking price has been updated. Confirm to submit with the new total."}
</Text>
{data.previousTotalAmount !== undefined && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Previous total
</Text>
<Text size="sm" td="line-through">
{data.previousTotalAmount.toLocaleString()} {data.currency}
</Text>
</Group>
)}
<Group justify="space-between">
<Text fw={700}>New total</Text>
<Text fw={800} c="edr-green">
{data.totalAmount.toLocaleString()} {data.currency}
</Text>
</Group>
{data.lineItems && data.lineItems.length > 0 && (
<Stack gap={4}>
{data.lineItems.map((item) => (
<Group key={item.code} justify="space-between">
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Review later
</Button>
<Button
color="edr-green"
radius="md"
loading={confirmPending}
onClick={onConfirm}
>
Confirm &amp; submit
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -0,0 +1,114 @@
import { Alert, Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
import { AlertCircle, MessageSquareWarning, Send } from "lucide-react";
import type { Freight } from "@edr/types";
import { PriceChangeModal } from "./PriceChangeModal";
import { ResubmitDocuments } from "./ResubmitDocuments";
import { useResubmitFlow } from "./useResubmitFlow";
interface ResubmitBookingModalProps {
booking: Freight.IBooking;
opened: boolean;
onClose: () => void;
}
/**
* Home-page modal for a CHANGES_REQUESTED booking: shows the staff change
* request, lets the customer update the documents they submitted, and resubmit
* for review — all without leaving the My Shipments list.
*
* Mounted only while `opened` so replacement state resets on each open.
*/
export function ResubmitBookingModal({
booking,
opened,
onClose,
}: ResubmitBookingModalProps) {
if (!opened) return null;
return <ResubmitBookingModalBody booking={booking} onClose={onClose} />;
}
function ResubmitBookingModalBody({
booking,
onClose,
}: {
booking: Freight.IBooking;
onClose: () => void;
}) {
const flow = useResubmitFlow(booking, { onResubmitted: onClose });
return (
<>
<Modal
opened
onClose={onClose}
centered
size={560}
radius={16}
padding={24}
title={
<Box>
<Text fz={16} fw={800} c="#10202F">
Update &amp; resubmit
</Text>
<Text fz={12} c="dimmed" ff="monospace">
{booking.reference}
</Text>
</Box>
}
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
styles={{ body: { paddingTop: 8 } }}
>
<Stack gap="md">
{booking.latestChangeRequestNote && (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={18} />}
title="Changes requested by EDR"
>
{booking.latestChangeRequestNote}
</Alert>
)}
<ResubmitDocuments flow={flow} />
{flow.validationError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{flow.validationError}
</Alert>
)}
{flow.mutations.some((m) => m.isError) && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
Something went wrong. Please try again.
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Close
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={flow.resubmit}
loading={flow.isBusy}
>
Resubmit booking
</Button>
</Group>
</Stack>
</Modal>
<PriceChangeModal
data={flow.priceChange}
onClose={flow.clearPriceChange}
onConfirm={flow.confirmSubmit}
confirmPending={flow.confirmSubmitPending}
/>
</>
);
}

View File

@@ -0,0 +1,123 @@
import { Box, Group, Loader, Stack, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2, Download, FileText } from "lucide-react";
import { IconSquare } from "../BookingDetailPage/components/Documents";
import { labelForDocCode } from "./resubmitDocs";
import type { ResubmitFlowController } from "./useResubmitFlow";
/**
* Document section for resubmitting a CHANGES_REQUESTED booking.
*
* Mirrors the new-booking document step: the fields come from the company's
* onboarding document setting (TIN, license, ID, passport, …) rendered via
* SmartFileInput. Documents already submitted on the booking are shown as an
* "on file" reference; the customer uploads here only to replace one, or to fill
* any required field that has nothing on file yet (those block resubmit).
*/
export function ResubmitDocuments({ flow }: { flow: ResubmitFlowController }) {
const { files, setting, settingLoading, documents, setDocuments, fieldErrors } =
flow;
// One reference row per distinct doc already on the booking (latest upload).
const onFile = dedupeLatestByCode(files);
return (
<Stack gap="lg">
{onFile.length > 0 && (
<Stack gap={8}>
<Text fz={13} fw={700} c="#10202F">
Already submitted
</Text>
{onFile.map((file) => (
<Group
key={file.id}
gap={12}
align="center"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: "10px 14px" }}
>
<Box
style={{
flexShrink: 0,
width: 34,
height: 34,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 9,
backgroundColor: "#EAF1FB",
color: "#2E5B96",
}}
>
<FileText size={17} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz="13px" fw={600} c="#10202F" truncate>
{labelForDocCode(file.code)}
</Text>
<Text fz="12px" c="dimmed" truncate>
{file.name}
</Text>
</Box>
<Group gap={8} wrap="nowrap">
<Group gap={5} c="#0A6F4D">
<CheckCircle2 size={14} />
<Text fz="11.5px" fw={600} c="#0A6F4D">
On file
</Text>
</Group>
<IconSquare
href={file.signedUrl ?? file.url}
icon={<Download size={15} />}
/>
</Group>
</Group>
))}
</Stack>
)}
<Box>
<Text fz={13} fw={700} c="#10202F" mb="xs">
Update documents
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Replace any document you need to change. Documents marked required must
be on file before you can resubmit.
</Text>
{settingLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
</Group>
) : setting ? (
<SmartFileInput
file={setting}
value={documents}
onChange={setDocuments}
errors={fieldErrors}
/>
) : (
<Text fz="13px" c="dimmed">
No document requirements are configured for your account. You can
resubmit using the documents already on file.
</Text>
)}
</Box>
</Stack>
);
}
type BookingFile = ResubmitFlowController["files"][number];
/** Keep one row per code (the most recent upload, i.e. last in the array). */
function dedupeLatestByCode(files: BookingFile[]): BookingFile[] {
const order: string[] = [];
const latest = new Map<string, BookingFile>();
for (const file of files) {
if (!latest.has(file.code)) order.push(file.code);
latest.set(file.code, file);
}
return order.map((code) => latest.get(code)!);
}

View File

@@ -0,0 +1,13 @@
export { PriceChangeModal } from "./PriceChangeModal";
export { ResubmitBookingModal } from "./ResubmitBookingModal";
export { ResubmitDocuments } from "./ResubmitDocuments";
export { labelForDocCode, type BookingFile } from "./resubmitDocs";
export {
documentSettingCode,
useBookingDocumentSetting,
} from "./useBookingDocumentSetting";
export {
useResubmitFlow,
type DocumentsValue,
type ResubmitFlowController,
} from "./useResubmitFlow";

View File

@@ -0,0 +1,33 @@
import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "../BookingDetailPage/constants";
/** A single uploaded file on a booking. */
export type BookingFile = NonNullable<Freight.IBooking["files"]>[number];
/** Human labels for known document codes (shipment + onboarding documents). */
const LABEL_BY_CODE = new Map<string, string>([
...REQUIRED_DOC_FIELDS.map((d) => [d.key, d.label] as const),
// Company onboarding document codes (see file-upload-settings seeder).
["tin_certificate", "TIN Certificate"],
["commercial_license", "Commercial License"],
["business_license", "Business License / Trade License"],
["investment_license", "Investment License"],
["national_id", "National ID"],
["national_id_passport", "National ID / Passport"],
["passport", "Passport"],
]);
/**
* Turn a file `code` (e.g. "tin_certificate", "commercial_invoice",
* "custom_172..._0") into a human label. Known codes use their configured label;
* ad-hoc / unknown codes are title-cased from the code itself.
*/
export function labelForDocCode(code: string): string {
const known = LABEL_BY_CODE.get(code);
if (known) return known;
return code
.replace(/^custom_\d+_\d+$/, "Additional document")
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}

View File

@@ -0,0 +1,35 @@
import { useQuery } from "@tanstack/react-query";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
/** Onboarding document setting code for the company's nationality. */
export function documentSettingCode(
nationality: string | null | undefined,
): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
/**
* Fetches the FileUploadSetting that describes the documents a booking requires
* (TIN, license, national ID, passport, …) — the same setting the new-booking
* document step uses, resolved from the company's nationality.
*
* Shared by the resubmit modal and the changes-requested detail view so both
* render an identical document section.
*/
export function useBookingDocumentSetting() {
const auth = useAuth();
const nationality = auth.company?.company?.nationality as
| string
| null
| undefined;
return useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode(nationality) },
}),
);
}

View File

@@ -0,0 +1,179 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { useBookingDocumentSetting } from "./useBookingDocumentSetting";
export type DocumentsValue = Record<string, File | File[] | null>;
/** True when a SmartFileInput value holds at least one file for a key. */
function hasFile(value: File | File[] | null | undefined): boolean {
if (!value) return false;
return Array.isArray(value) ? value.length > 0 : true;
}
/**
* Drives the "update documents and resubmit" flow for a booking that staff
* returned with `CHANGES_REQUESTED`.
*
* The document section mirrors the new-booking step: it's driven by the
* company's onboarding document setting (TIN, license, ID, passport, …) via
* SmartFileInput. Fields already present on the booking are treated as on file;
* any required field with neither an existing file nor a freshly-picked one
* blocks resubmit.
*
* Shared by the booking detail page and the home-page modal.
*/
export function useResubmitFlow(
booking: Freight.IBooking,
opts?: { onResubmitted?: () => void },
) {
const queryClient = useQueryClient();
const settingQuery = useBookingDocumentSetting();
// The booking may arrive from the lightweight list endpoint, which omits
// `files`. Fetch the full record so the already-submitted documents (and the
// required-field check that depends on them) are accurate everywhere.
const filesAlreadyLoaded = booking.files !== undefined;
const detailQuery = useQuery(
api.bookings.get.queryOptions({
input: { id: booking.id },
enabled: !filesAlreadyLoaded,
}),
);
const detailed = detailQuery.data ?? booking;
const files = detailed.files ?? [];
// Freshly-selected files keyed by fileKey (SmartFileInput value).
const [documents, setDocuments] = useState<DocumentsValue>({});
const [priceChange, setPriceChange] = useState<SubmitBookingResponse | null>(
null,
);
const [validationError, setValidationError] = useState<string>("");
// Only surface per-field "required" errors once the user has tried to submit.
const [showErrors, setShowErrors] = useState(false);
// Codes already attached to the booking from the original submission.
const existingCodes = useMemo(
() => new Set(files.map((f) => f.code)),
[files],
);
const fields = settingQuery.data?.fields ?? [];
// Required fields that have neither an existing file nor a newly-picked one.
const missingRequiredKeys = useMemo(() => {
return fields
.filter((f) => f.isRequired)
.filter(
(f) => !existingCodes.has(f.fileKey) && !hasFile(documents[f.fileKey]),
)
.map((f) => f.fileKey);
}, [fields, existingCodes, documents]);
const hasNewFiles = Object.values(documents).some(hasFile);
const invalidateLists = () =>
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
const updateMutation = useMutation({
mutationFn: (files: DocumentsValue) =>
api.bookings.update.call({ id: booking.id, dto: {}, documents: files }),
});
const submitMutation = useMutation({
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
onSuccess: (result) => {
if (result.priceChanged) {
setPriceChange(result);
return;
}
finishResubmit();
},
});
const confirmSubmitMutation = useMutation({
mutationFn: () => api.bookings.confirmSubmit.call({ id: booking.id }),
onSuccess: () => {
setPriceChange(null);
finishResubmit();
},
});
function finishResubmit() {
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: booking.id }),
});
invalidateLists();
opts?.onResubmitted?.();
}
/**
* Validate required documents, upload any newly-selected ones, then resubmit
* the booking for review.
*/
function resubmit() {
if (missingRequiredKeys.length > 0) {
setShowErrors(true);
setValidationError(
"Please attach all required documents before resubmitting.",
);
return;
}
setShowErrors(false);
setValidationError("");
const files: DocumentsValue = {};
for (const [key, value] of Object.entries(documents)) {
if (hasFile(value)) files[key] = value;
}
if (Object.keys(files).length > 0) {
updateMutation.mutate(files, {
onSuccess: () => {
setDocuments({});
submitMutation.mutate();
},
});
} else {
submitMutation.mutate();
}
}
const isBusy =
settingQuery.isLoading ||
updateMutation.isPending ||
submitMutation.isPending ||
confirmSubmitMutation.isPending;
return {
booking: detailed,
/** Documents already attached to the booking from the original submission. */
files,
setting: settingQuery.data,
settingLoading: settingQuery.isLoading || detailQuery.isLoading,
existingCodes,
documents,
setDocuments,
hasNewFiles,
missingRequiredKeys,
/** Per-field errors for SmartFileInput; only set after a failed submit. */
fieldErrors: showErrors
? Object.fromEntries(missingRequiredKeys.map((k) => [k, "Required"]))
: {},
canResubmit: missingRequiredKeys.length === 0,
validationError,
resubmit,
isBusy,
priceChange,
clearPriceChange: () => setPriceChange(null),
confirmSubmit: () => confirmSubmitMutation.mutate(),
confirmSubmitPending: confirmSubmitMutation.isPending,
mutations: [updateMutation, submitMutation, confirmSubmitMutation] as const,
};
}
export type ResubmitFlowController = ReturnType<typeof useResubmitFlow>;

View File

@@ -67,6 +67,29 @@ export default function ContractDetailPage() {
enabled: !!id && contract?.status !== "DRAFT",
});
// Multi-route contracts return one line per contracted route; single-route
// contracts return []. Drives the Routes section + the per-route order flow.
const { data: routeLines } = useQuery({
...api.bookingOrders.routes.queryOptions({
input: { contractBookingId: id! },
}),
enabled: !!id && contract?.status !== "DRAFT",
});
const poolLines = pool ?? [];
// Overall utilization across every pool line — drives the header ring + stat.
// Declared before the early returns so hook order stays stable across renders.
const totals = useMemo(() => {
const contracted = poolLines.reduce(
(s, l) => s + (l.contractedQuantity || 0),
0,
);
const ordered = poolLines.reduce((s, l) => s + (l.orderedQuantity || 0), 0);
const pct = contracted > 0 ? Math.round((ordered / contracted) * 100) : 0;
return { contracted, ordered, pct };
}, [poolLines]);
if (isLoading) {
return (
<Center mih={400} p="xl">
@@ -98,20 +121,8 @@ export default function ContractDetailPage() {
const isContainer = contract.freightType === "CONTAINER";
const isActive = contract.status === "CONTRACT_ACTIVE";
const awaitingPayment = contract.status === "FULLY_EXECUTED";
const poolLines = pool ?? [];
const showPool = contract.status !== "DRAFT";
// Overall utilization across every pool line — drives the header ring + stat.
const totals = useMemo(() => {
const contracted = poolLines.reduce(
(s, l) => s + (l.contractedQuantity || 0),
0,
);
const ordered = poolLines.reduce((s, l) => s + (l.orderedQuantity || 0), 0);
const pct = contracted > 0 ? Math.round((ordered / contracted) * 100) : 0;
return { contracted, ordered, pct };
}, [poolLines]);
return (
<Box style={{ padding: "28px 32px 40px" }}>
<Stack gap="lg">
@@ -218,6 +229,53 @@ export default function ContractDetailPage() {
</Group>
)}
{/* Contracted routes — every origin/destination pair the contract covers,
with its own remaining pool. Orders draw down one route at a time. */}
{showPool && routeLines && routeLines.length > 0 && (
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Group gap={8} mb="lg">
<Text fw={700} fz={16} style={{ color: INK }}>
Contracted routes
</Text>
<Badge size="sm" variant="light" color="violet" radius="sm">
{routeLines.length}
</Badge>
</Group>
<Text fz={13} c="dimmed" mb="md" mt={-8}>
Lanes this contract covers. Orders draw from the shared pool below
pick a lane per order for scheduling and routing.
</Text>
<Stack gap={10}>
{routeLines.map((route) => (
<Group
key={route.routeLineId}
justify="space-between"
wrap="nowrap"
p="sm"
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={38} radius="md" variant="light" color="edr-green">
<MapPin size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{route.originYardName ?? route.originYardId} {" "}
{route.destinationYardName ?? route.destinationYardId}
</Text>
{route.km != null && (
<Text fz={12} c="dimmed" truncate>
{route.km} km
</Text>
)}
</Box>
</Group>
</Group>
))}
</Stack>
</Card>
)}
{/* Drawdown pool */}
{showPool && (
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
@@ -356,14 +414,15 @@ export default function ContractDetailPage() {
Ship {new Date(order.scheduledDate).toLocaleDateString()}
{" · "}
{order.lines
.map(
(l) =>
`${Number.isInteger(l.quantity) ? l.quantity : l.quantity.toFixed(2)}${
l.containerTypeName
? ` ${l.containerTypeName}`
: ""
}`,
)
.map((l) => {
const qty = Number(l.quantity);
const label = Number.isInteger(qty)
? `${qty}`
: qty.toFixed(2);
return `${label}${
l.containerTypeName ? ` ${l.containerTypeName}` : ""
}`;
})
.join(", ")}
</Text>
</Box>

View File

@@ -209,7 +209,7 @@ export default function ContractsList() {
radius="md"
size="md"
leftSection={<Plus size={16} />}
onClick={() => navigate("/bookings/new")}
onClick={() => navigate("/bookings/new", { state: { fresh: true } })}
>
New Contract
</Button>

View File

@@ -59,9 +59,10 @@ export function PlaceOrderDialog({
const isMultiRoute = routeLines.length > 0;
const selectedRoute = routeLines.find((r) => r.routeLineId === routeLineId);
// The route the order ships on drives both the available-days query and the
// remaining-quantity check: the chosen route line for multi-route contracts,
// else the contract's own origin/destination.
// The route the order ships on drives ONLY the available-days (schedule) query:
// the chosen lane for multi-route contracts, else the contract's own
// origin/destination. Quantity is always drawn from the shared pool below —
// routes are pure lanes and carry no quantity.
const originYardId = isMultiRoute
? selectedRoute?.originYardId
: contract.originYard?.id;
@@ -129,15 +130,12 @@ export function PlaceOrderDialog({
setReeferQty("");
}
// Total quantity across the order; haz/reefer counts cannot exceed it.
const orderTotalQty = isMultiRoute
? typeof quantities["__route__"] === "number"
? (quantities["__route__"] as number)
: 0
: pool.reduce((sum, l) => {
const raw = quantities[lineKey(l)];
return sum + (typeof raw === "number" ? raw : 0);
}, 0);
// Total quantity across the order; haz/reefer counts cannot exceed it. Always
// summed from the shared pool lines, regardless of routing.
const orderTotalQty = pool.reduce((sum, l) => {
const raw = quantities[lineKey(l)];
return sum + (typeof raw === "number" ? raw : 0);
}, 0);
const hazValue = hazardousOn && typeof hazardousQty === "number" ? hazardousQty : 0;
const reeferValue = reeferOn && typeof reeferQty === "number" ? reeferQty : 0;
@@ -153,30 +151,8 @@ export function PlaceOrderDialog({
function handleSubmit() {
if (!scheduledDate) return;
if (isMultiRoute) {
if (!selectedRoute) return;
const raw = quantities["__route__"];
const qty = typeof raw === "number" ? raw : 0;
if (qty <= 0) return;
if (!hazReeferValid) return;
createMutation.mutate({
contractBookingId: contract.id,
routeLineId: selectedRoute.routeLineId,
scheduledDate: new Date(scheduledDate).toISOString(),
lines: [
{
containerTypeId: isContainer
? (selectedRoute.containerTypeId ?? null)
: null,
quantity: qty,
hazardousQuantity: hazValue,
reeferQuantity: reeferValue,
},
],
});
return;
}
// Multi-route contracts require a chosen lane (drives scheduling/routing).
if (isMultiRoute && !selectedRoute) return;
const lines: Freight.CreateBookingOrderLineDto[] = pool
.map((line) => {
@@ -201,19 +177,20 @@ export function PlaceOrderDialog({
createMutation.mutate({
contractBookingId: contract.id,
// The lane only routes/schedules the order; quantity comes from the pool.
...(isMultiRoute && selectedRoute
? { routeLineId: selectedRoute.routeLineId }
: {}),
scheduledDate: new Date(scheduledDate).toISOString(),
lines,
});
}
const orderableLines = pool.filter((l) => l.remainingQuantity > 0);
const routeQtyRaw = quantities["__route__"];
const hasQuantity = isMultiRoute
? typeof routeQtyRaw === "number" && routeQtyRaw > 0
: pool.some((l) => {
const raw = quantities[lineKey(l)];
return typeof raw === "number" && raw > 0;
});
const hasQuantity = pool.some((l) => {
const raw = quantities[lineKey(l)];
return typeof raw === "number" && raw > 0;
});
const canSubmit =
!!scheduledDate &&
hasQuantity &&
@@ -221,13 +198,10 @@ export function PlaceOrderDialog({
(!isMultiRoute || !!selectedRoute) &&
!createMutation.isPending;
// Routes are pure lanes — the label shows origin → destination only.
const routeOptions = routeLines.map((r) => ({
value: r.routeLineId,
label: `${r.originYardName ?? r.originYardId}${r.destinationYardName ?? r.destinationYardId} · ${formatQuantity(
r.remainingQuantity,
null,
isContainer,
)} remaining`,
label: `${r.originYardName ?? r.originYardId}${r.destinationYardName ?? r.destinationYardId}`,
}));
return (
@@ -285,104 +259,67 @@ export function PlaceOrderDialog({
styles={{ input: { height: 44 } }}
/>
{isMultiRoute ? (
<Stack gap="sm">
<Text fz={13} fw={600} style={{ color: INK }}>
Quantity
</Text>
{!selectedRoute ? (
<Text fz={13} c="dimmed">
Select a route to draw down from.
</Text>
) : selectedRoute.remainingQuantity <= 0 ? (
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
This route is fully drawn down no quantity remains.
</Alert>
) : (
<Group justify="space-between" wrap="nowrap" gap="md">
<div style={{ flex: 1 }}>
<Text fz={14} fw={600} style={{ color: INK }}>
{selectedRoute.containerTypeName ??
(isContainer ? "Containers" : "Tons")}
</Text>
<Text fz={12} c="dimmed">
{formatQuantity(
selectedRoute.remainingQuantity,
null,
isContainer,
)}{" "}
remaining
</Text>
</div>
<NumberInput
value={quantities["__route__"] ?? ""}
onChange={(v) =>
setQuantities({ __route__: v === "" ? "" : Number(v) })
}
min={0}
max={selectedRoute.remainingQuantity}
step={isContainer ? 1 : 0.5}
clampBehavior="strict"
radius="md"
w={130}
placeholder="0"
/>
</Group>
)}
</Stack>
) : (
<Stack gap="sm">
<Text fz={13} fw={600} style={{ color: INK }}>
Quantity
</Text>
{orderableLines.length === 0 && (
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
This contract is fully drawn down no quantity remains.
</Alert>
{isMultiRoute && !selectedRoute ? (
<Text fz={13} c="dimmed">
Select a route first, then enter how much to ship on it.
</Text>
) : (
<>
{orderableLines.length === 0 && (
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
This contract is fully drawn down no quantity remains.
</Alert>
)}
{orderableLines.map((line) => {
const key = lineKey(line);
const label = isContainer
? (line.containerTypeName ?? "Containers")
: line.unitOfMeasure === "PER_ITEM"
? "Items"
: "Tons";
return (
<Group key={key} justify="space-between" wrap="nowrap" gap="md">
<div style={{ flex: 1 }}>
<Text fz={14} fw={600} style={{ color: INK }}>
{label}
</Text>
<Text fz={12} c="dimmed">
{formatQuantity(
line.remainingQuantity,
line.unitOfMeasure,
isContainer,
)}{" "}
remaining
</Text>
</div>
<NumberInput
value={quantities[key] ?? ""}
onChange={(v) =>
setQuantities((prev) => ({
...prev,
[key]: v === "" ? "" : Number(v),
}))
}
min={0}
max={line.remainingQuantity}
step={
isContainer || line.unitOfMeasure === "PER_ITEM" ? 1 : 0.5
}
clampBehavior="strict"
radius="md"
w={130}
placeholder="0"
/>
</Group>
);
})}
</>
)}
{orderableLines.map((line) => {
const key = lineKey(line);
const label = isContainer
? (line.containerTypeName ?? "Containers")
: line.unitOfMeasure === "PER_ITEM"
? "Items"
: "Tons";
return (
<Group key={key} justify="space-between" wrap="nowrap" gap="md">
<div style={{ flex: 1 }}>
<Text fz={14} fw={600} style={{ color: INK }}>
{label}
</Text>
<Text fz={12} c="dimmed">
{formatQuantity(
line.remainingQuantity,
line.unitOfMeasure,
isContainer,
)}{" "}
remaining
</Text>
</div>
<NumberInput
value={quantities[key] ?? ""}
onChange={(v) =>
setQuantities((prev) => ({
...prev,
[key]: v === "" ? "" : Number(v),
}))
}
min={0}
max={line.remainingQuantity}
step={isContainer || line.unitOfMeasure === "PER_ITEM" ? 1 : 0.5}
clampBehavior="strict"
radius="md"
w={130}
placeholder="0"
/>
</Group>
);
})}
</Stack>
)}
<Stack gap="sm">
<Text fz={13} fw={600} style={{ color: INK }}>

View File

@@ -65,6 +65,21 @@ export const CONTRACT_STATUS_CONFIG: Record<
FULLY_EXECUTED: { label: "Awaiting Payment", color: "#9A6700", bg: "#FFF6E5" },
CONTRACT_ACTIVE: { label: "Active", color: "#0A6F4D", bg: "#E7F6EE" },
CONTRACT_CLOSED: { label: "Closed", color: "#6B7C8E", bg: "#EEF2F6" },
// Drawdown-order statuses, mirrored from the order's child booking as it moves
// through the same flow as a one-time booking (clearance → accept → pay →
// allocate). Reused by the order badge on ContractDetailPage.
PENDING: { label: "Pending", color: "#9A6700", bg: "#FFF6E5" },
AWAITING_DOCUMENTS: { label: "Awaiting Documents", color: "#9A6700", bg: "#FFF6E5" },
DOCUMENTS_UNDER_REVIEW: { label: "Documents Under Review", color: "#2E5B96", bg: "#EAF1FB" },
CLEARANCE_READY: { label: "Clearance Ready", color: "#0A6F4D", bg: "#E7F6EE" },
OPERATION_REQUEST_PENDING: { label: "Operation Review", color: "#9A6700", bg: "#FFF6E5" },
OPERATION_CHANGES_REQUESTED: { label: "Changes Requested", color: "#9A6700", bg: "#FFF6E5" },
OPERATION_PRICE_PENDING_CONFIRM: { label: "Confirm New Price", color: "#9A6700", bg: "#FFF6E5" },
ROAD_DISPATCH_PENDING: { label: "Awaiting Dispatch", color: "#9A6700", bg: "#FFF6E5" },
SELECTED_FOR_BATCH: { label: "Awaiting Payment", color: "#9A6700", bg: "#FFF6E5" },
PAID: { label: "Paid", color: "#0A6F4D", bg: "#E7F6EE" },
IN_TRANSIT: { label: "In Transit", color: "#2E5B96", bg: "#EAF1FB" },
COMPLETED: { label: "Completed", color: "#0A6F4D", bg: "#E7F6EE" },
EXPIRED: { label: "Expired", color: "#B42318", bg: "#FEECEB" },
CANCELLED: { label: "Cancelled", color: "#B42318", bg: "#FEECEB" },
REJECTED: { label: "Rejected", color: "#B42318", bg: "#FEECEB" },

View File

@@ -276,10 +276,14 @@ export const api = {
bookingsService.submitClearanceDocuments(id, files),
),
proceedToOperation: endpoint<{ id: string }, Freight.IBooking>(
proceedToOperation: endpoint<
{ id: string; scheduledDate: string },
Freight.IBooking
>(
"bookings",
"proceedToOperation",
({ id }) => bookingsService.proceedToOperation(id),
({ id, scheduledDate }) =>
bookingsService.proceedToOperation(id, scheduledDate),
),
checkPayment: endpoint<{ orderId: string }, { status: string }>(

View File

@@ -206,8 +206,14 @@ export const bookingsService = {
return data.data;
},
proceedToOperation: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/clearance/proceed`);
proceedToOperation: async (
id: string,
scheduledDate: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/proceed`,
{ scheduledDate },
);
return data.data;
},

View File

@@ -633,11 +633,15 @@ export interface CreateBookingContainerDto {
}
/** A contracted route+quantity line for a GENERAL contract. */
/**
* A contracted route (lane) of a general contract — a pure origin→destination
* pair. Routes carry NO quantity; the contract draws from a single shared pool.
*/
export interface CreateContractRouteDto {
originYardId: string;
destinationYardId: string;
containerTypeId?: string | undefined;
quantity: number;
/** Road distance (km) for this route; used to bill road orders. */
km?: number | undefined;
}
export interface CreateBookingDto {
@@ -648,8 +652,10 @@ export interface CreateBookingDto {
companyId?: string | undefined;
trainId?: string | undefined;
trainScheduleId?: string | undefined;
/** Optional for general contracts — they pick the date per order, not at creation. */
/** Binding shipment day — set at the operation-request step, not at creation. */
scheduledDate?: string | undefined;
/** Non-binding shipment-date estimate captured in the booking wizard. */
estimatedShipmentDate?: string | undefined;
/** Defaults to ONE_TIME. GENERAL_CONTRACT creates an umbrella contract. */
bookingType?: BookingType | undefined;
contractType: string;
@@ -673,6 +679,8 @@ export interface CreateBookingDto {
shippingLineId?: string | undefined;
cargoTotalWeightVgm: number;
isHazardous?: boolean | undefined;
/** Booking-level refrigerated flag (bulk freight only; containers derive reefer from the container type). */
isReefer?: boolean | undefined;
paymentCurrency: string;
pnrCode?: string | undefined;
startDate?: string | undefined;
@@ -726,17 +734,18 @@ export interface CreateBookingOrderLineDto {
}
/** Per-route contracted / ordered / remaining pool line (multi-route contracts). */
/**
* A contracted route (lane) of a general contract — a pure origin→destination
* pair the contract covers. Routes carry NO quantity; the contract draws from a
* single shared pool ({@link ContractQuantityLine}). An order picks one lane (for
* scheduling + road billing) and draws from that shared pool.
*/
export interface ContractRouteLine {
routeLineId: string;
originYardId: string;
originYardName?: string | null;
destinationYardId: string;
destinationYardName?: string | null;
containerTypeId?: string | null;
containerTypeName?: string | null;
contractedQuantity: number;
orderedQuantity: number;
remainingQuantity: number;
/** Road distance (km) for this route; used to bill road orders. Null for rail-only. */
km?: number | null;
}