diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index b3ed1c802..d10f19f10 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -36,8 +36,7 @@ "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", - "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", - "migration:run": "node dist/scripts/migrate.js", + "migration:run": "nest build && node dist/scripts/migrate.js", "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a3c09df97..66f64678a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -89,6 +89,7 @@ import { CargoesModule } from "./modules/cargoes/cargoes.module"; import { RoutesModule } from "./modules/routes/routes.module"; import { WarehousesModule } from "./modules/warehouses/warehouses.module"; import { OverviewModule } from "./modules/overview/overview.module"; +import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module"; import { VehiclesModule } from "./modules/vehicles/vehicles.module"; import { DriversModule } from "./modules/drivers/drivers.module"; import { FuelModule } from "./modules/fuel/fuel.module"; @@ -206,6 +207,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar RoutesModule, WarehousesModule, OverviewModule, + UserTradeAccessModule, VehiclesModule, DriversModule, FuelModule, diff --git a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts index 4f8dac4c7..4f060ca11 100644 --- a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts +++ b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts @@ -43,11 +43,17 @@ export function IsValidPhone(validationOptions?: ValidationOptions) { * Normalize a phone string to canonical E.164. Returns the canonical form when * parseable, otherwise the trimmed original (tolerant — never throws), or the * value unchanged when empty/nullish. + * + * Defaults the country to Ethiopia so bare local numbers (no "+", e.g. eTrade's + * "0355235416") resolve the same way the frontend's own toEthiopianE164 already + * assumes — without this hint, libphonenumber can't infer a country for a + * number with no "+" prefix and silently falls through to the untouched local + * string, which then never matches the "+251…" form submitted by the client. */ export function normalizeE164( value: string | null | undefined, ): string | null | undefined { if (value === undefined || value === null || value === '') return value; - const parsed = parsePhoneNumberFromString(value); + const parsed = parsePhoneNumberFromString(value, 'ET'); return parsed?.isValid() ? parsed.number : value.trim(); } diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index cf4c37b2d..f13c596d2 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -1,6 +1,7 @@ import "reflect-metadata"; import * as dotenv from "dotenv"; dotenv.config(); +import { createRequire } from "node:module"; import { NestFactory } from "@nestjs/core"; import type { NestExpressApplication } from "@nestjs/platform-express"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; @@ -20,6 +21,62 @@ import { AppModule } from "./app.module"; */ const JSON_BODY_LIMIT = '20mb'; +/** + * Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as + * `host=ip,host2=ip2`. Some internal hosts (MinIO) resolve only inside the + * deployment network, so dev machines get ENOTFOUND on every upload. Patching + * `dns.lookup` keeps the real hostname on the wire — the IP is used for the + * connection only — so TLS still validates against the certificate's CN. + * + * The module is loaded through `createRequire`, NOT `import * as dns`: an ESM + * namespace object is frozen, so assigning to it is silently dropped and the + * patch becomes a no-op. `require` returns the live module object every other + * caller (minio's http agent included) reads `lookup` off. + */ +function applyDnsHostOverrides(): void { + const raw = process.env.DNS_HOST_OVERRIDES?.trim(); + if (!raw) return; + + const overrides = new Map(); + for (const entry of raw.split(",")) { + const [host, ip] = entry.split("=").map((part) => part?.trim()); + if (host && ip) overrides.set(host.toLowerCase(), ip); + } + if (overrides.size === 0) return; + + const dns = createRequire(__filename)("node:dns") as typeof import("node:dns"); + const originalLookup = dns.lookup.bind(dns); + // `dns.lookup` is overloaded (options optional, all/family variants); the + // cast keeps that surface intact while we intercept only mapped hostnames. + (dns as { lookup: unknown }).lookup = (( + hostname: string, + options: unknown, + callback?: unknown, + ) => { + const ip = overrides.get(hostname?.toLowerCase?.()); + if (!ip) return (originalLookup as Function)(hostname, options, callback); + + const done = (typeof options === "function" ? options : callback) as ( + err: NodeJS.ErrnoException | null, + address: string | { address: string; family: number }[], + family?: number, + ) => void; + const family = ip.includes(":") ? 6 : 4; + const wantsAll = + typeof options === "object" && options !== null && (options as { all?: boolean }).all; + + process.nextTick(() => + wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family), + ); + }) as typeof dns.lookup; + + console.log( + `[DNS] Host overrides active: ${[...overrides].map(([h, ip]) => `${h}->${ip}`).join(", ")}`, + ); +} + +applyDnsHostOverrides(); + async function bootstrap() { const app = await NestFactory.create(AppModule); diff --git a/apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts b/apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts new file mode 100644 index 000000000..9e7cba924 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Per-backoffice-user trade-direction scope (import / export / intercity). + * A user with no row (or all three directions) is unrestricted. Admins + * (super_admin / organization_admin) bypass the scope entirely. + */ +export class CreateUserTradeAccess3150000000000 implements MigrationInterface { + name = 'CreateUserTradeAccess3150000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'user_trade_access', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + // IAM user id (iam.users) — no FK, iam schema is externally owned. + { name: 'user_id', type: 'uuid', isUnique: true }, + // Comma-separated subset of IMPORT,EXPORT,DOMESTIC (simple-array). + { name: 'directions', type: 'text', default: "''" }, + { name: 'updated_by_id', type: 'uuid', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.user_trade_access', + new TableIndex({ + name: 'idx_user_trade_access_user_id', + columnNames: ['user_id'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.user_trade_access', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/3150000000000-FixFaydaAddressShape.ts b/apps/edr-freight-api/src/migrations/3150000000000-FixFaydaAddressShape.ts new file mode 100644 index 000000000..7a8923f3c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3150000000000-FixFaydaAddressShape.ts @@ -0,0 +1,81 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * A normalizeUserInfo bug in VerifaydaService read the raw `address#en` / + * `address#am` claim objects (e.g. `{ "zone#en": "...", "region#en": "...", + * "woreda#en": "..." }`) straight through as if they were strings, so any + * company verified before the fix has `attributes.ownerAddress` / + * `poaAddress` stored as that raw object instead of a formatted string — + * which crashes the portal when it tries to render it as text. + * + * Reformats every affected row's ownerAddress/poaAddress into + * "woreda, zone, region" (falling back to whatever #en fields are present, + * in that preferred order, then any leftover fields), mirroring + * VerifaydaService.formatFaydaAddress. Only touches rows where the field is + * still a jsonb object, so it's idempotent and a no-op once repaired. + */ +export class FixFaydaAddressShape3150000000000 implements MigrationInterface { + name = "FixFaydaAddressShape3150000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION pg_temp.format_fayda_address(addr jsonb) + RETURNS text AS $$ + DECLARE + field_order text[] := ARRAY['houseNumber','kebele','woreda','city','subCity','zone','region','postalCode','country']; + f text; + v text; + parts text[] := '{}'; + used_keys text[] := '{}'; + kv record; + BEGIN + IF addr IS NULL OR jsonb_typeof(addr) != 'object' THEN + RETURN NULL; + END IF; + + FOREACH f IN ARRAY field_order LOOP + v := addr ->> (f || '#en'); + IF v IS NOT NULL AND trim(v) != '' THEN + parts := array_append(parts, trim(v)); + used_keys := array_append(used_keys, f || '#en'); + END IF; + END LOOP; + + FOR kv IN SELECT * FROM jsonb_each_text(addr) LOOP + IF kv.key LIKE '%#en' AND NOT (kv.key = ANY(used_keys)) + AND kv.value IS NOT NULL AND trim(kv.value) != '' THEN + parts := array_append(parts, trim(kv.value)); + END IF; + END LOOP; + + IF array_length(parts, 1) IS NULL THEN + RETURN NULL; + END IF; + RETURN array_to_string(parts, ', '); + END; + $$ LANGUAGE plpgsql; + + UPDATE freight.companies + SET attributes = jsonb_set( + attributes, + '{ownerAddress}', + to_jsonb(pg_temp.format_fayda_address(attributes -> 'ownerAddress')) + ) + WHERE jsonb_typeof(attributes -> 'ownerAddress') = 'object'; + + UPDATE freight.companies + SET attributes = jsonb_set( + attributes, + '{poaAddress}', + to_jsonb(pg_temp.format_fayda_address(attributes -> 'poaAddress')) + ) + WHERE jsonb_typeof(attributes -> 'poaAddress') = 'object'; + + DROP FUNCTION pg_temp.format_fayda_address(jsonb); + `); + } + + public async down(): Promise { + // Data repair — not reversible (the original malformed shape isn't worth restoring). + } +} diff --git a/apps/edr-freight-api/src/migrations/3160000000000-FixPaymentPaidAtType.ts b/apps/edr-freight-api/src/migrations/3160000000000-FixPaymentPaidAtType.ts new file mode 100644 index 000000000..35ccdc658 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3160000000000-FixPaymentPaidAtType.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * `freight.payments.paid_at` was created as `date` (CreatePaymentTable) and never + * migrated to `timestamp` alongside its siblings `refunded_at`/`expires_at` + * (UpdatePaymentTimestamp). TypeORM's postgres driver hydrates `date` columns as a + * plain "YYYY-MM-DD" string, not a `Date` — so `PaymentEntity.paidAt` (typed `Date`) + * was actually a string once read back from the DB, and + * `intent.paidAt?.toISOString()` in PaymentService.formatIntentStatus threw + * `TypeError: intent.paidAt.toISOString is not a function`. This hit every + * OTP-confirm response (CAC Bank) because confirmOtp always re-reads the intent + * before formatting the response. + */ +export class FixPaymentPaidAtType3160000000000 implements MigrationInterface { + name = "FixPaymentPaidAtType3160000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN paid_at TYPE timestamp + USING paid_at::timestamp; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN paid_at TYPE date + USING paid_at::date; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts b/apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts new file mode 100644 index 000000000..fb361b702 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Handling a freight type is not the same as handling it in both directions. A + * facility can be equipped to load containers onto a train but have no yard + * space to receive and stage inbound ones, so the origin and destination sides + * are stated independently per freight type. + * + * Backfilled from `handles_container` / `handles_bulk` so every existing row + * keeps its current behaviour: a facility that handles a type today handles it + * on both sides until someone narrows it in the backoffice. New rows default + * false — an unconfigured facility offers nothing rather than silently + * offering everything. + */ +export class YardFacilityOriginDestination3170000000000 implements MigrationInterface { + name = 'YardFacilityOriginDestination3170000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + ADD COLUMN IF NOT EXISTS has_container_facility_origin boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS has_bulk_facility_origin boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS has_container_facility_destination boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS has_bulk_facility_destination boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + UPDATE freight.yard_facilities + SET has_container_facility_origin = handles_container, + has_container_facility_destination = handles_container, + has_bulk_facility_origin = handles_bulk, + has_bulk_facility_destination = handles_bulk + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + DROP COLUMN IF EXISTS has_container_facility_origin, + DROP COLUMN IF EXISTS has_bulk_facility_origin, + DROP COLUMN IF EXISTS has_container_facility_destination, + DROP COLUMN IF EXISTS has_bulk_facility_destination + `); + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index d49cc304e..49384922e 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -59,6 +59,30 @@ export class BackofficeService { ]; } + /** + * IAM user ids of current employees (any org) holding ANY of the given + * permission keys — used by the notification recipients resolver's + * `permissionKeys` selector for department/role-scoped targeting. + */ + async getEmployeeUserIdsByPermission( + permissionKeys: string[], + ): Promise { + if (!permissionKeys.length) return []; + const rows: { userId: string | null }[] = await this.employeeRepository + .createQueryBuilder("employee") + .innerJoin("employee.employeePositions", "employeePosition") + .innerJoin("employeePosition.position", "position") + .innerJoin("position.positionPermission", "positionPermission") + .innerJoin("positionPermission.permission", "permission") + .where("employee.isCurrent = :isCurrent", { isCurrent: true }) + .andWhere("permission.key IN (:...permissionKeys)", { permissionKeys }) + .select("DISTINCT employee.user_id", "userId") + .getRawMany(); + return rows + .map((r) => r.userId) + .filter((id): id is string => Boolean(id)); + } + async createOrganizationUser( organizationId: string, dto: CreateOrganizationUserDto, diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index b9f0a74c0..49772316a 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -9,7 +9,11 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + import { BookingView } from "../../common/booking-guards"; +import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { BillingService } from "./billing.service"; import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @@ -18,14 +22,26 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @BookingView() @ApiBearerAuth() export class BillingController { - constructor(private readonly billingService: BillingService) {} + constructor( + private readonly billingService: BillingService, + private readonly userTradeAccessService: UserTradeAccessService, + ) {} @Get("invoices") @ApiOperation({ summary: "List invoices (paginated, filterable by company/status/search)", }) - findAll(@Query() query: FilterInvoiceDto) { - return this.billingService.findAllPaginated(query); + async findAll( + @Query() query: FilterInvoiceDto, + @CurrentUser() user: TCurrentUser, + ) { + // Per-user trade-direction scope, applied via each invoice's source booking. + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.billingService.findAllPaginated({ + ...query, + tradeDirections: allowed ?? undefined, + }); } @Get("invoices/:id") diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 771156fd3..a67e6f6ba 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./billing.controller"; import { PortalBillingController } from "./portal-billing.controller"; import { PaymentController } from "./payment.controller"; +import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; import { BillingService } from "./billing.service"; import { DocumentsModule } from "./documents/documents.module"; import { Invoice } from "./entities/invoice.entity"; @@ -19,6 +20,7 @@ import { CompaniesModule } from "../companies/companies.module"; forwardRef(() => PaymentModule), CompaniesModule, DocumentsModule, + UserTradeAccessModule, ], controllers: [BillingController, PortalBillingController, PaymentController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 850791f02..501bc8ee8 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -11,6 +11,7 @@ import { EventEmitter2 } from "@nestjs/event-emitter"; import { DataSource, EntityManager, In } from "typeorm"; import { CompaniesService } from "../companies/companies.service"; +import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentService } from "../payment/payment.service"; import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; import { @@ -167,6 +168,8 @@ export class BillingService { search?: string; page?: number; pageSize?: number; + /** Per-user trade-direction scope, applied via the source booking. */ + tradeDirections?: string[]; } = {}, ): Promise<{ items: Invoice[]; total: number }> { const page = filter.page && filter.page > 0 ? filter.page : 1; @@ -196,6 +199,14 @@ export class BillingService { ); } + if (filter.tradeDirections) { + applyBookingRefDirectionScope( + qb, + "invoice.source_id", + filter.tradeDirections, + ); + } + const [items, total] = await qb.getManyAndCount(); return { items, total }; } @@ -1126,23 +1137,24 @@ export class BillingService { .update({ id: invoice.id }, { paymentId: result.intentId }); // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); - // billing must not simulate it. Kept commented for local demos only. + // billing must not simulate it. Kept for local demos only. // An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the - // code — so the demo shortcut must never fire for it. - if ( - !result.immediateSuccess && - result.response.clientAction?.type !== "COLLECT_OTP" && - opts.method !== "CBE_BILL" - ) { - await this.payment.handlePaymentEvent({ - eventType: "payment.succeeded", - eventId: `demo-${result.intentId}`, - referenceId: invoice.sourceId, - intentId: result.intentId, - providerTxnId: result.providerTxnId, - paidAt: (result.paidAt ?? new Date()).toISOString(), - }); - } + // code — so the demo shortcut must never fire for it. Same for CBE_BILL: its + // bill must stay open until CBE actually settles it via /cbe/payment. + // if ( + // !result.immediateSuccess && + // result.response.clientAction?.type !== "COLLECT_OTP" && + // opts.method !== "CBE_BILL" + // ) { + // await this.payment.handlePaymentEvent({ + // eventType: "payment.succeeded", + // eventId: `demo-${result.intentId}`, + // referenceId: invoice.sourceId, + // intentId: result.intentId, + // providerTxnId: result.providerTxnId, + // paidAt: (result.paidAt ?? new Date()).toISOString(), + // }); + // } if (result.immediateSuccess) { await this.settleByPaymentId( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 06268342b..2c60b8771 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -22,6 +22,7 @@ import { IYardsRepository, YARDS_REPOSITORY, } from "../rule-engine/interfaces/yards.repository.interface"; +import { YardFacilitiesService } from "../rule-engine/services/yard-facilities.service"; import { BookingReferenceCargoTypeChildDto, BookingReferenceCargoTypeGroupDto, @@ -170,11 +171,18 @@ export class BookingReferenceDataService { private readonly shippingLinesRepository: IShippingLinesRepository, @Inject(CARGO_TYPES_REPOSITORY) private readonly cargoTypesRepository: ICargoTypesRepository, + private readonly yardFacilitiesService: YardFacilitiesService, ) { } async getReferenceData(): Promise { - const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] = - await Promise.all([ + const [ + yards, + containerTypes, + serviceTypes, + shippingLines, + cargoTypes, + facilityYards, + ] = await Promise.all([ this.yardsRepository.findAll({ where: { isActive: true }, order: { displayOrder: "ASC", code: "ASC" }, @@ -195,17 +203,30 @@ export class BookingReferenceDataService { where: { isActive: true }, order: { displayOrder: "ASC", code: "ASC" }, }), + this.yardFacilitiesService.listFacilityYards(), ]); + // Every active yard is still listed; a yard with no facility record simply + // reports no capability, so the forms drop it from the pickers themselves. + const facilityByYardId = new Map(facilityYards.map((f) => [f.yardId, f])); + return { - yard: yards.map( - (y): BookingReferenceYardDto => ({ + yard: yards.map((y): BookingReferenceYardDto => { + const facility = facilityByYardId.get(y.id); + return { id: y.id, name: y.label, code: y.code, country: y.country, - }), - ), + hasContainerFacilityOrigin: + facility?.hasContainerFacilityOrigin ?? false, + hasBulkFacilityOrigin: facility?.hasBulkFacilityOrigin ?? false, + hasContainerFacilityDestination: + facility?.hasContainerFacilityDestination ?? false, + hasBulkFacilityDestination: + facility?.hasBulkFacilityDestination ?? false, + }; + }), containers: groupContainersBySize(containerTypes), service: serviceTypes.map( (s): BookingReferenceServiceDto => ({ 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 7b1f34f36..837d0d801 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -43,6 +43,8 @@ import { RoAmendmentDto, } from '../contracts/dto/phased-clearance.dto'; import { BookingReferenceDataService } from './booking-reference-data.service'; +import { scopedDirections } from '../user-trade-access/trade-scope.util'; +import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; import { BookingsService } from './bookings.service'; import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; import { CreateBookingDto } from './dto/create-booking.dto'; @@ -150,6 +152,7 @@ export class BookingsController { private readonly containerReceiptService: ContainerReceiptService, private readonly firstMileService: FirstMileService, private readonly lastMileService: LastMileService, + private readonly userTradeAccessService: UserTradeAccessService, ) {} @Post() @@ -217,7 +220,16 @@ export class BookingsController { // Staff (backoffice) see every booking. Customers (portal) are always // force-scoped to their own company, regardless of any companyId they pass. if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - return this.bookingsService.findAll(filter); + // Per-user trade-direction scope (import/export/intercity checkboxes). + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + const dirs = scopedDirections(allowed, filter.tradeDirection); + return this.bookingsService.findAll( + filter, + undefined, + undefined, + dirs ?? undefined, + ); } // Global Logistics has clearance:view but NOT bookings:view — it is scoped // to the customs document-clearance queue only and never sees the general diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 38d7d2ac6..2063bb01d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,4 +1,5 @@ import { Module, forwardRef } from "@nestjs/common"; +import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; import { ConfigService } from "@nestjs/config"; import { TypeOrmModule } from "@nestjs/typeorm"; import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; @@ -80,6 +81,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; MinioModule, VehiclesModule, CompaniesModule, + UserTradeAccessModule, // CustomersModule, RuleEngineModule, FileUploadSettingsModule, 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 b16febe2e..185a6aaa7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -17,6 +17,7 @@ import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; +import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview, @@ -64,6 +65,8 @@ export interface BookingListFilterOptions { freightType?: string; bookingType?: string; tradeDirection?: string; + /** Per-user trade-direction scope — `[]` matches nothing. */ + tradeDirections?: string[]; paymentCurrency?: string; paymentStatus?: string; excludePaymentStatus?: string; @@ -1000,6 +1003,9 @@ export class BookingsRepository extends BaseRepository { tradeDirection: options.tradeDirection, }); } + if (options.tradeDirections) { + applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections); + } if (options.paymentCurrency) { qb.andWhere('booking.payment_currency = :paymentCurrency', { paymentCurrency: options.paymentCurrency, @@ -1289,6 +1295,38 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** + * EXPIRED bookings on the day's corridor — the batch board's expired lane. + * Expiry nulls train_schedule_id, so neither findAllBySchedule nor the + * ready-pool query can ever see them. + */ + findExpiredByCorridorDay( + corridorYardIds: string[], + day: string, + ): Promise { + if (corridorYardIds.length === 0) return Promise.resolve([]); + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') + .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') + .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) + .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { + corridorYardIds, + }) + .andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day }, + ) + .andWhere('sb.id IS NULL') + .andWhere(`booking.status = 'EXPIRED'`) + .orderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + /** * Commercial bookings on the day's corridor whose operation request was NOT * accepted by staff (still pending / changes / price-confirm) and are not yet 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 b8f73dfc8..67ac73a58 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1619,6 +1619,7 @@ export class BookingsService { filter: FilterBookingDto, forceCompanyId?: string, forceCompanyProfileId?: string, + tradeDirections?: string[], ): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; @@ -1638,6 +1639,7 @@ export class BookingsService { // ANDs both, so cross-company access is impossible. companyId: forceCompanyId ?? filter.companyId, companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, + tradeDirections, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index a793cc558..4c51d85a6 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -13,6 +13,18 @@ export class BookingReferenceYardDto { @ApiProperty({ example: 'Ethiopia' }) country!: string; + + @ApiProperty({ description: 'Can load containers onto a train here.' }) + hasContainerFacilityOrigin!: boolean; + + @ApiProperty({ description: 'Can load bulk cargo onto a train here.' }) + hasBulkFacilityOrigin!: boolean; + + @ApiProperty({ description: 'Can receive containers off a train here.' }) + hasContainerFacilityDestination!: boolean; + + @ApiProperty({ description: 'Can receive bulk cargo off a train here.' }) + hasBulkFacilityDestination!: boolean; } export class BookingReferenceContainerTypeDto { 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 d9798af5d..e0af07e0b 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -928,7 +928,7 @@ export class CompaniesService { ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); - await this.assertEtradeFieldsAuthentic(company, dto); + await this.applyEtradeSourcedFields(company, dto); // Naming (or renaming) a Power of Attorney is one of the writes that can // leave the company with a representative and nothing evidencing them, so @@ -3216,22 +3216,24 @@ export class CompaniesService { /** * An eTrade-sourced field can only ever hold what a fresh eTrade lookup for * this TIN actually returns — the portal never lets the customer type these - * once eTrade has supplied them, so a mismatch here means either stale - * client state or a hand-crafted request, and either way the write is - * refused rather than silently trusting it. + * once eTrade has supplied them. Rather than trust the client's copy (stale + * cache, hand-crafted request, or just a formatting mismatch) and reject it, + * refetch eTrade ourselves and overwrite the touched fields with whatever it + * says now — the client's submitted values for these keys only matter as a + * "this field is part of the save" flag, never as data we persist. */ - private async assertEtradeFieldsAuthentic( + private async applyEtradeSourcedFields( company: Company, dto: UpdateProfileDto, ): Promise { const touched = ETRADE_SOURCED_FIELDS.some( - (key) => dto[key] !== undefined, + (key) => key !== "tin" && dto[key] !== undefined, ); if (!touched) return; const tin = dto.tin ?? company.tin; const registration = await this.resolveEtradeRegistration(tin); - const expected: Partial> = { + const fresh: Partial> = { companyName: registration.companyName, licenceNumber: registration.licenceNumber, statusDescription: registration.statusDescription, @@ -3251,21 +3253,11 @@ export class CompaniesService { }; for (const key of ETRADE_SOURCED_FIELDS) { - const submitted = dto[key]; - if (submitted === undefined) continue; - const source = expected[key]; - // eTrade left this field blank — the onboarding/settings card falls back - // to letting the customer type it directly, so nothing to check against. - if (!source) continue; - const same = - key === "etradePhone" - ? normalizeE164(String(submitted)) === normalizeE164(source) - : submitted === source; - if (!same) { - throw new BadRequestException( - `${key} doesn't match eTrade's current record for this TIN. Re-verify with eTrade to pick up the latest details.`, - ); - } + if (key === "tin" || dto[key] === undefined) continue; + const value = fresh[key]; + // eTrade left this field blank — fall back to whatever the client sent + // (the onboarding/settings card lets the customer type it directly then). + if (value) (dto as Record)[key] = value; } } } 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 9f7d1ed39..596644fab 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 @@ -179,9 +179,12 @@ export class UpdateProfileDto { @MaxLength(100) houseNo?: string; + // Not validated as a phone number: eTrade-sourced, so a fresh eTrade lookup + // overwrites whatever the client sends here — see + // CompaniesService.applyEtradeSourcedFields. Presence just flags "this save + // touches an eTrade-owned field." @IsOptional() @IsString() @MaxLength(20) - @IsValidPhone() etradePhone?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 51bdb2df6..3292ee2d3 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -108,7 +108,9 @@ export class ETradeService { dateRegistered: businessInfo.DateRegistered, renewedFrom: businessInfo.RenewedFrom, renewalDate: businessInfo.RenewalDate, - renewedTo: businessInfo.RenewedTo, + // RenewedTo is ISO ("2018-07-07T00:00:00"); RenewedToDateString matches + // RenewedFrom/RenewalDate's "M/D/YYYY" format — use that for consistency. + renewedTo: businessInfo.RenewedToDateString, // eTrade returns uncoded uppercase text and sometimes a zone name in the // Region slot. Map it onto the canonical list; an unresolved value yields // "" so the form asks the user to pick rather than failing validation on diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 7eb87bf86..8da28e400 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -290,6 +290,7 @@ export class ContractBookingService { isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), cargoTotalWeightVgm: this.resolveBulkTons(dto), + bulkTotalWeightTons: this.resolveBulkWeightTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null, firstMilePickupLng: contract.firstMilePickupLng ?? null, @@ -773,6 +774,7 @@ export class ContractBookingService { cargoTypeId: this.resolveCargoTypeId(contract, dto), cargoFreeText: dto.cargoFreeText?.trim() || null, cargoTotalWeightVgm: this.resolveBulkTons(dto), + bulkTotalWeightTons: this.resolveBulkWeightTons(dto), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), // Completion is where the cargo — and therefore the price — is fixed, so // it is also where the billing currency is chosen. A bare instance was @@ -1253,6 +1255,7 @@ export class ContractBookingService { } probe.cargoTotalWeightVgm = this.resolveBulkTons(dto); + probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto); const cargoTypeId = this.resolveCargoTypeId(contract, dto); probe.cargoTypeId = cargoTypeId; if (cargoTypeId) { @@ -1297,11 +1300,7 @@ export class ContractBookingService { return; } - const requested = - (dto.bulkLines ?? []).reduce( - (sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0), - 0, - ) || this.resolveBulkTons(dto) || 0; + const requested = this.resolveBulkTons(dto); const remaining = outstanding.bulk?.outstanding ?? 0; // 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights. if (Math.abs(requested - remaining) > 0.001) { @@ -1344,8 +1343,10 @@ export class ContractBookingService { } } } else { + // PER_ITEM contracts are capped in items, so the item count is the + // consumption figure — tonnage is only wagon-sizing data. const requested = - (lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0; + Number(lines.bulk?.itemCount ?? lines.bulk?.cargoWeightTons ?? 0) || 0; const cap = capacity.find((c) => c.cap != null); if (cap && cap.remaining != null && requested > cap.remaining) { throw new BadRequestException( @@ -1373,11 +1374,7 @@ export class ContractBookingService { } } } else { - const requested = - (dto.bulkLines ?? []).reduce( - (sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0), - 0, - ) || this.resolveBulkTons(dto) || 0; + const requested = this.resolveBulkTons(dto); const cap = capacity.find((c) => c.cap != null); if (cap && cap.remaining != null && requested > cap.remaining) { throw new BadRequestException( @@ -1641,11 +1638,27 @@ export class ContractBookingService { private resolveBulkTons(dto: CreateBookingUnderContractDto): number { if (!dto.bulkLines?.length) return 0; return dto.bulkLines.reduce( - (sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0), + (sum, l) => sum + Number(l.itemCount ?? l.cargoWeightTons ?? 0), 0, ); } + /** + * Real tonnage of a PER_ITEM (break-bulk) booking, kept alongside the item + * count `cargoTotalWeightVgm` holds. Both are needed: the item count prices + * the booking, the tonnage sizes the wagons (`bulkItemWagonsRequired` derives + * per-item weight from tonnage ÷ items). Null for PER_TON bulk, where + * `cargoTotalWeightVgm` already IS the tonnage. + */ + private resolveBulkWeightTons( + dto: CreateBookingUnderContractDto, + ): number | null { + const lines = dto.bulkLines ?? []; + if (!lines.some((l) => Number(l.itemCount) > 0)) return null; + const tons = lines.reduce((sum, l) => sum + Number(l.cargoWeightTons ?? 0), 0); + return tons > 0 ? tons : null; + } + /** * Per-line handling counts. Each physical container carries its own hazardous * / reefer / return switch (entered next to its VGM), so the count is however @@ -1961,6 +1974,7 @@ export class ContractBookingService { originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, cargoTotalWeightVgm: this.resolveBulkTons(dto), + bulkTotalWeightTons: this.resolveBulkWeightTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, bookingContainers: resolved.map(({ line, ct, totalVgmTons }) => diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 8c004ade0..ec61fdd4a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -59,6 +59,8 @@ import { GlOperationsService } from './gl-operations.service'; import { BookingRequestService } from './booking-request.service'; import { SignaturesService } from '../signatures/signatures.service'; import { BookingsService } from '../bookings/bookings.service'; +import { scopedDirections } from '../user-trade-access/trade-scope.util'; +import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; import { CreateContractDto } from './dto/create-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto'; @@ -108,6 +110,7 @@ export class ContractsController { private readonly glOperationsService: GlOperationsService, private readonly bookingRequestService: BookingRequestService, private readonly signaturesService: SignaturesService, + private readonly userTradeAccessService: UserTradeAccessService, private readonly bookingClearanceService: BookingClearanceService, private readonly bookingsService: BookingsService, ) {} @@ -210,7 +213,16 @@ export class ContractsController { hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { - return this.contractsService.findAll(filter); + // Per-user trade-direction scope (import/export/intercity checkboxes). + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + const dirs = scopedDirections(allowed, filter.tradeDirection); + return this.contractsService.findAll( + filter, + undefined, + undefined, + dirs ?? undefined, + ); } const userId = user?.id; if (!userId) throw new UnauthorizedException('Authentication required'); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 658acf39d..0e60462a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -1,4 +1,5 @@ import { Module, forwardRef } from '@nestjs/common'; +import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; @@ -89,6 +90,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum NotificationsModule, NotificationInboxModule, CompaniesModule, + UserTradeAccessModule, // Provides the admin-editable contract document templates consumed by // ContractDocumentViewModelBuilder when rendering contract PDFs. ContractTemplatesModule, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 49ef3a29f..a7fddd816 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -5,6 +5,7 @@ import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm' import { Booking } from '../bookings/entities/booking.entity'; import { FileRecord } from '../files/entities/file.entity'; +import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { Contract } from './entities/contract.entity'; import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; @@ -38,6 +39,8 @@ export interface ContractListFilterOptions { serviceTypeId?: string; freightType?: string; tradeDirection?: string; + /** Per-user trade-direction scope — `[]` matches nothing. */ + tradeDirections?: string[]; paymentCurrency?: string; customsClearingEnabled?: boolean; /** true → only contracts with at least one uploaded clearance document. */ @@ -436,6 +439,9 @@ export class ContractsRepository extends BaseRepository { tradeDirection: options.tradeDirection, }); } + if (options.tradeDirections) { + applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections); + } if (options.paymentCurrency) { qb.andWhere('contract.payment_currency = :paymentCurrency', { paymentCurrency: options.paymentCurrency, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 5c0b5e29e..6e170f126 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -748,6 +748,7 @@ export class ContractsService { filter: FilterContractDto, forceCompanyId?: string, forceCompanyProfileId?: string, + tradeDirections?: string[], ): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; @@ -763,6 +764,7 @@ export class ContractsService { serviceTypeId: filter.serviceTypeId, freightType: filter.freightType, tradeDirection: filter.tradeDirection, + tradeDirections, paymentCurrency: filter.paymentCurrency, createdFrom: filter.createdFrom, createdTo: filter.createdTo, diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts index be7964a3d..e3e711d19 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -13,9 +13,8 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit * - `companyId` → all portal users linked to the company (external_profiles). * - `companyProfileId` → resolved to its company, then to that company's users. * - `organizationId` → all current employees of the org (backoffice staff). - * - * NOTE: permission-scoped staff targeting is intentionally unsupported — freight - * has no "users-by-permission" lookup. Target explicit userIds or an org instead. + * - `permissionKeys` → current employees (any org) holding any of these + * permission keys (e.g. department/role-scoped targeting). */ @Injectable() export class NotificationRecipientsService { @@ -82,6 +81,20 @@ export class NotificationRecipientsService { } } + if (recipients.permissionKeys?.length) { + try { + for (const uid of await this.backoffice.getEmployeeUserIdsByPermission( + recipients.permissionKeys, + )) { + ids.add(uid); + } + } catch (err) { + this.logger.warn( + `Failed to resolve permissionKeys recipients: ${(err as Error).message}`, + ); + } + } + return [...ids]; } } 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 557548b00..d5688e1c2 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -5,7 +5,7 @@ import { randomInt } from "node:crypto"; import { OtpRepository } from "./otp.repository"; -import { SmsClientService } from "../notifications/sms-client.service"; +import { NotificationsService } from "../notifications/notifications.service"; import { EmailClientService } from "../notifications/email-client.service"; /** @@ -95,7 +95,7 @@ export class OtpService { logger = new Logger(OtpService.name); constructor( private readonly otpRepository: OtpRepository, - private readonly smsClient: SmsClientService, + private readonly notifications: NotificationsService, private readonly emailClient: EmailClientService, ) { } @@ -263,17 +263,24 @@ export class OtpService { } } - /** SMS half of {@link dispatchEmail}; same swallow-and-report contract. */ + /** + * SMS half of {@link dispatchEmail}; same swallow-and-report contract. Sent + * via NotificationsService's direct-HTTP Ozeking strategy — the same + * transport the notification system uses — rather than the RabbitMQ + * `SMS_SERVICE` queue, so `queued: true` here means the gateway accepted the + * request, not just that a broker took ownership of the message. + */ private async dispatchSms( phone: string, otp: string, ): Promise { try { - const { queued } = await this.smsClient.sendSms({ - to: phone, - message: `Your verification code is ${otp}`, - }); - return { channel: "sms", queued }; + await this.notifications.directSend( + "sms", + phone, + `Your verification code is ${otp}`, + ); + return { channel: "sms", queued: true }; } catch (error) { return { channel: "sms", diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts index e63cc5d01..4e1ecc6d6 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.controller.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -5,6 +5,8 @@ import { ApiOperation, ApiTags, } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { BookingView } from '../../common/booking-guards'; import { OverviewQueryDto } from './dto/overview-query.dto'; @@ -18,43 +20,79 @@ import { OverviewStaffTabDto, } from './dto/overview-tab-response.dto'; import { OverviewService } from './overview.service'; +import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; @ApiTags('Overview') @ApiBearerAuth() @Controller('overview') export class OverviewController { - constructor(private readonly overviewService: OverviewService) {} + constructor( + private readonly overviewService: OverviewService, + private readonly userTradeAccessService: UserTradeAccessService, + ) {} @Get() @BookingView() @ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' }) @ApiOkResponse({ type: OverviewResponseDto }) - getDashboard(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getDashboard(query.range ?? '30d'); + async getDashboard( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getDashboard( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('bookings') @BookingView() @ApiOperation({ summary: 'Bookings tab metrics and charts' }) @ApiOkResponse({ type: OverviewBookingsTabDto }) - getBookingsTab(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getBookingsTab(query.range ?? '30d'); + async getBookingsTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getBookingsTab( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('contracts') @BookingView() @ApiOperation({ summary: 'Contracts tab metrics and charts' }) @ApiOkResponse({ type: OverviewContractsTabDto }) - getContractsTab(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getContractsTab(query.range ?? '30d'); + async getContractsTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getContractsTab( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('billing') @BookingView() @ApiOperation({ summary: 'Billing tab metrics and charts' }) @ApiOkResponse({ type: OverviewBillingTabDto }) - getBillingTab(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getBillingTab(query.range ?? '30d'); + async getBillingTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getBillingTab( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('operations') @@ -69,8 +107,16 @@ export class OverviewController { @BookingView() @ApiOperation({ summary: 'Customers tab metrics and charts' }) @ApiOkResponse({ type: OverviewCustomersTabDto }) - getCustomersTab(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getCustomersTab(query.range ?? '30d'); + async getCustomersTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getCustomersTab( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('staff') diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts index 43fcdac0f..ff19bb801 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.module.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -11,6 +11,7 @@ import { Contract } from "../contracts/entities/contract.entity"; import { PaymentEntity } from "../payment/entities/payment.entity"; import { Train } from "../trains/entities/train.entity"; import { Wagon } from "../wagons/entities/wagon.entity"; +import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; import { OverviewController } from "./overview.controller"; import { OverviewRepository } from "./overview.repository"; import { OverviewService } from "./overview.service"; @@ -29,6 +30,7 @@ import { OverviewService } from "./overview.service"; Employee, User, ]), + UserTradeAccessModule, ], controllers: [OverviewController], providers: [OverviewService, OverviewRepository], diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 4e56f415f..a8957d760 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -24,6 +24,10 @@ import { OVERVIEW_URGENT_PRIORITY_THRESHOLD, } from "./overview.constants"; import { Company } from "../companies/entities/company.entity"; +import { + bookingRefScopeSql, + directionScopeSql, +} from "../user-trade-access/trade-scope.util"; /** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */ const EXCLUDE_GENERAL_CONTRACT_BOOKINGS = @@ -93,7 +97,8 @@ export class OverviewRepository { private readonly userRepository: Repository, ) { } - async getBookingKpis(): Promise { + async getBookingKpis(dirs?: string[]): Promise { + const scope = directionScopeSql("booking.trade_direction", dirs); const row = await this.bookingRepository .createQueryBuilder("booking") .select( @@ -118,6 +123,7 @@ export class OverviewRepository { ) .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) .setParameters({ closedStatuses: [...OVERVIEW_CLOSED_STATUSES], needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES], @@ -202,12 +208,13 @@ export class OverviewRepository { }; } - async getBillingKpis(): Promise<{ + async getBillingKpis(dirs?: string[]): Promise<{ revenueMtdEtb: number; revenueMtdUsd: number; pendingPayments: number; successfulPaymentsMtd: number; }> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const revenueRow = await this.paymentRepository .createQueryBuilder("payment") .select( @@ -223,6 +230,7 @@ export class OverviewRepository { .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) + .andWhere(scope.sql, scope.params) .getRawOne>(); const pendingPayments = await this.paymentRepository @@ -230,6 +238,7 @@ export class OverviewRepository { .where("payment.status IN (:...statuses)", { statuses: ["action-required", "processing"], }) + .andWhere(scope.sql, scope.params) .getCount(); return { @@ -261,13 +270,16 @@ export class OverviewRepository { async getBookingTrend( days: number, + dirs?: string[], ): Promise<{ date: string; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .groupBy("booking.created_at::date") .orderBy("booking.created_at::date", "ASC") @@ -279,13 +291,15 @@ export class OverviewRepository { })); } - async getStatusCounts(): Promise> { + async getStatusCounts(dirs?: string[]): Promise> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.status", "status") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) .groupBy("booking.status") .getRawMany<{ status: string; count: string }>(); @@ -296,7 +310,9 @@ export class OverviewRepository { async getPaymentTrend( days: number, + dirs?: string[], ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select( @@ -316,6 +332,7 @@ export class OverviewRepository { `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, { days }, ) + .andWhere(scope.sql, scope.params) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); @@ -327,7 +344,11 @@ export class OverviewRepository { })); } - async getRecentBookings(limit: number): Promise { + async getRecentBookings( + limit: number, + dirs?: string[], + ): Promise { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .leftJoin("booking.company", "company") @@ -341,6 +362,7 @@ export class OverviewRepository { .addSelect("booking.created_at", "createdAt") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) .orderBy("booking.created_at", "DESC") .limit(limit) .getRawMany<{ @@ -366,9 +388,10 @@ export class OverviewRepository { })); } - async getBookingsByFreightType(): Promise< - { label: string; count: number }[] - > { + async getBookingsByFreightType( + dirs?: string[], + ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.freight_type", "label") @@ -376,6 +399,7 @@ export class OverviewRepository { .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere("booking.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("booking.freight_type") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); @@ -386,7 +410,10 @@ export class OverviewRepository { })); } - async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> { + async getBookingsByCurrency( + dirs?: string[], + ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.payment_currency", "label") @@ -394,6 +421,7 @@ export class OverviewRepository { .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere("booking.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("booking.payment_currency") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); @@ -404,11 +432,15 @@ export class OverviewRepository { })); } - async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> { + async getPaymentsByStatus( + dirs?: string[], + ): Promise<{ status: string; count: number }[]> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select("payment.status", "status") .addSelect("COUNT(*)::int", "count") + .where(scope.sql, scope.params) .groupBy("payment.status") .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); @@ -419,9 +451,12 @@ export class OverviewRepository { })); } - async getPaymentsByMethod(): Promise< + async getPaymentsByMethod( + dirs?: string[], + ): Promise< { method: string; count: number; amountEtb: number; amountUsd: number }[] > { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select("payment.method", "method") @@ -434,6 +469,7 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, "amountUsd", ) + .where(scope.sql, scope.params) .groupBy("payment.method") .orderBy("count", "DESC") .getRawMany<{ @@ -451,9 +487,10 @@ export class OverviewRepository { })); } - async getRevenueByCurrency(): Promise< - { currency: string; amount: number }[] - > { + async getRevenueByCurrency( + dirs?: string[], + ): Promise<{ currency: string; amount: number }[]> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select("payment.currency", "currency") @@ -462,6 +499,7 @@ export class OverviewRepository { .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) + .andWhere(scope.sql, scope.params) .groupBy("payment.currency") .getRawMany<{ currency: string; amount: string }>(); @@ -556,7 +594,9 @@ export class OverviewRepository { async getTopCustomersByBookings( limit: number, + dirs?: string[], ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .leftJoin("booking.company", "company") @@ -564,6 +604,7 @@ export class OverviewRepository { .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("company.name") .orderBy("count", "DESC") .limit(limit) @@ -632,7 +673,8 @@ export class OverviewRepository { // ── Contracts (overview Contract tab) ────────────────────────────────────── - async getContractKpis(): Promise { + async getContractKpis(dirs?: string[]): Promise { + const scope = directionScopeSql("contract.trade_direction", dirs); const row = await this.contractRepository .createQueryBuilder("contract") .select( @@ -656,6 +698,7 @@ export class OverviewRepository { "createdToday", ) .where("contract.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) .setParameters({ closedStatuses: [...OVERVIEW_CONTRACT_CLOSED_STATUSES], needsActionStatuses: [...OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES], @@ -673,24 +716,33 @@ export class OverviewRepository { }; } - async getContractStatusCounts(): Promise> { + async getContractStatusCounts( + dirs?: string[], + ): Promise> { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select("contract.status", "status") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) .groupBy("contract.status") .getRawMany<{ status: string; count: string }>(); return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)])); } - async getContractTrend(days: number): Promise<{ date: string; count: number }[]> { + async getContractTrend( + days: number, + dirs?: string[], + ): Promise<{ date: string; count: number }[]> { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select(`to_char(contract.created_at::date, 'YYYY-MM-DD')`, "date") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) .andWhere(`contract.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .groupBy("contract.created_at::date") .orderBy("contract.created_at::date", "ASC") @@ -699,13 +751,17 @@ export class OverviewRepository { return rows.map((row) => ({ date: row.date, count: Number(row.count) })); } - async getContractsByKind(): Promise<{ label: string; count: number }[]> { + async getContractsByKind( + dirs?: string[], + ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select("contract.contract_kind", "label") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") .andWhere("contract.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("contract.contract_kind") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); @@ -713,13 +769,17 @@ export class OverviewRepository { return rows.map((row) => ({ label: row.label, count: Number(row.count) })); } - async getContractsByFreightType(): Promise<{ label: string; count: number }[]> { + async getContractsByFreightType( + dirs?: string[], + ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select("contract.freight_type", "label") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") .andWhere("contract.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("contract.freight_type") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); @@ -727,7 +787,11 @@ export class OverviewRepository { return rows.map((row) => ({ label: row.label, count: Number(row.count) })); } - async getRecentContracts(limit: number): Promise { + async getRecentContracts( + limit: number, + dirs?: string[], + ): Promise { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .leftJoin("contract.company", "company") @@ -741,6 +805,7 @@ export class OverviewRepository { .addSelect("contract.contract_valid_until", "validUntil") .addSelect("contract.created_at", "createdAt") .where("contract.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) .orderBy("contract.created_at", "DESC") .limit(limit) .getRawMany<{ diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts index 8a7b71b69..4b1f5d6bf 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.service.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -40,7 +40,10 @@ export class OverviewService { return { bookingsByPipeline, bookingsByStatus }; } - async getDashboard(range: OverviewRangeQuery = '30d'): Promise { + async getDashboard( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; const [ @@ -55,16 +58,16 @@ export class OverviewService { paymentTrend, recentBookings, ] = await Promise.all([ - this.overviewRepository.getBookingKpis(), - this.overviewRepository.getContractKpis(), + this.overviewRepository.getBookingKpis(dirs), + this.overviewRepository.getContractKpis(dirs), this.overviewRepository.getOperationsKpis(), this.overviewRepository.getCustomerKpis(), - this.overviewRepository.getBillingKpis(), + this.overviewRepository.getBillingKpis(dirs), this.overviewRepository.getStaffKpis(), - this.overviewRepository.getBookingTrend(days), - this.overviewRepository.getStatusCounts(), - this.overviewRepository.getPaymentTrend(days), - this.overviewRepository.getRecentBookings(8), + this.overviewRepository.getBookingTrend(days, dirs), + this.overviewRepository.getStatusCounts(dirs), + this.overviewRepository.getPaymentTrend(days, dirs), + this.overviewRepository.getRecentBookings(8, dirs), ]); const { bookingsByPipeline, bookingsByStatus } = @@ -91,7 +94,10 @@ export class OverviewService { }; } - async getBookingsTab(range: OverviewRangeQuery = '30d'): Promise { + async getBookingsTab( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; const [ @@ -102,12 +108,12 @@ export class OverviewService { bookingsByCurrency, recentBookings, ] = await Promise.all([ - this.overviewRepository.getBookingKpis(), - this.overviewRepository.getBookingTrend(days), - this.overviewRepository.getStatusCounts(), - this.overviewRepository.getBookingsByFreightType(), - this.overviewRepository.getBookingsByCurrency(), - this.overviewRepository.getRecentBookings(8), + this.overviewRepository.getBookingKpis(dirs), + this.overviewRepository.getBookingTrend(days, dirs), + this.overviewRepository.getStatusCounts(dirs), + this.overviewRepository.getBookingsByFreightType(dirs), + this.overviewRepository.getBookingsByCurrency(dirs), + this.overviewRepository.getRecentBookings(8, dirs), ]); const { bookingsByPipeline, bookingsByStatus } = @@ -130,6 +136,7 @@ export class OverviewService { async getContractsTab( range: OverviewRangeQuery = '30d', + dirs?: string[], ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; @@ -141,12 +148,12 @@ export class OverviewService { contractsByFreightType, recentContracts, ] = await Promise.all([ - this.overviewRepository.getContractKpis(), - this.overviewRepository.getContractTrend(days), - this.overviewRepository.getContractStatusCounts(), - this.overviewRepository.getContractsByKind(), - this.overviewRepository.getContractsByFreightType(), - this.overviewRepository.getRecentContracts(8), + this.overviewRepository.getContractKpis(dirs), + this.overviewRepository.getContractTrend(days, dirs), + this.overviewRepository.getContractStatusCounts(dirs), + this.overviewRepository.getContractsByKind(dirs), + this.overviewRepository.getContractsByFreightType(dirs), + this.overviewRepository.getRecentContracts(8, dirs), ]); const contractsByStatus = Object.entries(statusCounts) @@ -170,16 +177,19 @@ export class OverviewService { }; } - async getBillingTab(range: OverviewRangeQuery = '30d'): Promise { + async getBillingTab( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; const [kpis, paymentTrend, paymentsByStatus, paymentsByMethod, revenueByCurrency] = await Promise.all([ - this.overviewRepository.getBillingKpis(), - this.overviewRepository.getPaymentTrend(days), - this.overviewRepository.getPaymentsByStatus(), - this.overviewRepository.getPaymentsByMethod(), - this.overviewRepository.getRevenueByCurrency(), + this.overviewRepository.getBillingKpis(dirs), + this.overviewRepository.getPaymentTrend(days, dirs), + this.overviewRepository.getPaymentsByStatus(dirs), + this.overviewRepository.getPaymentsByMethod(dirs), + this.overviewRepository.getRevenueByCurrency(dirs), ]); return { @@ -217,7 +227,10 @@ export class OverviewService { }; } - async getCustomersTab(range: OverviewRangeQuery = '30d'): Promise { + async getCustomersTab( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] = @@ -225,7 +238,7 @@ export class OverviewService { this.overviewRepository.getCustomerKpis(), this.overviewRepository.getCustomerGrowthTrend(days), this.overviewRepository.getCustomersByType(), - this.overviewRepository.getTopCustomersByBookings(8), + this.overviewRepository.getTopCustomersByBookings(8, dirs), ]); return { diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index ce072beca..0cf3b886c 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -49,7 +49,7 @@ export class PaymentEntity extends BaseEntity { @Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" }) status!: PaymentStatus - @Column({ type: "date", nullable: true, name: "paid_at" }) + @Column({ type: "timestamp", nullable: true, name: "paid_at" }) paidAt?: Date @Column({ type: "timestamp", nullable: true, name: "refunded_at" }) diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index b5ff51c48..b7dfa3c7e 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -16,6 +16,7 @@ import { BillQueryRequestDto, BillQueryResponseDto, } from "./internal-payment.dto"; +import { Public } from "@edr/api-common"; import { PaymentService } from "./payment.service"; import { BillingService } from "../billing/billing.service"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; @@ -28,6 +29,9 @@ import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; * this HTTP endpoint remains as a transport-agnostic fallback. */ @ApiTags("Internal Payments") +// Skips the global JwtGuard (no end-user JWT on a service-to-service call); +// ServiceAuthGuard below still enforces the shared SERVICE_AUTH_TOKEN. +@Public() @UseGuards(ServiceAuthGuard) @Controller("internal/payments") export class InternalPaymentController { diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 2ae62d4f3..3602657de 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -15,8 +15,10 @@ import { ApiProduces, } from "@nestjs/swagger"; import { Response } from "express"; -import { Public } from "@edr/api-common"; +import { CurrentUser, Public } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { BookingStaff, BookingView } from "../../common/booking-guards"; +import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { PaymentService } from "./payment.service"; import { IntentStatusDto } from "./payments.dto"; @@ -24,7 +26,10 @@ import { IntentStatusDto } from "./payments.dto"; @ApiTags("Payment") @Controller("payments") export class PaymentController { - constructor(private readonly paymentService: PaymentService) { } + constructor( + private readonly paymentService: PaymentService, + private readonly userTradeAccessService: UserTradeAccessService, + ) { } // Customer-detail payments tab — same one-of rule as the bookings tab. @Get("by-company/:companyId/customer-view") @@ -52,18 +57,23 @@ export class PaymentController { @ApiQuery({ name: "page", required: false }) @ApiQuery({ name: "pageSize", required: false }) async getAll( + @CurrentUser() user: TCurrentUser, @Query("search") search?: string, @Query("status") status?: string, @Query("method") method?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string, ) { + // Per-user trade-direction scope, applied via the booking in ref_id. + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); return this.paymentService.getAll({ search, status, method, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 10, + tradeDirections: allowed ?? undefined, }); } diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 961aa32bd..220250d93 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -13,6 +13,7 @@ import { import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { BillingModule } from "../billing/billing.module"; +import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; // import { FirstMileModule } from "../first-mile/first-mile.module"; // import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; @@ -64,6 +65,7 @@ function rabbitMQImport(): DynamicModule[] { timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000, }), ConfigModule, + UserTradeAccessModule, forwardRef(() => BillingModule), // forwardRef(() => TrainSchedulingModule), // FirstMileModule, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts index ed0f8da58..aca224c07 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts @@ -188,3 +188,45 @@ describe("PaymentClientService.confirmOtp", () => { ); }); }); + +describe("PaymentService.markIntentSucceeded", () => { + const build = (rows: Record[]) => { + const repo = makeRepo(rows); + const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) }; + const service = new PaymentService( + repo as never, + {} as never, + billing as never, + ); + return { service, repo, billing }; + }; + + it("re-notifies billing on an already-success intent so a settle that died mid-way converges on redelivery", async () => { + const paidAt = new Date("2026-08-01T09:00:00.000Z"); + const { service, repo, billing } = build([ + localIntent({ status: "success", transactionId: "txn-1", paidAt }), + ]); + + const result = await service.markIntentSucceeded("intent-1", { + notify: true, + }); + + expect(result.alreadyFinalized).toBe(true); + // No re-write of the intent row… + expect(repo.update).not.toHaveBeenCalled(); + // …but billing still gets the (idempotent) settle call. + expect(billing.settleByPaymentId).toHaveBeenCalledWith( + "intent-1", + "txn-1", + paidAt, + ); + }); + + it("does not notify billing when notify is false, even when already success", async () => { + const { service, billing } = build([localIntent({ status: "success" })]); + + await service.markIntentSucceeded("intent-1", { notify: false }); + + expect(billing.settleByPaymentId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 0f628e39c..99c4aca46 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -7,6 +7,7 @@ import { Logger, NotFoundException, } from "@nestjs/common"; +import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; @@ -110,6 +111,8 @@ export class PaymentService { method?: string; page?: number; pageSize?: number; + /** Per-user trade-direction scope, applied via the booking in ref_id. */ + tradeDirections?: string[]; }) { const { search, status, method, page = 1, pageSize = 10 } = filters; const skip = (page - 1) * pageSize; @@ -128,6 +131,9 @@ export class PaymentService { if (method) { qb.andWhere("payment.method = :method", { method }); } + if (filters.tradeDirections) { + applyBookingRefDirectionScope(qb, "payment.ref_id", filters.tradeDirections); + } const [items, total] = await qb .orderBy("payment.createdAt", "DESC") @@ -232,10 +238,15 @@ export class PaymentService { referenceType: PaymentReferenceType.SHIPMENT, referenceId: input.referenceId, orderRef: input.orderRef, - // amountMinor: input.amountMinor, // CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was - // debited against the intent amount, so the 1-birr dev shortcut would break it. - amountMinor: isCbeBill ? input.amountMinor : 1, + // debited against the intent amount, so the dev shortcut would break it. + // CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev + // shortcut floor is 10, not 1. + amountMinor: isCbeBill + ? input.amountMinor + : input.method === ProviderMethod.CAC_BANK + ? 10 + : 1, currency: input.currency, provider: input.method as ProviderMethod, platform: input.platform, @@ -446,7 +457,19 @@ export class PaymentService { ): Promise<{ alreadyFinalized: boolean }> { const intent = await this.paymentRepo.findOneBy({ id: intentId }); if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success") return { alreadyFinalized: true }; + if (intent.status === "success") { + // Still notify billing: a prior delivery may have flipped the intent to + // success and then died before the invoice settled (the two steps are not + // atomic). settleByPaymentId is idempotent — no open invoice, no-op. + if (opts.notify !== false) { + await this.billing.settleByPaymentId( + intent.id, + opts.providerTxnId ?? intent.transactionId ?? undefined, + opts.paidAt ?? intent.paidAt ?? undefined, + ); + } + return { alreadyFinalized: true }; + } const paidAt = opts.paidAt ?? new Date(); await this.paymentRepo.update( diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts index 5712eed03..9c69f0135 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts @@ -37,6 +37,27 @@ export class YardFacility extends BaseEntity { @Column({ name: 'handles_bulk', type: 'boolean', default: true }) handlesBulk!: boolean; + /** + * Per-side capability. Loading a type onto a train and receiving it off one + * need different ground: a facility can be equipped to send containers but + * have no space to stage arriving ones. `handles_container` / `handles_bulk` + * stay the coarse "is this type handled here at all" switch; these four say + * on which side. A yard is offered as a contract origin for a freight type + * when it handles the type AND the matching `_origin` flag is set, and as a + * destination on the same rule with `_destination`. + */ + @Column({ name: 'has_container_facility_origin', type: 'boolean', default: false }) + hasContainerFacilityOrigin!: boolean; + + @Column({ name: 'has_bulk_facility_origin', type: 'boolean', default: false }) + hasBulkFacilityOrigin!: boolean; + + @Column({ name: 'has_container_facility_destination', type: 'boolean', default: false }) + hasContainerFacilityDestination!: boolean; + + @Column({ name: 'has_bulk_facility_destination', type: 'boolean', default: false }) + hasBulkFacilityDestination!: boolean; + @Column({ name: 'equipment_notes', type: 'text', nullable: true }) equipmentNotes?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts index 4b69c328f..2579c8d22 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts @@ -13,8 +13,20 @@ export interface YardFacilityInfo { /** Containers need a reach stacker/gantry — not every facility has one. */ handlesContainer: boolean; handlesBulk: boolean; + /** + * The same capability split by side of the trip — loading onto a train and + * receiving off one need different ground. Always false where the coarse + * `handles*` flag for that type is false. + */ + hasContainerFacilityOrigin: boolean; + hasBulkFacilityOrigin: boolean; + hasContainerFacilityDestination: boolean; + hasBulkFacilityDestination: boolean; } +/** Which side of the trip a yard is being considered for. */ +export type YardSide = 'ORIGIN' | 'DESTINATION'; + /** * Which yards can handle cargo, and what kind. * @@ -38,7 +50,11 @@ export class YardFacilitiesService { y.has_facility AS "hasFacility", f.has_warehouse AS "hasWarehouse", f.handles_container AS "handlesContainer", - f.handles_bulk AS "handlesBulk" + f.handles_bulk AS "handlesBulk", + f.has_container_facility_origin AS "hasContainerFacilityOrigin", + f.has_bulk_facility_origin AS "hasBulkFacilityOrigin", + f.has_container_facility_destination AS "hasContainerFacilityDestination", + f.has_bulk_facility_destination AS "hasBulkFacilityDestination" FROM freight.yards y LEFT JOIN freight.yard_facilities f ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`; @@ -51,17 +67,33 @@ export class YardFacilitiesService { hasWarehouse: boolean | null; handlesContainer: boolean | null; handlesBulk: boolean | null; + hasContainerFacilityOrigin: boolean | null; + hasBulkFacilityOrigin: boolean | null; + hasContainerFacilityDestination: boolean | null; + hasBulkFacilityDestination: boolean | null; }): YardFacilityInfo { // No facility record means no capability, whatever the flag says. const hasFacility = Boolean(row.hasFacility); + const handlesContainer = hasFacility && Boolean(row.handlesContainer); + const handlesBulk = hasFacility && Boolean(row.handlesBulk); return { yardId: row.yardId, yardCode: row.yardCode, yardLabel: row.yardLabel, hasFacility, hasWarehouse: hasFacility && Boolean(row.hasWarehouse), - handlesContainer: hasFacility && Boolean(row.handlesContainer), - handlesBulk: hasFacility && Boolean(row.handlesBulk), + handlesContainer, + handlesBulk, + // Gated on the coarse flag so the two can't contradict each other: a + // per-side flag left set on a type the facility no longer handles at all + // never resurrects that type. + hasContainerFacilityOrigin: + handlesContainer && Boolean(row.hasContainerFacilityOrigin), + hasBulkFacilityOrigin: handlesBulk && Boolean(row.hasBulkFacilityOrigin), + hasContainerFacilityDestination: + handlesContainer && Boolean(row.hasContainerFacilityDestination), + hasBulkFacilityDestination: + handlesBulk && Boolean(row.hasBulkFacilityDestination), }; } @@ -97,4 +129,26 @@ export class YardFacilitiesService { ? facility.handlesContainer : facility.handlesBulk; } + + /** + * Can this facility take this cargo on this side of the trip? The rule behind + * the contract's origin/destination yard pickers — keep it here so the API + * and the forms can't drift apart on what is offerable. + */ + canHandleFreightOnSide( + facility: YardFacilityInfo | null, + freightType: string | null | undefined, + side: YardSide, + ): boolean { + if (!facility?.hasFacility) return false; + const isContainer = String(freightType).toUpperCase() === 'CONTAINER'; + if (side === 'ORIGIN') { + return isContainer + ? facility.hasContainerFacilityOrigin + : facility.hasBulkFacilityOrigin; + } + return isContainer + ? facility.hasContainerFacilityDestination + : facility.hasBulkFacilityDestination; + } } diff --git a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts index a1f245e23..6d7898b53 100644 --- a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts +++ b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts @@ -7,12 +7,14 @@ export class SaveSignatureDto { @MinLength(1) signerDisplayName!: string; - @ApiProperty({ - description: 'PNG signature image as base64 (with or without data URL prefix)', + @ApiPropertyOptional({ + description: + 'PNG signature image as base64 (with or without data URL prefix). Omit to keep the existing saved signature (stamp-only update).', }) + @IsOptional() @IsString() @MinLength(20) - signatureImageBase64!: string; + signatureImageBase64?: string; @ApiPropertyOptional({ description: diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts index 836888dab..d346457a0 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts @@ -12,7 +12,8 @@ import { SavedSignatureDto } from './dto/save-signature.dto'; export interface UpsertSignatureInput { userId: string; signerDisplayName: string; - signatureImageBase64: string; + /** Optional; omitted = keep the existing saved signature (stamp-only update). */ + signatureImageBase64?: string; /** Optional company stamp/seal; omitted = keep the existing saved stamp. */ stampImageBase64?: string; } @@ -46,12 +47,14 @@ export class SignaturesService { const previousFileId = existing?.signatureFileId ?? null; const previousStampFileId = existing?.stampFileId ?? null; - const fileRecord = await this.filesService.upload({ - resourceId: input.userId, - resource: 'saved_signatures', - code: 'signature', - file: this.toUploadFile('signature', input.userId, input.signatureImageBase64), - }); + const fileRecord = input.signatureImageBase64 + ? await this.filesService.upload({ + resourceId: input.userId, + resource: 'saved_signatures', + code: 'signature', + file: this.toUploadFile('signature', input.userId, input.signatureImageBase64), + }) + : null; const stampRecord = input.stampImageBase64 ? await this.filesService.upload({ @@ -65,13 +68,13 @@ export class SignaturesService { const saved = await this.signaturesRepository.upsert({ userId: input.userId, signerDisplayName: input.signerDisplayName, - signatureFileId: fileRecord.id, - // Omitted stamp keeps whatever was saved before. + // Omitted image keeps whatever was saved before. + ...(fileRecord ? { signatureFileId: fileRecord.id } : {}), ...(stampRecord ? { stampFileId: stampRecord.id } : {}), }); const staleIds = [ - previousFileId !== fileRecord.id ? previousFileId : null, + fileRecord && previousFileId !== fileRecord.id ? previousFileId : null, stampRecord && previousStampFileId !== stampRecord.id ? previousStampFileId : null, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 5203c3a14..efdf4f691 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1437,6 +1437,7 @@ export class BookingBatchService implements OnModuleInit { */ async getBatchBoard( query: BatchBoardQueryDto = {}, + allowedDirections?: string[], ): Promise { // Board cards are heavy (per-schedule booking summaries), so the default // page is smaller than the toolkit-wide 20. @@ -1444,6 +1445,11 @@ export class BookingBatchService implements OnModuleInit { defaultPageSize: 12, }); + // The board is IMPORT-only — a user scoped away from IMPORT sees nothing. + if (allowedDirections && !allowedDirections.includes("IMPORT")) { + return { items: [], meta: buildPaginationMeta(0, page, pageSize) }; + } + // Status filter: any subset of the lifecycle. Omitted = all statuses, so // arrived / cancelled / dispatched schedules stay visible as history. const allowedStatuses = new Set(BATCH_BOARD_STATUSES); @@ -1632,6 +1638,18 @@ export class BookingBatchService implements OnModuleInit { for (const b of candidates) { if (!pinnedIds.has(b.id)) bookings.push(b); } + // Expiry frees the schedule pin (expire() nulls train_schedule_id), so + // expired bookings match neither query above — merge them back so the + // board keeps its expired lane. Display-only: boardState maps them to + // EXPIRED, which every capacity meter already excludes. + const expiredPool = + await this.bookingsRepository.findExpiredByCorridorDay( + stops, + eatDay(s.scheduledDepartureDate), + ); + for (const b of expiredPool) { + if (!pinnedIds.has(b.id)) bookings.push(b); + } } catch (err) { // The board must still render the pinned bookings. this.logger.warn( @@ -3131,6 +3149,14 @@ export class BookingBatchService implements OnModuleInit { async intercityCapacity(scheduleId: string): Promise<{ budget: CorridorBudget; needFor: (booking: Booking) => Capacity; + /** + * Per-wagon-type split of `needFor(booking).wagons`, against THIS + * schedule's own wagon stock — so the same booking reads differently on a + * different train. Empty when the stock can't be resolved. + */ + breakdownFor: ( + booking: Booking, + ) => Array<{ wagonTypeId: string; code: string; wagons: number }>; } | null> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); @@ -3145,7 +3171,24 @@ export class BookingBatchService implements OnModuleInit { // still accepts ride-alongs on its empty legs — that is the whole point // of the ride-along flow. const budget = await this.remainingBudget(schedule, limits, wagonDims); - return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; + // Physical stock of THIS schedule's train (built consist, or the yard fleet + // it will draw from) — what makes the breakdown train-specific. + const stock = await this.trainSchedulingService.wagonStockForSchedule( + schedule.id, + schedule.originStationId, + budget.stops, + ); + return { + budget, + needFor: (booking) => this.needFor(booking, wagonDims), + breakdownFor: (booking) => + this.wagonBreakdownFor( + booking, + wagonDims, + stock.remainingByTypeId, + stock.codesByTypeId, + ), + }; } /** @@ -4094,6 +4137,78 @@ export class BookingBatchService implements OnModuleInit { }; } + /** + * The wagon count of {@link wagonsFor}, split across the wagon TYPES this + * particular train stocks — "3 × N35 + 1 × PW2" rather than a bare 4. + * + * `wagonsFor` sizes the booking on ONE representative type (the first the + * cargo type allows), which is all the abstract budget needs. Staff placing a + * ride-along need the physical picture: how many of each type this schedule + * must actually give up. So each allowed type is sized on its OWN capacity and + * items-fit, then filled greedily from the type with the largest per-wagon + * take, bounded by what the schedule has left of it. + * + * Because the stock is per-schedule, the same booking breaks down differently + * on a train stocking 60T N35s than on one stocking 40T PW2s. Returns [] when + * the booking's types are unconfigured or the train stocks none of them — the + * caller then shows the plain total. + */ + private wagonBreakdownFor( + booking: Booking, + wagonDims: WagonDims, + stockByTypeId: Map, + codesByTypeId: Map, + ): Array<{ wagonTypeId: string; code: string; wagons: number }> { + const total = this.wagonsFor(booking, wagonDims); + if (total <= 0) return []; + + // Per-wagon take of each allowed type ON THIS TRAIN, largest first: a type + // that swallows more of the booking per wagon needs fewer wagons. + const options = this.allowedDimsWithTypes(booking, wagonDims) + .filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0) + .map((o) => { + const wagonTypeId = o.wagonTypeId as string; + const wagonsIfAlone = Math.max( + 1, + bulkItemWagonsRequired( + booking, + o.dims.capacityTons, + bulkItemsFitFor(booking.cargoType, wagonTypeId), + ) || + (o.dims.capacityTons > 0 + ? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons) + : total), + ); + return { + wagonTypeId, + code: codesByTypeId.get(wagonTypeId) ?? '—', + available: stockByTypeId.get(wagonTypeId) ?? 0, + // Share of the whole booking one wagon of this type carries. + takePerWagon: 1 / wagonsIfAlone, + }; + }) + .sort((a, b) => b.takePerWagon - a.takePerWagon); + if (!options.length) return []; + + // Fill greedily by take, capped by stock; `remaining` is the fraction of the + // booking still unplaced, so a wagon of any type covers `takePerWagon` of it. + const out: Array<{ wagonTypeId: string; code: string; wagons: number }> = []; + let remaining = 1; + for (const option of options) { + if (remaining <= 1e-9) break; + const wagons = Math.min( + option.available, + Math.ceil(remaining / option.takePerWagon), + ); + if (wagons <= 0) continue; + out.push({ wagonTypeId: option.wagonTypeId, code: option.code, wagons }); + remaining -= wagons * option.takePerWagon; + } + // The train cannot hold the whole booking in the types it stocks — the + // `fits` check already fails it; report only what it CAN take. + return out; + } + private fits(need: Capacity, budget: Capacity): boolean { return ( need.wagons <= budget.wagons && diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.wagon-breakdown.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.wagon-breakdown.spec.ts new file mode 100644 index 000000000..c1ea5b8a1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.wagon-breakdown.spec.ts @@ -0,0 +1,108 @@ +import { BookingBatchService } from './booking-batch.service'; +import { Booking } from '../bookings/entities/booking.entity'; + +/** + * The intercity ride-along board shows WHICH wagon types a booking takes from + * the train it is being placed on ("3 × N35 + 1 × PW2"), not just how many + * wagons. Because the split is drawn against that schedule's own stock, the + * same booking must read differently on a different train. + */ +describe('BookingBatchService — intercity wagon breakdown', () => { + const N35 = 'wagon-type-n35'; + const PW2 = 'wagon-type-pw2'; + + const dims = (capacityTons: number) => ({ + capacityTons, + lengthMeters: 14, + tareWeightTons: 20, + }); + + const wagonDims = { + bulk: dims(60), + container: dims(60), + byWagonTypeId: new Map([ + [N35, dims(60)], + [PW2, dims(20)], + ]), + }; + + const codes = new Map([ + [N35, 'N35'], + [PW2, 'PW2'], + ]); + + /** + * 400 break-bulk items weighing 800t — 2t per item. On a 60t N35 that is 30 + * items per wagon (14 wagons); on a 20t PW2, 10 items (40 wagons). + */ + const perItemBooking = { + id: 'booking-1', + freightType: 'BULK', + cargoTotalWeightVgm: 400, + bulkTotalWeightTons: 800, + bookingContainers: [], + cargoType: { + wagonTypes: [{ id: N35 }, { id: PW2 }], + itemsPerWagonMap: {}, + }, + } as unknown as Booking; + + const service = Object.create( + BookingBatchService.prototype, + ) as BookingBatchService; + + const breakdown = ( + booking: Booking, + stock: Map, + ): Array<{ code: string; wagons: number }> => + ( + service as unknown as { + wagonBreakdownFor: ( + b: Booking, + d: typeof wagonDims, + s: Map, + c: Map, + ) => Array<{ code: string; wagons: number }>; + } + ) + .wagonBreakdownFor(booking, wagonDims, stock, codes) + .map(({ code, wagons }) => ({ code, wagons })); + + it('takes the highest-capacity type first when the train stocks plenty', () => { + const rows = breakdown(perItemBooking, new Map([[N35, 50], [PW2, 50]])); + expect(rows).toEqual([{ code: 'N35', wagons: 14 }]); + }); + + it('falls back to the smaller type for the remainder when the big one runs short', () => { + // Only 10 of the 14 N35s the booking wants — the rest rides PW2s. Ten N35s + // carry 10/14 of the booking, leaving 4/14, which needs ceil(40 × 4/14) PW2s. + const rows = breakdown(perItemBooking, new Map([[N35, 10], [PW2, 50]])); + expect(rows[0]).toEqual({ code: 'N35', wagons: 10 }); + expect(rows[1].code).toBe('PW2'); + expect(rows[1].wagons).toBeGreaterThan(0); + }); + + it('reads differently on a train that stocks only the small type', () => { + const rows = breakdown(perItemBooking, new Map([[PW2, 60]])); + expect(rows).toEqual([{ code: 'PW2', wagons: 40 }]); + }); + + it('honours the configured items-per-wagon fit over raw tonnage', () => { + // Floor space binds before weight: an N35 physically holds 20 of these + // items even though 30 would fit by weight → 20 wagons, not 14. + const floorBound = { + ...perItemBooking, + cargoType: { + wagonTypes: [{ id: N35 }], + itemsPerWagonMap: { [N35]: 20 }, + }, + } as unknown as Booking; + expect(breakdown(floorBound, new Map([[N35, 50]]))).toEqual([ + { code: 'N35', wagons: 20 }, + ]); + }); + + it('returns nothing when the train stocks none of the allowed types', () => { + expect(breakdown(perItemBooking, new Map([['other-type', 30]]))).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 5775148d7..4e2724f49 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -155,12 +155,17 @@ export class IntercityService { return { ...this.mapBooking(booking, need), need, + wagonBreakdown: capacity?.breakdownFor(booking) ?? [], fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)), }; }), accepted: accepted.map((booking) => { const need = capacity?.needFor(booking) ?? null; - return { ...this.mapBooking(booking, need), need }; + return { + ...this.mapBooking(booking, need), + need, + wagonBreakdown: capacity?.breakdownFor(booking) ?? [], + }; }), }; } @@ -200,7 +205,9 @@ export class IntercityService { where: { id: bookingId }, relations: { bookingContainers: { containerType: true }, - cargoType: true, + // wagonTypes drives the break-bulk items-per-wagon fit — the accept + // check must size the booking exactly as the candidate list did. + cargoType: { wagonTypes: true }, }, }); if (!booking) { @@ -326,6 +333,10 @@ export class IntercityService { .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') + // The allowed wagon-type list is what sizes a break-bulk (PER_ITEM) + // booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and + // the wagon count silently degrades to tonnage-only. + .leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) @@ -355,6 +366,10 @@ export class IntercityService { .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') + // The allowed wagon-type list is what sizes a break-bulk (PER_ITEM) + // booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and + // the wagon count silently degrades to tonnage-only. + .leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 7b0f37b66..485a3ceea 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -1,6 +1,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; import type { AuthUserPayload } from "../../common/resolve-auth-user-id"; +import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { resolveAuthUserId } from "../../common/resolve-auth-user-id"; import { @@ -66,6 +67,7 @@ export class TrainSchedulingController { private readonly intercityService: IntercityService, private readonly bookingJourneyService: BookingJourneyService, private readonly billingService: BillingService, + private readonly userTradeAccessService: UserTradeAccessService, ) { } @Get("my-booking-windows") @@ -130,8 +132,14 @@ export class TrainSchedulingController { summary: "Batch monitoring board: paginated import schedules (all statuses) with bookings grouped by state", }) - getBatchBoard(@Query() query: BatchBoardQueryDto) { - return this.bookingBatchService.getBatchBoard(query); + async getBatchBoard( + @Query() query: BatchBoardQueryDto, + @CurrentUser() user: AuthUserPayload, + ) { + // Batch board is IMPORT-only — a user without IMPORT access sees nothing. + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.bookingBatchService.getBatchBoard(query, allowed ?? undefined); } @Get("batch-board/:scheduleId") @@ -868,15 +876,31 @@ export class TrainSchedulingController { @Get("container/schedules") @TrainSchedulingView() @ApiOperation({ summary: "List container train schedules (paginated)" }) - getContainerTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) { - return this.trainSchedulingService.getContainerTrainSchedules(query); + async getContainerTrainSchedules( + @Query() query: ListTrainSchedulesQueryDto, + @CurrentUser() user: AuthUserPayload, + ) { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.trainSchedulingService.getContainerTrainSchedules( + query, + allowed ?? undefined, + ); } @Get("bulk/schedules") @TrainSchedulingView() @ApiOperation({ summary: "List bulk train schedules (paginated)" }) - getBulkTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) { - return this.trainSchedulingService.getContainerTrainSchedules(query); + async getBulkTrainSchedules( + @Query() query: ListTrainSchedulesQueryDto, + @CurrentUser() user: AuthUserPayload, + ) { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.trainSchedulingService.getContainerTrainSchedules( + query, + allowed ?? undefined, + ); } @Get("container/schedules/:id") 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 35503d432..56f2cff00 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 @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; import { BillingModule } from '../billing/billing.module'; +import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; import { LocomotivesModule } from '../locomotives/locomotives.module'; @@ -63,6 +64,7 @@ import { ContractsModule } from '../contracts/contracts.module'; ]), forwardRef(() => BookingsModule), BillingModule, + UserTradeAccessModule, NotificationsModule, NotificationInboxModule, LocomotivesModule, 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 80030c6c3..406f51988 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 @@ -1435,4 +1435,44 @@ describe('TrainSchedulingService', () => { ).rejects.toThrow(/free only 5/); }); }); + + describe('effectiveWagonsRequired', () => { + const effective = (booking: unknown): number => + (service as never as { effectiveWagonsRequired(b: unknown): number }) + .effectiveWagonsRequired(booking); + + // 20-item / 100T break-bulk on 70T wagons with a 4-items-per-wagon fit: + // ceil(20/4) = 5 wagons. + const perItemBooking = (wagonsRequired: number | null) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: 20, + bulkTotalWeightTons: 100, + wagonsRequired, + cargoType: { + wagonTypes: [{ id: 'wt-nw5', capacityTons: 70 }], + itemsPerWagonMap: { 'wt-nw5': 4 }, + }, + }); + + it('overrides a stale too-small stamp with the item-aware recompute', () => { + // Stamped 1 by old code that read the PER_ITEM count (20) as tons. + expect(effective(perItemBooking(1))).toBe(5); + }); + + it('keeps a stored stamp that is at least the recompute', () => { + expect(effective(perItemBooking(7))).toBe(7); + }); + + it('trusts the stamp when BULK cargo relations are not loaded', () => { + expect( + effective({ + freightType: 'BULK', + cargoTotalWeightVgm: 20, + bulkTotalWeightTons: 100, + wagonsRequired: 5, + cargoType: null, + }), + ).toBe(5); + }); + }); }); 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 a23e763f2..495feedf0 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 @@ -136,6 +136,8 @@ import { deriveScheduleDirection } from './derive-schedule-direction.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { bookingCargoTons, + bulkItemsFitFor, + bulkItemWagonsRequired, deriveTrainCapacityFromLocomotive, combinedLocomotiveLimits, trainSetLocomotiveLimits, @@ -3897,13 +3899,25 @@ export class TrainSchedulingService { return Object.assign(detail, { warehouseAutomation }); } - async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) { + async getContainerTrainSchedules( + query: ListTrainSchedulesQueryDto = {}, + allowedDirections?: string[], + ) { const { page, pageSize, skip, take } = normalizePagination(query); + // Per-user trade-direction scope: schedules carry a `direction` column. + if (allowedDirections && allowedDirections.length === 0) { + return { + items: [], + meta: buildPaginationMeta(0, page, pageSize), + }; + } + // Exact-match filters (enum/id semantics). Freight type is derived from // the bookings aboard — no column to match — so it rides on `id` as an // EXISTS fragment instead. const base: FindOptionsWhere = {}; + if (allowedDirections) base.direction = In(allowedDirections) as never; if (query.status) base.status = query.status; if (query.originStationId) base.originStationId = query.originStationId; if (query.destinationStationId) base.destinationStationId = query.destinationStationId; @@ -7335,7 +7349,14 @@ export class TrainSchedulingService { const byLength = containerWagonsForLines(booking.bookingContainers ?? []); const byWeight = cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0; - const wagons = Math.max(1, stored, byLength, byWeight); + // Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw + // tonnage suggests — their tare must be pulled too (batch dimsFor parity). + const byItems = bulkItemWagonsRequired( + booking, + dims.capacityTons, + bulkItemsFitFor(booking.cargoType, wagonTypeId), + ); + const wagons = Math.max(1, stored, byLength, byWeight, byItems); return roundTons(cargo + wagons * dims.tareWeightTons); } @@ -7819,7 +7840,7 @@ export class TrainSchedulingService { */ private effectiveWagonsRequired(booking: Booking): number { const stored = Number(booking.wagonsRequired); - if (stored > 0) return Math.ceil(stored); + const storedCeil = stored > 0 ? Math.ceil(stored) : 0; const bulkCapacities = (booking.cargoType?.wagonTypes ?? []) .map((wt) => Number(wt.capacityTons)) .filter((c) => c > 0); @@ -7827,7 +7848,15 @@ export class TrainSchedulingService { booking.freightType === 'BULK' && bulkCapacities.length ? Math.max(...bulkCapacities) : undefined; - return wagonsRequiredForBooking(booking, bulkCapacity); + // BULK with no cargo relations loaded: recomputing would size against a + // 1T capacity and read a PER_ITEM item count as tons — trust the stamp. + if (booking.freightType === 'BULK' && bulkCapacity === undefined && storedCeil > 0) { + return storedCeil; + } + // Stored is a candidate, never an early return (batch parity): rows + // stamped while BULK sizing read the PER_ITEM item count as tons carry a + // too-small footprint — a 20-item/100T booking was stamped 1 wagon. + return Math.max(storedCeil, wagonsRequiredForBooking(booking, bulkCapacity)); } /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 6bafcc730..8d9339186 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -315,3 +315,131 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => expect(result.plan).toHaveLength(1); }); }); + +describe('planWagonsWithStock — break-bulk (PER_ITEM) item-aware packing', () => { + const pw2: WagonType = { + id: 'wt-pw2', + code: 'PW2', + capacityTons: 70, + lengthMeters: 17, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, + } as WagonType; + const nw5: WagonType = { + id: 'wt-nw5', + code: 'NW5', + capacityTons: 70, + lengthMeters: 14, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, + } as WagonType; + + // 20 machinery items, 100T total (5T each). NW5 fits 4/wagon, PW2 fits 3. + const machineryBooking = (): Booking => + ({ + id: 'BULK-ITEMS', + reference: 'BULK-ITEMS', + freightType: 'BULK', + cargoTypeId: 'ct-machinery', + cargoTotalWeightVgm: 20, + bulkTotalWeightTons: 100, + cargoType: { + id: 'ct-machinery', + cargoTypeName: 'Machinery', + itemsPerWagonMap: { 'wt-nw5': 4, 'wt-pw2': 3 }, + wagonTypes: [pw2, nw5], + }, + }) as unknown as Booking; + + const allowed = { + byContainerTypeId: new Map(), + byCargoTypeId: new Map([['ct-machinery', [pw2, nw5]]]), + }; + + it('packs whole items per wagon by the items-fit map, not raw tonnage', () => { + const result = planWagonsWithStock({ + bookings: [machineryBooking()], + allowed, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([ + [pw2.id, 50], + [nw5.id, 50], + ]), + codesByTypeId: new Map([ + [pw2.id, pw2.code], + [nw5.id, nw5.code], + ]), + }, + }); + + expect(result.deferred).toHaveLength(0); + // Best fit: NW5 at 4 items/wagon → ceil(20/4) = 5 wagons, 20T each. + expect(result.plan).toHaveLength(5); + expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true); + expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([20, 20, 20, 20, 20]); + }); + + it('weight cap binds before items-fit when items are heavy', () => { + // 14 items of 10T on 70T wagons with a 100-item floor fit → 7 items/wagon. + const heavy = { + ...machineryBooking(), + cargoTotalWeightVgm: 14, + bulkTotalWeightTons: 140, + cargoType: { + id: 'ct-machinery', + cargoTypeName: 'Machinery', + itemsPerWagonMap: { 'wt-nw5': 100, 'wt-pw2': 100 }, + wagonTypes: [pw2, nw5], + }, + } as unknown as Booking; + const result = planWagonsWithStock({ + bookings: [heavy], + allowed, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([ + [pw2.id, 50], + [nw5.id, 50], + ]), + codesByTypeId: new Map([ + [pw2.id, pw2.code], + [nw5.id, nw5.code], + ]), + }, + }); + + expect(result.deferred).toHaveLength(0); + expect(result.plan).toHaveLength(2); + expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 70]); + }); + + it('PER_TON bulk (no bulkTotalWeightTons) still packs by weight', () => { + const loose = { + ...machineryBooking(), + cargoTotalWeightVgm: 100, + bulkTotalWeightTons: null, + } as unknown as Booking; + const result = planWagonsWithStock({ + bookings: [loose], + allowed, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([ + [pw2.id, 50], + [nw5.id, 50], + ]), + codesByTypeId: new Map([ + [pw2.id, pw2.code], + [nw5.id, nw5.code], + ]), + }, + }); + + expect(result.deferred).toHaveLength(0); + expect(result.plan).toHaveLength(2); + expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 30]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 0cf56ded5..0810b3369 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -2,6 +2,11 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { + bookingCargoTons, + bulkItemsFitFor, + bulkItemWagonsForAllowedTypes, +} from './train-capacity.util'; import { sortBookingsForScheduling, type BookingWagonShortage, @@ -64,6 +69,12 @@ type OpenSlot = { /** Kind purity: a bulk wagon carries ONE cargo type at a time. */ cargoTypeId: string | null; freeCapacityTons: number; + /** + * Whole-item slots left on this wagon (break-bulk PER_ITEM cargo only — + * bounded by the cargo type's items-per-wagon fit and by tonnage). Undefined + * for weight-only (PER_TON) bulk and container wagons. + */ + freeItems?: number; /** * Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers * prefer a same-leg slot but may extend onto a different-leg one (span @@ -110,10 +121,19 @@ const shortageFor = ( booking.freightType === 'BULK' ? Math.max( 1, - Math.ceil( - Number(booking.cargoTotalWeightVgm ?? 0) / - Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), - ), + // Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map + // respected); PER_TON falls through to tonnage over the largest + // candidate. bookingCargoTons, not raw VGM — for PER_ITEM that + // column is the item count, not tons. + bulkItemWagonsForAllowedTypes( + booking, + booking.cargoType, + Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), + ) || + Math.ceil( + bookingCargoTons(booking) / + Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), + ), ) : Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); const wagonsAvailable = candidates.reduce( @@ -347,18 +367,64 @@ export function planWagonsWithStock(params: { }; } const allowedIds = new Set(candidates.map((wt) => wt.id)); - let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0)); + // Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the + // real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves + // it either way. Items are indivisible, so a wagon takes whole items only, + // bounded by tonnage AND by the cargo type's items-per-wagon fit. + const quantity = Number(booking.cargoTotalWeightVgm ?? 0); + const perItem = + Number(booking.bulkTotalWeightTons ?? 0) > 0 && quantity > 0; + let remainingWeight = roundTons(bookingCargoTons(booking)); + const perItemTons = perItem ? remainingWeight / quantity : 0; + let remainingItems = perItem ? quantity : 0; + + /** Whole items one wagon of this slot's type can still take. */ + const itemRoomOf = (open: OpenSlot): number => + Math.min( + open.freeItems ?? Number.MAX_SAFE_INTEGER, + perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0, + ); + /** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */ + const itemBudgetOf = (open: OpenSlot): number => { + const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId); + const byTonnage = + perItemTons > 0 + ? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons)) + : 1; + return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage); + }; let placedAnywhere = false; + // Per-item: prefer the type carrying the most whole items per wagon. + // openSlot's own capacity sort is stable, so this order breaks its ties. + const itemBudgetOfType = (wt: WagonType): number => + Math.min( + bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER, + perItemTons > 0 + ? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons)) + : 1, + ); + const orderedCandidates = perItem + ? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a)) + : candidates; + // Top off wagons already carrying THIS cargo type before opening new ones. + // ponytail: per-item cargo only shares wagons that were opened per-item + // (freeItems tracked); mixing itemized and loose loads of one cargo type + // on one wagon is not modeled — open a new wagon instead. for (const open of openSlots) { - if (remainingWeight <= 0) break; + if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break; if (open.kind !== 'BULK') continue; if (open.legKey !== legKey) continue; if (open.cargoTypeId !== cargoTypeId) continue; if (!allowedIds.has(open.slot.wagonTypeId)) continue; if (open.freeCapacityTons <= 0) continue; - const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight)); + if (perItem !== (open.freeItems !== undefined)) continue; + const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0; + if (perItem && takeItems <= 0) continue; + const take = perItem + ? roundTons(takeItems * perItemTons) + : roundTons(Math.min(open.freeCapacityTons, remainingWeight)); addAllocation( open.slot, booking.id, @@ -367,14 +433,39 @@ export function planWagonsWithStock(params: { AllocationLoadType.Bulk, ); open.freeCapacityTons = roundTons(open.freeCapacityTons - take); + if (perItem) { + open.freeItems = (open.freeItems ?? 0) - takeItems; + remainingItems -= takeItems; + } remainingWeight = roundTons(remainingWeight - take); placedAnywhere = true; } - while (remainingWeight > 0 || !placedAnywhere) { - const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg); + while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) { + // Per-item: openSlot's stock-depth tie-break would override the fit + // preference, so hand it exactly the best in-stock type (full candidate + // list only when none has stock, for the proper shortfall message). + const inStockBest = perItem + ? orderedCandidates.find((wt) => availableFor(wt.id, leg) > 0) + : undefined; + const openedSlot = openSlot( + inStockBest ? [inStockBest] : orderedCandidates, + 'BULK', + cargoTypeId, + leg, + ); if ('message' in openedSlot) return openedSlot; - const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); + let take: number; + if (perItem) { + // An item heavier than a whole wagon still charges 1 wagon per item + // (creation-time validation owns rejecting that case). + const takeItems = Math.max(1, Math.min(itemBudgetOf(openedSlot), remainingItems)); + take = roundTons(Math.min(takeItems * perItemTons, remainingWeight)); + openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems; + remainingItems -= takeItems; + } else { + take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); + } addAllocation( openedSlot.slot, booking.id, @@ -399,6 +490,7 @@ export function planWagonsWithStock(params: { teuPerEdge: [...open.teuPerEdge], covered: { ...open.covered }, freeCapacityTons: open.freeCapacityTons, + freeItems: open.freeItems, assignedWeightTons: open.slot.assignedWeightTons, allocationCount: open.slot.allocations.length, allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons), @@ -420,6 +512,7 @@ export function planWagonsWithStock(params: { open.teuPerEdge = [...snap.teuPerEdge]; open.covered = { ...snap.covered }; open.freeCapacityTons = snap.freeCapacityTons; + open.freeItems = snap.freeItems; open.slot.assignedWeightTons = snap.assignedWeightTons; open.slot.allocations.length = snap.allocationCount; snap.allocationWeights.forEach((weight, allocationIndex) => { diff --git a/apps/edr-freight-api/src/modules/user-trade-access/dto/upsert-user-trade-access.dto.ts b/apps/edr-freight-api/src/modules/user-trade-access/dto/upsert-user-trade-access.dto.ts new file mode 100644 index 000000000..1c2db098e --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/dto/upsert-user-trade-access.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayUnique, IsIn } from 'class-validator'; +import { Freight } from '@edr/types'; + +export class UpsertUserTradeAccessDto { + @ApiProperty({ + description: + 'Trade directions the user may see. All three (or no config row) = unrestricted; empty array = sees nothing.', + isArray: true, + enum: ['IMPORT', 'EXPORT', 'DOMESTIC'], + example: ['IMPORT', 'DOMESTIC'], + }) + @ArrayUnique() + @IsIn(['IMPORT', 'EXPORT', 'DOMESTIC'], { each: true }) + directions!: Freight.ScheduleTradeDirection[]; +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/entities/user-trade-access.entity.ts b/apps/edr-freight-api/src/modules/user-trade-access/entities/user-trade-access.entity.ts new file mode 100644 index 000000000..6ef5ca1fb --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/entities/user-trade-access.entity.ts @@ -0,0 +1,27 @@ +import { BaseEntity } from '@edr/api-common'; +import { Freight } from '@edr/types'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * Which trade directions (IMPORT / EXPORT / DOMESTIC=Intercity) a backoffice + * user may see. No row, or all three directions, means unrestricted. + */ +@Entity({ schema: 'freight', name: 'user_trade_access' }) +export class UserTradeAccess extends BaseEntity { + /** IAM user id (iam.users) — no FK, iam schema is externally owned. */ + @Index() + @Column({ name: 'user_id', type: 'uuid', unique: true }) + userId!: string; + + @Column({ name: 'directions', type: 'text', default: '' }) + directionsRaw!: string; + + @Column({ name: 'updated_by_id', type: 'uuid', nullable: true }) + updatedById!: string | null; + + get directions(): Freight.ScheduleTradeDirection[] { + return this.directionsRaw + ? (this.directionsRaw.split(',') as Freight.ScheduleTradeDirection[]) + : []; + } +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.ts b/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.ts new file mode 100644 index 000000000..37ffa93c9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.ts @@ -0,0 +1,100 @@ +import { Freight } from '@edr/types'; +import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm'; + +/** + * Resolve the effective direction list for a query. + * + * @param allowed the user's scope — null = unrestricted + * @param requested an explicit ?tradeDirection=… filter, if any + * @returns directions to filter by, `null` = no filter, `[]` = show nothing + */ +export function scopedDirections( + allowed: Freight.ScheduleTradeDirection[] | null, + requested?: string | null, +): string[] | null { + if (!allowed) return requested ? [requested] : null; + if (!requested) return [...allowed]; + return allowed.includes(requested as Freight.ScheduleTradeDirection) + ? [requested] + : []; +} + +/** + * Apply a direction scope to a query builder column. + * `dirs = null` → untouched; `dirs = []` → matches nothing. + */ +export function applyDirectionScope( + qb: T, + column: string, + dirs: string[] | null, +): T { + if (dirs === null) return qb; + if (dirs.length === 0) { + qb.andWhere('1 = 0'); + return qb; + } + // Unique param name so multiple scopes can coexist on one query. + const param = `scopeDirs_${column.replace(/\W/g, '_')}`; + qb.andWhere(`${column} IN (:...${param})`, { [param]: dirs }); + return qb; +} + +/** + * SQL-fragment form of {@link applyDirectionScope} for fluent query chains: + * `.andWhere(f.sql, f.params)`. `dirs = null/undefined` → TRUE (no-op). + */ +export function directionScopeSql( + column: string, + dirs: string[] | null | undefined, +): { sql: string; params: Record } { + if (!dirs) return { sql: 'TRUE', params: {} }; + if (dirs.length === 0) return { sql: 'FALSE', params: {} }; + const param = `scopeDirs_${column.replace(/\W/g, '_')}`; + return { sql: `${column} IN (:...${param})`, params: { [param]: dirs } }; +} + +/** + * SQL-fragment form of {@link applyBookingRefDirectionScope}: hides rows whose + * varchar ref column points at a booking outside the scope; rows that do not + * point at a booking stay visible (they carry no direction to scope by). + */ +export function bookingRefScopeSql( + refColumn: string, + dirs: string[] | null | undefined, +): { sql: string; params: Record } { + if (!dirs) return { sql: 'TRUE', params: {} }; + const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`; + const disallowed = dirs.length + ? `b.trade_direction NOT IN (:...${param})` + : 'TRUE'; + return { + sql: `NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`, + params: dirs.length ? { [param]: dirs } : {}, + }; +} + +/** + * Scope rows whose direction lives on a related booking referenced by a + * varchar id column (invoices.source_id, payments.ref_id). Rows that do not + * point at a booking stay visible — they carry no direction to scope by. + */ +export function applyBookingRefDirectionScope( + qb: SelectQueryBuilder, + refColumn: string, + dirs: string[] | null, +): SelectQueryBuilder { + if (dirs === null) return qb; + const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`; + const disallowed = dirs.length + ? `b.trade_direction NOT IN (:...${param})` + : 'TRUE'; + qb.andWhere( + new Brackets((w) => { + w.where( + `NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`, + ); + }), + ); + if (dirs.length) qb.setParameter(param, dirs); + return qb; +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.controller.ts b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.controller.ts new file mode 100644 index 000000000..3c6255fd9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + Param, + ParseUUIDPipe, + Put, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { StaffReference } from '../../common/booking-guards'; +import { isFreightApprovalAdmin } from '../../common/freight-permission.util'; +import { UpsertUserTradeAccessDto } from './dto/upsert-user-trade-access.dto'; +import { UserTradeAccessService } from './user-trade-access.service'; + +@ApiTags('user-trade-access') +@Controller('user-trade-access') +@StaffReference() +@ApiBearerAuth() +export class UserTradeAccessController { + constructor(private readonly service: UserTradeAccessService) {} + + @Get() + @ApiOperation({ summary: 'List every configured user trade-direction scope' }) + list(@CurrentUser() user: TCurrentUser) { + this.assertAdmin(user); + return this.service.listConfigs(); + } + + @Get('me') + @ApiOperation({ summary: "Current user's effective trade-direction scope" }) + async me(@CurrentUser() user: TCurrentUser) { + const allowed = await this.service.resolveAllowedDirections(user); + return { + restricted: allowed !== null, + directions: allowed ?? ['IMPORT', 'EXPORT', 'DOMESTIC'], + }; + } + + @Put(':userId') + @ApiOperation({ + summary: 'Set the trade directions a backoffice user may see', + }) + upsert( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: UpsertUserTradeAccessDto, + @CurrentUser() user: TCurrentUser, + ) { + this.assertAdmin(user); + return this.service.upsert( + userId, + dto.directions, + (user as { id?: string } | null)?.id ?? null, + ); + } + + private assertAdmin(user: TCurrentUser) { + if (!isFreightApprovalAdmin(user)) { + throw new ForbiddenException( + 'Only super or organization admins can manage trade-direction access', + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.module.ts b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.module.ts new file mode 100644 index 000000000..8b3515930 --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { UserTradeAccess } from './entities/user-trade-access.entity'; +import { UserTradeAccessController } from './user-trade-access.controller'; +import { UserTradeAccessRepository } from './user-trade-access.repository'; +import { UserTradeAccessService } from './user-trade-access.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserTradeAccess])], + controllers: [UserTradeAccessController], + providers: [UserTradeAccessService, UserTradeAccessRepository], + exports: [UserTradeAccessService], +}) +export class UserTradeAccessModule {} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.repository.ts b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.repository.ts new file mode 100644 index 000000000..1df85b5ee --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.repository.ts @@ -0,0 +1,23 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { UserTradeAccess } from './entities/user-trade-access.entity'; + +@Injectable() +export class UserTradeAccessRepository extends BaseRepository { + constructor( + @InjectRepository(UserTradeAccess) repository: Repository, + ) { + super(repository); + } + + findByUserId(userId: string): Promise { + return this.repository.findOne({ where: { userId } }); + } + + findAllConfigs(): Promise { + return this.repository.find({ order: { updatedAt: 'DESC' } }); + } +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.service.ts b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.service.ts new file mode 100644 index 000000000..bcabdea8b --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { Freight } from '@edr/types'; +import { isFreightApprovalAdmin } from '../../common/freight-permission.util'; +import { UserTradeAccess } from './entities/user-trade-access.entity'; +import { UserTradeAccessRepository } from './user-trade-access.repository'; + +const ALL: Freight.ScheduleTradeDirection[] = ['IMPORT', 'EXPORT', 'DOMESTIC']; + +/** Loose current-user shape: JWT payloads and TCurrentUser both fit. */ +export type ScopeUser = + | ({ id?: string; sub?: string; roles?: { key?: string }[] } & object) + | null + | undefined; + +export type UserTradeAccessView = { + userId: string; + directions: Freight.ScheduleTradeDirection[]; + updatedAt: Date; +}; + +@Injectable() +export class UserTradeAccessService { + constructor(private readonly repository: UserTradeAccessRepository) {} + + async listConfigs(): Promise { + const rows = await this.repository.findAllConfigs(); + return rows.map((r) => this.toView(r)); + } + + async upsert( + userId: string, + directions: Freight.ScheduleTradeDirection[], + actorId?: string | null, + ): Promise { + // Normalize to canonical order so "all three" compares reliably. + const normalized = ALL.filter((d) => directions.includes(d)); + const existing = await this.repository.findByUserId(userId); + const saved = existing + ? await this.repository.update(existing.id, { + directionsRaw: normalized.join(','), + updatedById: actorId ?? null, + }) + : await this.repository.create({ + userId, + directionsRaw: normalized.join(','), + updatedById: actorId ?? null, + }); + return this.toView(saved as UserTradeAccess); + } + + /** + * Effective scope for the current user. + * `null` = unrestricted (no config, all three directions, admin, or no user + * on the request — routes without auth cannot be scoped). + */ + async resolveAllowedDirections( + user: ScopeUser, + ): Promise { + const userId = user?.id ?? user?.sub; + if (!userId) return null; + if (isFreightApprovalAdmin(user)) return null; + const row = await this.repository.findByUserId(userId); + if (!row) return null; + const dirs = row.directions; + if (dirs.length >= ALL.length) return null; + return dirs; + } + + private toView(row: UserTradeAccess): UserTradeAccessView { + return { + userId: row.userId, + directions: row.directions, + updatedAt: row.updatedAt, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts index dc3558ccc..d4f50eb62 100644 --- a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts +++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts @@ -7,12 +7,15 @@ export interface GenerateClientAssertionInput { expiresIn?: string; } +// Mirrors the National ID Program's own reference implementation +// (fayda-auth-python): plain {alg: RS256} header, no kid, no jti — eSignet +// resolves the verification key from client_id alone. export async function generateClientAssertion( input: GenerateClientAssertionInput, ): Promise { const privateKey = await importJWK(input.privateJwk, 'RS256'); return new SignJWT({}) - .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setProtectedHeader({ alg: 'RS256' }) .setIssuer(input.clientId) .setSubject(input.clientId) .setAudience(input.audience) diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts index 16441bd0d..af627f25a 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -396,13 +396,63 @@ export class VerifaydaService { ); } + // Like name/gender, address is flattened by eSignet into top-level + // `address#en` / `address#am` keys — but since it's a structured claim, + // each of those is itself an object whose *leaf* fields carry the same + // locale suffix again, e.g. + // `address#en: { "zone#en": "...", "region#en": "...", "woreda#en": "..." }`. + private static readonly ADDRESS_FIELD_ORDER = [ + 'houseNumber', + 'kebele', + 'woreda', + 'city', + 'subCity', + 'zone', + 'region', + 'postalCode', + 'country', + ]; + + private formatFaydaAddress( + address: Record | undefined, + locale: 'en' | 'am', + ): string | undefined { + if (!address) return undefined; + + const formatted = address[`formatted#${locale}`]; + if (typeof formatted === 'string' && formatted.trim()) return formatted; + + const suffix = `#${locale}`; + const byField = new Map(); + for (const [key, value] of Object.entries(address)) { + if (!key.endsWith(suffix) || typeof value !== 'string' || !value.trim()) continue; + byField.set(key.slice(0, -suffix.length), value); + } + + const ordered = VerifaydaService.ADDRESS_FIELD_ORDER.filter((f) => + byField.has(f), + ).map((f) => byField.get(f)!); + const rest = [...byField.entries()] + .filter(([f]) => !VerifaydaService.ADDRESS_FIELD_ORDER.includes(f)) + .map(([, v]) => v); + + const parts = [...ordered, ...rest]; + return parts.length > 0 ? parts.join(', ') : undefined; + } + private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo { const nameEn = raw['name#en'] as string | undefined; const nameAm = raw['name#am'] as string | undefined; const genderEn = raw['gender#en'] as string | undefined; const genderAm = raw['gender#am'] as string | undefined; - const addressEn = raw['address#en'] as string | undefined; - const addressAm = raw['address#am'] as string | undefined; + const addressEn = this.formatFaydaAddress( + raw['address#en'] as Record | undefined, + 'en', + ); + const addressAm = this.formatFaydaAddress( + raw['address#am'] as Record | undefined, + 'am', + ); const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined; return { diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts index 442a22d2f..03e52d5b0 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts @@ -21,7 +21,8 @@ export interface FaydaUserInfo { gender?: string; birthdate?: string; picture?: string; - address?: Record; + 'address#en'?: Record; + 'address#am'?: Record; [key: string]: unknown; } diff --git a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts index 1822a877f..fafbd3a9a 100644 --- a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts +++ b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts @@ -59,14 +59,23 @@ export class YardFacilitiesSeeder { [yard.id], ); + // Every facility works both sides of the trip today, so the per-side + // flags mirror the freight-type flags. Narrow an individual yard here + // when a real one turns out to load a type but not receive it. await this.dataSource.query( `INSERT INTO freight.yard_facilities - (yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes) - VALUES ($1, $2, $3, true, $4) + (yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes, + has_container_facility_origin, has_container_facility_destination, + has_bulk_facility_origin, has_bulk_facility_destination) + VALUES ($1, $2, $3, true, $4, $3, $3, true, true) ON CONFLICT (yard_id) WHERE deleted_at IS NULL DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, handles_container = EXCLUDED.handles_container, handles_bulk = EXCLUDED.handles_bulk, + has_container_facility_origin = EXCLUDED.has_container_facility_origin, + has_container_facility_destination = EXCLUDED.has_container_facility_destination, + has_bulk_facility_origin = EXCLUDED.has_bulk_facility_origin, + has_bulk_facility_destination = EXCLUDED.has_bulk_facility_destination, updated_at = NOW()`, [yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`], ); diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 568b3a47a..675eced1a 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -55,6 +55,7 @@ "@tanstack/react-table": "^8.21.3", "@tinymce/tinymce-react": "^6.3.0", "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", + "@types/three": "^0.185.3", "@vis.gl/react-google-maps": "^1.8.3", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", @@ -103,6 +104,7 @@ "sonner": "^2.0.7", "stream-browserify": "^3.0.0", "tailwind-merge": "^3.6.0", + "three": "^0.185.1", "tinymce": "^8.6.0", "xlsx": "^0.18.5", "zod": "^3.25.76", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index d523d2e8a..ceb2a7d06 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -111,6 +111,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import TradeAccessPage from "./pages/configuration/TradeAccessPage"; import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; @@ -585,6 +586,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/configuration/train-scheduling-rules", permission: FREIGHT_PERMS.trainScheduling.rulesManage, }, + { + label: "Trade access", + href: "/dashboard/configuration/trade-access", + permission: FREIGHT_PERMS.admin, + }, ], }, { @@ -1549,6 +1555,14 @@ const App = () => { } /> + + + + } + /> {/* @@ -21,7 +23,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { label="Cargo type" value={booking.cargoType?.label ?? booking.freightType} /> - + + {items != null && } { + const { tons, items } = cargoTonsAndItems(booking); + return items != null ? `${tons} tons (${items} items)` : `${tons} tons`; + })(), + }, { icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) }, ]; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx index 3ee5d72a0..ed9802150 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx @@ -22,6 +22,7 @@ import { import type { LucideIcon } from "lucide-react"; import type { BookingDetail } from "@/types/booking"; +import { cargoTonsAndItems } from "@/utils/cargoWeight"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; @@ -52,7 +53,7 @@ export function BookingRequestHero({ (sum, c) => sum + Number(c.quantity ?? 0), 0, ); - const weight = Number(booking.cargoTotalWeightVgm ?? 0); + const { tons: weight, items: itemCount } = cargoTonsAndItems(booking); return ( + setContainerLines((prev) => + prev.map((l, i) => { + if (i !== lineIdx) return l; + const units = l.units.filter((_, j) => j !== unitIdx); + return withDerivedCounts({ ...l, quantity: String(units.length), units }); + }), + ); + // Same client-side validation as the customer portal shipment form // (new-shipment-form/schema.ts): ISO container numbers unique within the // shipment, positive VGM per unit, hazardous/reefer counts bounded by the @@ -1362,6 +1374,8 @@ export default function GlCreateBookingForm() { } onChange={(e) => { patchLine(lineIdx, { quantity: e.currentTarget.value }); + }} + onBlur={(e) => { syncUnits(lineIdx, Number(e.currentTarget.value || 0)); }} radius={10} @@ -1495,6 +1509,14 @@ export default function GlCreateBookingForm() { /> ))} + removeUnit(lineIdx, unitIdx)} + > + + ))} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index 831e1bc1b..2ca73f10a 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -182,18 +182,18 @@ const FreightDashboardHeader = ({ )} + } + onClick={() => navigate("/dashboard/profile#signature")} + > + Signature & Stamp + } onClick={() => navigate("/dashboard/profile")} > Profile - } - onClick={() => navigate("/dashboard/profile#signature")} - > - My signature - } diff --git a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx index ca895b70b..0a70b3aec 100644 --- a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { FileSignature, Loader2 } from "lucide-react"; +import { FileSignature, Loader2, Stamp } from "lucide-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; @@ -27,9 +27,9 @@ import { } from "@edr/ui-common"; /** - * Lets the signed-in user view and update the reusable signature stored on - * their profile. The same signature is offered for approval when signing a - * booking contract. + * Lets the signed-in user view and update the reusable signature and company + * stamp stored on their profile — managed independently of each other. Both + * are offered when signing a booking contract. */ export function MySignatureCard() { const { user } = useAuth(); @@ -38,94 +38,132 @@ export function MySignatureCard() { ); const saveMutation = useMutation(api.signatures.save.mutationOptions()); - const [open, setOpen] = useState(false); + const [signatureOpen, setSignatureOpen] = useState(false); + const [stampOpen, setStampOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); const [stampData, setStampData] = useState(null); const defaultName = user?.name?.en || user?.username || user?.email || ""; + const savedName = saved?.signerDisplayName ?? defaultName; - const openDialog = () => { - setSignerName(saved?.signerDisplayName ?? defaultName); + const openSignatureDialog = () => { + setSignerName(savedName); setSignatureData(null); - setStampData(saved?.stampImageUrl ?? null); - setOpen(true); + setSignatureOpen(true); }; - const save = () => { + const saveSignature = () => { if (!signatureData || !signerName.trim()) return; saveMutation.mutate( { signerDisplayName: signerName.trim(), signatureImageBase64: signatureData, - // Only send the stamp when it changed — omitted keeps the saved one. - ...(stampData && stampData !== saved?.stampImageUrl - ? { stampImageBase64: stampData } - : {}), + // Stamp untouched — it is managed by its own dialog. }, { onSuccess: () => { toast.success("Signature saved"); - setOpen(false); + setSignatureOpen(false); }, onError: () => toast.error("Failed to save signature"), }, ); }; + const openStampDialog = () => { + setStampData(saved?.stampImageUrl ?? null); + setStampOpen(true); + }; + + const saveStamp = () => { + if (!stampData) return; + saveMutation.mutate( + { + signerDisplayName: savedName || defaultName, + // Signature untouched — stamp-only update. + stampImageBase64: stampData, + }, + { + onSuccess: () => { + toast.success("Stamp saved"); + setStampOpen(false); + }, + onError: () => toast.error("Failed to save stamp"), + }, + ); + }; + return ( - My signature + Signature & Stamp This signature can be reused to sign booking contracts. - + {isLoading ? (
- ) : saved?.signatureImageUrl ? ( -
-
- My saved signature -
-

- Saved as {saved.signerDisplayName} -

-
) : ( -

- You have not saved a signature yet. -

- )} - {saved?.stampImageUrl && ( -
-
- My saved company stamp + <> +
+ {saved?.signatureImageUrl ? ( + <> +
+ My saved signature +
+

+ Saved as {saved.signerDisplayName} +

+ + ) : ( +

+ You have not saved a signature yet. +

+ )} +
-

Company stamp

-
+ +
+ {saved?.stampImageUrl ? ( + <> +
+ My saved company stamp +
+

Company stamp

+ + ) : ( +

+ You have not uploaded a company stamp yet. +

+ )} + +
+ )} - - + Save your signature @@ -145,21 +183,16 @@ export function MySignatureCard() { />
- - + + + +
); } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx index 12ef228df..69dc7f2b8 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx @@ -38,6 +38,7 @@ import { formatRouteLabel } from "@/services/routes.service"; import { useToast } from "@/hooks/use-toast"; import { trainSchedulingService } from "@/services/trainScheduling.service"; import type { BookingDetail } from "@/types/booking"; +import { cargoTonsAndItems } from "@/utils/cargoWeight"; import type { ContainerPlacement, FreightType, @@ -416,7 +417,7 @@ export function AllocateBookingWizard({ const amount = Number(booking.totalAmount); const containers = booking.bookingContainers ?? []; const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); - const weight = Number(booking.cargoTotalWeightVgm ?? 0); + const { tons: weight, items: itemCount } = cargoTonsAndItems(booking); const holdCountdown = formatCountdown(booking.holdExpiresAt); const containerComplete = @@ -945,7 +946,13 @@ export function AllocateBookingWizard({ })}`} hint={booking.paymentStatus} /> - + —; return ( <> - {fmt(need.wagons)} + + {wagonBreakdown?.length ? ( + // Which wagon TYPES this train gives up, not just how many wagons — + // a break-bulk booking's count depends on each type's capacity and + // its configured items-per-wagon fit, so it differs per train. + + {wagonBreakdown.map((entry) => ( + + {entry.wagons} × {entry.code} + + ))} + + ) : ( + fmt(need.wagons) + )} + {fmt(need.weightTons)} t {fmt(need.lengthMeters)} m @@ -285,7 +301,7 @@ export function IntercityRideAlongPanel({ - + {row.fits ? ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx new file mode 100644 index 000000000..38295a393 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx @@ -0,0 +1,1083 @@ +import { Badge, Box, Button, CloseButton, Divider, Group, Paper, ScrollArea, Stack, Text } from "@mantine/core"; +import { ChevronLeft, ChevronRight, Gauge, Maximize2, Minimize2, Train, TrainFront, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; + +import type { TrainScheduleDetail } from "@/types/trainScheduling"; + +type Wagon = NonNullable["wagons"][number]; + +interface Train3DVisualizationProps { + schedule: TrainScheduleDetail; + onClose: () => void; +} + +// shipping-line style colors — no purple +const CONTAINER_PALETTE = [0xb63a2b, 0x1f6fb2, 0x1e8449, 0xd9a213, 0xd35f1e, 0x11707f, 0x7a3b1e, 0x35506e]; + +// wagon body color keyed by wagon type — same type = same color across the train +const WAGON_TYPE_PALETTE = [0x2e6f8e, 0xb0722d, 0x5c7f3b, 0x8e3b3b, 0x3b6e63, 0x8a6d2f, 0x505a7d, 0x6d5540]; +const wagonTypeColor = (w: Wagon, fallback: number) => + w.wagonType?.code ? WAGON_TYPE_PALETTE[Math.abs(hash32(w.wagonType.code)) % WAGON_TYPE_PALETTE.length] : fallback; + +const hash32 = (key: string) => { + let h = 0; + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0; + return h; +}; + +const hashColor = (key: string) => CONTAINER_PALETTE[Math.abs(hash32(key)) % CONTAINER_PALETTE.length]; + +type WagonKind = "container" | "bulk" | "tank" | "flat"; + +const wagonKind = (w: Wagon): WagonKind => { + const code = `${w.wagonType?.code ?? ""} ${w.wagonType?.name ?? ""}`.toUpperCase(); + if (/TANK|LIQUID|FUEL/.test(code)) return "tank"; + if (/HOPPER|BULK|OPEN|GONDOLA/.test(code) || w.allocations.some((a) => a.bulkLoad)) return "bulk"; + if (/FLAT|CONT/.test(code) || w.allocations.some((a) => a.containerItems?.length)) return "container"; + return "flat"; +}; + +const WAGON_BODY_COLOR: Record = { + container: 0x5f7d9c, + bulk: 0xa8683f, + tank: 0xb8bec8, + flat: 0x9aa4ae, +}; + +const M = 1; // 1 unit = 1 meter +const GAUGE = 1.435 * M; +const WHEEL_R = 0.46 * M; +const DECK_H = 1.2 * M; + +// deterministic pseudo-random so scenery doesn't jump between rebuilds +const makeRng = (seed: number) => () => { + seed = (seed * 1664525 + 1013904223) % 4294967296; + return seed / 4294967296; +}; + +function makeLabelSprite(text: string, bg: string): THREE.Sprite { + const canvas = document.createElement("canvas"); + canvas.width = 512; + canvas.height = 128; + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = bg; + ctx.beginPath(); + ctx.roundRect(6, 6, 500, 116, 28); + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,0.9)"; + ctx.lineWidth = 6; + ctx.stroke(); + ctx.fillStyle = "#ffffff"; + ctx.font = "bold 58px system-ui, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(text, 256, 68); + const texture = new THREE.CanvasTexture(canvas); + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, depthTest: false })); + sprite.scale.set(7, 1.75, 1); + return sprite; +} + +function buildClouds(scene: THREE.Scene, rng: () => number) { + const mat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1, transparent: true, opacity: 0.92 }); + for (let i = 0; i < 8; i++) { + const cloud = new THREE.Group(); + const puffs = 3 + Math.floor(rng() * 4); + for (let p = 0; p < puffs; p++) { + const puff = new THREE.Mesh(new THREE.SphereGeometry(8 + rng() * 10, 12, 10), mat); + puff.position.set((p - puffs / 2) * 11, rng() * 4, (rng() - 0.5) * 10); + puff.scale.y = 0.55; + cloud.add(puff); + } + // high and pushed to the sides so they never sit between camera and train + cloud.position.set((rng() - 0.5) * 1800, 220 + rng() * 80, (rng() > 0.5 ? 1 : -1) * (350 + rng() * 400)); + scene.add(cloud); + } +} + +function buildAcacia(rng: () => number): THREE.Group { + // flat-topped acacia + const tree = new THREE.Group(); + const trunkH = 2.6 + rng() * 2; + const trunkMat = new THREE.MeshStandardMaterial({ color: 0x7a5230, roughness: 1 }); + const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.35, trunkH, 7), trunkMat); + trunk.position.y = trunkH / 2; + trunk.rotation.z = (rng() - 0.5) * 0.25; + tree.add(trunk); + for (const tilt of [-0.6, 0.6]) { + const branch = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.14, trunkH * 0.6, 6), trunkMat); + branch.position.set(tilt * 0.8, trunkH * 0.85, (rng() - 0.5) * 0.6); + branch.rotation.z = tilt; + tree.add(branch); + } + const canopyMat = new THREE.MeshStandardMaterial({ + color: rng() > 0.5 ? 0x2f7d32 : 0x3c8d40, + roughness: 1, + }); + const canopy = new THREE.Mesh(new THREE.SphereGeometry(2.6 + rng() * 1.6, 10, 8), canopyMat); + canopy.position.y = trunkH + 0.9; + canopy.scale.set(1.2, 0.32, 1.2); // flat umbrella top + tree.add(canopy); + return tree; +} + +function buildShrub(rng: () => number): THREE.Mesh { + const dry = [0x3c7d33, 0x4f9e3f, 0x2e6b28, 0x5da84a]; + const shrub = new THREE.Mesh( + new THREE.SphereGeometry(0.5 + rng() * 0.8, 7, 6), + new THREE.MeshStandardMaterial({ color: dry[Math.floor(rng() * dry.length)], roughness: 1 }), + ); + shrub.scale.y = 0.55; + shrub.position.y = 0.25; + return shrub; +} + +function buildRock(rng: () => number): THREE.Mesh { + const rock = new THREE.Mesh( + new THREE.DodecahedronGeometry(0.5 + rng() * 1.6, 0), + new THREE.MeshStandardMaterial({ color: rng() > 0.5 ? 0xa08d76 : 0x8d7f6d, roughness: 1 }), + ); + rock.scale.set(1 + rng(), 0.55 + rng() * 0.4, 1 + rng()); + rock.position.y = 0.3; + rock.rotation.y = rng() * Math.PI; + return rock; +} + +function buildCamel(rng: () => number): THREE.Group { + const g = new THREE.Group(); + const mat = new THREE.MeshStandardMaterial({ color: rng() > 0.5 ? 0xc49a5f : 0xb28850, roughness: 1 }); + const body = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.95, 0.85), mat); + body.position.y = 1.55; + g.add(body); + const hump = new THREE.Mesh(new THREE.SphereGeometry(0.55, 10, 8), mat); + hump.position.set(-0.15, 2.25, 0); + hump.scale.set(1.15, 0.9, 0.85); + g.add(hump); + const neck = new THREE.Mesh(new THREE.BoxGeometry(0.3, 1.3, 0.3), mat); + neck.position.set(1.05, 2.35, 0); + neck.rotation.z = -0.25; + g.add(neck); + const head = new THREE.Mesh(new THREE.BoxGeometry(0.65, 0.32, 0.3), mat); + head.position.set(1.45, 2.95, 0); + g.add(head); + const legGeo = new THREE.BoxGeometry(0.17, 1.15, 0.17); + for (const [x, z] of [[-0.8, -0.28], [-0.8, 0.28], [0.8, -0.28], [0.8, 0.28]]) { + const leg = new THREE.Mesh(legGeo, mat); + leg.position.set(x, 0.58, z); + g.add(leg); + } + g.rotation.y = rng() * Math.PI * 2; + return g; +} + +function buildGoat(rng: () => number): THREE.Group { + const g = new THREE.Group(); + const colors = [0xe8e4da, 0x4a3b2d, 0x9c8a72]; + const mat = new THREE.MeshStandardMaterial({ color: colors[Math.floor(rng() * colors.length)], roughness: 1 }); + const body = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.5, 0.4), mat); + body.position.y = 0.62; + g.add(body); + const head = new THREE.Mesh(new THREE.BoxGeometry(0.32, 0.3, 0.26), mat); + head.position.set(0.55, 0.85, 0); + g.add(head); + const legGeo = new THREE.BoxGeometry(0.09, 0.4, 0.09); + for (const [x, z] of [[-0.32, -0.13], [-0.32, 0.13], [0.32, -0.13], [0.32, 0.13]]) { + const leg = new THREE.Mesh(legGeo, mat); + leg.position.set(x, 0.2, z); + g.add(leg); + } + g.rotation.y = rng() * Math.PI * 2; + return g; +} + +function buildScenery(scene: THREE.Scene, trackLen: number) { + const rng = makeRng(1234567); + buildClouds(scene, rng); + const spread = Math.max(trackLen, 600); + // grass variation: darker meadow patches + const patchMat = new THREE.MeshStandardMaterial({ color: 0x4f8f3a, roughness: 1 }); + const patchGeo = new THREE.CircleGeometry(4, 8); + const patches = new THREE.InstancedMesh(patchGeo, patchMat, 160); + const m4 = new THREE.Matrix4(); + const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0)); + const v = new THREE.Vector3(); + const s = new THREE.Vector3(); + for (let i = 0; i < 160; i++) { + const z = (rng() > 0.5 ? 1 : -1) * (6 + rng() * 220); + v.set((rng() - 0.5) * spread * 1.4, 0.02, z); + s.setScalar(0.6 + rng() * 2.4); + m4.compose(v, q, s); + patches.setMatrixAt(i, m4); + } + scene.add(patches); + // low green hills on the horizon sides + const duneMat = new THREE.MeshStandardMaterial({ color: 0x4a8a38, roughness: 1 }); + for (let i = 0; i < 12; i++) { + const dune = new THREE.Mesh(new THREE.SphereGeometry(30 + rng() * 50, 12, 8), duneMat); + dune.scale.set(1.6 + rng(), 0.12 + rng() * 0.08, 1 + rng()); + dune.position.set((rng() - 0.5) * spread * 1.6, 0, (rng() > 0.5 ? 1 : -1) * (180 + rng() * 200)); + scene.add(dune); + } + // sparse acacias, dry shrubs, rocks + for (let i = 0; i < 34; i++) { + const tree = buildAcacia(rng); + tree.position.set((rng() - 0.5) * spread * 1.2, 0, (rng() > 0.5 ? 1 : -1) * (18 + rng() * 170)); + tree.scale.setScalar(0.9 + rng() * 1.1); + scene.add(tree); + } + for (let i = 0; i < 120; i++) { + const shrub = buildShrub(rng); + shrub.position.set((rng() - 0.5) * spread * 1.3, 0.15, (rng() > 0.5 ? 1 : -1) * (8 + rng() * 200)); + scene.add(shrub); + } + for (let i = 0; i < 40; i++) { + const rock = buildRock(rng); + rock.position.x = (rng() - 0.5) * spread * 1.3; + rock.position.z = (rng() > 0.5 ? 1 : -1) * (10 + rng() * 190); + scene.add(rock); + } + // camel caravans + goat herds + for (let h = 0; h < 5; h++) { + const cx = (rng() - 0.5) * spread; + const cz = (rng() > 0.5 ? 1 : -1) * (30 + rng() * 130); + const count = 2 + Math.floor(rng() * 3); + for (let a = 0; a < count; a++) { + const camel = buildCamel(rng); + camel.position.set(cx + a * 4 + (rng() - 0.5) * 2, 0, cz + (rng() - 0.5) * 6); + scene.add(camel); + } + } + for (let h = 0; h < 4; h++) { + const cx = (rng() - 0.5) * spread; + const cz = (rng() > 0.5 ? 1 : -1) * (20 + rng() * 120); + const count = 3 + Math.floor(rng() * 4); + for (let a = 0; a < count; a++) { + const goat = buildGoat(rng); + goat.position.set(cx + (rng() - 0.5) * 10, 0, cz + (rng() - 0.5) * 8); + scene.add(goat); + } + } +} + +function buildTrack(scene: THREE.Scene, length: number) { + const railMat = new THREE.MeshStandardMaterial({ color: 0x9aa0a8, metalness: 0.9, roughness: 0.35 }); + const railGeo = new THREE.BoxGeometry(length, 0.18 * M, 0.08 * M); + for (const z of [-GAUGE / 2, GAUGE / 2]) { + const rail = new THREE.Mesh(railGeo, railMat); + rail.position.set(0, 0.28 * M, z); + scene.add(rail); + } + const sleeperGeo = new THREE.BoxGeometry(0.24 * M, 0.14 * M, 2.4 * M); + const sleeperMat = new THREE.MeshStandardMaterial({ color: 0x3b2f26, roughness: 1 }); + const count = Math.floor(length / 0.65); + const sleepers = new THREE.InstancedMesh(sleeperGeo, sleeperMat, count); + const m4 = new THREE.Matrix4(); + for (let i = 0; i < count; i++) { + m4.setPosition(-length / 2 + i * 0.65, 0.12 * M, 0); + sleepers.setMatrixAt(i, m4); + } + scene.add(sleepers); + // ballast bed + const ballast = new THREE.Mesh( + new THREE.BoxGeometry(length, 0.1 * M, 4.2 * M), + new THREE.MeshStandardMaterial({ color: 0x565a5f, roughness: 1 }), + ); + ballast.position.y = 0.03; + scene.add(ballast); + // ground + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(4000, 1600), + new THREE.MeshStandardMaterial({ color: 0x5da043, roughness: 1 }), + ); + ground.rotation.x = -Math.PI / 2; + ground.position.y = -0.02; + scene.add(ground); +} + +function addWheels(group: THREE.Group, length: number, wheels: THREE.Mesh[]) { + const geo = new THREE.CylinderGeometry(WHEEL_R, WHEEL_R, 0.25, 20); + const mat = new THREE.MeshStandardMaterial({ color: 0x1c1f24, metalness: 0.7, roughness: 0.4 }); + const bogieOffsets = [-length / 2 + 1.8, length / 2 - 1.8]; + for (const x of bogieOffsets) { + for (const dx of [-0.9, 0.9]) { + for (const z of [-GAUGE / 2, GAUGE / 2]) { + const wheel = new THREE.Mesh(geo, mat); + wheel.rotation.x = Math.PI / 2; + wheel.position.set(x + dx, WHEEL_R + 0.05, z); + group.add(wheel); + wheels.push(wheel); + } + } + const bogie = new THREE.Mesh( + new THREE.BoxGeometry(2.6, 0.4, GAUGE + 0.4), + new THREE.MeshStandardMaterial({ color: 0x22262c, roughness: 0.8 }), + ); + bogie.position.set(x, WHEEL_R + 0.35, 0); + group.add(bogie); + } +} + +function buildLocomotive( + loco: { id?: string; code: string } | undefined, + wheels: THREE.Mesh[], + pickables: THREE.Object3D[], +): THREE.Group { + const g = new THREE.Group(); + const len = 20 * M; + const bodyMat = new THREE.MeshStandardMaterial({ color: 0x1e6f42, metalness: 0.4, roughness: 0.5 }); + const body = new THREE.Mesh(new THREE.BoxGeometry(len, 3.2, 3), bodyMat); + body.position.y = DECK_H + 1.6; + g.add(body); + const cab = new THREE.Mesh(new THREE.BoxGeometry(4.4, 1.1, 3.05), new THREE.MeshStandardMaterial({ color: 0xf2a516, metalness: 0.3, roughness: 0.5 })); + cab.position.set(len / 2 - 2.6, DECK_H + 3.75, 0); + g.add(cab); + const nose = new THREE.Mesh(new THREE.BoxGeometry(1.6, 2.2, 2.6), bodyMat); + nose.position.set(len / 2 + 0.6, DECK_H + 1.1, 0); + g.add(nose); + // headlight + const light = new THREE.Mesh( + new THREE.SphereGeometry(0.22, 12, 12), + new THREE.MeshStandardMaterial({ color: 0xfff6c9, emissive: 0xfff2a8, emissiveIntensity: 2 }), + ); + light.position.set(len / 2 + 1.4, DECK_H + 1.6, 0); + g.add(light); + const spot = new THREE.SpotLight(0xfff2c0, 60, 90, 0.4, 0.6); + spot.position.copy(light.position); + spot.target.position.set(len / 2 + 40, 0.5, 0); + g.add(spot, spot.target); + addWheels(g, len, wheels); + g.userData.length = len; + const locoId = loco?.id ?? loco?.code ?? "loco"; + g.userData.locoId = locoId; + g.traverse((o) => { + o.userData.locoId = locoId; + }); + pickables.push(g); + return g; +} + +function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3D[]): THREE.Group { + const g = new THREE.Group(); + const len = Math.max(8, wagon.lengthMeters || 14) * M; + const kind = wagonKind(wagon); + const bodyMat = new THREE.MeshStandardMaterial({ + color: wagonTypeColor(wagon, WAGON_BODY_COLOR[kind]), + metalness: 0.35, + roughness: 0.6, + }); + + // frame/deck common to all + const deck = new THREE.Mesh(new THREE.BoxGeometry(len, 0.35, 3), bodyMat); + deck.position.y = DECK_H; + g.add(deck); + + if (kind === "container") { + const items = wagon.allocations.flatMap((a) => + (a.containerItems ?? []).map((c) => ({ ...c, bookingRef: a.bookingReference ?? a.bookingId })), + ); + const n = Math.max(items.length, 0); + const slotLen = n > 1 ? len / n - 0.3 : Math.min(12.2, len - 1.5); + items.forEach((c, i) => { + const cx = n > 1 ? -len / 2 + (i + 0.5) * (len / n) : 0; + const box = new THREE.Mesh( + new THREE.BoxGeometry(slotLen, 2.6, 2.44), + new THREE.MeshStandardMaterial({ color: hashColor(c.bookingRef), metalness: 0.2, roughness: 0.55 }), + ); + box.position.set(cx, DECK_H + 0.35 / 2 + 1.3, 0); + // corrugation hint: thin ribs + const rib = new THREE.Mesh( + new THREE.BoxGeometry(slotLen * 0.98, 2.4, 2.5), + new THREE.MeshStandardMaterial({ color: 0x000000, transparent: true, opacity: 0.12 }), + ); + rib.position.copy(box.position); + g.add(box, rib); + }); + } else if (kind === "bulk") { + // open hopper walls + const wallMat = new THREE.MeshStandardMaterial({ + color: wagonTypeColor(wagon, 0x8a5a34), + metalness: 0.25, + roughness: 0.7, + }); + const wallH = 2.2; + const side = new THREE.BoxGeometry(len, wallH, 0.12); + for (const z of [-1.45, 1.45]) { + const wall = new THREE.Mesh(side, wallMat); + wall.position.set(0, DECK_H + wallH / 2 + 0.17, z); + g.add(wall); + } + const end = new THREE.BoxGeometry(0.12, wallH, 3); + for (const x of [-len / 2 + 0.06, len / 2 - 0.06]) { + const wall = new THREE.Mesh(end, wallMat); + wall.position.set(x, DECK_H + wallH / 2 + 0.17, 0); + g.add(wall); + } + const loaded = wagon.allocations.some((a) => a.bulkLoad) || wagon.assignedWeightTons > 0; + if (loaded) { + // cargo mound: squashed bumpy cylinder rows + const cargoMat = new THREE.MeshStandardMaterial({ color: 0x5c4a33, roughness: 1 }); + const mounds = Math.max(2, Math.floor(len / 4)); + for (let i = 0; i < mounds; i++) { + const mound = new THREE.Mesh(new THREE.SphereGeometry(1.5, 12, 8), cargoMat); + mound.scale.set((len / mounds) * 0.42, 0.55, 0.9); + mound.position.set(-len / 2 + (i + 0.5) * (len / mounds), DECK_H + wallH * 0.85, 0); + g.add(mound); + } + } + } else if (kind === "tank") { + const tank = new THREE.Mesh( + new THREE.CylinderGeometry(1.4, 1.4, len - 1.6, 24), + new THREE.MeshStandardMaterial({ color: 0xb9bec7, metalness: 0.6, roughness: 0.3 }), + ); + tank.rotation.z = Math.PI / 2; + tank.position.y = DECK_H + 1.6; + g.add(tank); + const dome = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.45, 0.5, 16), bodyMat); + dome.position.y = DECK_H + 3.1; + g.add(dome); + } else { + // empty flat: low side rails + const rail = new THREE.Mesh(new THREE.BoxGeometry(len, 0.35, 0.1), bodyMat); + for (const z of [-1.45, 1.45]) { + const r = rail.clone(); + r.position.set(0, DECK_H + 0.35, z); + g.add(r); + } + } + + // floating loaded/empty badge above the wagon + const loaded = wagon.allocations.length > 0 || wagon.assignedWeightTons > 0; + const labelText = loaded + ? `#${wagon.position ?? wagon.sequenceNo} · ${Math.round(wagon.assignedWeightTons)} t` + : `#${wagon.position ?? wagon.sequenceNo} · EMPTY`; + const label = makeLabelSprite(labelText, loaded ? "rgba(22, 130, 60, 0.95)" : "rgba(120, 128, 138, 0.9)"); + label.position.set(0, DECK_H + 5.4, 0); + g.add(label); + + addWheels(g, len, wheels); + g.userData.length = len; + g.userData.wagonId = wagon.id; + g.traverse((o) => { + o.userData.wagonId = wagon.id; + }); + pickables.push(g); + return g; +} + +function InfoRow({ label, value }: { label: string; value: string | number | null | undefined }) { + if (value === null || value === undefined || value === "") return null; + return ( + + {label} + {value} + + ); +} + +function LocoDetailPanel({ + locoId, + schedule, + onClose, + onDrive, +}: { + locoId: string; + schedule: TrainScheduleDetail; + onClose: () => void; + onDrive: () => void; +}) { + const locos = schedule.trainSet?.locomotives?.length + ? schedule.trainSet.locomotives + : schedule.trainSet?.locomotive + ? [schedule.trainSet.locomotive] + : []; + const loco = locos.find((l) => l.id === locoId || l.code === locoId); + const ts = schedule.trainSet; + return ( + + + Locomotive {loco?.code ?? ""} + + + + + + {loco ? ( + <> + {loco.name ? {loco.name} : null} + + {loco.status} + + + {loco.maxTrainLengthMeters ? ( + + ) : null} + + ) : ( + No locomotive assigned yet. + )} + + + + + + + + + + + {ts ? ( + <> + + + + + {ts.heaviestLeg ? ( + + ) : null} + + ) : null} + + + + ); +} + +function WagonDetailPanel({ wagon, schedule, onClose }: { wagon: Wagon; schedule: TrainScheduleDetail; onClose: () => void }) { + const bookings = schedule.bookings ?? []; + return ( + + + + Wagon {wagon.position ?? wagon.sequenceNo} + {wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""} + + + + + + + {wagon.wagonType?.code ?? "UNKNOWN"} + {wagon.wagonType?.name} + + + Capacity {wagon.capacityTons} t · Loaded {wagon.assignedWeightTons} t + {wagon.tareWeightTons ? ` · Tare ${wagon.tareWeightTons} t` : ""} · {wagon.lengthMeters} m + + {wagon.allocations.length === 0 ? ( + Empty wagon — no allocations. + ) : ( + wagon.allocations.map((alloc) => { + const booking = bookings.find((b) => b.id === alloc.bookingId); + return ( + + + + {alloc.bookingReference ?? "—"} + {alloc.allocatedWeightTons} t + + {booking ? ( + <> + Customer: {booking.customer ?? "—"} + + {booking.origin ?? "?"} → {booking.destination ?? "?"} · {booking.status ?? "—"} + {booking.loadingStatus ? ` · ${booking.loadingStatus}` : ""} + + {booking.contractReference ? ( + Contract {booking.contractReference} + ) : null} + + ) : null} + {(alloc.containerItems ?? []).length > 0 ? ( + <> + + {(alloc.containerItems ?? []).map((c) => ( + + {c.containerNumber ?? "—"} + {c.grossWeightTons ?? "?"} t + + ))} + + ) : null} + {alloc.bulkLoad ? ( + + Bulk: {alloc.bulkLoad.cargoDescription ?? "cargo"} · {alloc.bulkLoad.weightTons} t + + ) : null} + + + ); + }) + )} + + + + ); +} + +export function Train3DVisualization({ schedule, onClose }: Train3DVisualizationProps) { + const containerRef = useRef(null); + const canvasHostRef = useRef(null); + const [selectedWagonId, setSelectedWagonId] = useState(null); + const [selectedLocoId, setSelectedLocoId] = useState(null); + const [driveMode, setDriveMode] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const cameraApiRef = useRef<{ + overview: () => void; + front: () => void; + rear: () => void; + focusWagon: (wagonId: string) => void; + } | null>(null); + + const wagons = schedule.trainSet?.wagons ?? []; + const dispatched = String(schedule.status).toUpperCase() === "DISPATCHED"; + // drive mode always simulates motion, even before dispatch + const moving = dispatched || driveMode; + const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null; + const orderedWagons = [...wagons].sort( + (a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo), + ); + const selectedIndex = orderedWagons.findIndex((w) => w.id === selectedWagonId); + + const stepWagon = (dir: 1 | -1) => { + if (orderedWagons.length === 0) return; + const next = + selectedIndex < 0 + ? dir === 1 + ? 0 + : orderedWagons.length - 1 + : (selectedIndex + dir + orderedWagons.length) % orderedWagons.length; + const wagon = orderedWagons[next]; + setSelectedLocoId(null); + setSelectedWagonId(wagon.id); + cameraApiRef.current?.focusWagon(wagon.id); + }; + + const firstLocoId = + schedule.trainSet?.locomotives?.[0]?.id ?? + schedule.trainSet?.locomotive?.id ?? + "LOCO"; + + useEffect(() => { + const host = canvasHostRef.current; + if (!host) return; + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0xa7d8f0); + scene.fog = new THREE.Fog(0xd9e8f0, 900, 3200); // far desert haze — never clouds the train + + const camera = new THREE.PerspectiveCamera(55, host.clientWidth / host.clientHeight, 0.1, 5000); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.15; + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(host.clientWidth, host.clientHeight); + host.appendChild(renderer.domElement); + + // lights: bright sunny day + scene.add(new THREE.HemisphereLight(0xcfe8ff, 0x6f9c4e, 1.0)); + const sun = new THREE.DirectionalLight(0xfff4d6, 2.4); + sun.position.set(-120, 180, 90); + scene.add(sun); + const fill = new THREE.DirectionalLight(0xdbeaff, 0.6); + fill.position.set(100, 60, -80); + scene.add(fill); + // visible sun disc + const sunDisc = new THREE.Mesh( + new THREE.SphereGeometry(18, 16, 16), + new THREE.MeshBasicMaterial({ color: 0xfff2b0 }), + ); + sunDisc.position.set(-500, 380, 300); + scene.add(sunDisc); + + // train assembly + const wheels: THREE.Mesh[] = []; + const pickables: THREE.Object3D[] = []; + const train = new THREE.Group(); + const gap = 1.0 * M; + let cursor = 0; + const locos = schedule.trainSet?.locomotives?.length + ? schedule.trainSet.locomotives + : schedule.trainSet?.locomotive + ? [schedule.trainSet.locomotive] + : [{ code: "LOCO" }]; + const locoGroups: Array<{ id: string; group: THREE.Group }> = []; + for (const loco of locos) { + const lg = buildLocomotive(loco, wheels, pickables); + lg.position.x = cursor - lg.userData.length / 2; + cursor -= lg.userData.length + gap; + train.add(lg); + locoGroups.push({ id: lg.userData.locoId as string, group: lg }); + } + const ordered = [...wagons].sort((a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo)); + const wagonGroups: Array<{ id: string; group: THREE.Group }> = []; + for (const wagon of ordered) { + const wg = buildWagon(wagon, wheels, pickables); + wg.position.x = cursor - wg.userData.length / 2; + cursor -= wg.userData.length + gap; + train.add(wg); + wagonGroups.push({ id: wagon.id, group: wg }); + } + const trainLen = -cursor; + train.position.x = trainLen / 2; // center train on origin + scene.add(train); + + const trackLen = Math.max(trainLen * 2.5, 400); + const finalTrackLen = moving ? Math.max(trackLen, 1600) : trackLen; + buildTrack(scene, finalTrackLen); + buildScenery(scene, finalTrackLen); + + camera.position.set(trainLen * 0.12, 14, 42); + const controls = new OrbitControls(camera, renderer.domElement); + controls.target.set(0, 2, 0); + controls.maxPolarAngle = Math.PI / 2 - 0.03; + controls.maxDistance = Math.max(600, trainLen * 1.6); + controls.enableDamping = true; + controls.zoomToCursor = true; // wheel zooms toward the point under the pointer + controls.enabled = !driveMode; // cab ride owns the camera + + // camera fly-to: goal recomputed each frame so it tracks a moving train + let cameraGoal: (() => { pos: THREE.Vector3; target: THREE.Vector3 }) | null = null; + const worldPos = new THREE.Vector3(); + const flyToObject = (obj: THREE.Object3D, dist: number, height: number) => { + cameraGoal = () => { + obj.getWorldPosition(worldPos); + return { + pos: new THREE.Vector3(worldPos.x + dist * 0.35, height, worldPos.z + dist), + target: new THREE.Vector3(worldPos.x, 2.2, worldPos.z), + }; + }; + }; + const frontGroup = train.children[0]; + const rearGroup = wagonGroups[wagonGroups.length - 1]?.group ?? frontGroup; + cameraApiRef.current = { + overview: () => { + // side view fitted with the HORIZONTAL fov — much closer than the naive + // vertical-fov fit, and low to the ground so the train fills the frame + const vFov = (camera.fov * Math.PI) / 180; + const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect); + const dist = Math.max(40, (trainLen / 2 / Math.tan(hFov / 2)) * 1.08); + cameraGoal = () => { + train.getWorldPosition(worldPos); + const cx = worldPos.x - trainLen / 2; // train extends in -x from its origin + return { + pos: new THREE.Vector3(cx, Math.max(10, dist * 0.1), dist), + target: new THREE.Vector3(cx, 2.5, 0), + }; + }; + }, + front: () => flyToObject(frontGroup, 26, 8), + rear: () => flyToObject(rearGroup, 20, 7), + focusWagon: (wagonId: string) => { + const entry = wagonGroups.find((w) => w.id === wagonId); + if (entry) flyToObject(entry.group, 16, 6); + }, + }; + // user grabs the mouse → stop auto-flying, hand control back + controls.addEventListener("start", () => { + cameraGoal = null; + }); + if (driveMode) { + // cab ride: camera on the driver's seat of the lead loco, looking down the line + const locoLen = frontGroup.userData.length as number; + cameraGoal = () => { + frontGroup.getWorldPosition(worldPos); + return { + pos: new THREE.Vector3(worldPos.x + locoLen / 2 - 3.2, DECK_H + 3.9, 1.05), + target: new THREE.Vector3(worldPos.x + locoLen / 2 + 140, 2.2, 0), + }; + }; + } else if (moving) { + // dispatched → open following the rolling train so motion is obvious + cameraApiRef.current.overview(); + } + + // picking + const raycaster = new THREE.Raycaster(); + const pointer = new THREE.Vector2(); + let highlighted: THREE.Object3D | null = null; + const setEmissive = (root: THREE.Object3D, on: boolean) => { + root.traverse((o) => { + const mesh = o as THREE.Mesh; + const mat = mesh.material as THREE.MeshStandardMaterial | undefined; + if (mat?.emissive) { + mat.emissive.setHex(on ? 0x335588 : 0x000000); + mat.emissiveIntensity = on ? 0.9 : 1; + } + }); + }; + const onClick = (e: MouseEvent) => { + const rect = renderer.domElement.getBoundingClientRect(); + pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + raycaster.setFromCamera(pointer, camera); + const hits = raycaster.intersectObjects(pickables, true); + const wagonId = hits[0]?.object.userData.wagonId as string | undefined; + const locoId = hits[0]?.object.userData.locoId as string | undefined; + if (highlighted) setEmissive(highlighted, false); + highlighted = wagonId + ? (pickables.find((p) => p.userData.wagonId === wagonId) ?? null) + : locoId + ? (locoGroups.find((l) => l.id === locoId)?.group ?? null) + : null; + if (highlighted) setEmissive(highlighted, true); + setSelectedWagonId(wagonId ?? null); + setSelectedLocoId(locoId && !wagonId ? locoId : null); + if (wagonId) cameraApiRef.current?.focusWagon(wagonId); + else if (locoId) { + const lg = locoGroups.find((l) => l.id === locoId); + if (lg) flyToObject(lg.group, 30, 9); + } + }; + renderer.domElement.addEventListener("click", onClick); + + const onResize = () => { + camera.aspect = host.clientWidth / host.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize(host.clientWidth, host.clientHeight); + }; + const resizeObserver = new ResizeObserver(onResize); + resizeObserver.observe(host); + + let raf = 0; + let last = performance.now(); + let elapsed = 0; + const speed = 14; // m/s visual speed when dispatched + const animate = () => { + raf = requestAnimationFrame(animate); + const now = performance.now(); + const dt = Math.min((now - last) / 1000, 0.1); + last = now; + if (moving) { + elapsed += dt; + train.position.x += speed * dt; + for (const w of wheels) w.rotation.y += (speed * dt) / WHEEL_R; + // subtle rail-joint sway so motion reads even up close + train.position.y = Math.sin(elapsed * 9) * 0.03; + train.rotation.z = Math.sin(elapsed * 4.5) * 0.0025; + // loop over the long track stretch; jump happens far off-screen + const half = finalTrackLen / 2 - trainLen - 40; + if (train.position.x > half + trainLen) train.position.x = -half; + } + if (cameraGoal) { + const goal = cameraGoal(); + const k = Math.min(1, dt * (driveMode ? 7 : 3.2)); + camera.position.lerp(goal.pos, k); + controls.target.lerp(goal.target, k); + // static scene: release goal once settled so orbiting feels free again + if (!moving && camera.position.distanceTo(goal.pos) < 0.15) cameraGoal = null; + } + controls.update(); + renderer.render(scene, camera); + }; + animate(); + + return () => { + cameraApiRef.current = null; + cancelAnimationFrame(raf); + resizeObserver.disconnect(); + renderer.domElement.removeEventListener("click", onClick); + controls.dispose(); + renderer.dispose(); + host.removeChild(renderer.domElement); + scene.traverse((o) => { + const mesh = o as THREE.Mesh; + mesh.geometry?.dispose?.(); + const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + mats.forEach((m) => m?.dispose?.()); + }); + }; + // rebuild scene only when schedule identity/status changes + }, [schedule.id, schedule.status, moving, driveMode, wagons.length]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const onFsChange = () => setIsFullscreen(Boolean(document.fullscreenElement)); + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape" || document.fullscreenElement) return; + if (driveMode) setDriveMode(false); + else onClose(); + }; + document.addEventListener("fullscreenchange", onFsChange); + window.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("fullscreenchange", onFsChange); + window.removeEventListener("keydown", onKey); + }; + }, [onClose, driveMode]); + + const toggleFullscreen = () => { + if (document.fullscreenElement) void document.exitFullscreen(); + else void containerRef.current?.requestFullscreen(); + }; + + return ( + + + + + {driveMode + ? "DRIVER VIEW — SIMULATION" + : dispatched + ? "DISPATCHED — TRAIN IN MOTION" + : `${schedule.status} — TRAIN STOPPED`} + + + {schedule.route?.name ?? schedule.reference ?? "Train"} · {wagons.length} wagons + + + + + + + + + + + {selectedIndex >= 0 + ? `Wagon ${orderedWagons[selectedIndex].position ?? orderedWagons[selectedIndex].sequenceNo} / ${orderedWagons.length}` + : `${orderedWagons.length} wagons`} + + + + + + Drag to orbit · scroll to zoom · click a wagon for details + + + + + + + {selectedWagon ? ( + setSelectedWagonId(null)} /> + ) : selectedLocoId ? ( + setSelectedLocoId(null)} + onDrive={() => setDriveMode(true)} + /> + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useMyTradeAccess.ts b/apps/edr-freight-web/backoffice/src/hooks/useMyTradeAccess.ts new file mode 100644 index 000000000..6f60f685f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useMyTradeAccess.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + ALL_TRADE_DIRECTIONS, + userTradeAccessService, + type TradeDirection, +} from "@/services/userTradeAccess.service"; + +/** + * Current user's trade-direction scope. While loading (or on error) it + * reports full access — the API enforces the real scope regardless; this + * hook only trims filter dropdowns to the directions the user can see. + */ +export function useMyTradeAccess() { + const { data } = useQuery({ + queryKey: ["user-trade-access", "me"], + queryFn: userTradeAccessService.me, + staleTime: 5 * 60 * 1000, + }); + + const directions: TradeDirection[] = data?.directions ?? [ + ...ALL_TRADE_DIRECTIONS, + ]; + + return { + restricted: data?.restricted ?? false, + directions, + /** Trim `{ value }`-shaped dropdown options to the allowed directions. */ + filterOptions: (options: T[]): T[] => + options.filter((o) => directions.includes(o.value as TradeDirection)), + }; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx index 6ea4c40e9..f176fc05d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx @@ -12,6 +12,7 @@ import toast from "react-hot-toast"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; +import { StampUpload } from "@/components/contracts/StampUpload"; import { bookingSurface } from "@/components/bookings/booking-ui.styles"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { invalidateBookingDetail } from "@/utils/queryInvalidation"; @@ -40,6 +41,9 @@ export default function BookingContractPage() { const [signOpen, setSignOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); + // Company stamp: prefilled from the profile, or uploaded here when none is + // saved yet. + const [stampData, setStampData] = useState(null); // When the user has a saved signature we offer it for approval first; they // can switch to drawing a fresh one. const [drawNew, setDrawNew] = useState(false); @@ -55,6 +59,7 @@ export default function BookingContractPage() { const savedSignature = data?.savedSignature ?? null; const savedSignatureImage = savedSignature?.signatureImageUrl ?? null; + const savedStampImage = savedSignature?.stampImageUrl ?? null; // Show the approval view only while a saved signature exists and the user // hasn't opted to draw a new one. const usingSaved = Boolean(savedSignatureImage) && !drawNew; @@ -98,6 +103,8 @@ export default function BookingContractPage() { // approve it; otherwise start with an empty pad. setSignerName(savedSignature?.signerDisplayName ?? ""); setSignatureData(null); + // Prefill with the reusable stamp saved on the profile; still replaceable. + setStampData(savedStampImage); setDrawNew(false); setSignOpen(true); }; @@ -106,10 +113,12 @@ export default function BookingContractPage() { if (!canSign || !signerName.trim()) return; // Approve the saved signature, or submit the freshly drawn one. const image = usingSaved ? savedSignatureImage : signatureData; - if (!image) return; + // The API rejects a STAFF signature without a stamp. + if (!image || !stampData) return; signMutation.mutate({ role: "STAFF", signatureImageBase64: image, + stampImageBase64: stampData, signerDisplayName: signerName.trim(), consentText: "I agree to the terms of this contract.", }); @@ -234,6 +243,15 @@ export default function BookingContractPage() { ) : ( )} + - ) : ( + {hasContainerStep ? ( - )} + ) : null} @@ -955,6 +948,18 @@ export default function TrainScheduleV2DetailPage() { + {(schedule.trainSet?.wagons?.length ?? 0) > 0 ? ( + + ) : null} {canPrintMarshalling ? ( + )} {showRetry && ( - )} - {status === "verified" && mutation.data && !mutation.data.tinTaken && ( - )} - + {notFound && ( -
- - - - - Your account is approved - -
- - ); - } + // if (companyStatus === "active") { + // return ( + //
+ //
+ // + // + // + // + // Your account is approved + // + //
+ //
+ // ); + // } return null; } - const pendingLabel = pending - .map((p) => p.type.replace(/_/g, " ")) - .join(", "); + const pendingLabel = pending.map((p) => p.type.replace(/_/g, " ")).join(", "); return (
diff --git a/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx index edb72b91f..dd911d284 100644 --- a/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx +++ b/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { FileSignature, Loader2 } from "lucide-react"; +import { FileSignature, Loader2, Stamp } from "lucide-react"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { StampUpload } from "@/components/contracts/StampUpload"; @@ -26,41 +26,56 @@ import { } from "@edr/ui-common"; /** - * Lets the signed-in customer view and update the reusable signature stored on - * their profile. The same signature is offered for approval when signing a - * booking contract. + * Lets the signed-in customer view and update the reusable signature and + * company stamp stored on their profile — managed independently of each + * other. Both are offered when signing a booking contract. */ export function MySignatureCard() { const { user } = useAuth(); const { data: saved, isPending } = useMySignature(); const saveMutation = useSaveSignature(); - const [open, setOpen] = useState(false); + const [signatureOpen, setSignatureOpen] = useState(false); + const [stampOpen, setStampOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); const [stampData, setStampData] = useState(null); const defaultName = user?.name?.en || user?.username || user?.email || ""; + const savedName = saved?.signerDisplayName ?? defaultName; - const openDialog = () => { - setSignerName(saved?.signerDisplayName ?? defaultName); + const openSignatureDialog = () => { + setSignerName(savedName); setSignatureData(null); - setStampData(saved?.stampImageUrl ?? null); - setOpen(true); + setSignatureOpen(true); }; - const save = () => { + const saveSignature = () => { if (!signatureData || !signerName.trim()) return; saveMutation.mutate( { signerDisplayName: signerName.trim(), signatureImageBase64: signatureData, - // Only send the stamp when it changed — omitted keeps the saved one. - ...(stampData && stampData !== saved?.stampImageUrl - ? { stampImageBase64: stampData } - : {}), + // Stamp untouched — it is managed by its own dialog. }, - { onSuccess: () => setOpen(false) }, + { onSuccess: () => setSignatureOpen(false) }, + ); + }; + + const openStampDialog = () => { + setStampData(saved?.stampImageUrl ?? null); + setStampOpen(true); + }; + + const saveStamp = () => { + if (!stampData) return; + saveMutation.mutate( + { + signerDisplayName: savedName || defaultName, + // Signature untouched — stamp-only update. + stampImageBase64: stampData, + }, + { onSuccess: () => setStampOpen(false) }, ); }; @@ -69,53 +84,70 @@ export function MySignatureCard() { - My signature + Signature & Stamp Reused to approve and sign booking contracts. - + {isPending ? (
- ) : saved?.signatureImageUrl ? ( -
-
- My saved signature -
-

- Saved as {saved.signerDisplayName} -

-
) : ( -

- You have not saved a signature yet. -

- )} - {saved?.stampImageUrl && ( -
-
- My saved company stamp + <> +
+ {saved?.signatureImageUrl ? ( + <> +
+ My saved signature +
+

+ Saved as {saved.signerDisplayName} +

+ + ) : ( +

+ You have not saved a signature yet. +

+ )} +
-

Company stamp

-
+ +
+ {saved?.stampImageUrl ? ( + <> +
+ My saved company stamp +
+

Company stamp

+ + ) : ( +

+ You have not uploaded a company stamp yet. +

+ )} + +
+ )} - - + Save your signature @@ -135,21 +167,16 @@ export function MySignatureCard() { />
-
- + + + + ); } diff --git a/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts index 04c91a683..85e951dac 100644 --- a/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts +++ b/apps/edr-freight-web/portal/src/hooks/useInvoicePayment.ts @@ -1,5 +1,6 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; +import { Freight } from "@edr/types"; import { useState } from "react"; import { invoicesService } from "@/services/invoices.service"; @@ -34,9 +35,18 @@ function apiMessage(err: unknown, fallback: string): string { * charge through a different endpoint (warehouse fee invoices); OTP * confirmation always goes through billing, which owns the intent either way. */ +/** CBE bill payment: no redirect — the payer takes this reference to any CBE channel. */ +interface BillAction { + invoiceId: string; + billReference: string; + instructions?: string; + expiresAt?: string; +} + export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { const [otpInvoiceId, setOtpInvoiceId] = useState(null); const [otpMessage, setOtpMessage] = useState(); + const [billAction, setBillAction] = useState(null); const payMutation = useMutation({ mutationFn: (vars: { @@ -52,6 +62,17 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { setOtpInvoiceId(vars.invoiceId); return; } + // CBE_BILL settles asynchronously via CBE, not the browser — show the + // bill reference instead of redirecting to a (nonexistent) checkout page. + if (data?.clientAction?.type === "SHOW_BILL_REFERENCE") { + setBillAction({ + invoiceId: vars.invoiceId, + billReference: data.clientAction.billReference ?? "", + instructions: data.clientAction.instructions, + expiresAt: data.clientAction.expiresAt, + }); + return; + } window.location.href = data?.clientAction?.type === "REDIRECT" && data.clientAction.url ? data.clientAction.url @@ -72,10 +93,27 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { }, }); + // Poll the invoice while the CBE bill dialog is open — CBE settles out of + // band (branch/app/USSD), so this is the only way the browser learns it paid. + useQuery({ + queryKey: ["invoice-bill-poll", billAction?.invoiceId], + queryFn: async () => { + const invoice = await invoicesService.get(billAction!.invoiceId); + if (invoice.status === Freight.InvoiceStatus.Paid) { + setBillAction(null); + window.location.reload(); + } + return invoice; + }, + enabled: billAction !== null, + refetchInterval: 5000, + }); + const reset = () => { payMutation.reset(); otpMutation.reset(); setOtpInvoiceId(null); + setBillAction(null); }; return { @@ -109,6 +147,14 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) { setOtpInvoiceId(null); }, }, + /** Drives the modal's "pay at CBE" step; `open` only for CBE_BILL. */ + bill: { + open: billAction !== null, + billReference: billAction?.billReference, + instructions: billAction?.instructions, + expiresAt: billAction?.expiresAt, + close: () => setBillAction(null), + }, }; } diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx index 9581a4f3d..4b16e132d 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx @@ -55,9 +55,8 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) { invoicesService.listForSource("booking", payItem!.targetId), enabled: payItem !== null, }); - const payableInvoiceId = payItemInvoices.find((inv) => - isPayable(inv.status), - )?.id; + const payableInvoice = payItemInvoices.find((inv) => isPayable(inv.status)); + const payableInvoiceId = payableInvoice?.id; const pay = useInvoicePayment(); @@ -171,7 +170,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) { pay.reset(); } }} - currency={undefined} + currency={payableInvoice?.currency} processing={pay.processing} error={ pay.error ?? @@ -180,6 +179,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) { : null) } otp={pay.otp} + bill={pay.bill} onConfirm={(method, payerAccount) => payableInvoiceId && pay.pay(payableInvoiceId, method, payerAccount) diff --git a/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx b/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx index 4c30c878a..3bdad19f1 100644 --- a/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx @@ -6,7 +6,7 @@ export default function MySignaturePage() {

- My signature + Signature & Stamp

Saved and reused to approve and sign booking contracts. 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 9c62b494d..0dc02eaa4 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -197,6 +197,7 @@ export default function CompanyProfileForm({ companyEmail: "", companyPhone: "", companyAddress: "", + etradePhone: "", tinNumber: "", vatNumber: "", ownerPassportNumber: "", @@ -279,6 +280,14 @@ export default function CompanyProfileForm({ // setting region/zone/woreda/kebele/houseNo above is enough — no need to // compose it here. companyPhone is derived below (identity → eTrade → // account), not set directly here. + // etradePhone is the raw number eTrade returned for this TIN — kept as its + // own field (distinct from companyPhone, which prefers the Fayda-verified + // owner's phone) so the backend's "matches eTrade's current record" check + // always compares against what eTrade actually said, not the owner's phone. + setValue( + "etradePhone", + data.managerPhone || data.regularPhone || data.mobilePhone, + ); setEtradeOwner({ name: data.managerName, @@ -288,6 +297,25 @@ export default function CompanyProfileForm({ }); }; + // TIN changed since the last successful lookup — the registration/address + // fields it filled in describe the OLD TIN, not this one, so clear them + // rather than leaving them stale on screen. + const handleETradeReset = () => { + setValue("licenceNumber", ""); + setValue("statusDescription", ""); + setValue("dateRegistered", ""); + setValue("renewedFrom", ""); + setValue("renewalDate", ""); + setValue("renewedTo", ""); + setValue("region", ""); + setValue("zone", ""); + setValue("woreda", ""); + setValue("kebele", ""); + setValue("houseNo", ""); + setValue("etradePhone", ""); + setEtradeOwner(null); + }; + // companyEmail/companyPhone are no longer typed — the Fayda-verified owner // is the highest-trust source (that's the whole point of verifying), eTrade's // registered number and the account email/phone are the fallbacks used @@ -297,7 +325,7 @@ export default function CompanyProfileForm({ shouldValidate: true, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.email, user.email]); + }, [identity?.owner.email, user.email, rehydrate]); useEffect(() => { setValue( @@ -309,7 +337,7 @@ export default function CompanyProfileForm({ { shouldValidate: true }, ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber]); + }, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber, rehydrate]); // "Same as …" links. A checked card prefills the target step's fields from the // source step and disables them (kept mirrored while linked); unchecking clears @@ -649,7 +677,7 @@ export default function CompanyProfileForm({ } > onIdentityChange?.()} @@ -718,6 +746,7 @@ export default function CompanyProfileForm({ error={errors.tinNumber?.message} onDataLoaded={handleETradeDataLoaded} onStatusChange={setTinStatus} + onReset={handleETradeReset} /> {tinVerified && ( (null); + // Company stamp image, captured at signup and stored on the new profile so + // it is ready when the customer signs their first contract. Optional — + // individuals without a stamp can add one later from Settings. + const [stampData, setStampData] = useState(null); // Two-stage signup: fill the form, then a mandatory OTP challenge before the // account is actually created. The code goes to BOTH the email and phone just @@ -179,6 +185,18 @@ export default function SignupPage() { }; const result = await signup(payload); if (result.success) { + // Signup logs the user in, so the stamp can land on their profile + // right away. Non-fatal — it can also be added later from Settings. + if (stampData) { + try { + await signaturesService.saveMySignature({ + signerDisplayName: payload.name.en, + stampImageBase64: stampData, + }); + } catch { + // Ignore — account exists; the stamp can be re-uploaded later. + } + } navigate("/portal"); } else { setOtpError(result.error.message); @@ -269,6 +287,13 @@ export default function SignupPage() { {...register("confirmPassword")} /> + + {error ? ( {label} - + {value && value.trim() ? value : "—"} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/StepSection.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/StepSection.tsx index be65e9c73..0b0878c99 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/StepSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/StepSection.tsx @@ -1,4 +1,5 @@ import { Badge, Group, Stack, Text } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; import { Check, X } from "lucide-react"; import type { ReactNode } from "react"; @@ -32,6 +33,7 @@ export default function StepSection({ children: ReactNode; }) { const badge = STATUS_BADGE[status]; + const isMobile = useMediaQuery("(max-width: 48em)"); return ( @@ -75,7 +77,7 @@ export default function StepSection({ )} -

+
{children}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts index f1a9f4301..a3aad0ee2 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts @@ -70,7 +70,7 @@ export function stepPayload( woreda: d.woreda, kebele: d.kebele, houseNo: d.houseNo, - etradePhone: d.companyPhone, + etradePhone: d.etradePhone, }; case "personnel": return { @@ -101,6 +101,7 @@ export function toFormValues(p: ProfileResponse): FormData { companyEmail: p.companyEmail ?? "", companyPhone: p.companyPhone ?? "", companyAddress: p.companyAddress ?? "", + etradePhone: p.etradePhone ?? "", tinNumber: tin, vatNumber: p.vatNumber ?? "", ownerPassportNumber: p.identity?.owner.passportNumber ?? "", diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 2cd06ef8f..e2842dbe6 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -21,6 +21,10 @@ export 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(), + // Raw phone from the eTrade lookup itself — kept separate from companyPhone + // (which shows the Fayda-verified owner's phone once verified) so the two + // can diverge without the backend's eTrade-authenticity check misfiring. + etradePhone: z.string().optional(), tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), vatNumber: z .string() @@ -124,6 +128,7 @@ export const stepFields: Record = { "companyEmail", "companyPhone", "companyAddress", + "etradePhone", "tinNumber", "vatNumber", "ownerPassportNumber", diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index 1477b7189..775d9fe41 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -9,7 +9,6 @@ import { Divider, Group, Loader, - Modal, Paper, SimpleGrid, Stack, @@ -34,13 +33,7 @@ import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/component import { saveBlob } from "@/utils/download"; import { formatCurrency } from "@/lib/currency"; import { BORDER, INK, MUTED } from "../contracts/contract-ui"; -import { - billedTo, - fmtDate, - InvoiceStatusBadge, - isPayable, - titleCase, -} from "./invoice-ui"; +import { billedTo, fmtDate, InvoiceStatusBadge, isPayable, titleCase } from "./invoice-ui"; function MetaItem({ label, value }: { label: string; value: string }) { return ( @@ -70,12 +63,6 @@ export default function InvoiceDetailPage() { } = useQuery(api.invoices.get.queryOptions({ input: { id } })); const [payModalOpen, setPayModalOpen] = useState(false); - // CBE bill payment: the bill reference to pay at any CBE channel (no redirect). - const [billAction, setBillAction] = useState<{ - billReference?: string; - instructions?: string; - expiresAt?: string; - } | null>(null); // Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges // one of the signed-in customer's own invoices (unlike the admin-facing @@ -368,7 +355,7 @@ export default function InvoiceDetailPage() { { if (!pay.processing) { setPayModalOpen(false); @@ -380,66 +367,11 @@ export default function InvoiceDetailPage() { processing={pay.processing} error={pay.error} otp={pay.otp} + bill={pay.bill} onConfirm={(method, payerAccount) => pay.pay(id, method, payerAccount) } /> - - {/* CBE bill payment — show the bill number; settlement arrives via CBE, not the browser */} - setBillAction(null)} - centered - radius={18} - size={440} - title={Pay at CBE} - > - - - {billAction?.instructions ?? - "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} - - - - {billAction?.billReference} - - - - - Amount due:{" "} - - {formatCurrency(amountDue, invoice.currency)} - - - {billAction?.expiresAt && ( - - Pay before:{" "} - - {fmtDate(billAction.expiresAt)} - - - )} - - The invoice updates automatically once CBE confirms your payment. - - - ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx index 062a8a766..8c5abf707 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx @@ -11,6 +11,7 @@ import { import toast from "react-hot-toast"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; +import { StampUpload } from "@/components/contracts/StampUpload"; import { bookingsService, type SignContractPayload, @@ -25,6 +26,9 @@ export default function BookingContractPage() { const [signOpen, setSignOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); + // Company stamp: prefilled from the profile, or uploaded here when the + // customer has never saved one. + const [stampData, setStampData] = useState(null); // When a saved signature exists we offer it for approval first; the customer // can switch to drawing a fresh one. const [drawNew, setDrawNew] = useState(false); @@ -37,12 +41,14 @@ export default function BookingContractPage() { const savedSignature = data?.savedSignature ?? null; const savedSignatureImage = savedSignature?.signatureImageUrl ?? null; + const savedStampImage = savedSignature?.stampImageUrl ?? null; const usingSaved = Boolean(savedSignatureImage) && !drawNew; const openSign = () => { // Prefill from the saved signature so the customer only has to approve it. setSignerName(savedSignature?.signerDisplayName ?? ""); setSignatureData(null); + setStampData(savedStampImage); setDrawNew(false); setSignOpen(true); }; @@ -51,10 +57,12 @@ export default function BookingContractPage() { if (!signerName.trim()) return; // Approve the saved signature, or submit the freshly drawn one. const image = usingSaved ? savedSignatureImage : signatureData; - if (!image) return; + // The API rejects a CUSTOMER signature without a stamp. + if (!image || !stampData) return; signMutation.mutate({ role: "CUSTOMER", signatureImageBase64: image, + stampImageBase64: stampData, signerDisplayName: signerName.trim(), consentText: "I agree to the terms of this contract.", }); @@ -191,6 +199,15 @@ export default function BookingContractPage() { ) : ( )} +
+ +
+ + + + {canAssignCustomerTruck && ( + {})} + /> + )} + + + + } + right={} + /> +
+
+ + +
+ +
+
+ @@ -269,6 +295,7 @@ export function ReadonlyBookingView({ processing={pay.processing} error={pay.error} otp={pay.otp} + bill={pay.bill} onConfirm={pay.confirm} /> {viewer} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx index 2e9477f80..a57dd0a9d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx @@ -26,7 +26,7 @@ interface ProviderOption { accent: string; } -// Only Telebirr, Waafi and CBE bill payment are enabled for now. +// Only Telebirr, Waafi, CAC Bank and CBE bill payment are enabled for now. const PROVIDERS: ProviderOption[] = [ { method: "TELEBIRR", @@ -63,6 +63,10 @@ const PROVIDERS: ProviderOption[] = [ /** Providers that debit against an SMS OTP instead of redirecting to a page. */ const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK"; +/** CAC Bank SMS codes are 4 digits. */ +const OTP_LENGTH = 4; +/** Providers that settle asynchronously via a bill reference instead of a redirect. */ +const isBillMethod = (method: PaymentMethod) => method === "CBE_BILL"; /** * Pick the provider that settles in the booking's currency. USD → Waafi, @@ -180,6 +184,7 @@ export function PaymentMethodModal({ processing, error, otp, + bill, }: { opened: boolean; onClose: () => void; @@ -192,14 +197,21 @@ export function PaymentMethodModal({ error?: string | null; /** CAC Bank OTP step, from `useInvoicePayment`. Omit to disable OTP providers. */ otp?: InvoicePaymentFlow["otp"]; + /** CBE bill-reference step, from `useInvoicePayment`. Omit to disable CBE_BILL. */ + bill?: InvoicePaymentFlow["bill"]; }) { const providers = useMemo( - () => providersForCurrency(currency).filter((p) => otp || !isOtpMethod(p.method)), - [currency, otp], + () => + providersForCurrency(currency).filter( + (p) => + (otp || !isOtpMethod(p.method)) && (bill || !isBillMethod(p.method)), + ), + [currency, otp, bill], ); const [method, setMethod] = useState(providers[0].method); const [mobile, setMobile] = useState(""); const [code, setCode] = useState(""); + const [copied, setCopied] = useState(false); // Keep the selection valid when the currency (and therefore provider list) changes. useEffect(() => { @@ -217,6 +229,101 @@ export function PaymentMethodModal({ const needsMobile = isOtpMethod(method); const canSubmit = !needsMobile || mobile.trim().length > 0; + if (bill?.open) { + return ( + + + + Pay at CBE + + + {bill.instructions ?? + "Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD."} + + + + + {bill.billReference} + + + + + {amountLabel && ( + + Amount due: {amountLabel} + + )} + {bill.expiresAt && ( + + Pay before:{" "} + + {new Date(bill.expiresAt).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + + )} + + + This page updates automatically once CBE confirms your payment. + + + + + + ); + } + if (otp?.open) { return ( - + + + Verification code + otp.submit(value)} aria-label="One-time password" /> - + {otp.error && ( @@ -276,7 +388,7 @@ export function PaymentMethodModal({ radius={12} color="edr-green" loading={otp.submitting} - disabled={otp.submitting || code.trim().length === 0} + disabled={otp.submitting || code.trim().length !== OTP_LENGTH} onClick={() => otp.submit(code.trim())} styles={{ root: { height: 46, flex: 1 }, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx index fc7935962..ab11da3a0 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx @@ -1,10 +1,18 @@ import { Box, Group, Text } from "@mantine/core"; -import type { ReactNode } from "react"; +import { MapPin } from "lucide-react"; +import { type ReactNode, useState } from "react"; import { bookingStatusLabel } from "@/pages/bookings/booking-display"; +import { ShipmentTrackingModal } from "@/pages/bookings/tracking/ShipmentTrackingModal"; import type { BookingDetail } from "../booking-detail-types"; -import { fmtDate, isDraftLike, isNegative, serviceTypeLabel } from "../utils"; +import { + fmtDate, + isDraftLike, + isNegative, + serviceTypeLabel, + yardLabel, +} from "../utils"; import { CardTitle, SectionCard } from "./layout"; type Row = { label: string; value: ReactNode; muted?: boolean }; @@ -53,14 +61,39 @@ export function ScheduleCard({ title: string; consignment?: boolean; }) { + const [trackingOpen, setTrackingOpen] = useState(false); const service = serviceTypeLabel(booking); const equipmentReturn = booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return"; - const assignedTrain: Row = { - label: "Assigned train", - value: booking.trainId ?? "Not yet assigned", - muted: !booking.trainId, - }; + const assignedTrain: Row = booking.trainScheduleId + ? { + label: "Assigned train", + value: ( + + ), + } + : { + label: "Assigned train", + value: "Not yet assigned", + muted: true, + }; const statusRow: Row = { label: "Status", @@ -115,6 +148,17 @@ export function ScheduleCard({ ))} + + {booking.trainScheduleId && ( + setTrackingOpen(false)} + bookingId={booking.id} + bookingReference={booking.reference} + originLabel={yardLabel(booking.originYard)} + destinationLabel={yardLabel(booking.destinationYard)} + /> + )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx index 8051e9986..a2a48c7dc 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx @@ -69,10 +69,7 @@ export function ShipmentDetailsCard({ booking }: { booking: BookingDetail }) { ], ["Scheduled date", fmtDate(booking.scheduledDate)], ], - [ - ["Shipping line", shippingLineLabel(booking)], - ["Assigned train", booking.trainId ?? "Not yet assigned"], - ], + [["Shipping line", shippingLineLabel(booking)]], ]; const badges: string[] = []; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx index a146dfb11..d3bbc55e9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -243,6 +243,7 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { processing={pay.processing} error={pay.error} otp={pay.otp} + bill={pay.bill} /> ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts index 924ee16a4..22b347e85 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts @@ -83,6 +83,10 @@ export function totalVgmTons(b: BookingDetail): number { ); if (sum > 0) return sum; } + // Break-bulk (PER_ITEM): cargoTotalWeightVgm holds the ITEM COUNT — the + // real tonnage lives in bulkTotalWeightTons. + const bulkTons = Number(b.bulkTotalWeightTons || 0); + if (bulkTons > 0) return bulkTons; return Number(b.cargoTotalWeightVgm || 0); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx index 3f20c79e4..4a9ef757c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx @@ -77,6 +77,7 @@ const STATUS_FILTERS = [ key: "all", label: "All bookings", statuses: undefined as string | undefined, + assignedToSchedule: undefined as "true" | "false" | undefined, }, { key: "active", @@ -89,9 +90,16 @@ const STATUS_FILTERS = [ key: "payment", label: "Awaiting payment", statuses: - "SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED", + "SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS", }, { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT,ARRIVED" }, + { + key: "allocated", + label: "Allocated to a train", + statuses: undefined as string | undefined, + assignedToSchedule: "true" as const, + }, + { key: "expired", label: "Expired", statuses: "EXPIRED" }, { key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" }, { key: "closed", @@ -348,7 +356,10 @@ export default function BookingsListPage() { const [trackingBooking, setTrackingBooking] = useState(null); - const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses; + const activeFilter = STATUS_FILTERS.find((t) => t.key === statusFilter); + const statuses = activeFilter?.statuses; + const assignedToSchedule = + "assignedToSchedule" in activeFilter! ? activeFilter.assignedToSchedule : undefined; const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"]; const resetPage = () => @@ -372,6 +383,7 @@ export default function BookingsListPage() { const filter: BookingListFilter = useMemo( () => ({ statuses, + assignedToSchedule, bookingType: typeFilter ?? undefined, freightType: freightFilter ?? undefined, createdFrom: createdFrom || undefined, @@ -386,6 +398,7 @@ export default function BookingsListPage() { }), [ statuses, + assignedToSchedule, typeFilter, freightFilter, createdFrom, @@ -423,6 +436,8 @@ export default function BookingsListPage() { draft: draftCount, done: doneCount, transit: undefined, + allocated: undefined, + expired: undefined, closed: undefined, }; @@ -557,7 +572,12 @@ export default function BookingsListPage() { size: 130, meta: hMeta, header: () => , - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, { id: "scheduling", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx index 24d9d421e..ec5abad1b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx @@ -144,17 +144,31 @@ export function paymentStatusLabel(status?: string | null): string { return PAYMENT_LABELS[status] ?? titleCaseStatus(status); } -/** Payment status pill. */ -export function PaymentBadge({ status }: { status?: string | null }) { - if (!status) return ; +/** + * Payment status pill. Once the booking's own lifecycle status has moved past + * payment (PAID or later — stage ≥ 3 in STATUS_CONFIG), payment is a settled + * fact: show "Paid" even if a stale/lagging `paymentStatus` value says + * otherwise, rather than surface a contradictory "Paid booking, pending + * payment" row. + */ +export function PaymentBadge({ + status, + bookingStatus, +}: { + status?: string | null; + bookingStatus?: string | null; +}) { + const settled = bookingStatus ? (STATUS_CONFIG[bookingStatus]?.stage ?? 0) >= 3 : false; + const effective = settled ? "PAID" : status; + if (!effective) return ; return ( - {PAYMENT_LABELS[status] ?? titleCaseStatus(status)} + {PAYMENT_LABELS[effective] ?? titleCaseStatus(effective)} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx index 7a9949e8a..2eb07a0b1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx @@ -56,6 +56,7 @@ export function PayNowButton({ processing={pay.processing} error={pay.error} otp={pay.otp} + bill={pay.bill} onConfirm={pay.confirm} /> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts index bb1bd33ab..eb6e170d1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts @@ -45,6 +45,7 @@ export function useBookingPayment(bookingId: string) { ? "No payable invoice found for this booking yet. Please refresh or contact support." : flow.error, otp: flow.otp, + bill: flow.bill, confirm: (method: PaymentMethod, payerAccount?: string) => { if (!payableInvoiceId) { setNoInvoice(true); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 79b904f47..6ceb1fb69 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -1316,7 +1316,10 @@ export default function ContractDetailPage() { )} - + diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index c525b03c4..8f13e1466 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -33,8 +33,7 @@ import { contractsService } from "@/services/contracts.service"; import { api } from "@/services/api"; import { extractApiError } from "@/utils/result"; -const CONSENT_TEXT = - "I have read the entire contract and agree to its terms."; +const CONSENT_TEXT = "I have read the entire contract and agree to its terms."; /** * Customer contract preview + sign. Customers must scroll through the full @@ -224,7 +223,10 @@ export default function ContractViewPage() { } return ( - + - - - + + + + + + @@ -618,8 +652,7 @@ function PriceConfirmModal({ quantity: li.quantity, amount: li.amount, })), - total: - validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + total: validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), }; }, [validation, baseTotal]); @@ -712,8 +745,8 @@ function PriceConfirmModal({ ))} - Adjust the 20ft container weights or quantities so pairs differ - by no more than 10 tons. + Adjust the 20ft container weights or quantities so pairs + differ by no more than 10 tons. @@ -807,7 +840,12 @@ function PriceConfirmModal({ )} - + {total.lines.map((line, i) => ( @@ -989,8 +1027,9 @@ function ScheduleStep({ ?.cargoTypeCode ?? undefined, totalWeightTons: tons, }; - // itemCount is referenced so the query refreshes when a PER_ITEM cargo - // amount changes (weight is the sizing input the backend uses). + // Tonnage is the sizing input the day-feasibility endpoint takes, and + // PER_ITEM cargo now captures it too — itemCount stays in the deps so the + // query still refreshes when only the item count changes. }, [ route, isContainer, @@ -1003,7 +1042,9 @@ function ScheduleStep({ const isIntercity = contract.tradeDirection === "DOMESTIC"; const { data: availableDays, isLoading } = useQuery({ ...api.bookings.getAvailableDaysForCargo.queryOptions({ - input: cargoQuery ?? ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery), + input: + cargoQuery ?? + ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery), }), enabled: cargoQuery !== null && !isIntercity, }); @@ -1060,7 +1101,12 @@ function ScheduleStep({ title="Schedule" description="Intercity shipments have no fixed day." /> - }> + } + > Your shipment rides the next import/export train passing through your corridor. Operations assign it to a train with free capacity — you will be notified when it is accepted and payment is due. @@ -1106,7 +1152,12 @@ function ScheduleStep({ )} /> {cargoQuery === null ? ( - }> + } + > Enter your cargo details first — available shipment days depend on the wagons your cargo needs. @@ -1159,6 +1210,9 @@ function CargoStep({ contract: Freight.IContract; }) { const isContainer = contract.freightType === "CONTAINER"; + // Break-bulk (PER_ITEM) cargo needs BOTH the item count (which prices it) and + // the total tonnage (which sizes the wagons); PER_TON needs tonnage only. + const isPerItem = bulkUnitOfMeasure(contract) === "PER_ITEM"; // Sizes enabled by the contract scope. const sizes = useMemo( () => @@ -1190,8 +1244,7 @@ function CargoStep({ // still needs its container number. useEffect(() => { if (!remainderMode || isContainer) return; - const field = - bulkUnitOfMeasure(contract) === "PER_ITEM" ? "itemCount" : "cargoWeightTons"; + const field = isPerItem ? "itemCount" : "cargoWeightTons"; if (!form.getValues(field)) { form.setValue(field, String(remainderLines[0].remaining), { shouldValidate: false, @@ -1239,11 +1292,11 @@ function CargoStep({ "containers", sizes.map((size) => ({ containerSize: size, - quantity: "1", + quantity: "0", hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", - units: [emptyUnit()], + units: [], })), { shouldValidate: false }, ); @@ -1283,11 +1336,11 @@ function CargoStep({ return ( current.find((l) => l.containerSize === size) ?? { containerSize: size, - quantity: "1", + quantity: "0", hazardousQuantity: "0", reeferQuantity: "0", returnQuantity: "0", - units: [emptyUnit()], + units: [], } ); } @@ -1309,7 +1362,10 @@ function CargoStep({ })), }; }); - form.setValue("containers", next, { shouldValidate: true, shouldDirty: true }); + form.setValue("containers", next, { + shouldValidate: true, + shouldDirty: true, + }); setImportErrors([]); setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`); }; @@ -1324,7 +1380,12 @@ function CargoStep({ /> {sizes.length > 0 && ( - + @@ -1452,10 +1513,11 @@ function CargoStep({ title={`Odd number of 20ft containers (${ft20})`} > - 20ft containers travel two per wagon, so they must be booked in - even numbers. Please add one more 20ft container or remove one - (e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) — the - booking cannot be submitted with an unpaired 20ft container. + 20ft containers travel two per wagon, so they must be booked + in even numbers. Please add one more 20ft container or remove + one (e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) — + the booking cannot be submitted with an unpaired 20ft + container. ); @@ -1475,6 +1537,27 @@ function CargoStep({ {remainderNotice} + {isPerItem && ( + ( + + )} + /> + )} )} /> - ( - - )} - /> {contract.isHazardous && ( ({ - containerNumber: "", - sealNumber: "", - vgmTons: "", - isHazardous: false, - isReefer: false, - isReturn: false, -}); - function ContainerLineEditor({ form, index, @@ -1694,13 +1754,29 @@ function ContainerLineEditor({ syncHandlingCounts(next); }; + // Drop one container row and shrink quantity to match — the inverse of + // syncUnits growing the array when quantity goes up. + const removeUnit = (unitIdx: number) => { + const current = form.getValues(`containers.${index}.units`) ?? []; + const next = current.filter((_, j) => j !== unitIdx); + form.setValue(`containers.${index}.units`, next, { shouldValidate: false }); + form.setValue(`containers.${index}.quantity`, String(next.length), { + shouldValidate: true, + }); + syncHandlingCounts(next); + }; + /** * Line totals are a roll-up of the per-container switches — the count is * however many containers ticked each service. Kept in form state so the * price estimate and the submitted payload stay in step with the switches. */ const syncHandlingCounts = ( - units: Array<{ isHazardous?: boolean; isReefer?: boolean; isReturn?: boolean }>, + units: Array<{ + isHazardous?: boolean; + isReefer?: boolean; + isReturn?: boolean; + }>, ) => { const set = ( key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity", @@ -1781,8 +1857,10 @@ function ContainerLineEditor({ styles={fieldStyles} onChange={(e) => { field.onChange(e.currentTarget.value); - const qty = Number(e.currentTarget.value || 0); - syncUnits(qty); + }} + onBlur={(e) => { + field.onBlur(); + syncUnits(Number(e.currentTarget.value || 0)); }} /> )} @@ -1828,86 +1906,100 @@ function ContainerLineEditor({ ))} )} - {Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => ( - - ( - - field.onChange(e.currentTarget.value.toUpperCase()) - } - placeholder="e.g. MSCU1234567" - error={fieldState.error?.message} - radius={10} - styles={fieldStyles} - style={{ flex: 1 }} - /> - )} - /> - ( - - )} - /> - ( - - )} - /> - {handlingColumns.map((col) => ( + {Array.from({ length: Math.max(quantity, units.length) }).map( + (_, u) => ( + ( - - - toggleUnitHandling(u, col.key, e.currentTarget.checked) - } - size="sm" - /> - + render={({ field, fieldState }) => ( + + field.onChange(e.currentTarget.value.toUpperCase()) + } + placeholder="e.g. MSCU1234567" + error={fieldState.error?.message} + radius={10} + styles={fieldStyles} + style={{ flex: 1 }} + /> )} /> - ))} - - ))} + ( + + )} + /> + ( + + )} + /> + {handlingColumns.map((col) => ( + ( + + + toggleUnitHandling( + u, + col.key, + e.currentTarget.checked, + ) + } + size="sm" + /> + + )} + /> + ))} + removeUnit(u)} + > + + + + ), + )} ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx index 756dd7fd9..55109143e 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx @@ -25,6 +25,28 @@ export function Step4Route({ const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); const operationType = form.watch("operationType"); + const cargoType = form.watch("cargoType"); + + // A yard is only offerable for the side it can actually work: loading cargo + // onto a train and receiving it off one need different equipment, and a yard + // with no facility at all reports false on every flag. + const yardHandlesSide = useCallback( + ( + yard: Freight.BookingReferenceYard | undefined, + side: "origin" | "destination", + ) => { + if (!yard) return false; + if (side === "origin") { + return cargoType === "bulk" + ? yard.hasBulkFacilityOrigin + : yard.hasContainerFacilityOrigin; + } + return cargoType === "bulk" + ? yard.hasBulkFacilityDestination + : yard.hasContainerFacilityDestination; + }, + [cargoType], + ); const { originCountry, destinationCountry } = useMemo(() => { switch (operationType) { @@ -47,23 +69,28 @@ export function Step4Route({ }, [referenceData]); const yardsForSide = useCallback( - (country: string | null, excludeYardId: string) => + ( + country: string | null, + excludeYardId: string, + side: "origin" | "destination", + ) => yardOptions .filter((o) => o.value !== excludeYardId) .filter((o) => { - if (!country) return true; const yard = referenceData?.yard.find((y) => y.id === o.value); + if (!yardHandlesSide(yard, side)) return false; + if (!country) return true; return yard?.country === country; }), - [yardOptions, referenceData], + [yardOptions, referenceData, yardHandlesSide], ); const originData = useMemo( - () => yardsForSide(originCountry, destinationYard), + () => yardsForSide(originCountry, destinationYard, "origin"), [yardsForSide, originCountry, destinationYard], ); const destData = useMemo( - () => yardsForSide(destinationCountry, originYard), + () => yardsForSide(destinationCountry, originYard, "destination"), [yardsForSide, destinationCountry, originYard], ); @@ -88,6 +115,18 @@ export function Step4Route({ } }, [destinationCountry, dest, form]); + // Switching cargo type can strand an already-picked yard that has no facility + // for the new type on that side. Same pristine-hydration guard as above. + useEffect(() => { + if (!form.formState.isDirty) return; + if (origin && !yardHandlesSide(origin, "origin")) { + form.setValue("originYard", ""); + } + if (dest && !yardHandlesSide(dest, "destination")) { + form.setValue("destinationYard", ""); + } + }, [origin, dest, yardHandlesSide, form]); + const directionStyle: Record = { EXPORT: "bg-sky-50 text-sky-800 border-sky-200", IMPORT: "bg-amber-50 text-amber-800 border-amber-200", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx index 5aca3fc81..93f5c23c5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx @@ -1,4 +1,4 @@ -import { Controller, type UseFormReturn } from "react-hook-form"; +import { type UseFormReturn } from "react-hook-form"; import { Badge, Box, @@ -7,13 +7,11 @@ import { Paper, Stack, Text, - Textarea, } from "@mantine/core"; import { CheckCircle2, Circle, ClipboardCheck, - Coins, FileText, MapPin, Package, @@ -220,13 +218,13 @@ export function Step8Review({ // not of the stored form flag — a stale draft flag must not misreport it. // Without bundling, the customer may still name their own clearing agent. const ownAgent = values.customsClearingAgent?.trim(); - const customsValue = isIntercity - ? "Not applicable — domestic transport" + const customsTag: { label: string; color: string } = isIntercity + ? { label: "Not applicable · domestic", color: "gray" } : serviceType?.includesCustoms || values.customsClearingEnabled - ? "Included — Global Logistics" + ? { label: "EDR handles it · Global Logistics", color: "edr-green" } : ownAgent - ? `Own agent — ${ownAgent}` - : "Not requested"; + ? { label: `Own agent · ${ownAgent}`, color: "blue" } + : { label: "Not requested", color: "gray" }; // Mirror the step-2 gating: imports never truck the first mile, exports never // truck the last mile, and a service that doesn't bundle a mile can't have it. @@ -351,27 +349,26 @@ export function Step8Review({ label="Service" value={serviceType?.serviceName ?? "—"} /> - } - label="Quotation currency" - value={ - <> - USD - - You choose the billing currency on each shipment. - - - } - /> } label="Route" - value={`${originYardName} → ${destinationYardName}`} - /> - } - label="Trade direction" - value={directionLabel} + value={ + + + {originYardName} → {destinationYardName} + + } + style={{ flexShrink: 0 }} + > + {directionLabel} + + + } /> } @@ -420,7 +417,16 @@ export function Step8Review({ } label="Customs clearing" - value={customsValue} + value={ + + {customsTag.label} + + } /> {/* Step-3 toggles appear only when the customer selected them — an off toggle is left off the summary entirely. */} @@ -478,20 +484,6 @@ export function Step8Review({ {documentsEditor} )} - - ( -