diff --git a/apps/edr-freight-api/src/migrations/1820000000011-AddTrainSetLocomotives.ts b/apps/edr-freight-api/src/migrations/1820000000011-AddTrainSetLocomotives.ts new file mode 100644 index 000000000..b014e71a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000011-AddTrainSetLocomotives.ts @@ -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 { + 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 { + 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;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts b/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts new file mode 100644 index 000000000..6b77f53a3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS estimated_shipment_date timestamptz NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS estimated_shipment_date; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts index 8eb258a88..3c5bc4019 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts @@ -41,12 +41,54 @@ export class BookingOrdersService { ) {} /** Orders placed against a contract, with their lines and child booking. */ - listByContract(contractBookingId: string): Promise { - return this.ordersRepository.findByContract(contractBookingId); + async listByContract(contractBookingId: string): Promise { + const orders = await this.ordersRepository.findByContract(contractBookingId); + await Promise.all(orders.map((o) => this.syncOrderFromChild(o))); + return orders; } - findById(id: string): Promise { - return this.ordersRepository.findById(id); + async findById(id: string): Promise { + 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 { + 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}` : ''), + ); } } diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts index 3c85ad31e..bfdcc72b8 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts index 7b4728e02..d6fff951a 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts index 4cf5dfe59..58122bd65 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts @@ -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> { - 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(); - 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 { - // 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); } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 0315de60d..f9d8fb17e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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, }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index 12ce77d6d..f9c7e182f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -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' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 2c2e0ce0e..d819ee527 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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 { + 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([ + ...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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 8197125f1..331706c5a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 6cfac85fa..e8f0a1393 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -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 { 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') { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 78555c57f..c47064014 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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 { + 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 { 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 { + 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 { + 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 diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index e66c85522..677ef03fd 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 5693cfaf5..00f6e41c1 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 945dea459..8a4d946ad 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -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 { } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index ce0082f83..16027f9c3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 8ec002d49..58e71143c 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -25,6 +25,7 @@ export class TrainSchedulesRepository extends BaseRepository { route: true, trainSet: { locomotive: true, + locomotives: { locomotive: true }, wagons: { wagonType: true, physicalWagon: true, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 5c2486fa3..60aab2862 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 593bb7bee..5ec385924 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -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>, +): 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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.spec.ts new file mode 100644 index 000000000..ce3d6166b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.spec.ts @@ -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(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.ts new file mode 100644 index 000000000..f4af19cc1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.ts @@ -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 | null { + const used = new Set(usedNumbers); + for (const number of pool) { + if (!used.has(number)) return number; + } + return null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 64112d720..e38fc4d75 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 163008ecc..6f22f7a83 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -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); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 5a5c7630f..92aca8ac8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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 { + 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 { 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) => ({ diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts new file mode 100644 index 000000000..4ad52a226 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts index 9099824d5..fde6d75c6 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts index f11052727..19ed7ea73 100644 --- a/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts @@ -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], }) diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 669ea2153..12cca3f53 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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; diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index d5ae772d4..ae48ada66 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -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 { @@ -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 { + 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 { @@ -448,7 +434,13 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { // ── 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" }, ]; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index df7b8fbf4..7a14e3ffa 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,691 +1,743 @@ -import { - Boxes, - Building2, - Container, - FileText, - LayoutDashboard, - LayoutGrid, - Network, - Package, - PackageCheck, - PackageOpen, - Paperclip, - Send, - Settings, - SlidersHorizontal, - Train, - Truck, - Users, - Wallet, -} from "lucide-react"; -import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; - -import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; -import { useAuth } from "./auth/useAuth"; -import LoadingScreen from "./components/LoadingScreen"; -import LoginPage from "./pages/auth/LoginPage"; -import BookingContractPage from "./pages/bookings/BookingContractPage"; -import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; -import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; -import NewBookingPage from "./pages/bookings/NewBookingPage"; -import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; -import CustomersPage from "./pages/customers/CustomersPage"; -import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; -import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; -import MyProfilePage from "./pages/dashboard/MyProfilePage"; -import OverviewPage from "./pages/dashboard/OverviewPage"; -import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; -import PaymentsPage from "./pages/payments/PaymentsPage"; -//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; -import { RequirePermission } from "./components/auth/RequirePermission"; -import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; -import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; -import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; -import RolesPage from "./pages/dashboard/user-management/RolesPage"; -import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; -import UsersPage from "./pages/dashboard/user-management/UsersPage"; -import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; -import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; -import FleetResourcePage from "./pages/fleet/FleetResourcePage"; -import RoutesPage from "./pages/fleet/RoutesPage"; -import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; -import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; -import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; -import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; -import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; -import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; -import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; -import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; -import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; -import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; -import FirstMilePage from "./pages/operations/FirstMilePage"; -import LastMilePage from "./pages/operations/LastMilePage"; -import TrainDetailPage from "./pages/trains/TrainDetailPage"; -import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; -import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; -import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; -import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; -import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; -import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; -import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; -import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; -import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; -import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; -import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; -import { HealthCheck } from "./features/health/HealthCheck"; - -const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ - { - title: "Main menu", - items: [ - { - label: "Overview", - href: "/dashboard/overview", - icon: , - }, - { - label: "UM", - href: "/um", - icon: , - }, - { - label: "Booking requests", - href: "/dashboard/booking-requests", - icon: , - }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - }, - { - label: "Payments", - href: "/dashboard/payments", - icon: , - permission: FREIGHT_PERMS.bookings.view, - }, - ...demoItems, - ], - }, - { - title: "Operations", - items: [ - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - ], - }, - { - title: "Fleet Management", - items: [ - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - title: "Warehouse Management", - items: [ - { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", - icon: , - }, - { - label: "Inventory", - href: "/dashboard/warehouse-inventory", - icon: , - }, - { - label: "Arrival Queue", - href: "/dashboard/arrival-queue", - icon: , - }, - { - label: "Loading Queue", - href: "/dashboard/loading-queue", - icon: , - }, - { - label: "Loaded Inventory", - href: "/dashboard/loaded-inventory", - icon: , - }, - { - label: "Dispatch Queue", - href: "/dashboard/dispatch-queue", - icon: , - }, - { - label: "Inventory Inquiry", - href: "/dashboard/inventory-inquiry", - icon: , - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , - }, - ], - }, - { - title: "Administration", - items: [ - { - label: "File settings", - href: "/dashboard/file-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Dropdown settings", - href: "/dashboard/dropdown-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ - { - label: "Configuration", - href: "/dashboard/configuration", - icon: , - children: [ - ...getCategorySidebarChildren("configuration"), - // { - // label: "Train scheduling rules", - // href: "/dashboard/configuration/train-scheduling-rules", - // }, - ], - }, - { - label: "Rules", - href: "/dashboard/rules", - icon: , - children: getCategorySidebarChildren("rules"), - }, - ], - }, -]; - -/** Keep only items the user is permitted to see; drop now-empty sections. */ -const filterSidebarByPermission = ( - sections: SidebarSection[], - user: ReturnType["user"], -): SidebarSection[] => { - const itemAllowed = (item: SidebarItem): boolean => { - if (!item.permission) return true; - const keys = Array.isArray(item.permission) - ? item.permission - : [item.permission]; - return keys.some((key) => hasFreightPermission(user, key)); - }; - - return sections - .map((section) => ({ - ...section, - items: section.items.filter(itemAllowed), - })) - .filter((section) => section.items.length > 0); -}; - -const DashboardShell = () => { - const navigate = useNavigate(); - const location = useLocation(); - const { user, logout } = useAuth(); - - const demoItems: SidebarItem[] = []; - - const sidebarSections = filterSidebarByPermission( - buildSidebarSections(demoItems), - user, - ); - const displayName = user?.name?.en || user?.username || user?.email || "User"; - - return ( - - - - ); -}; - -const App = () => { - const { user, loading } = useAuth(); - - if (loading) { - return ; - } - - if (!user) { - return ( - - } /> - } /> - } /> - - ); - } - - return ( - - } /> - } /> - } /> - }> - } /> - } /> - } /> - - } /> - - - - } - /> - } /> - } /> - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - {/* Legacy embedded user management routes */} - } /> - } /> - } /> - {/* } /> */} - } /> - } /> - - - - - } - /> - - - - } - /> - - } - /> - - - - } - /> - } /> - } /> - } /> - - } - /> - } /> - - } - /> - } /> - - } /> - } /> - - } /> - } /> - - - } /> - - ); -}; - -export default App; +import { + Boxes, + Building2, + Container, + FileText, + LayoutDashboard, + LayoutGrid, + Network, + Package, + PackageCheck, + PackageOpen, + Paperclip, + Send, + Settings, + ShieldCheck, + SlidersHorizontal, + Train, + Truck, + Users, + Wallet, +} from "lucide-react"; +import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; + +import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; +import { useAuth } from "./auth/useAuth"; +import LoadingScreen from "./components/LoadingScreen"; +import LoginPage from "./pages/auth/LoginPage"; +import BookingContractPage from "./pages/bookings/BookingContractPage"; +import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; +import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage"; +import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; +import NewBookingPage from "./pages/bookings/NewBookingPage"; +import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; +import CustomersPage from "./pages/customers/CustomersPage"; +import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; +import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; +import MyProfilePage from "./pages/dashboard/MyProfilePage"; +import OverviewPage from "./pages/dashboard/OverviewPage"; +import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; +import PaymentsPage from "./pages/payments/PaymentsPage"; +//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; +import { RequirePermission } from "./components/auth/RequirePermission"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; +import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; +import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; +import RolesPage from "./pages/dashboard/user-management/RolesPage"; +import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; +import UsersPage from "./pages/dashboard/user-management/UsersPage"; +import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; +import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import RoutesPage from "./pages/fleet/RoutesPage"; +import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; +import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; +import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; +import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; +import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; +import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; +import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; +import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; +import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; +import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import FirstMilePage from "./pages/operations/FirstMilePage"; +import LastMilePage from "./pages/operations/LastMilePage"; +import TrainDetailPage from "./pages/trains/TrainDetailPage"; +import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; +import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; +import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; +import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; +import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; +import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; +import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; +import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; +import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; +import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; + +const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ + { + title: "Main menu", + items: [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + { + label: "UM", + href: "/um", + icon: , + }, + { + label: "Booking requests", + href: "/dashboard/booking-requests", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + }, + { + label: "Payments", + href: "/dashboard/payments", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, + ...demoItems, + ], + }, + { + title: "Operations", + items: [ + { + label: "Document Clearance", + href: "/dashboard/clearance", + icon: , + permission: FREIGHT_PERMS.bookings.reviewDocuments, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + ], + }, + { + title: "Fleet Management", + items: [ + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], + }, + { + title: "Warehouse Management", + items: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Inventory", + href: "/dashboard/warehouse-inventory", + icon: , + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + }, + ], + }, + { + title: "Administration", + items: [ + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + ], + }, + { + title: "Freight configuration", + mutedTitle: true, + items: [ + { + label: "Configuration", + href: "/dashboard/configuration", + icon: , + children: [ + ...getCategorySidebarChildren("configuration"), + // { + // label: "Train scheduling rules", + // href: "/dashboard/configuration/train-scheduling-rules", + // }, + ], + }, + { + label: "Rules", + href: "/dashboard/rules", + icon: , + children: getCategorySidebarChildren("rules"), + }, + ], + }, +]; + +/** Keep only items the user is permitted to see; drop now-empty sections. */ +const filterSidebarByPermission = ( + sections: SidebarSection[], + user: ReturnType["user"], +): SidebarSection[] => { + const itemAllowed = (item: SidebarItem): boolean => { + if (!item.permission) return true; + const keys = Array.isArray(item.permission) + ? item.permission + : [item.permission]; + return keys.some((key) => hasFreightPermission(user, key)); + }; + + return sections + .map((section) => ({ + ...section, + items: section.items.filter(itemAllowed), + })) + .filter((section) => section.items.length > 0); +}; + +const DashboardShell = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { user, logout } = useAuth(); + + const demoItems: SidebarItem[] = []; + + const sidebarSections = filterSidebarByPermission( + buildSidebarSections(demoItems), + user, + ); + const displayName = user?.name?.en || user?.username || user?.email || "User"; + + return ( + + + + ); +}; + +const App = () => { + const { user, loading } = useAuth(); + + if (loading) { + return ; + } + + if (!user) { + return ( + + } /> + } /> + } /> + + ); + } + + return ( + + } /> + } /> + } /> + }> + } /> + } /> + + + + + } + /> + + + + } + /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Legacy embedded user management routes */} + } /> + } /> + } /> + {/* } /> */} + } /> + } /> + + + + + } + /> + + + + } + /> + + } + /> + + + + } + /> + } /> + } /> + } /> + + } + /> + } /> + + } + /> + } /> + + } /> + } /> + + } /> + } /> + + + } /> + + ); +}; + +export default App; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index 2fac40263..01a0db69a 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -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 { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx index c0eaaaace..e5ca1dabd 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx @@ -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 = { intake: , in_approval: , approved_contract: , + clearance: , payment: , + ops_review: , operations: , completed: , closed: , diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx index a4976ec61..67851ab30 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx @@ -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({ ))} + + {includesCustoms ? ( + + + + + Customs clearing included automatically + + + + ) : booking.customsClearingAgent ? ( + + + + + Customs clearing agent:{" "} + + {booking.customsClearingAgent} + + + + + ) : null} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx similarity index 51% rename from apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx rename to apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index cf51c4967..d8c0577fd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -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(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 ( - - } - > - {bookings.length} awaiting review - - } - /> - -
- {/* ── Review queue ─────────────────────────────────────────────── */} - - - - Review queue - - - {filtered.length} - - - - setSearch(e.currentTarget.value)} - placeholder="Search reference…" - size="xs" - radius="md" - mb="xs" - leftSection={} - rightSection={ - search ? ( - setSearch("")} - /> - ) : null - } - /> - - {isLoading ? ( - - - - Loading… - - - ) : filtered.length === 0 ? ( - - - - - - {search - ? "No bookings match your search." - : "Nothing awaiting document review."} - - - ) : ( - - - {filtered.map((b) => ( - setSelectedId(b.id)} - /> - ))} - - - )} - - - {/* ── Review panel ─────────────────────────────────────────────── */} - - {activeId ? ( - - qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] }) - } - /> - ) : ( - - )} - -
-
- ); +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 ( - - - - - {booking.reference} - - - - {booking.tradeDirection} - - - {booking.freightType} - - - - - - ); -} +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 ( - - - - - - - No booking selected - - - Pick a booking from the review queue to inspect its customer documents - and start clearance. - - - - ); -} - -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>({}); const [openQuery, setOpenQuery] = useState>({}); const [outputFiles, setOutputFiles] = useState>({}); 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 ( - - - - Loading clearance… - - + + + Loading clearance… + ); } - const progressPct = - stats.total === 0 ? 0 : Math.round((stats.approved / stats.total) * 100); - return ( - - {/* ── Progress summary ───────────────────────────────────────────── */} - - - - - Customer documents - - - Approve each document, or open a query to tell the customer what to - fix. - - - {clearance.allApproved ? ( - } - > - All approved - - ) : ( - } - > - Review pending - - )} - - - - - - - - + + {stats.approved}/{stats.total} approved - - - - {/* ── Document review list ───────────────────────────────────────── */} - - {customerDocs.map((doc) => ( - - 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} - /> - ))} - - - {/* ── Customs output documents (GL-supplied) ─────────────────────── */} - {clearance.outputCode && ( - - - - - - - Customs output documents + } + > + + {!hideSummary && stats.total > 0 && ( + + + + + + + + + )} + {customerDocs.length === 0 ? ( + + No customer documents are required for this booking. - + ) : ( + customerDocs.map((doc) => ( + + 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} + /> + )) + )} + + + + {clearance.outputCode && ( + {glDocs.map((doc) => ( - + {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" }} > @@ -506,7 +285,7 @@ function ClearanceReviewPanel({ Upload output documents - + )} {finalizeMutation.isError && ( @@ -517,14 +296,23 @@ function ClearanceReviewPanel({ )} - {/* ── Finalize bar ───────────────────────────────────────────────── */} - + - - {clearance.allApproved - ? "All required documents are approved. You can finalize clearance." - : "Approve every required document to unlock finalization."} - + + + + + + {clearance.allApproved + ? "All required documents are approved — you can finalize." + : "Approve every required document to unlock finalization."} + + + + + } + right={ + <> + + + setCancelDialogOpen(true)} /> + + } + /> + + + + setCancelDialogOpen(false)} + title={Cancel booking} + radius="lg" + centered + > + + + Are you sure you want to cancel {booking.reference}? + This action cannot be undone. + + setCancelReason(e.currentTarget.value)} + radius="md" + data-autofocus + /> + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx index 351ff6f5f..1ba37fb85 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -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) => { - 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) => + api.bookings.uploadDocuments.call({ id: booking.id, files }), onSuccess: () => { setSelectedFiles({}); setDocError(""); @@ -190,19 +175,7 @@ export function DraftBookingView({ ]} /> - - {booking.status === "CHANGES_REQUESTED" && - booking.latestChangeRequestNote ? ( - - navigate(`/bookings/${booking.id}/edit?section=documents`) - } - > - {booking.latestChangeRequestNote} - - ) : undefined} - + 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 ( {} : 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 ? ( navigate("/bookings/new")} + onRebook={() => navigate("/bookings/new", { state: { fresh: true } })} /> ) : isPendingConsolidation ? ( - - - Approved - - - ); - } - if (doc.reviewStatus === "QUERIED") { - return ( - - - - Queried - - - ); - } - if (doc.file) { - return ( - - - - Pending review - - - ); - } - return ( - - Not uploaded - - ); -} /** - * 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>({}); - const [adHoc, setAdHoc] = useState>( - [], - ); - - 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 ( Operation @@ -132,246 +33,56 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { ); } - if (isLoading || !clearance) { + if (flow.isLoading || !flow.clearance) { return ( Clearance documents - - Loading clearance… - ); } - const isReady = status === "CLEARANCE_READY"; - const canUpload = - status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW"; - - function handleSubmit() { - const files: Record = { ...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 ( Clearance documents - {clearance.includesCustoms && ( - - Customs clearance - - )} - {isReady ? ( - } mb="md"> - Clearance is ready. You can now proceed to operation. - - ) : status === "DOCUMENTS_UNDER_REVIEW" ? ( - } mb="md"> - Global Logistics is reviewing your documents. Queried documents below - need to be re-uploaded. - - ) : ( - } mb="md"> - Upload the documents below to start the clearance review. - - )} - - - {customerDocs.map((doc) => ( - - - - - - - - - {doc.label} - {doc.required ? " *" : ""} - - {doc.file && ( - - {doc.file.name} - - )} - - - - - {doc.file && ( - } /> - )} - {canUpload && doc.reviewStatus !== "APPROVED" && ( - - f && setPending((p) => ({ ...p, [doc.fileKey]: f })) - } - accept="application/pdf,image/*" - > - {(props) => ( - - )} - - )} - - - {doc.reviewStatus === "QUERIED" && doc.note && ( - - Query: {doc.note} - - )} - {pending[doc.fileKey] && ( - - Ready to upload: {pending[doc.fileKey].name} - - )} - - ))} - - - {/* GL output documents (read-only to the customer). */} - {glDocs.length > 0 && ( - <> - - Customs output documents - - - {glDocs.map((doc) => ( - + {flow.canUpload && ( + + Submit documents + + )} + {flow.isReady && ( + + )} - - {adHoc.map((row, i) => ( - - - setAdHoc((rows) => - rows.map((r, j) => - j === i ? { ...r, name: e.currentTarget.value } : r, - ), - ) - } - style={{ flex: 1 }} - radius="md" - /> - - setAdHoc((rows) => - rows.map((r, j) => (j === i ? { ...r, file: f } : r)), - ) - } - accept="application/pdf,image/*" - > - {(props) => ( - - )} - - - ))} - - - )} - - {uploadMutation.isError && ( - } mt="md"> - {uploadMutation.error instanceof Error - ? uploadMutation.error.message - : "Upload failed. Please try again."} - - )} - - - {canUpload && ( - - )} - {isReady && ( - - )} - + } + /> ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index 92eb042a4..67be567db 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -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.", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx index 02e0318fd..46f1d1921 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx @@ -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 ( + + ); + } + // Brand-new draft: collect the required documents before first submit. if (isDraftLike(booking.status)) { return ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index c7448fca7..f7c9190b3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -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({ ); } - if (status === "CHANGES_REQUESTED") { - return ( - - ); + // CHANGES_REQUESTED + clearance/operation steps are handled in place by a + // modal (update & resubmit, upload clearance docs, schedule & proceed). + if (bookingHasInlineAction(booking)) { + return ; } const payableStatus = isGeneralContract ? "FULLY_EXECUTED" @@ -668,7 +660,16 @@ export default function MyBookings() { Track every cargo booking — from draft to delivery. - + {/* ── Summary stat cards ──────────────────────────────────────── */} @@ -828,8 +829,10 @@ export default function MyBookings() { : "Create your first booking to get started."} {!query && ( - diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index f551b090b..cbc16d2ab 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -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 = { + 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( () => 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 && ( - - )} - {step === 6 && } + {step === 6 && } {step === 7 && ( {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."} + {pricingData.lineItems.length > 0 && ( + + + Price breakdown + + + {pricingData.lineItems.map((item) => { + const hasUnit = + item.unitAmount != null && + item.quantity != null && + item.quantity > 0; + return ( + + + + {item.description} + + {hasUnit && ( + + {item.quantity!.toLocaleString()} ×{" "} + {item.unitAmount!.toLocaleString()} {item.currency} + {item.unit + ? ` · ${formatPriceUnit(item.unit)}` + : ""} + + )} + + + {item.amount.toLocaleString()} {item.currency} + + + ); + })} + + + )} Reject + {/* 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. */} + ) : ( @@ -827,6 +923,69 @@ export default function NewBookingPage() { {priceChangeResult.currency} + {priceChangeResult.lineItems && + priceChangeResult.lineItems.length > 0 && ( + + + Price breakdown + + + {priceChangeResult.lineItems.map((item) => { + const hasUnit = + item.unitAmount != null && + item.quantity != null && + item.quantity > 0; + return ( + + + + {item.description} + + {hasUnit && ( + + {item.quantity!.toLocaleString()} ×{" "} + {item.unitAmount!.toLocaleString()}{" "} + {item.currency} + {item.unit + ? ` · ${formatPriceUnit(item.unit)}` + : ""} + + )} + + + {item.amount.toLocaleString()} {item.currency} + + + ); + })} + + + )} + + {isChangesRequested ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx new file mode 100644 index 000000000..23676efe8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx @@ -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 ; +} + +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 ( + + + {action?.title ?? "Booking"} + + + {reference} + + + } + overlayProps={{ backgroundOpacity: 0.5, blur: 4 }} + styles={{ body: { paddingTop: 8 } }} + > + {flow.isLoading || !flow.clearance ? ( + + Loading clearance… + + ) : ( + + + {flow.canUpload && ( + + )} + {flow.isReady && ( + + )} + + } + /> + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx new file mode 100644 index 000000000..579fcc9d6 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx @@ -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 ( + + + + Approved + + + ); + } + if (doc.reviewStatus === "QUERIED") { + return ( + + + + Queried + + + ); + } + if (doc.file) { + return ( + + + + Pending review + + + ); + } + return ( + + Not uploaded + + ); +} + +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 ( + + {isReady ? ( + } 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."} + + ) : status === "DOCUMENTS_UNDER_REVIEW" ? ( + } 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."} + + ) : ( + } 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."} + + )} + + {isInitialUpload && missingRequired.length > 0 && ( + + + Still required:{" "} + {missingRequired.map((d) => d.label).join(", ")} + + + )} + + + {customerDocs.map((doc) => ( + + + + + + + + + {doc.label} + {doc.required ? " *" : ""} + + {doc.file && ( + + {doc.file.name} + + )} + + + + + {doc.file && ( + } /> + )} + {canUpload && doc.reviewStatus !== "APPROVED" && ( + f && stagePending(doc.fileKey, f)} + accept="application/pdf,image/*" + > + {(props) => ( + + )} + + )} + + + {doc.reviewStatus === "QUERIED" && doc.note && ( + + Query: {doc.note} + + )} + {pending[doc.fileKey] && ( + + Ready to upload: {pending[doc.fileKey].name} + + )} + + ))} + + + {/* GL output documents (read-only to the customer). */} + {glDocs.length > 0 && ( + <> + + Customs output documents + + + {glDocs.map((doc) => ( + + + {doc.label} + + {doc.file ? ( + } /> + ) : ( + + Pending + + )} + + ))} + + + )} + + {/* Ad-hoc / additional documents. */} + {canUpload && ( + + + + Additional documents + + + + + {adHoc.map((row, i) => ( + + setAdHocName(i, e.currentTarget.value)} + style={{ flex: 1 }} + radius="md" + /> + setAdHocFile(i, f)} + accept="application/pdf,image/*" + > + {(props) => ( + + )} + + + ))} + + + )} + + {uploadMutation.isError && ( + } mt="md"> + {uploadMutation.error instanceof Error + ? uploadMutation.error.message + : "Upload failed. Please try again."} + + )} + + {isReady && ( + + + Choose your shipment day + + + Only days with a scheduled departure on your route can be selected. + The operations team assigns the specific train for that day. + + + + )} + + {proceedMutation.isError && ( + } mt="md"> + {proceedMutation.error instanceof Error + ? proceedMutation.error.message + : "Could not request the operation. Please try again."} + + )} + + {footer} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/OperationDatePicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/OperationDatePicker.tsx new file mode 100644 index 000000000..5e5782dfa --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/OperationDatePicker.tsx @@ -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 ( + + + + + {format(month, "MMMM yyyy")} + + + + + {isLoading ? ( + + + + Loading available days… + + + ) : ( + <> + + {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => ( + + {d} + + ))} + + + {cells.map((c) => { + const clickable = c.hasDeparture && c.inMonth; + return ( + + ); + })} + + {value && ( + + Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")} + + )} + {!isLoading && departureDays.size === 0 && ( + + No scheduled departures found for this route yet. + + )} + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts new file mode 100644 index 000000000..5f88349dc --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts @@ -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 = { + 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, +): 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, +): boolean { + return ( + booking.status === "CHANGES_REQUESTED" || + getBookingNextAction(booking) !== null + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/index.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/index.ts new file mode 100644 index 000000000..1f37a72d8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/index.ts @@ -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"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts new file mode 100644 index 000000000..e8380c8ec --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/useClearanceFlow.ts @@ -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>({}); + const [adHoc, setAdHoc] = useState([]); + // Binding shipment day chosen for the operation request (yyyy-MM-dd). + const [scheduledDate, setScheduledDate] = useState(""); + + 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 = { ...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; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/contract/ContractSignButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/contract/ContractSignButton.tsx new file mode 100644 index 000000000..c58199df6 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/contract/ContractSignButton.tsx @@ -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, +): 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 ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx index 1fe6e639e..5d1ac90d9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx @@ -57,7 +57,13 @@ export function StepIndicator({ }), }} > - {done ? : item.id} + {done ? ( + + ) : ( + // Display the 1-based position, not the raw step id — ids can + // be non-contiguous (e.g. the schedule step was removed). + index + 1 + )} { - // 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>> = { "extraRoutes", "isHazardous", "isRefrigerated", + // Estimated shipment date now lives in the Route step (one-time bookings only). + "scheduledDate", ], - 5: ["scheduledDate"], 6: ["documents"], 7: ["notes"], }; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx index b2ee1fd7e..e4337cda6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx @@ -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} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx index 7cb091d68..e0ae67384 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx @@ -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) => { + form.setValue("documents", next, { shouldDirty: true }); + }; return ( } 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." /> - - 0 ? "#ECF6F1" : "#FBECEC", - color: total > 0 ? "#0A6F4D" : "#B42318", - }} - > - {total > 0 ? : } - - - {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."} - - - - {total > 0 && ( - - {documents.map((doc, i) => ( + {onboardingDocs.length > 0 && ( + + + On file from your onboarding + + {onboardingDocs.map((doc, i) => ( - Uploaded + On file ))} )} + + + Documents for this booking + + + {docSettingQuery.isLoading ? ( + + + + ) : docSettingQuery.data ? ( + + ) : ( + + No document requirements are configured for your account. The documents + on file from your onboarding will be attached to this booking + automatically. + + )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index e326f84e7..2b72393f6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -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 ( (
-
+
{referenceData?.service .filter((s) => s.canBeBookedAlone) .map((s) => ( - field.onChange(s.id)} - icon={} - iconBg="#EEF0FB" - iconColor="#4F46E5" title={s.serviceName} description={s.description} /> @@ -263,43 +263,95 @@ export function Step2ServiceType({ )} {/* Customs Clearing */} - {includesCustoms && ( - ( - } - 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 ? ( + + + - {customsClearingEnabled && ( - ( - - )} - /> - )} - + + + + + Customs Clearing Service + + + Customs documentation and clearance is included automatically with this service. + + + + + + Included + + + + + ) : ( + ( + + + + + + + + Customs Clearing Agent + + + Enter the name of the customs clearing agent for this shipment. + + + + + )} /> )} @@ -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 ( + + ); +} + function ServiceToggle({ icon, title, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 168790f90..85453ccb5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -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 = { 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 ( @@ -168,21 +237,23 @@ export function Step4Route({ {directionLabel[direction]}
)} - {showRouteQuantity && ( - + {/* Estimated shipment date — one-time bookings only. General contracts + pick the date per order drawn against the contract later. */} + {!isGeneralContract && ( + ( - } 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({ - 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. - {extraRoutes.map((rf, i) => ( - - - ( - - )} - /> - - - ( - - )} - /> - - - ( - field.onChange(String(v ?? ""))} - radius="md" - /> - )} - /> - - - ( - field.onChange(String(v ?? ""))} - radius="md" - /> - )} - /> - - - - ))} + + ( + + )} + /> + + + ( + + )} + /> + + + + ); + })} )} @@ -329,21 +379,26 @@ export function Step4Route({ /> )} /> - ( - } - 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" && ( + ( + } + iconBg="#E9F0F8" + iconColor="#2E5B96" + title="Refrigerated Cargo" + description="Temperature-controlled transport applies a refrigeration surcharge." + checked={field.value} + onChange={(v) => field.onChange(v)} + /> + )} + /> + )} ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index 80b17f16e..f3af690a3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -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(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; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index 5590d9779..f658b84cd 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -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, + ) + .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({ /> - } - title="Schedule" - onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)} - > - - + {!isGeneralContract && ( + } + title="Schedule" + onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)} + > + + + )} } @@ -400,7 +409,7 @@ export function Step8Review({ > {onboardingDocsCount > 0 ? ( - onboardingDocs.map((doc, i) => ( + docsToShow.map((doc, i) => ( - Uploaded + Attached )) @@ -421,13 +430,13 @@ export function Step8Review({ - No onboarding documents found on your active profile. + No documents attached yet. )} - Documents from your onboarding will be attached to this booking. + These documents will be attached to this booking. @@ -463,10 +472,12 @@ export function Step8Review({ done={Boolean(values.originYard && values.destinationYard)} label="Route selected" /> - + {!isGeneralContract && ( + + )} 0} - label="Onboarding documents attached" + label="Documents attached" /> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/useBookingDraft.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/useBookingDraft.ts new file mode 100644 index 000000000..75eeb7aaf --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/useBookingDraft.ts @@ -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; + 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 { + 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 | 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 }; +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/PriceChangeModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/PriceChangeModal.tsx new file mode 100644 index 000000000..94563bd5a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/PriceChangeModal.tsx @@ -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 ( + Price has changed} + radius="lg" + centered + > + {data && ( + + + {data.message ?? + "The booking price has been updated. Confirm to submit with the new total."} + + {data.previousTotalAmount !== undefined && ( + + + Previous total + + + {data.previousTotalAmount.toLocaleString()} {data.currency} + + + )} + + New total + + {data.totalAmount.toLocaleString()} {data.currency} + + + {data.lineItems && data.lineItems.length > 0 && ( + + {data.lineItems.map((item) => ( + + + {item.description} + + + {item.amount.toLocaleString()} {item.currency} + + + ))} + + )} + + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitBookingModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitBookingModal.tsx new file mode 100644 index 000000000..fd46cbe97 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitBookingModal.tsx @@ -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 ; +} + +function ResubmitBookingModalBody({ + booking, + onClose, +}: { + booking: Freight.IBooking; + onClose: () => void; +}) { + const flow = useResubmitFlow(booking, { onResubmitted: onClose }); + + return ( + <> + + + Update & resubmit + + + {booking.reference} + + + } + overlayProps={{ backgroundOpacity: 0.5, blur: 4 }} + styles={{ body: { paddingTop: 8 } }} + > + + {booking.latestChangeRequestNote && ( + } + title="Changes requested by EDR" + > + {booking.latestChangeRequestNote} + + )} + + + + {flow.validationError && ( + }> + {flow.validationError} + + )} + + {flow.mutations.some((m) => m.isError) && ( + }> + Something went wrong. Please try again. + + )} + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx new file mode 100644 index 000000000..b6eaaac81 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/ResubmitDocuments.tsx @@ -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 ( + + {onFile.length > 0 && ( + + + Already submitted + + {onFile.map((file) => ( + + + + + + + {labelForDocCode(file.code)} + + + {file.name} + + + + + + + On file + + + } + /> + + + ))} + + )} + + + + Update documents + + + Replace any document you need to change. Documents marked required must + be on file before you can resubmit. + + + {settingLoading ? ( + + + + ) : setting ? ( + + ) : ( + + No document requirements are configured for your account. You can + resubmit using the documents already on file. + + )} + + + ); +} + +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(); + 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)!); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/index.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/index.ts new file mode 100644 index 000000000..a6a5094bc --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/index.ts @@ -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"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts new file mode 100644 index 000000000..d8b660413 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/resubmitDocs.ts @@ -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[number]; + +/** Human labels for known document codes (shipment + onboarding documents). */ +const LABEL_BY_CODE = new Map([ + ...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()); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useBookingDocumentSetting.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useBookingDocumentSetting.ts new file mode 100644 index 000000000..65774f97f --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useBookingDocumentSetting.ts @@ -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) }, + }), + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useResubmitFlow.ts b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useResubmitFlow.ts new file mode 100644 index 000000000..18d09595b --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/resubmit/useResubmitFlow.ts @@ -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; + +/** 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({}); + const [priceChange, setPriceChange] = useState( + null, + ); + const [validationError, setValidationError] = useState(""); + // 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; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 9d483669d..b921826bc 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -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 (
@@ -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 ( @@ -218,6 +229,53 @@ export default function ContractDetailPage() { )} + {/* 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 && ( + + + + Contracted routes + + + {routeLines.length} + + + + Lanes this contract covers. Orders draw from the shared pool below — + pick a lane per order for scheduling and routing. + + + {routeLines.map((route) => ( + + + + + + + + {route.originYardName ?? route.originYardId} →{" "} + {route.destinationYardName ?? route.destinationYardId} + + {route.km != null && ( + + {route.km} km + + )} + + + + ))} + + + )} + {/* Drawdown pool */} {showPool && ( @@ -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(", ")} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx index 9d66c6ad8..64273bb60 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -209,7 +209,7 @@ export default function ContractsList() { radius="md" size="md" leftSection={} - onClick={() => navigate("/bookings/new")} + onClick={() => navigate("/bookings/new", { state: { fresh: true } })} > New Contract diff --git a/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx index cfcd7d176..a2864d2c3 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx @@ -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 ? ( - - - Quantity - - {!selectedRoute ? ( - - Select a route to draw down from. - - ) : selectedRoute.remainingQuantity <= 0 ? ( - }> - This route is fully drawn down — no quantity remains. - - ) : ( - -
- - {selectedRoute.containerTypeName ?? - (isContainer ? "Containers" : "Tons")} - - - {formatQuantity( - selectedRoute.remainingQuantity, - null, - isContainer, - )}{" "} - remaining - -
- - setQuantities({ __route__: v === "" ? "" : Number(v) }) - } - min={0} - max={selectedRoute.remainingQuantity} - step={isContainer ? 1 : 0.5} - clampBehavior="strict" - radius="md" - w={130} - placeholder="0" - /> -
- )} -
- ) : ( Quantity - {orderableLines.length === 0 && ( - }> - This contract is fully drawn down — no quantity remains. - + {isMultiRoute && !selectedRoute ? ( + + Select a route first, then enter how much to ship on it. + + ) : ( + <> + {orderableLines.length === 0 && ( + }> + This contract is fully drawn down — no quantity remains. + + )} + {orderableLines.map((line) => { + const key = lineKey(line); + const label = isContainer + ? (line.containerTypeName ?? "Containers") + : line.unitOfMeasure === "PER_ITEM" + ? "Items" + : "Tons"; + return ( + +
+ + {label} + + + {formatQuantity( + line.remainingQuantity, + line.unitOfMeasure, + isContainer, + )}{" "} + remaining + +
+ + 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" + /> +
+ ); + })} + )} - {orderableLines.map((line) => { - const key = lineKey(line); - const label = isContainer - ? (line.containerTypeName ?? "Containers") - : line.unitOfMeasure === "PER_ITEM" - ? "Items" - : "Tons"; - return ( - -
- - {label} - - - {formatQuantity( - line.remainingQuantity, - line.unitOfMeasure, - isContainer, - )}{" "} - remaining - -
- - 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" - /> -
- ); - })}
- )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx index 9f34ba745..4496a67a5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx @@ -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" }, diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index fc696b6d8..bd0340e38 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -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 }>( diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 2cebf6fea..6a8c76795 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -206,8 +206,14 @@ export const bookingsService = { return data.data; }, - proceedToOperation: async (id: string): Promise => { - const { data } = await client.post(`/api/bookings/${id}/clearance/proceed`); + proceedToOperation: async ( + id: string, + scheduledDate: string, + ): Promise => { + const { data } = await client.post( + `/api/bookings/${id}/clearance/proceed`, + { scheduledDate }, + ); return data.data; }, diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 6651f0bfc..412e7efa3 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -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; }