diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 73312aae3..4ccffb6a4 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -52,3 +52,10 @@ MINIO_SECRET_KEY= # Redis REDIS_HOST=localhost REDIS_PORT=6379 + +# --- Notification broker (RabbitMQ) --------------------------------------------- +# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service). +# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker). +RABBITMQ_ENABLED=false +RABBITMQ_URL=amqp://localhost:5672 +SMS_QUEUE=sms_queue diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index bf180bdff..580173cc1 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -38,7 +38,7 @@ "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", - "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz", + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", "axios": "^1.16.1", diff --git a/apps/edr-freight-api/src/migrations/1810000000002-MakeCompanyProfileReferenceNullable.ts b/apps/edr-freight-api/src/migrations/1810000000002-MakeCompanyProfileReferenceNullable.ts new file mode 100644 index 000000000..bc1765cfe --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000002-MakeCompanyProfileReferenceNullable.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Company-profile references are now minted only when a profile is approved + * (status → Active); pending profiles carry NULL. Drop the NOT NULL constraint + * on freight.company_profiles.reference. The existing unique index is kept — + * Postgres treats NULLs as distinct, so multiple pending (NULL) profiles don't + * collide. + */ +export class MakeCompanyProfileReferenceNullable1810000000002 + implements MigrationInterface +{ + name = "MakeCompanyProfileReferenceNullable1810000000002"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."company_profiles" ALTER COLUMN "reference" DROP NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Reinstating NOT NULL requires every row to have a reference; any pending + // (NULL) profiles get a placeholder so the constraint can be re-applied. + await queryRunner.query( + `UPDATE "freight"."company_profiles" SET "reference" = 'PENDING-' || left(replace("id"::text, '-', ''), 12) WHERE "reference" IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."company_profiles" ALTER COLUMN "reference" SET NOT NULL`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1810000000003-CreateOtpVerifications.ts b/apps/edr-freight-api/src/migrations/1810000000003-CreateOtpVerifications.ts new file mode 100644 index 000000000..ec0610f52 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000003-CreateOtpVerifications.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +/** + * Create the public.otp_verifications table backing the OTP module + * (OtpVerification entity). One row per phone, holding the latest server-issued + * code and whether that phone has been verified. + */ +export class CreateOtpVerifications1810000000003 + implements MigrationInterface +{ + name = "CreateOtpVerifications1810000000003"; + + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable("otp_verifications"); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: "otp_verifications", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + default: "gen_random_uuid()", + }, + { name: "phone", type: "varchar", isUnique: true }, + { name: "otp", type: "varchar" }, + { name: "verified", type: "boolean", default: false }, + { name: "created_at", type: "timestamptz", default: "now()" }, + { name: "updated_at", type: "timestamptz", default: "now()" }, + { name: "deleted_at", type: "timestamptz", isNullable: true }, + ], + }), + true, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("otp_verifications", true); + } +} 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/1820000000011-DropEmailPhoneFromExternalProfiles.ts b/apps/edr-freight-api/src/migrations/1820000000011-DropEmailPhoneFromExternalProfiles.ts new file mode 100644 index 000000000..757c20720 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000011-DropEmailPhoneFromExternalProfiles.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Contact email/phone for an external profile is sourced from IAM (the user's + * identity) and from the company record, so the duplicated `email`/`phone` + * columns on external_profiles are redundant and are dropped. Dropping `email` + * also removes its UNIQUE constraint. + */ +export class DropEmailPhoneFromExternalProfiles1820000000011 + implements MigrationInterface +{ + name = 'DropEmailPhoneFromExternalProfiles1820000000011'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.external_profiles DROP COLUMN IF EXISTS email;`, + ); + await queryRunner.query( + `ALTER TABLE freight.external_profiles DROP COLUMN IF EXISTS phone;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Re-added as nullable (the original email was UNIQUE NOT NULL) since the + // dropped values cannot be recovered to satisfy those constraints. + await queryRunner.query( + `ALTER TABLE freight.external_profiles ADD COLUMN IF NOT EXISTS email varchar(150);`, + ); + await queryRunner.query( + `ALTER TABLE freight.external_profiles ADD COLUMN IF NOT EXISTS phone varchar(20);`, + ); + } +} 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 45d2fbf7c..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, @@ -371,6 +392,17 @@ export class BookingsService { tradeDirection, fallbackType, ); + + // A customer booking under their own account may only do so once the + // resolved operational profile has been approved by the backoffice. Staff- + // and government-initiated bookings (companyId supplied explicitly) bypass + // this gate. + const customerSelfBooking = !dto.companyId && !!userId; + if (customerSelfBooking && companyProfileId) { + await this.companiesService.assertCompanyProfileApprovedForBooking( + companyProfileId, + ); + } } const needsConsolidation = @@ -385,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); @@ -394,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, @@ -411,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, @@ -423,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', @@ -450,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( @@ -460,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, }), ), @@ -480,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); @@ -577,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, }); @@ -597,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, }; @@ -617,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) { @@ -692,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, @@ -738,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/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 2fba1e878..4bcc3252a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -41,6 +41,7 @@ import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto"; import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; +import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -226,6 +227,17 @@ export class CompaniesController { await this.companiesService.setOnboardingStep(user.id, dto.step); } + @Get("onboarding/requirements") + @ApiOperation({ + summary: + "What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)", + }) + async getOnboardingRequirements( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.getOnboardingRequirements(user.id); + } + @Post("onboarding/complete") @ApiOperation({ summary: "Mark the current user's onboarding as complete" }) async completeOnboarding( diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index d275c57a7..88871f8ad 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { HttpModule } from "@nestjs/axios"; import { FilesModule } from "../files/files.module"; +import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module"; import { MinioModule } from "../minio/minio.module"; import { CompaniesController } from "./companies.controller"; import { CompaniesService } from "./companies.service"; @@ -20,6 +21,7 @@ import { ETradeService } from "./services/etrade.service"; TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), HttpModule, FilesModule, + FileUploadSettingsModule, MinioModule, ], controllers: [CompaniesController], diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 9c1e290be..a838495d5 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -3,6 +3,7 @@ import { NotFoundException, ConflictException, BadRequestException, + ForbiddenException, } from "@nestjs/common"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; @@ -12,7 +13,10 @@ import { DashboardScope, } from "./company-dashboard.repository"; import { MinioService } from "../minio/minio.service"; +import { FilesService } from "../files/files.service"; +import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { ETradeService } from "./services/etrade.service"; +import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; @@ -53,9 +57,67 @@ export class CompaniesService { private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, private readonly minioService: MinioService, + private readonly filesService: FilesService, + private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, ) { } + /** + * Required company-information fields that must be filled before onboarding can + * be submitted. The backend owns this list so the portal never has to know + * which fields are mandatory — it just renders what's reported outstanding. + * `get` reads the value from the company (some live in the attributes blob). + */ + private readonly REQUIRED_COMPANY_INFO: { + key: string; + label: string; + get: (company: Company) => unknown; + }[] = [ + { + key: "tinNumber", + label: "Company TIN", + get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null), + }, + { key: "companyEmail", label: "Company email", get: (c) => c.email }, + { key: "companyPhone", label: "Company phone", get: (c) => c.phone }, + { key: "companyAddress", label: "Company address", get: (c) => c.address }, + { key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber }, + { + key: "contactPersonName", + label: "Contact person name", + get: (c) => c.attributes?.contactPersonName, + }, + { + key: "contactPersonPhone", + label: "Contact person phone", + get: (c) => c.attributes?.contactPersonPhone, + }, + { + key: "generalManagerName", + label: "General manager name", + get: (c) => c.attributes?.generalManagerName, + }, + { + key: "generalManagerEmail", + label: "General manager email", + get: (c) => c.attributes?.generalManagerEmail, + }, + { + key: "generalManagerPhone", + label: "General manager phone", + get: (c) => c.attributes?.generalManagerPhone, + }, + ]; + + /** The nationality-based document setting code for a company. */ + private documentSettingCodeFor( + nationality: CompanyNationality | null | undefined, + ): string { + return nationality === CompanyNationality.Foreign + ? "company_onboarding_documents_foreign" + : "company_onboarding_documents_ethiopian"; + } + async createCompany(dto: CreateCompanyDto): Promise { const exists = await this.companiesRepo.existsByTin(dto.tin); if (exists) { @@ -77,10 +139,12 @@ export class CompaniesService { } } - const existingProfile = await this.profilesRepo.findByEmail(identity.email); + const existingProfile = await this.profilesRepo.findByUserId( + identity.userId, + ); if (existingProfile) { throw new ConflictException( - `Profile with email ${identity.email} already exists`, + `Profile for user ${identity.userId} already exists`, ); } @@ -114,8 +178,6 @@ export class CompaniesService { companyId: company.id, firstName: identity.firstName, lastName: identity.lastName, - email: identity.email, - phone: normalizeE164(identity.phone) ?? identity.phone, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, activeProfileType, @@ -134,15 +196,13 @@ export class CompaniesService { input.type, ); if (existing) continue; - const reference = await this.companyProfilesRepo.generateReference( - input.type, - ); + // No reference yet — these profiles await backoffice approval, which + // is when the reference is minted (see setCompanyProfileStatus). await this.companyProfilesRepo.create({ companyId: company.id, type: input.type, - reference, businessLicense: input.businessLicense ?? null, - status: ProfileStatus.Active, + status: ProfileStatus.Pending, }); } company.companyProfiles = await this.companyProfilesRepo.findByCompanyId( @@ -191,15 +251,6 @@ export class CompaniesService { return this.getCompanyInfoByUserId(identity.userId); } - // A profile may exist for the same email under a different IAM id — block - // duplicates as the final create does. - const byEmail = await this.profilesRepo.findByEmail(identity.email); - if (byEmail) { - throw new ConflictException( - `Profile with email ${identity.email} already exists`, - ); - } - const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); const activeProfileType = @@ -224,8 +275,6 @@ export class CompaniesService { companyId: company.id, firstName: identity.firstName, lastName: identity.lastName, - email: identity.email, - phone: normalizeE164(identity.phone) ?? identity.phone, isPrimaryContact: true, activeProfileType, onboardingStep: "company", @@ -251,12 +300,11 @@ export class CompaniesService { type, ); if (existing) continue; - const reference = await this.companyProfilesRepo.generateReference(type); + // No reference yet — minted on backoffice approval (setCompanyProfileStatus). await this.companyProfilesRepo.create({ companyId, type, - reference, - status: ProfileStatus.Active, + status: ProfileStatus.Pending, }); } } @@ -527,6 +575,8 @@ export class CompaniesService { attrUpdates.contactPersonEmail = dto.contactPersonEmail; if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone); + if (dto.contactVerifiedPhone !== undefined) + attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone); if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName; if (dto.generalManagerEmail !== undefined) @@ -576,10 +626,10 @@ export class CompaniesService { async createProfile(dto: CreateExternalProfileDto): Promise { await this.findCompanyById(dto.companyId); - const existing = await this.profilesRepo.findByEmail(dto.email); + const existing = await this.profilesRepo.findByUserId(dto.userId); if (existing) { throw new ConflictException( - `Profile with email ${dto.email} already exists`, + `Profile for user ${dto.userId} already exists`, ); } @@ -622,12 +672,33 @@ export class CompaniesService { profileId: string, status: ProfileStatus, ): Promise { - const updated = await this.companyProfilesRepo.updateStatus( - profileId, - status, - ); + const existing = await this.companyProfilesRepo.findById(profileId); + if (!existing) + throw new NotFoundException(`Company profile ${profileId} not found`); + + // A reference number is only minted the first time a profile is approved + // (status → Active). Pending/unapproved profiles carry no reference. + const patch: Partial = { status }; + if (status === ProfileStatus.Active && !existing.reference) { + patch.reference = await this.companyProfilesRepo.generateReference( + existing.type, + ); + } + + const updated = await this.companyProfilesRepo.update(profileId, patch); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); + + // Approving any profile promotes a pending company to active, so the + // customer can start working as soon as their first profile is cleared. + if (status === ProfileStatus.Active) { + const company = await this.companiesRepo.findById(updated.companyId); + if (company && company.status === CompanyStatus.Pending) { + await this.companiesRepo.update(updated.companyId, { + status: CompanyStatus.Active, + }); + } + } return updated; } @@ -649,7 +720,7 @@ export class CompaniesService { const existing = await this.companyProfilesRepo.findByType(companyId, type); if (existing) { throw new ConflictException( - `Company already has a ${type} profile (${existing.reference})`, + `Company already has a ${type} profile (${existing.reference ?? "pending approval"})`, ); } @@ -813,6 +884,100 @@ export class CompaniesService { await this.profilesRepo.update(profile.id, { onboardingStep: step }); } + /** + * Server-driven onboarding requirements for the current user's company. + * + * The backend resolves the nationality-based document set, checks which + * company documents and per-profile licenses are already uploaded, and reports + * exactly what is still outstanding. The portal renders this list verbatim and + * relies on `isComplete` to decide when to auto-finish — it never decides for + * itself which documents apply or which fields are mandatory. + */ + async getOnboardingRequirements( + userId: string, + ): Promise { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + + // 1. Required company-information fields. + const missingInfo = this.REQUIRED_COMPANY_INFO.filter( + (f) => !f.get(company), + ).map((f) => ({ key: f.key, label: f.label })); + + // 2. Nationality-based company documents + which are already uploaded. + const documentSettingCode = this.documentSettingCodeFor(company.nationality); + const [setting, uploadedFiles] = await Promise.all([ + this.fileUploadSettingsService + .getByCode(documentSettingCode) + .catch(() => null), + this.filesService.findByResource(company.id, "companies"), + ]); + const uploadedCodes = new Set(uploadedFiles.map((f) => f.code)); + const documents = (setting?.fields ?? []) + .slice() + .sort((a, b) => a.displayOrder - b.displayOrder) + .map((f) => ({ + fileKey: f.fileKey, + fileLabel: f.fileLabel, + helpText: f.helpText ?? null, + isRequired: f.isRequired, + isMultiple: f.isMultiple, + maxFiles: f.maxFiles, + allowedExtensions: f.allowedExtensions, + maxSizeMb: f.maxSizeMb, + displayOrder: f.displayOrder, + uploaded: uploadedCodes.has(f.fileKey), + })); + const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded); + + // 3. Per-operational-profile business licenses. + const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({ + profileId: p.id, + type: p.type, + reference: p.reference ?? "", + uploaded: (p.businessLicenseFiles?.length ?? 0) > 0, + })); + const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); + + const outstanding = [ + ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), + ...missingDocs.map((d) => `Upload your ${d.fileLabel}`), + ...missingLicenses.map( + (p) => + `Upload a business license for your ${p.type.replace(/_/g, " ")} profile`, + ), + ]; + + // Progress spans every required item the user has to satisfy: company-info + // fields, required documents and one license per operational profile. + const requiredDocCount = documents.filter((d) => d.isRequired).length; + const total = + this.REQUIRED_COMPANY_INFO.length + + requiredDocCount + + licenseProfiles.length; + const completed = + total - + (missingInfo.length + missingDocs.length + missingLicenses.length); + + return new OnboardingRequirementsResponseDto({ + documentSettingCode, + nationality: company.nationality ?? CompanyNationality.Ethiopian, + companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo }, + documents, + licenseProfiles, + progress: { completed, total }, + isComplete: outstanding.length === 0, + onboardingCompleted: profile.onboardingCompleted, + outstanding, + }); + } + + /** + * Submit onboarding for review. Validation is delegated entirely to + * getOnboardingRequirements (the same source of truth the portal renders), so + * the gate can never drift from what the UI shows. On success the company and + * all its operational profiles move to PENDING — the backoffice approves each + * profile before it can be used (see setCompanyProfileStatus). + */ async markOnboardingComplete( userId: string, ): Promise<{ profile: ExternalProfile; company: Company }> { @@ -821,23 +986,21 @@ export class CompaniesService { throw new NotFoundException(`Profile for user ${userId} not found`); const companyId = profile.company?.id ?? profile.companyId; - const company = await this.findCompanyById(companyId); - // Guard against finishing on a still-draft company (TIN never filled in). - if (!company.tin || company.tin.startsWith("D")) { + const requirements = await this.getOnboardingRequirements(userId); + if (!requirements.isComplete) { throw new BadRequestException( - "Company information is incomplete — please fill in your company details before finishing.", + requirements.outstanding[0] ?? + "Your onboarding is incomplete. Please complete all required steps before submitting.", ); } - // Every operational profile must have at least one business-license file - // (stored directly on the profile). + // Send every operational profile in for approval; the company itself becomes + // active once the backoffice approves at least one profile. const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); for (const cp of profiles) { - if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) { - throw new BadRequestException( - `Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`, - ); + if (cp.status !== ProfileStatus.Pending) { + await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending); } } @@ -852,6 +1015,25 @@ export class CompaniesService { return this.getCompanyInfoByUserId(userId); } + /** + * Block a customer from booking under a profile that isn't approved yet. + * Called from the booking-create path for self-service bookings; staff- and + * government-initiated bookings bypass this. No-op when the profile can't be + * found (defensive — resolution is best-effort upstream). + */ + async assertCompanyProfileApprovedForBooking( + companyProfileId: string, + ): Promise { + const profile = await this.companyProfilesRepo.findById(companyProfileId); + if (!profile) return; + if (profile.status !== ProfileStatus.Active) { + const role = profile.type.replace(/_/g, " "); + throw new ForbiddenException( + `Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`, + ); + } + } + /** * Authorize and resolve a company_profile that must belong to the current * user's company — used before accepting/returning its license files. diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index bc2d95224..15aec5ac6 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository { } async generateReference(type: ProfileType): Promise { - const seqName = SEQUENCE_MAP[type]; + // The sequences live in the same schema as the entity (e.g. "freight"), but + // the connection's search_path is "public" — so the sequence MUST be + // schema-qualified or `nextval` fails with "relation does not exist". + const schema = this.repository.metadata.schema ?? "public"; + const seqName = `"${schema}".${SEQUENCE_MAP[type]}`; const result = await this.repository.query( `SELECT nextval('${seqName}') AS next_id`, ); diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts index 7a9b94c44..ff0f94495 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts @@ -1,5 +1,4 @@ -import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator'; -import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsString, IsNotEmpty, IsOptional, MaxLength, IsBoolean, IsUUID } from 'class-validator'; export class CreateExternalProfileDto { @IsUUID() @@ -20,16 +19,6 @@ export class CreateExternalProfileDto { @MaxLength(100) lastName!: string; - @IsEmail() - @IsNotEmpty() - email!: string; - - @IsOptional() - @IsString() - @MaxLength(20) - @IsValidPhone() - phone?: string; - @IsOptional() @IsString() @MaxLength(50) diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts new file mode 100644 index 000000000..92f9fa513 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -0,0 +1,78 @@ +/** + * Server-driven description of what a company still needs to finish onboarding. + * + * The portal renders this verbatim instead of deciding for itself which + * documents apply or which fields are mandatory: the backend resolves the + * nationality-based document set, checks which files are already uploaded, and + * reports exactly what is outstanding. `isComplete` is the single source of + * truth the wizard uses to auto-finish. + */ + +export interface OnboardingInfoField { + key: string; + label: string; +} + +export interface OnboardingDocumentField { + fileKey: string; + fileLabel: string; + helpText: string | null; + isRequired: boolean; + isMultiple: boolean; + maxFiles: number; + allowedExtensions: string[]; + maxSizeMb: number; + displayOrder: number; + /** True when a file with this code is already stored for the company. */ + uploaded: boolean; +} + +export interface OnboardingLicenseProfile { + profileId: string; + type: string; + reference: string; + /** True when at least one business-license file is stored on the profile. */ + uploaded: boolean; +} + +export class OnboardingRequirementsResponseDto { + /** Resolved document setting code (by nationality) the docs were drawn from. */ + documentSettingCode: string; + nationality: string; + + /** Required company-information fields and whether each is filled. */ + companyInfo: { + complete: boolean; + missingFields: OnboardingInfoField[]; + }; + + /** The document fields the portal should render, with upload state. */ + documents: OnboardingDocumentField[]; + + /** Per-operational-profile business-license requirements. */ + licenseProfiles: OnboardingLicenseProfile[]; + + /** Overall setup progress across fields + documents + licenses. */ + progress: { completed: number; total: number }; + + /** True once every required field, document and license is satisfied. */ + isComplete: boolean; + + /** Whether the user has already submitted onboarding (awaiting approval). */ + onboardingCompleted: boolean; + + /** Human-readable list of everything still outstanding (empty when complete). */ + outstanding: string[]; + + constructor(init: Omit) { + this.documentSettingCode = init.documentSettingCode; + this.nationality = init.nationality; + this.companyInfo = init.companyInfo; + this.documents = init.documents; + this.licenseProfiles = init.licenseProfiles; + this.progress = init.progress; + this.isComplete = init.isComplete; + this.onboardingCompleted = init.onboardingCompleted; + this.outstanding = init.outstanding; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 97f2d9f50..89a52b5e6 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -34,6 +34,8 @@ export class ProfileResponseDto { contactPersonPosition: string | null; contactPersonEmail: string | null; contactPersonPhone: string | null; + /** Phone that passed SMS OTP verification (drives the verify-step resume). */ + contactVerifiedPhone: string | null; generalManagerName: string | null; generalManagerEmail: string | null; generalManagerPhone: string | null; @@ -81,6 +83,7 @@ export class ProfileResponseDto { this.contactPersonPosition = attrs.contactPersonPosition ?? null; this.contactPersonEmail = attrs.contactPersonEmail ?? null; this.contactPersonPhone = attrs.contactPersonPhone ?? null; + this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null; this.generalManagerName = attrs.generalManagerName ?? null; this.generalManagerEmail = attrs.generalManagerEmail ?? null; this.generalManagerPhone = attrs.generalManagerPhone ?? null; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index b62182968..5d90d8d60 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -28,7 +28,7 @@ export class ResponseCompanyProfileDto { this.id = profile.id; this.companyId = profile.companyId; this.type = profile.type; - this.reference = profile.reference; + this.reference = profile.reference ?? ''; this.status = profile.status; this.businessLicense = profile.businessLicense; this.licenseFiles = profile.businessLicenseFiles ?? []; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index 7e17bcc60..256641074 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -10,8 +10,6 @@ export class ResponseExternalProfileDto { companyId: string; firstName: string; lastName: string; - email: string; - phone?: string | null; nationalId?: string | null; jobTitle?: string | null; isPrimaryContact: boolean; @@ -34,8 +32,6 @@ export class ResponseExternalProfileDto { this.companyId = profile.companyId; this.firstName = profile.firstName; this.lastName = profile.lastName; - this.email = profile.email; - this.phone = profile.phone; this.nationalId = profile.nationalId; this.jobTitle = profile.jobTitle; this.isPrimaryContact = profile.isPrimaryContact; diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index c99ef9d3c..316038dc9 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -67,6 +67,16 @@ export class UpdateProfileDto { @IsValidPhone() contactPersonPhone?: string; + /** + * The contact-person phone that completed SMS OTP verification. Persisted so + * the onboarding "verify" step can resume its "done" state after a refresh + * (compared against the current contactPersonPhone on the client). + */ + @IsOptional() + @IsString() + @IsValidPhone() + contactVerifiedPhone?: string; + @IsOptional() @IsString() generalManagerName?: string; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index c0cb41a63..e61668a07 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -40,14 +40,19 @@ export class CompanyProfile extends BaseEntity { @Column({ name: "type", type: "varchar", length: 32, enum: ProfileType }) type!: ProfileType; + /** + * Official profile reference (e.g. "EX-00001"). Minted only when the profile + * is approved (status → Active); pending/unapproved profiles carry NULL. + * The unique index tolerates this because Postgres treats NULLs as distinct. + * API responses surface it as "" when absent — see ResponseCompanyProfileDto. + */ @Column({ name: "reference", type: "varchar", length: 20, - nullable: false, - unique: true, + nullable: true, }) - reference!: string; + reference!: string | null; @Column({ name: "status", diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 3b1554cc9..93e499b5e 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -23,12 +23,6 @@ export class ExternalProfile extends BaseEntity { @Column({ name: 'last_name', type: 'varchar', length: 100 }) lastName!: string; - @Column({ name: 'email', type: 'varchar', length: 150, unique: true }) - email!: string; - - @Column({ name: 'phone', type: 'varchar', length: 20, nullable: true }) - phone?: string | null; - @Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true }) nationalId?: string | null; diff --git a/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts index 581dfd72b..70c05abd7 100644 --- a/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts @@ -23,8 +23,4 @@ export class ExternalProfileRepository extends BaseRepository { async findByCompanyId(companyId: string): Promise { return this.repository.find({ where: { companyId } as any }); } - - async findByEmail(email: string): Promise { - return this.repository.findOne({ where: { email } as any }); - } } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 161604e81..78c3d43ff 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -59,7 +59,7 @@ export class FirstMileController { @TrainSchedulingManage() @ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' }) acceptBooking(@Param('reference') reference: string) { - return this.firstMileService.acceptBooking(reference); + return this.firstMileService.acceptBookingByReference(reference); } @Post() diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 2d4079a8b..a00e54fff 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -44,8 +44,8 @@ export class FirstMileService { * paid before any first-mile work proceeds. Throws if the reference is * unknown or the booking has not reached PAID status. */ - async acceptBooking(bookingReference: string): Promise { - const booking = await this.bookingsRepository.findById(bookingReference); + async acceptBooking(bookingId: string): Promise { + const booking = await this.bookingsRepository.findById(bookingId); if (!booking) { return null; @@ -61,6 +61,22 @@ export class FirstMileService { }); } + async acceptBookingByReference(bookingReference: string): Promise { + const booking = await this.bookingsRepository.findByReference(bookingReference); + + if (!booking) { + return null; + } + + if (booking.paymentStatus !== 'PAID') { + return null; + } + + return this.create({ + bookingId: booking.id, + advancedPayment: 0, + }); + } async findAll(filter: FirstMileListFilter = {}): Promise<{ data: FirstMile[]; meta: { total: number; page: number; pageSize: number; totalPages: number }; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index ea1e29a3d..e8abf52c6 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -59,7 +59,7 @@ export class LastMileController { @TrainSchedulingManage() @ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' }) acceptBooking(@Param('reference') reference: string) { - return this.lastMileService.acceptBooking(reference); + return this.lastMileService.acceptBookingByReference(reference); } @Post() diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index fa654f6ec..32c2de721 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -2,13 +2,22 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; +import { DriversModule } from '../drivers/drivers.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; import { LastMileController } from './last-mile.controller'; import { LastMileRepository } from './last-mile.repository'; import { LastMileService } from './last-mile.service'; @Module({ - imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule], + imports: [ + TypeOrmModule.forFeature([LastMile]), + BookingsModule, + VehiclesModule, + DriversModule, + NotificationsModule, + ], controllers: [LastMileController], providers: [LastMileRepository, LastMileService], exports: [LastMileRepository, LastMileService], diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 5712f809c..55d5d2965 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,7 +1,10 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource, FindOptionsWhere } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; +import { DriversService } from '../drivers/drivers.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; @@ -26,10 +29,14 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [ @Injectable() export class LastMileService { + private readonly logger = new Logger(LastMileService.name); + constructor( private readonly lastMileRepository: LastMileRepository, private readonly bookingsRepository: BookingsRepository, - private readonly dataSource: DataSource, + private readonly vehiclesService: VehiclesService, + private readonly driversService: DriversService, + private readonly notificationsService: NotificationsService, ) {} async acceptBooking(bookingReference: string): Promise { @@ -82,7 +89,26 @@ export class LastMileService { return this.create({ bookingId: booking.id, - advancedPayment: booking.totalAmount, + advancedPayment: 0, + }); + } + + async acceptBookingByReference(bookingReference: string): Promise { + const booking = await this.bookingsRepository.findByReference(bookingReference); + + if (!booking) { + throw new NotFoundException(`Booking ${bookingReference} not found`); + } + + if (booking.paymentStatus !== 'PAID') { + throw new BadRequestException( + `Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`, + ); + } + + return this.create({ + bookingId: booking.id, + advancedPayment: 0, }); } @@ -152,7 +178,7 @@ export class LastMileService { } async update(id: string, dto: UpdateLastMileDto): Promise { - await this.findById(id); + const existing = await this.findById(id); const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -168,9 +194,50 @@ export class LastMileService { throw new NotFoundException(`Last-mile record ${id} not found`); } + // Notify assigned driver on every explicit vehicle assignment or reassignment + if (dto.vehicleId) { + void this.notifyDriverAssignment(dto.vehicleId, existing); + } + return updated; } + private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { + try { + const vehicle = await this.vehiclesService.findById(vehicleId); + if (!vehicle.assignedDriverId) { + this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`); + return; + } + + const driver = await this.driversService.findById(vehicle.assignedDriverId); + if (!driver.phoneNumber) { + this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`); + return; + } + + type BookingWithYards = { + reference?: string; + lastMileDeliveryAddress?: string | null; + destinationYard?: { label?: string } | null; + }; + const booking = (record as LastMile & { booking?: BookingWithYards }).booking; + + await this.notificationsService.notifyDriverVehicleAssignment({ + driverPhone: driver.phoneNumber, + driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), + vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, + bookingReference: booking?.reference ?? record.bookingId, + pickupAddress: booking?.destinationYard?.label, + destinationYard: booking?.lastMileDeliveryAddress, + }); + + this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + } catch (err) { + this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); + } + } + async remove(id: string): Promise { await this.findById(id); await this.lastMileRepository.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/notifications/dtos/sms.dto.ts b/apps/edr-freight-api/src/modules/notifications/dtos/sms.dto.ts new file mode 100644 index 000000000..b263b54f0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/dtos/sms.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class SendMessage { + @ApiProperty() + @IsNotEmpty() + @IsString() + to!: string; + + @ApiProperty() + @IsNotEmpty() + @IsString() + message!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + from?: string; +} + +export class SingleMessageDto { + @ApiProperty({ + description: 'Recipient phone number', + example: '+1234567890', + }) + @IsString() + @IsNotEmpty() + to!: string; + + @ApiProperty({ + description: 'Message content', + example: 'Test Single SMS from', + }) + @IsString() + @IsNotEmpty() + message!: string; +} + +export class BulkMessagesDto { + @ApiProperty({ type: [SendMessage] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SendMessage) + messages!: SendMessage[]; +} diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 70e00c9ac..663f931ef 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -1,14 +1,29 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; +import { ClientsModule, Transport } from "@nestjs/microservices"; import { NotificationsService } from "./notifications.service"; +import { SmsClientService } from "./sms-client.service"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; @Module({ - imports: [ConfigModule], + imports: [ + ConfigModule, + ClientsModule.register([ + { + name: "SMS_SERVICE", + transport: Transport.RMQ, + options: { + urls: [process.env.RABBITMQ_URL as string], + queue: process.env.SMS_QUEUE ?? "sms_queue", + queueOptions: { durable: true }, + }, + }, + ]), + ], controllers: [], - providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService], - exports: [NotificationsService], + providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService], + exports: [NotificationsService, SmsClientService], }) export class NotificationsModule {} diff --git a/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts b/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts new file mode 100644 index 000000000..f94c0c20e --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts @@ -0,0 +1,68 @@ +import { + Inject, + Injectable, + Logger, + OnApplicationBootstrap, +} from "@nestjs/common"; +import { ClientProxy } from "@nestjs/microservices"; +import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto"; + +@Injectable() +export class SmsClientService implements OnApplicationBootstrap { + private readonly logger = new Logger(SmsClientService.name); + + constructor( + @Inject("SMS_SERVICE") + private smsClient: ClientProxy, + ) {} + + private readonly enabled = process.env.RABBITMQ_ENABLED !== "false"; + + async onApplicationBootstrap() { + if (!this.enabled) return; + this.smsClient + .connect() + .then(() => { + this.logger.log("connected to SMS service"); + }) + .catch((err) => { + console.error("Error happened at SMS service", err); + }); + } + + async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> { + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped SMS`); + return { queued: false }; + } + this.smsClient.emit("send-sms", { + to: dto.to, + text: dto.message, + appKey: "IFHCRS-LICENSE-MANAGEMENT", + }); + // Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery. + this.logger.log( + `SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`, + ); + // Recipient + content are PII — debug only. + this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`); + return { queued: true }; + } + + async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> { + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`); + return { queued: false }; + } + const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from })); + this.smsClient.emit("ozeking-bulk-sms", { + messages, + appKey: "IFHCRS-LICENSE-MANAGEMENT", + }); + this.logger.log( + `BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`, + ); + this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`); + return { queued: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts index 9866ca570..5850cbb1a 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -24,13 +24,9 @@ export class OtpController { @Post("send") async sendOtp( @Body("phone") - phone: string, - @Body("otp") - otp: string + phone: string ) { - return this.otpService.sendOtp( - phone,otp - ); + return this.otpService.sendOtp(phone); } // --------------------------------------------------------------------------- diff --git a/apps/edr-freight-api/src/modules/otp/otp.module.ts b/apps/edr-freight-api/src/modules/otp/otp.module.ts index 7a6d1faa6..ec1d9f9ed 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.module.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.module.ts @@ -12,11 +12,14 @@ import { OtpService } from "./otp.service"; import { OtpRepository } from "./otp.repository"; +import { NotificationsModule } from "../notifications/notifications.module"; + @Module({ imports: [ TypeOrmModule.forFeature([ OtpVerification, ]), + NotificationsModule, ], controllers: [OtpController], diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index 4e16be20a..ffa9c4e68 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -5,14 +5,15 @@ import { Injectable, } from "@nestjs/common"; -import axios from "axios"; - import { OtpRepository } from "./otp.repository"; +import { SmsClientService } from "../notifications/sms-client.service"; + @Injectable() export class OtpService { constructor( - private readonly otpRepository: OtpRepository + private readonly otpRepository: OtpRepository, + private readonly smsClient: SmsClientService ) {} // --------------------------------------------------------------------------- @@ -29,11 +30,12 @@ export class OtpService { // Send OTP // --------------------------------------------------------------------------- - async sendOtp(phone: string, otp: string) { + async sendOtp(phone: string) { try { - // generate otp - // const otp = - // this.generateOtp(); + // The verification code is generated server-side — never supplied by the + // caller — so the OTP stays a secret known only to the server and the + // recipient of the SMS. + const otp = this.generateOtp(); // find existing phone const existingPhone = @@ -55,33 +57,11 @@ export class OtpService { ); } - // send sms - await axios.post( - "https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms", - { - to: phone, - - sourceId: "EDR", - - sourceName: - "EDR Freight", - - appKey: - "YOUR_APP_KEY", - - text: `Your verification code is ${otp}`, - - callbackUrl: "", - }, - { - headers: { - accept: "*/*", - - "Content-Type": - "application/json", - }, - } - ); + // send sms (queued to RabbitMQ via the shared SMS service) + await this.smsClient.sendSms({ + to: phone, + message: `Your verification code is ${otp}`, + }); return { success: true, 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/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."} + + - - ); -} - -/** Shown after onboarding while the company awaits backoffice approval. */ -function PendingApprovalBanner() { - return ( -
- - - Your company is awaiting EDR approval. You can browse, but creating - bookings is disabled until your company is approved. - -
- ); -} - /** Keeps authenticated users off the login/signup pages. */ function RedirectIfAuthed() { const { isPending, isAuthenticated } = useAuth(); @@ -308,7 +270,10 @@ const App = () => { } /> } /> {/* Profile was merged into Settings — keep old links working. */} - } /> + } + /> } /> } /> diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 284497af2..ac7c8e092 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -53,7 +53,12 @@ export interface AppLayoutProps { title?: string; sidebarItems: SidebarItem[]; activeHref?: string; - onNavigate?: (href: string) => void; + /** + * Navigate to a route. Accepts an optional options object (e.g. `{ state }`) + * forwarded to the router — used to pass navigation state like `fresh: true` + * to the new-booking wizard. Compatible with react-router's `navigate`. + */ + onNavigate?: (href: string, options?: { state?: unknown }) => void; enableThemeToggle?: boolean; userName?: string; userEmail?: string; @@ -157,7 +162,8 @@ export function AppLayout({ const primaryDarkColor = theme.colors["edr-green"][7]; const activePath = activeHref.toLowerCase(); - const navigate = (href: string) => onNavigate?.(href); + const navigate = (href: string, options?: { state?: unknown }) => + onNavigate?.(href, options); const toggleTheme = () => { setColorScheme(computedColorScheme === "dark" ? "light" : "dark"); @@ -478,7 +484,7 @@ export function AppLayout({ } color="edr-green" - onClick={() => navigate("/bookings/new")} + onClick={() => navigate("/bookings/new", { state: { fresh: true } })} > New Booking diff --git a/apps/edr-freight-web/portal/src/components/NewBookingButton.tsx b/apps/edr-freight-web/portal/src/components/NewBookingButton.tsx new file mode 100644 index 000000000..cb0e2e726 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/NewBookingButton.tsx @@ -0,0 +1,60 @@ +import { Box, Button, Tooltip } from "@mantine/core"; +import { Link } from "react-router-dom"; +import { Lock, Plus } from "lucide-react"; +import useAuth from "@/hooks/useAuth"; + +interface NewBookingButtonProps { + label?: string; + size?: string; + mt?: string; +} + +/** + * New-booking entry point that respects approval status: a customer can only + * create bookings under a profile once the backoffice has approved it. While the + * active profile is pending the button is disabled with an explanation, so the + * gate is communicated rather than silently failing at submit time. + */ +export function NewBookingButton({ + label = "New booking", + size, + mt, +}: NewBookingButtonProps) { + const { canBook, activeProfileStatus } = useAuth(); + + if (!canBook) { + const message = + activeProfileStatus === "pending" + ? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved." + : "Bookings aren't available for this profile yet."; + return ( + + + + + + ); + } + + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx new file mode 100644 index 000000000..d0024c6f6 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx @@ -0,0 +1,191 @@ +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, Clock } from "lucide-react"; +import { api } from "@/services/api"; +import useAuth from "@/hooks/useAuth"; +import type { OnboardingRequirements } from "@/services/companies.service"; + +interface OnboardingResumeBannerProps { + /** Re-opens the onboarding wizard. */ + onResume: () => void; +} + +interface BannerCopy { + title: string; + subtitle: string; + cta: string; +} + +/** + * Wording is driven entirely by the backend's outstanding-items list — the + * client never decides what's required, it just narrates what's left. + */ +function getCopy( + requirements: OnboardingRequirements | undefined, + pct: number, +): BannerCopy { + // No data yet (or nothing started) — treat it as a fresh start. + if (!requirements || requirements.progress.completed === 0) { + return { + title: "Set up your company profile", + subtitle: "Unlock bookings, tracking and billing — it only takes a minute.", + cta: "Start onboarding", + }; + } + + // Everything's filled in but not yet submitted for review. + if (requirements.isComplete) { + return { + title: "Everything's ready to go", + subtitle: "Submit your profile to send it for approval.", + cta: "Submit for review", + }; + } + + const remaining = requirements.outstanding.length; + if (remaining <= 2) { + return { + title: `Almost done — you're ${pct}% set up`, + subtitle: `Just ${remaining} more ${ + remaining === 1 ? "item" : "items" + } to finish: ${requirements.outstanding.join(", ")}.`, + cta: "Finish onboarding", + }; + } + + return { + title: `You're ${pct}% set up`, + subtitle: `${requirements.progress.completed} of ${requirements.progress.total} details added — finish to unlock bookings, tracking and billing.`, + cta: "Continue onboarding", + }; +} + +/** Circular percentage meter that reads at a glance against the dark banner. */ +function ProgressRing({ pct }: { pct: number }) { + const size = 56; + const stroke = 5; + const r = (size - stroke) / 2; + const circumference = 2 * Math.PI * r; + const offset = circumference * (1 - pct / 100); + + return ( + + + + + + {pct}% + + ); +} + +/** + * Prominent banner shown on onboarding-allowed pages after the wizard is + * dismissed. Progress and copy are read straight from the backend's onboarding + * requirements, so the banner always agrees with the wizard about what's left. + */ +export default function OnboardingResumeBanner({ + onResume, +}: OnboardingResumeBannerProps) { + const requirementsQuery = useQuery( + api.companies.onboardingRequirements.queryOptions({ retry: false }), + ); + + const requirements = requirementsQuery.data; + const { completed, total } = requirements?.progress ?? { + completed: 0, + total: 0, + }; + const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + const { title, subtitle, cta } = getCopy(requirements, pct); + + return ( +
+
+
+ + + + + + + + + {title} + + + {subtitle} + +
+ +
+
+ ); +} + +/** + * Shown once onboarding is submitted but the company's operational profiles are + * still being reviewed. Communicates that approval is per-profile and that + * bookings unlock as each profile is cleared. Self-hides when nothing is pending. + */ +export function AccountReviewBanner() { + const { company } = useAuth(); + const profiles = company?.company?.companyProfiles ?? []; + const pending = profiles.filter((p) => p.status === "pending"); + const approved = profiles.filter((p) => p.status === "active"); + + if (profiles.length === 0 || pending.length === 0) return null; + + const pendingLabel = pending + .map((p) => p.type.replace(/_/g, " ")) + .join(", "); + + return ( +
+
+
+ + + + + + Your account is under review + + + We're reviewing your {pendingLabel}{" "} + {pending.length === 1 ? "profile" : "profiles"}. You can create + bookings under a profile as soon as it's approved. + + +
+ + {approved.length} of {profiles.length} approved + +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 3628d7403..8d6bc3d93 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -14,8 +14,11 @@ import { ArrowRight, Building2, CheckCircle2, + Clock, FileText, Globe2, + PartyPopper, + ShieldCheck, UploadCloud, User, UserCheck, @@ -43,6 +46,7 @@ type FormStep = | "company" | "personnel" | "contact" + | "verify" | "poa" | "documents" | "additional"; @@ -50,6 +54,7 @@ const FORM_STEPS: FormStep[] = [ "company", "personnel", "contact", + "verify", "poa", "documents", "additional", @@ -90,6 +95,11 @@ const STEP_META: Record< title: "Contact Person", description: "Who should we reach out to about this account?", }, + verify: { + icon: , + title: "Verify Contact Person", + description: "Confirm the contact phone with a one-time SMS code.", + }, poa: { icon: , title: "Power of Attorney", @@ -142,10 +152,14 @@ export default function OnboardingWizardDialog({ onClose, }: OnboardingWizardDialogProps) { const queryClient = useQueryClient(); - const { user, company, onboardingStep } = useAuth(); + const { user, company, onboardingStep, onboardingCompleted } = useAuth(); const existingProfiles = company?.company?.companyProfiles ?? []; const companyAlreadyStarted = Boolean(company?.company?.id); + // A draft can exist with zero operational profiles (e.g. an interrupted start). + // Such a draft must re-run role selection so the profiles actually get created + // — otherwise the user is stuck with nothing to upload a license against. + const hasOperationalProfiles = existingProfiles.length > 0; const savedNationality = (company?.company?.nationality as CompanyNationality | null) ?? null; @@ -157,7 +171,11 @@ export default function OnboardingWizardDialog({ // Phases: nationality → role → form. If a draft already exists, resume // straight into the form with nationality + roles pre-selected. const [phase, setPhase] = useState<"nationality" | "role" | "form">( - companyAlreadyStarted ? "form" : "nationality", + companyAlreadyStarted + ? hasOperationalProfiles + ? "form" + : "role" + : "nationality", ); const [nationality, setNationality] = useState( savedNationality, @@ -174,6 +192,10 @@ export default function OnboardingWizardDialog({ // Mirror of CompanyProfileForm's active step so the global header + progress // pill can reflect it (the form no longer renders its own stepper). const [formStep, setFormStep] = useState(resumeFormStep); + // Once submission succeeds we swap the whole wizard body for a congratulations + // panel, and keep the modal open (the gate would otherwise tear it down the + // moment onboardingCompleted flips true). + const [completed, setCompleted] = useState(false); // Saved profile data, for rehydrating the form fields after a refresh. const profileQuery = useQuery( @@ -184,6 +206,19 @@ export default function OnboardingWizardDialog({ }), ); + // Server-driven onboarding requirements: the backend decides which document + // set applies (by nationality) and what's still outstanding, so the client + // never makes that choice itself. This is the heavier "second request" — it's + // only issued while onboarding is still incomplete; once the getInfo flag says + // we're done, it never fires. + const requirementsQuery = useQuery( + api.companies.onboardingRequirements.queryOptions({ + enabled: companyAlreadyStarted && !onboardingCompleted, + retry: false, + refetchOnWindowFocus: false, + }), + ); + const refreshInfo = useCallback( () => queryClient.invalidateQueries({ @@ -225,7 +260,10 @@ export default function OnboardingWizardDialog({ } return api.companies.completeOnboarding.call(); }, - onSuccess: refreshInfo, + onSuccess: async () => { + await refreshInfo(); + setCompleted(true); + }, onError: (err) => setStartError(extractApiError(err).message), }); @@ -260,7 +298,9 @@ export default function OnboardingWizardDialog({ resumedRef.current = true; setRoles(existingProfiles.map((p) => p.type)); setNationality(savedNationality); - setPhase("form"); + // Resume into the form only when profiles exist; otherwise send the user to + // role selection so the missing operational profiles get created. + setPhase(hasOperationalProfiles ? "form" : "role"); const idx = FORM_STEPS.indexOf(resumeFormStep); if (idx > furthestIdxRef.current) furthestIdxRef.current = idx; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -329,8 +369,22 @@ export default function OnboardingWizardDialog({ const stepMeta = STEP_META[activeStep]; const activeIdx = WIZARD_STEPS.indexOf(activeStep); + // Closing from the congratulations panel also clears the completed flag so a + // future reopen (shouldn't happen once onboarded) starts clean. + const handleClose = useCallback(() => { + if (completed) setCompleted(false); + onClose(); + }, [completed, onClose]); + + // Prefer the backend-resolved document code; fall back to the local mapping + // only until the requirements query lands (the documents step is reached well + // after the draft — and thus the requirements — exist). + const resolvedDocumentSettingCode = + requirementsQuery.data?.documentSettingCode ?? + documentSettingCode(effectiveNationality); + const formProps = { - documentSettingCode: documentSettingCode(effectiveNationality), + documentSettingCode: resolvedDocumentSettingCode, documentFiles, onDocumentFilesChange: setDocumentFiles, user, @@ -346,16 +400,20 @@ export default function OnboardingWizardDialog({ roleProfiles, licenseFiles, onLicenseChange: setLicenseFiles, + // Surface a failed final submit (license/document upload or complete) inside + // the form — otherwise the server message (e.g. a 500) would be invisible on + // the submit step. + submitError: phase === "form" ? startError : null, }; return ( - - - {stepMeta.icon} - {stepMeta.title} - - - {stepMeta.description} - - - - + completed ? null : ( + + + + {stepMeta.icon} + {stepMeta.title} + + + {stepMeta.description} + + + + + ) } > + {completed ? ( + + ) : ( {phase === "nationality" ? ( @@ -438,10 +501,65 @@ export default function OnboardingWizardDialog({ )} + )} ); } +/** + * Replaces the wizard body once onboarding is submitted: congratulates the user + * and sets the expectation that their company is now under review, and that + * bookings unlock per profile as the team approves each one. + */ +function OnboardingCompletePanel({ onClose }: { onClose: () => void }) { + return ( + + + + + + + You're all set! + + Thanks for completing your company profile. Your application has been + submitted and is now with our team for review. + + + + + + + + Each operational profile (importer, exporter, freight forwarder) is + reviewed and approved individually. + + + + + + You can start creating bookings under a profile as soon as it's + approved — we'll let you know the moment that happens. + + + + + + + ); +} + /** * Continuous progress pill: a single rounded track that fills left-to-right as * the user advances, with faint ticks marking each step boundary. diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 6a22ef954..3d30b7d15 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -89,6 +89,7 @@ export const URL_CONSTANTS = { ONBOARDING_START: "/api/companies/onboarding/start", ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", + ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements", DASHBOARD: "/api/companies/dashboard", FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index ec54a9a30..9d17b1fed 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -74,13 +74,6 @@ const useAuth = () => { setCookie("auth-token", res.token, 7); setCookie("refresh-token", res.refreshToken, 7); await authQuery.refetch(); - const otpCode = res.otp?.split(" ")?.[6] ?? ""; - localStorage.setItem("otp", otpCode); - localStorage.setItem("otp-phone", payload.phoneNumber); - localStorage.setItem("otp-email", payload.email); - api.auth.sendOTP - .call({ phone: payload.phoneNumber, otp: otpCode }) - .catch(() => { }); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; @@ -164,6 +157,15 @@ const useAuth = () => { companyInfo?.profile?.onboardingCompleted ?? false; const onboardingStep = companyInfo?.profile?.onboardingStep ?? null; + // Booking is gated on backoffice approval of the active operational profile: + // a customer can only book under a profile once its status is "active". + const activeProfile = + companyInfo?.company?.companyProfiles?.find( + (p) => p.id === activeCompanyProfileId, + ) ?? null; + const activeProfileStatus = activeProfile?.status ?? null; + const canBook = activeProfileStatus === "active"; + /** Refetch everything scoped to the active operational profile. */ const invalidateScopedData = async () => { await Promise.all([ @@ -232,6 +234,8 @@ const useAuth = () => { customer: isAuthenticated ? (companyQuery.data ?? null) : null, activeProfileType, activeCompanyProfileId, + activeProfileStatus, + canBook, companyType, companyStatus, isCompanyApproved, diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx index 4da182283..23cc908a6 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -9,7 +9,6 @@ import { HelloSection, InvoicesSection, RecentActivitySection, - SetupPrompt, ShipmentsSection, StatsSection, } from "./components"; @@ -21,7 +20,6 @@ export default function MyPortalPage() { null, ); const { - customer, companyProfiles, bookingsQuery, dashboardQuery, @@ -67,8 +65,6 @@ export default function MyPortalPage() {
)} - - {canPay ? ( + ) : canSign ? ( + + ) : hasInlineAction ? ( + ) : ( - + !profile[field]); -} - -interface SetupPromptProps { - show: boolean; -} - -export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) { - const profileQuery = useQuery( - api.companies.getProfile.queryOptions({ retry: false }), - ); - - const incomplete = !profileQuery.isPending && isProfileIncomplete(profileQuery.data); - - if (!show && !incomplete) return null; - - return ( - - - - - {incomplete && } - - {incomplete ? "Complete Your Profile" : "Setup your Company Profile"} - - - - {incomplete - ? "Your company profile is incomplete. Fill in the missing details to unlock all features." - : "Complete your company information to unlock all features and start booking shipments."} - - - - - {incomplete ? "Complete Profile" : "Complete Setup"} - - - - - - - - - - - ); -}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx index 413caafc4..a17af733d 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx @@ -1,15 +1,30 @@ import { Box, Group, Text } from "@mantine/core"; -import { memo } from "react"; import type { LucideIcon } from "lucide-react"; +import { memo } from "react"; import { cv } from "../constants"; +/** Accent families map a KPI to a soft tile + strong ink pair from the theme. */ +type Accent = "green" | "amber" | "blue" | "slate"; + +const ACCENTS: Record = { + green: { soft: cv("edr-soft"), ink: cv("edr-green.7") }, + amber: { soft: cv("edr-amber-soft"), ink: cv("edr-amber-text") }, + blue: { soft: cv("edr-blue-soft"), ink: cv("edr-blue") }, + slate: { soft: cv("edr-slate-soft"), ink: cv("edr-slate") }, +}; + interface StatKpiProps { icon: LucideIcon; label: string; value: string; delta: string; - deltaColor: string; + /** Color family for the icon chip. */ + accent: Accent; + /** Tint of the delta pill — defaults to the card accent. */ + deltaTone?: Accent | "muted"; + /** Draw a separating border on the left (on wide layouts). */ divider?: boolean; + loading?: boolean; } export const StatKpi = memo(function StatKpi({ @@ -17,30 +32,67 @@ export const StatKpi = memo(function StatKpi({ label, value, delta, - deltaColor, + accent, + deltaTone, divider, + loading, }: StatKpiProps) { + const a = ACCENTS[accent]; + const tone = deltaTone ?? accent; + const pill = + tone === "muted" + ? { bg: cv("edr-slate-soft2"), fg: cv("edr-muted") } + : { bg: ACCENTS[tone].soft, fg: ACCENTS[tone].ink }; + return ( - - - - {label} - - - - - {value} - - - {delta} - + {/* Icon chip + metric label, aligned on one line. */} + + + + + + + + {loading ? "—" : value} + + {delta && !loading && ( + + + {delta} + + + )} + + + {label} + + + + {/* Value + its trend pill, grouped together at the bottom of the cell. */} + ); }); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx index 8a0545fb7..dbd12546b 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx @@ -1,7 +1,7 @@ +import { formatCurrency } from "@/pages/billing/invoices.mock"; import { SimpleGrid } from "@mantine/core"; import { CheckCircle2, Clock3, Truck, Wallet } from "lucide-react"; import { memo } from "react"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; import { formatPct } from "../constants"; import { Card } from "./Card"; import { StatKpi } from "./StatKpi"; @@ -29,39 +29,51 @@ export const StatsSection = memo(function StatsSection({ completionRate, spendYtd, spendYtdChangePct, + dashboardLoading, }: StatsSectionProps) { return ( - - + + 0 ? `+${newActiveThisWeek} this week` : ""} + loading={bookingsLoading} /> diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts index de3ddb448..4bf94f45f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts @@ -6,8 +6,8 @@ export { FreightVolumeSection } from "./FreightVolumeSection"; export { HelloSection } from "./HelloSection"; export { InvoicesSection } from "./InvoicesSection"; export { RecentActivitySection } from "./RecentActivitySection"; -export { SetupPrompt } from "./SetupPrompt"; export { ShipmentsSection } from "./ShipmentsSection"; export { StatKpi } from "./StatKpi"; export { StatsSection } from "./StatsSection"; export { Stepper } from "./Stepper"; + diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts index d1231406c..70e58f77c 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -1,10 +1,13 @@ import { ArrowRight, + CalendarClock, CheckCircle2, Clock3, FileCheck2, FilePen, + FileUp, MapPin, + ShieldCheck, Truck, Wallet, type LucideIcon, @@ -161,6 +164,110 @@ export const STATUS_CONFIG: Record = { badgeDot: "edr-green.5", action: { label: "View", kind: "outline" }, }, + AWAITING_DOCUMENTS: { + stage: 3, + icon: FileUp, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Clearance documents needed", + step: "edr-accent", + badgeLabel: "Docs needed", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Upload documents", kind: "amber", icon: ArrowRight }, + }, + DOCUMENTS_UNDER_REVIEW: { + stage: 3, + icon: ShieldCheck, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Clearance under review · re-upload any queried docs", + step: "edr-blue-dot", + badgeLabel: "In review", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "Review documents", kind: "outline" }, + }, + CLEARANCE_READY: { + stage: 3, + icon: CalendarClock, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Cleared · choose a shipment day to proceed", + step: "edr-green.5", + badgeLabel: "Cleared", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "Schedule & proceed", kind: "amber", icon: ArrowRight }, + }, + OPERATION_REQUEST_PENDING: { + stage: 3, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Operation request under review by operations", + step: "edr-blue-dot", + badgeLabel: "Op. review", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + OPERATION_REQUESTED: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Operation requested · operator taking it forward", + step: "edr-green.5", + badgeLabel: "Operation requested", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + OPERATION_CHANGES_REQUESTED: { + stage: 3, + icon: FilePen, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Operations requested changes · please review", + step: "edr-accent", + badgeLabel: "Revise", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Review", kind: "dark" }, + }, + OPERATION_PRICE_PENDING_CONFIRM: { + stage: 3, + icon: Wallet, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Price adjusted · confirm to proceed", + step: "edr-accent", + badgeLabel: "Confirm price", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Confirm", kind: "amber", icon: ArrowRight }, + }, + ROAD_DISPATCH_PENDING: { + stage: 3, + icon: Truck, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Accepted · awaiting truck dispatch", + step: "edr-blue-dot", + badgeLabel: "Awaiting dispatch", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, PNR_GENERATED: { stage: 3, icon: FileCheck2, @@ -317,6 +424,84 @@ export const STATUS_CONFIG: Record = { badgeDot: "edr-green.5", action: { label: "View", kind: "outline" }, }, + PRICE_CHANGED_PENDING_CONFIRM: { + stage: 1, + icon: Wallet, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Price changed · confirm to continue", + step: "edr-accent", + badgeLabel: "Confirm price", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Confirm", kind: "amber", icon: ArrowRight }, + }, + READY_FOR_ASSIGNMENT: { + stage: 2, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Approved · awaiting wagon assignment", + step: "edr-blue-dot", + badgeLabel: "Assigning", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + WAGON_ASSIGNED: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Wagon assigned · preparing for loading", + step: "edr-green.5", + badgeLabel: "Wagon assigned", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + INVOICED: { + stage: 3, + icon: Wallet, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Invoice issued · awaiting payment", + step: "edr-blue-dot", + badgeLabel: "Invoiced", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + CONTRACT_ACTIVE: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Contract active · accepting orders", + step: "edr-green.5", + badgeLabel: "Active", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + CONTRACT_CLOSED: { + stage: 4, + icon: CheckCircle2, + iconColor: "edr-slate", + tile: "edr-slate-soft2", + hint: "Contract closed · quantity used or window elapsed", + step: "edr-step", + badgeLabel: "Closed", + badgeBg: "edr-slate-soft2", + badgeText: "edr-slate", + badgeDot: "edr-step", + action: { label: "View", kind: "outline" }, + }, }; export const ACTION_PROPS: Record< diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index ab7cb0f40..4f61c7a61 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,10 +1,10 @@ import { Alert, Button, - Checkbox, Divider, Group, Loader, + PinInput, SimpleGrid, Stack, Text, @@ -16,7 +16,11 @@ import { AlertCircle, ArrowLeft, ArrowRight, - // UserCheck, + CheckCircle2, + RotateCw, + ShieldCheck, + Smartphone, + UserCheck, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -37,18 +41,29 @@ import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; import ETradeInfo from "@/components/onboarding/ETradeInfo"; +import { extractApiError } from "@/utils/result"; type CompanyStep = | "company" | "personnel" | "contact" + | "verify" | "poa" | "documents" | "additional"; +/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ +const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9); +const samePhone = (a?: string | null, b?: string | null) => { + const da = phoneDigits(a); + return da.length === 9 && da === phoneDigits(b); +}; +/** Mask all but the first 7 chars of an E.164 phone for display. */ +const maskPhone = (p: string) => + p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; + const onboardingSchema = z.object({ - companyFirstName: z.string().min(1, "First name is required"), - companyLastName: z.string().min(1, "Last name is required"), + companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), companyPhone: z .string() @@ -58,10 +73,7 @@ const onboardingSchema = z.object({ // Derived from the eTrade address parts (kebele/woreda/zone/region); no // standalone input — the granular fields live in the registration section. companyAddress: z.string().optional(), - tinNumber: z - .string() - .length(10, "TIN must be exactly 10 digits") - .regex(/^00\d{8}$/, "TIN must be 10 digits starting with 00"), + tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), vatNumber: z .string() .min(1, "VAT number is required") @@ -73,18 +85,15 @@ const onboardingSchema = z.object({ renewedFrom: z.string().optional(), renewalDate: z.string().optional(), renewedTo: z.string().optional(), - region: z.string().optional(), - zone: z.string().optional(), - woreda: z.string().optional(), - kebele: z.string().optional(), - houseNo: z.string().optional(), + // Address fields are user-entered and required (the registration/license + // fields above are read-only confirmations pulled from eTrade). + region: z.string().min(1, "Region is required"), + zone: z.string().min(1, "Zone is required"), + woreda: z.string().min(1, "Woreda is required"), + kebele: z.string().min(1, "Kebele is required"), + houseNo: z.string().min(1, "House number is required"), etradePhone: z.string().optional(), - contactPersonFirstName: z - .string() - .min(1, "Contact person first name is required"), - contactPersonLastName: z - .string() - .min(1, "Contact person last name is required"), + contactPersonName: z.string().min(1, "Contact person name is required"), contactPersonPosition: z.string().optional(), contactPersonEmail: z .string() @@ -95,15 +104,13 @@ const onboardingSchema = z.object({ .string() .min(1, "Contact person phone is required") .refine(isValidPhone, "Enter a valid phone number"), - generalManagerFirstName: z.string().min(1, "GM first name is required"), - generalManagerLastName: z.string().min(1, "GM last name is required"), - generalManagerEmail: z.string().email("Invalid GM email"), + generalManagerName: z.string().min(1, "Manager name is required"), + generalManagerEmail: z.string().email("Invalid Manager email"), generalManagerPhone: z .string() - .min(1, "GM phone is required") + .min(1, "Manager phone is required") .refine(isValidPhone, "Enter a valid phone number"), - poaFirstName: z.string().optional(), - poaLastName: z.string().optional(), + poaName: z.string().optional(), poaPhone: z .string() .optional() @@ -117,8 +124,7 @@ type FormData = z.infer; const stepFields: Record = { company: [ - "companyFirstName", - "companyLastName", + "companyName", "companyEmail", "companyPhone", "companyLocation", @@ -140,40 +146,25 @@ const stepFields: Record = { "etradePhone", ], personnel: [ - "generalManagerFirstName", - "generalManagerLastName", + "generalManagerName", "generalManagerEmail", "generalManagerPhone", ], contact: [ - "contactPersonFirstName", - "contactPersonLastName", + "contactPersonName", "contactPersonPosition", "contactPersonEmail", "contactPersonPhone", ], + verify: [], poa: [], documents: [], additional: [], }; -/** Join first + last into the single name the API stores. */ -function joinName(first?: string, last?: string): string { - return [first?.trim(), last?.trim()].filter(Boolean).join(" "); -} - -/** Split a stored single name into first (first token) + last (the rest). */ -function splitName(full?: string | null): { first: string; last: string } { - const trimmed = (full ?? "").trim(); - if (!trimmed) return { first: "", last: "" }; - const idx = trimmed.indexOf(" "); - if (idx === -1) return { first: trimmed, last: "" }; - return { first: trimmed.slice(0, idx), last: trimmed.slice(idx + 1).trim() }; -} - function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { return { - companyName: joinName(data.companyFirstName, data.companyLastName), + companyName: data.companyName, companyEmail: data.companyEmail, companyPhone: data.companyPhone, companyLocation: data.companyLocation, @@ -182,20 +173,14 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { vatNumber: data.vatNumber, fanNumber: data.fanNumber, attributes: { - contactPersonName: joinName( - data.contactPersonFirstName, - data.contactPersonLastName, - ), + contactPersonName: data.contactPersonName, contactPersonPosition: data.contactPersonPosition || undefined, contactPersonEmail: data.contactPersonEmail || undefined, contactPersonPhone: data.contactPersonPhone, - generalManagerName: joinName( - data.generalManagerFirstName, - data.generalManagerLastName, - ), + generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, generalManagerPhone: data.generalManagerPhone, - poaName: joinName(data.poaFirstName, data.poaLastName) || undefined, + poaName: data.poaName || undefined, poaPhone: data.poaPhone || undefined, poaAddress: data.poaAddress || undefined, poaEmail: data.poaEmail || undefined, @@ -205,11 +190,14 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { } /** Map one wizard step's form values to the profile-update payload it saves. */ -function stepPayload(step: CompanyStep, d: FormData): Partial { +function stepPayload( + step: CompanyStep, + d: FormData, +): Partial { switch (step) { case "company": return { - companyName: joinName(d.companyFirstName, d.companyLastName), + companyName: d.companyName, companyEmail: d.companyEmail, companyPhone: d.companyPhone, companyLocation: d.companyLocation, @@ -232,26 +220,20 @@ function stepPayload(step: CompanyStep, d: FormData): Partial + + {label} + + + {value && value.trim() ? value : "—"} + + + ); +} + export default function CompanyProfileForm({ documentSettingCode, documentFiles: controlledFiles, @@ -327,6 +315,7 @@ export default function CompanyProfileForm({ roleProfiles, licenseFiles, onLicenseChange, + submitError, }: { documentSettingCode: string; documentFiles?: Record; @@ -354,6 +343,8 @@ export default function CompanyProfileForm({ /** Newly-selected license files per profile id. */ licenseFiles?: Record; onLicenseChange?: (value: Record) => void; + /** Server error from the final submit (uploads/complete), shown verbatim. */ + submitError?: string | null; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -400,8 +391,7 @@ export default function CompanyProfileForm({ } = useForm({ resolver: zodResolver(onboardingSchema), defaultValues: { - companyFirstName: "", - companyLastName: "", + companyName: "", companyEmail: "", companyPhone: "", companyLocation: "", @@ -421,17 +411,14 @@ export default function CompanyProfileForm({ kebele: "", houseNo: "", etradePhone: "", - contactPersonFirstName: "", - contactPersonLastName: "", + contactPersonName: "", contactPersonPosition: "", contactPersonEmail: "", contactPersonPhone: "", - generalManagerFirstName: "", - generalManagerLastName: "", + generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", - poaFirstName: "", - poaLastName: "", + poaName: "", poaPhone: "", poaAddress: "", poaEmail: "", @@ -441,25 +428,47 @@ export default function CompanyProfileForm({ values: rehydrate ? toFormValues(rehydrate) : undefined, }); + // eTrade carries no email, so the company/contact email fields start blank. + // Seed them from the registering user's account email — but only while empty, + // so a typed or rehydrated value is never overwritten. + useEffect(() => { + if (!user?.email) return; + if (!watch("companyEmail")) { + setValue("companyEmail", user.email, { shouldValidate: true }); + } + if (!watch("contactPersonEmail")) { + setValue("contactPersonEmail", user.email); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.email, rehydrate]); + + // Keep the (hidden, derived) company address in sync with the editable address + // fields — so it reflects both the eTrade auto-fill and any later user edits, + // instead of only whatever was composed at lookup time. + const region = watch("region"); + const zone = watch("zone"); + const woreda = watch("woreda"); + const kebele = watch("kebele"); + const houseNo = watch("houseNo"); + useEffect(() => { + const composed = [houseNo, kebele, woreda, zone, region] + .filter((part) => part && part.trim()) + .join(", "); + setValue("companyAddress", composed); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [region, zone, woreda, kebele, houseNo]); + // The business owner/manager pulled from eTrade — powers "Use owner as - // manager" on the General Manager step. + // manager" on the General Manager step. Null until a TIN lookup succeeds. const [etradeOwner, setEtradeOwner] = useState<{ name: string; phone: string; - email?: string; } | null>(null); - // Mirror the three "copy from previous person" checkboxes. - const [ownerIsGm, setOwnerIsGm] = useState(false); - const [gmIsContact, setGmIsContact] = useState(false); - const [contactIsPoa, setContactIsPoa] = useState(false); - const handleETradeDataLoaded = (data: CompanyRegistrationData) => { // Company name comes from the eTrade manager/owner name on the license. if (data.managerName) { - const { first, last } = splitName(data.managerName); - setValue("companyFirstName", first, { shouldValidate: true }); - setValue("companyLastName", last, { shouldValidate: true }); + setValue("companyName", data.managerName, { shouldValidate: true }); } setValue("licenceNumber", data.licenceNumber); setValue("statusDescription", data.statusDescription); @@ -476,18 +485,9 @@ export default function CompanyProfileForm({ "etradePhone", toEthiopianE164(data.regularPhone || data.mobilePhone), ); - - // Compose a readable company address from the granular eTrade parts. - const addressParts = [ - data.houseNo, - data.kebele, - data.woreda, - data.zone, - data.region, - ].filter((part) => part && part.trim()); - if (addressParts.length) { - setValue("companyAddress", addressParts.join(", ")); - } + // companyAddress is composed reactively from the address fields below, so + // setting region/zone/woreda/kebele/houseNo above is enough — no need to + // compose it here. // Pre-fill the company contact phone from eTrade's mobile number. const mobile = toEthiopianE164(data.mobilePhone || data.regularPhone); @@ -500,53 +500,149 @@ export default function CompanyProfileForm({ phone: toEthiopianE164( data.managerPhone || data.regularPhone || data.mobilePhone, ), - email: data.managerEmail || undefined, }); }; /** Fill the General Manager from the eTrade business owner. */ - const toggleOwnerAsGm = (checked: boolean) => { - setOwnerIsGm(checked); - if (!checked || !etradeOwner) return; - const { first, last } = splitName(etradeOwner.name); - setValue("generalManagerFirstName", first, { shouldValidate: true }); - setValue("generalManagerLastName", last, { shouldValidate: true }); - setValue("generalManagerEmail", etradeOwner.email ?? "", { - shouldValidate: true, - }); + const useOwnerAsManager = () => { + if (!etradeOwner) return; + setValue("generalManagerName", etradeOwner.name); setValue("generalManagerPhone", etradeOwner.phone ?? "", { shouldValidate: true, }); }; - /** Copy the General Manager into the Contact Person fields (toggleable). */ - const toggleGmAsContact = (checked: boolean) => { - setGmIsContact(checked); - if (!checked) return; - setValue("contactPersonFirstName", watch("generalManagerFirstName")); - setValue("contactPersonLastName", watch("generalManagerLastName")); + /** Copy the General Manager into the Contact Person fields (still editable). */ + const useGmAsContact = () => { + setValue("contactPersonName", watch("generalManagerName"), { + shouldValidate: true, + }); setValue("contactPersonEmail", watch("generalManagerEmail")); - setValue("contactPersonPhone", watch("generalManagerPhone")); + setValue("contactPersonPhone", watch("generalManagerPhone"), { + shouldValidate: true, + }); }; - /** Copy the Contact Person into the PoA fields (toggleable, still editable). */ - const toggleContactAsPoa = (checked: boolean) => { - setContactIsPoa(checked); - if (!checked) return; - setValue("poaFirstName", watch("contactPersonFirstName")); - setValue("poaLastName", watch("contactPersonLastName")); + /** Copy the Contact Person into the PoA fields (still editable). */ + const useContactAsPoa = () => { + setValue("poaName", watch("contactPersonName")); setValue("poaEmail", watch("contactPersonEmail")); setValue("poaPhone", watch("contactPersonPhone")); }; + /** Populate the Contact Person from the currently logged-in user. */ + const useLoggedInUserAsContact = () => { + setValue("contactPersonName", user?.name?.en ?? "", { + shouldValidate: true, + }); + if (user?.email) setValue("contactPersonEmail", user.email); + setValue("contactPersonPhone", user?.phoneNumber ?? "", { + shouldValidate: true, + }); + }; + + // --- Contact-phone SMS OTP verification ----------------------------------- + // The phone we verify is the contact-person phone, normalised to E.164 so it + // matches what the backend persists as `contactVerifiedPhone`. + const contactPhoneE164 = toEthiopianE164(watch("contactPersonPhone") ?? ""); + // Source of truth for "already verified" comes from the onboarding/profile + // info (rehydrate) — so a refresh resumes the verify step's "done" state. + const [verifiedPhone, setVerifiedPhone] = useState( + rehydrate?.contactVerifiedPhone ?? null, + ); + useEffect(() => { + if (rehydrate?.contactVerifiedPhone) { + setVerifiedPhone(rehydrate.contactVerifiedPhone); + } + }, [rehydrate?.contactVerifiedPhone]); + const phoneVerified = samePhone(verifiedPhone, contactPhoneE164); + + const [otpSent, setOtpSent] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [sendingOtp, setSendingOtp] = useState(false); + const [verifyingOtp, setVerifyingOtp] = useState(false); + const [otpError, setOtpError] = useState(null); + const [resendIn, setResendIn] = useState(0); + + // Resend cooldown countdown (no Date.now needed — pure setTimeout ticks). + useEffect(() => { + if (resendIn <= 0) return; + const t = setTimeout(() => setResendIn((s) => s - 1), 1000); + return () => clearTimeout(t); + }, [resendIn]); + + // A changed contact phone invalidates any in-flight code entry (the previous + // code was for a different number). Verified state is handled separately via + // the phone comparison, so this only resets the send/enter UI. + useEffect(() => { + setOtpSent(false); + setOtpCode(""); + setOtpError(null); + }, [contactPhoneE164]); + + const sendContactOtp = async () => { + setOtpError(null); + if (!contactPhoneE164) { + setOtpError("Enter a valid contact phone number first."); + return; + } + setSendingOtp(true); + try { + await api.auth.sendOTP.call({ phone: contactPhoneE164 }); + setOtpSent(true); + setOtpCode(""); + setResendIn(60); + } catch (err) { + setOtpError(extractApiError(err).message); + } finally { + setSendingOtp(false); + } + }; + + const verifyContactOtp = async () => { + setOtpError(null); + if (otpCode.length !== 6) { + setOtpError("Enter the 6-digit code we sent you."); + return; + } + setVerifyingOtp(true); + try { + await api.auth.verifyOTP.call({ phone: contactPhoneE164, otp: otpCode }); + setVerifiedPhone(contactPhoneE164); + setOtpSent(false); + // Persist the verified phone so the step resumes as "done" after a refresh + // (best-effort — the OTP itself already succeeded server-side). + onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {}); + } catch (err) { + setOtpError(extractApiError(err).message); + } finally { + setVerifyingOtp(false); + } + }; + const hasDocuments = Boolean(uploadSetting?.fields?.length); + // The registration/license details come straight from the eTrade lookup and + // are not user-editable — shown as a read-only confirmation once a TIN lookup + // (or rehydration) has filled them in. The address fields below are separate: + // user-entered and required. We watch the values so the display stays current. + const registration = watch([ + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewalDate", + "renewedFrom", + "renewedTo", + ]); + const hasRegistrationDetails = registration.some((v) => v && v.trim()); + // Single source of truth for step sequence — navigation, labels and the // progress bar all derive from this so adding/removing a step is one edit. const stepOrder: CompanyStep[] = [ "company", "personnel", "contact", + "verify", "poa", "documents", "additional", @@ -589,6 +685,20 @@ export default function CompanyProfileForm({ handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + // Contact-phone verification gates advancing past the verify step. The + // verified phone is already persisted (on verify success), so there's + // nothing extra to save here. + if (step === "verify") { + if (!phoneVerified) { + setSaveError( + "Please verify the contact person's phone number to continue.", + ); + return; + } + setSaveError(null); + setStep(stepOrder[currentIdx + 1]); + return; + } // The documents step has nothing to persist; field steps validate + save // before advancing. if (step !== "documents") { @@ -626,23 +736,15 @@ export default function CompanyProfileForm({ + First Name *} - placeholder="Global" - error={errors.companyFirstName?.message} - {...register("companyFirstName")} - /> - Last Name *} - placeholder="Logistics Ltd" - error={errors.companyLastName?.message} - {...register("companyLastName")} - /> - - - Company Email *} + label="Company Email" type="email" placeholder="ops@company.com" error={errors.companyEmail?.message} @@ -656,21 +758,21 @@ export default function CompanyProfileForm({ /> Location *} + label="Location" placeholder="Addis Ababa, Ethiopia" error={errors.companyLocation?.message} {...register("companyLocation")} /> VAT Number *} + label="VAT Number" placeholder="VAT-12345" maxLength={10} error={errors.vatNumber?.message} {...register("vatNumber")} /> FAN Number (16 digits) *} + label="FAN Number (16 digits)" placeholder="1234567890123456" maxLength={16} error={errors.fanNumber?.message} @@ -678,145 +780,126 @@ export default function CompanyProfileForm({ /> - <> + {hasRegistrationDetails && ( + <> - - Registration Details - - - Auto-filled from eTrade — these fields cannot be edited. - + + + Registration Details + + + from eTrade · read-only + + - - - - - - - - - - - - - - Address Information - - - - - - - - - - - - + )} + + + + Address Information + + + + + + + + + + + + + )} {step === "personnel" && ( <> - - General Manager - - toggleOwnerAsGm(e.currentTarget.checked)} + + + General Manager + + {etradeOwner && ( + + )} + + First Name *} - placeholder="Abebe" - error={errors.generalManagerFirstName?.message} - {...register("generalManagerFirstName")} - /> - Last Name *} - placeholder="Bikila" - error={errors.generalManagerLastName?.message} - {...register("generalManagerLastName")} - /> - - - Email *} + label="Email" type="email" placeholder="gm@company.com" error={errors.generalManagerEmail?.message} @@ -834,36 +917,48 @@ export default function CompanyProfileForm({ {step === "contact" && ( <> - - Contact Person - - toggleGmAsContact(e.currentTarget.checked)} - /> + + + Contact Person + + + + {watch("generalManagerName") && ( + + )} + + First Name *} - placeholder="Jane" - error={errors.contactPersonFirstName?.message} - {...register("contactPersonFirstName")} + label="Name" + placeholder="Jane Smith" + error={errors.contactPersonName?.message} + {...register("contactPersonName")} /> - Last Name *} - placeholder="Smith" - error={errors.contactPersonLastName?.message} - {...register("contactPersonLastName")} - /> - - + + - - )} + {step === "verify" && ( + + + + + Verify the contact person + + + + We'll text a one-time code to the contact person's phone to + confirm it's reachable. This is required before you continue. + + + {!contactPhoneE164 ? ( + } + > + Add a valid contact phone number on the previous step first. + + ) : phoneVerified ? ( + } + title="Phone verified" + > + {maskPhone(contactPhoneE164)} has been verified. + + ) : ( + + + + + {maskPhone(contactPhoneE164)} + + + + {!otpSent ? ( + + ) : ( + + + Enter the 6-digit code we sent to{" "} + {maskPhone(contactPhoneE164)}. + + + + + + + + )} + + {otpError && ( + } + > + {otpError} + + )} + + )} + + )} + {step === "poa" && ( <> - - Power of Attorney details are optional. Fill them in if you have - them, or skip to continue. - - toggleContactAsPoa(e.currentTarget.checked)} + + + Power of Attorney details are optional. Fill them in if you + have them, or skip to continue. + + {watch("contactPersonName") && ( + + )} + + - - - - {})} + onChange={onLicenseChange ?? (() => { })} /> )} @@ -983,6 +1176,17 @@ export default function CompanyProfileForm({ )} + {submitError && ( + } + title="Couldn't submit your application" + > + {submitError} + + )} + {showBack ? ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx new file mode 100644 index 000000000..0599d685e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx @@ -0,0 +1,205 @@ +import { + Alert, + Button, + Group, + Modal, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { AlertCircle, Send, XCircle } from "lucide-react"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; + +import { PriceChangeModal } from "@/pages/bookings/resubmit/PriceChangeModal"; +import { ResubmitDocuments } from "@/pages/bookings/resubmit/ResubmitDocuments"; +import { useResubmitFlow } from "@/pages/bookings/resubmit/useResubmitFlow"; + +import { CardTitle, PageShell, SectionCard } from "./components/layout"; +import { BodyGrid } from "./components/layout"; +import { ActionRequiredBanner, MutationErrors } from "./components/Notices"; +import { PageHeader } from "./components/PageHeader"; +import { EstimateCard } from "./components/pricing"; +import { ScheduleCard } from "./components/ScheduleCard"; +import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; +import { StatusHero } from "./components/StatusHero"; +import { SupportCard } from "./components/SupportCard"; + +/** + * Detail-page view for a booking staff returned with CHANGES_REQUESTED. + * + * Unlike a fresh draft, this booking already went through submission, so the + * documents shown are exactly the files the customer submitted (`booking.files`) + * — not a fixed required-document list. The customer reviews the staff note, + * replaces any document they need to update, and resubmits in place. + */ +export function ChangesRequestedView({ + booking, + onBookingUpdated, +}: { + booking: Freight.IBooking; + onBookingUpdated: () => void; +}) { + const navigate = useNavigate(); + const flow = useResubmitFlow(booking, { onResubmitted: onBookingUpdated }); + + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); + const [cancelReason, setCancelReason] = useState(""); + + const { data: generatedPricing } = useQuery( + api.bookings.generatePrice.queryOptions({ + input: { id: booking.id }, + enabled: !booking.pricingBreakdown, + }), + ); + const pricing = (booking.pricingBreakdown ?? + generatedPricing ?? + null) as Freight.PricingBreakdown | null; + + const cancelMutation = useMutation({ + mutationFn: (reason: string) => + api.bookings.cancel.call({ id: booking.id, reason }), + onSuccess: () => { + setCancelDialogOpen(false); + onBookingUpdated(); + }, + }); + + return ( + + setCancelDialogOpen(true), + onSupport: () => navigate("/support"), + }} + /> + + + + + {booking.latestChangeRequestNote ? ( + + {booking.latestChangeRequestNote} + + ) : undefined} + + + + + + + + Your documents + + + Update the documents for this booking, then resubmit for review. + Replace any that changed and attach any that are still required. + + + + + {flow.validationError && ( + } + mt="md" + > + {flow.validationError} + + )} + + + + + } + 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 89c32d26f..f7c9190b3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -34,8 +34,8 @@ import { import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal"; import { PayNowButton } from "./payments/PayNowButton"; -import useAuth from "@/hooks/useAuth"; -import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; +import { BookingActionButton } from "./clearance/BookingActionButton"; +import { bookingHasInlineAction } from "./clearance/bookingNextAction"; import { BookingTypeBadge, CargoModeCell, @@ -65,7 +65,11 @@ import { // ── Status filter options (grouped by lifecycle) ────────────────────────────── const STATUS_FILTERS = [ - { key: "all", label: "All bookings", statuses: undefined as string | undefined }, + { + key: "all", + label: "All bookings", + statuses: undefined as string | undefined, + }, { key: "active", label: "In progress", @@ -81,12 +85,19 @@ const STATUS_FILTERS = [ }, { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" }, { key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" }, - { key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" }, + { + key: "closed", + label: "Cancelled / rejected", + statuses: "CANCELLED,REJECTED", + }, ] as const; type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"]; -const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label })); +const SELECT_DATA = STATUS_FILTERS.map((f) => ({ + value: f.key, + label: f.label, +})); // ── Summary stat cards (clickable lifecycle filters) ────────────────────────── @@ -97,42 +108,42 @@ const STAT_CARDS: Array<{ iconBg: string; iconColor: string; }> = [ - { - key: "all", - label: "All bookings", - icon: LayoutList, - iconBg: "#ECF6F1", - iconColor: "#0A8A5F", - }, - { - key: "active", - label: "In progress", - icon: Package, - iconBg: "#FDF3E0", - iconColor: "#C77F09", - }, - { - key: "payment", - label: "Awaiting payment", - icon: Wallet, - iconBg: "#FEF6E6", - iconColor: "#F2A516", - }, - { - key: "draft", - label: "Drafts", - icon: FileEdit, - iconBg: "#F1F4F7", - iconColor: "#475569", - }, - { - key: "done", - label: "Completed", - icon: CheckCircle2, - iconBg: "#ECF6F1", - iconColor: "#0A8A5F", - }, -]; + { + key: "all", + label: "All bookings", + icon: LayoutList, + iconBg: "#ECF6F1", + iconColor: "#0A8A5F", + }, + { + key: "active", + label: "In progress", + icon: Package, + iconBg: "#FDF3E0", + iconColor: "#C77F09", + }, + { + key: "payment", + label: "Awaiting payment", + icon: Wallet, + iconBg: "#FEF6E6", + iconColor: "#F2A516", + }, + { + key: "draft", + label: "Drafts", + icon: FileEdit, + iconBg: "#F1F4F7", + iconColor: "#475569", + }, + { + key: "done", + label: "Completed", + icon: CheckCircle2, + iconBg: "#ECF6F1", + iconColor: "#0A8A5F", + }, + ]; // ── Status badge (reuses the shared portal status config) ───────────────────── @@ -140,8 +151,12 @@ function StatusBadge({ status }: { status: string }) { const cfg = STATUS_CONFIG[status]; const label = cfg?.badgeLabel ?? status.replace(/_/g, " "); const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7"; - const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569"; - const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8"; + const text = cfg + ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` + : "#475569"; + const dot = cfg + ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` + : "#94A3B8"; return ( } - style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }} + style={{ + backgroundColor: "var(--mantine-color-edr-ink-0)", + color: "#fff", + }} onClick={go} > Continue ); } - 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" @@ -221,7 +229,14 @@ function PrimaryAction({ return ; } return ( - ); @@ -233,7 +248,11 @@ function ColHeader({ label }: { label: string }) { fz={11} fw={700} c="edr-muted" - style={{ letterSpacing: "0.6px", textTransform: "uppercase", whiteSpace: "nowrap" }} + style={{ + letterSpacing: "0.6px", + textTransform: "uppercase", + whiteSpace: "nowrap", + }} > {label} @@ -248,22 +267,19 @@ function fmtDate(iso?: string | null): string { return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); + year: "numeric", + month: "short", + day: "numeric", + }); } // ── Main component ──────────────────────────────────────────────────────────── // Lightweight count query for a single lifecycle filter (reads only `total`). -function useStatusCount( - statuses: string | undefined, - companyProfileId?: string, -): number | undefined { +function useStatusCount(statuses: string | undefined): number | undefined { const { data } = useQuery( api.bookings.list.queryOptions({ - input: { statuses, companyProfileId, page: 1, pageSize: 1 }, + input: { statuses, page: 1, pageSize: 1 }, staleTime: 30_000, }), ); @@ -339,21 +355,10 @@ export default function MyBookings() { const [query, setQuery] = useState(""); const [typeFilter, setTypeFilter] = useState(null); const [freightFilter, setFreightFilter] = useState(null); - const [serviceFilter, setServiceFilter] = useState(null); const [createdFrom, setCreatedFrom] = useState(""); const [createdTo, setCreatedTo] = useState(""); - - // Operational-service options (importer / exporter / freight forwarder) for - // the per-page filter. Empty for non-customer companies. - const { company } = useAuth(); - const companyProfiles = company?.company?.companyProfiles ?? []; - const serviceOptions = companyProfiles.map((p) => ({ - value: p.id, - label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`, - })); - const [trackingBooking, setTrackingBooking] = useState( - null, - ); + const [trackingBooking, setTrackingBooking] = + useState(null); const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses; @@ -366,15 +371,10 @@ export default function MyBookings() { }; const hasExtraFilters = - !!typeFilter || - !!freightFilter || - !!serviceFilter || - !!createdFrom || - !!createdTo; + !!typeFilter || !!freightFilter || !!createdFrom || !!createdTo; const clearExtraFilters = () => { setTypeFilter(null); setFreightFilter(null); - setServiceFilter(null); setCreatedFrom(""); setCreatedTo(""); resetPage(); @@ -385,7 +385,6 @@ export default function MyBookings() { statuses, bookingType: typeFilter ?? undefined, freightType: freightFilter ?? undefined, - companyProfileId: serviceFilter ?? undefined, createdFrom: createdFrom || undefined, // include the whole selected end day createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined, @@ -396,7 +395,6 @@ export default function MyBookings() { statuses, typeFilter, freightFilter, - serviceFilter, createdFrom, createdTo, pagination.pageIndex, @@ -408,33 +406,25 @@ export default function MyBookings() { api.bookings.list.queryOptions({ input: filter }), ); - // Per-card lifecycle counts (one cheap query each, total-only). Scoped to the - // selected service so the cards match the filtered table. - const svc = serviceFilter ?? undefined; - const allCount = useStatusCount(undefined, svc); + // Per-card lifecycle counts (one cheap query each, total-only). + const allCount = useStatusCount(undefined); const activeCount = useStatusCount( STATUS_FILTERS.find((f) => f.key === "active")!.statuses, - svc, ); const paymentCount = useStatusCount( STATUS_FILTERS.find((f) => f.key === "payment")!.statuses, - svc, ); const draftCount = useStatusCount( STATUS_FILTERS.find((f) => f.key === "draft")!.statuses, - svc, ); const doneCount = useStatusCount( STATUS_FILTERS.find((f) => f.key === "done")!.statuses, - svc, ); const transitCount = useStatusCount( STATUS_FILTERS.find((f) => f.key === "transit")!.statuses, - svc, ); const closedCount = useStatusCount( STATUS_FILTERS.find((f) => f.key === "closed")!.statuses, - svc, ); const cardCounts: Record = { all: allCount, @@ -462,8 +452,7 @@ export default function MyBookings() { const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; - const showEmpty = - !isLoading && !isError && rows.length === 0; + const showEmpty = !isLoading && !isError && rows.length === 0; const columns: ColumnDef[] = [ { @@ -473,7 +462,8 @@ export default function MyBookings() { header: () => , cell: ({ row }) => { const b = row.original; - const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container"; + const cargoLabel = + b.freightType === "BULK" ? "Bulk cargo" : "Container"; return ( - + @@ -594,7 +588,12 @@ export default function MyBookings() { const booking = row.original; const trackable = TRACKABLE_STATUSES.has(booking.status); return ( - e.stopPropagation()}> + e.stopPropagation()} + > {trackable && (