mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
Merge pull request #288 from Tria-plc/freight_feature/profile
Freight feature/profile
This commit is contained in:
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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}` : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 { }
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,7 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
route: true,
|
||||
trainSet: {
|
||||
locomotive: true,
|
||||
locomotives: { locomotive: true },
|
||||
wagons: {
|
||||
wagonType: true,
|
||||
physicalWagon: true,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 couldn’t load this booking’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 origin→destination lanes
|
||||
// the contract covers — they carry NO quantity. Route #1 is the primary
|
||||
// origin/destination; the rest come from the extra-routes step. The
|
||||
// contracted quantity lives in a single shared pool (the container
|
||||
// quantities / bulk total), drawn down per order against a chosen lane.
|
||||
...(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 & 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 & 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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>;
|
||||
@@ -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 & sign
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 origin–destination 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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 & submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)!);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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) },
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user