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/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/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 88ef92b3d..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 }; } 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 78004f024..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, 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/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/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/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.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 669c7c27b..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") 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 3d3b992d3..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); 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.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 9c60c9821..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 @@ -3899,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; 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/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 = () => { } /> + + + + } + /> {/* (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 ( @@ -85,47 +106,64 @@ export function MySignatureCard() { 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/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/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 4aee1e32a..0f8cc87ea 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -1,3 +1,4 @@ +import { useMyTradeAccess } from "@/hooks/useMyTradeAccess"; import { ActionIcon, Box, @@ -139,6 +140,7 @@ export default function BookingRequestsPage() { const [statusFilter, setStatusFilter] = useState(() => paramStatuses.split(",").filter(Boolean), ); + const { filterOptions } = useMyTradeAccess(); const [directionFilter, setDirectionFilter] = useState( paramDirection, ); @@ -602,7 +604,7 @@ export default function BookingRequestsPage() { /> setSearch(e.target.value)} + placeholder="Search by name or email…" + className="w-full max-w-sm rounded-md border px-3 py-2 text-sm" + /> + + {loading ? ( +

Loading users…

+ ) : ( + + + + User + Email + {ALL_TRADE_DIRECTIONS.map((d) => ( + + {TRADE_DIRECTION_LABELS[d]} + + ))} + Access + + + + {rows.map((row) => { + const dirs = directionsFor(row.userId); + const unrestricted = dirs.length === ALL_TRADE_DIRECTIONS.length; + return ( + + {row.name} + {row.email} + {ALL_TRADE_DIRECTIONS.map((d) => ( + + toggle(row.userId, d)} + aria-label={`${row.name} — ${TRADE_DIRECTION_LABELS[d]}`} + /> + + ))} + + {unrestricted ? ( + + Full access + + ) : dirs.length === 0 ? ( + + No data + + ) : ( + + {dirs.map((d) => TRADE_DIRECTION_LABELS[d]).join(" + ")}{" "} + only + + )} + + + ); + })} + {rows.length === 0 && ( + + + No users found. + + + )} + +
+ )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index b8a871b4b..8a7949410 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -1,3 +1,4 @@ +import { useMyTradeAccess } from "@/hooks/useMyTradeAccess"; import { ActionIcon, Box, @@ -92,6 +93,7 @@ export default function ClearanceDocumentsPage() { const [bookingStatuses, setBookingStatuses] = useState( BOOKING_STATUS_OPTIONS[0].value, ); + const { filterOptions } = useMyTradeAccess(); const [directionFilter, setDirectionFilter] = useState(null); const [freightTypeFilter, setFreightTypeFilter] = useState(null); const [ownershipFilter, setOwnershipFilter] = useState(null); @@ -309,7 +311,7 @@ export default function ClearanceDocumentsPage() { { setDirectionFilter(v); diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index e89977eef..968817985 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -71,6 +71,7 @@ import { PreviewSummary, ScheduleWarningsAlert, } from "@/components/trainScheduling/ScheduleWarningsAlert"; +import { Train3DVisualization } from "@/components/trainScheduling/Train3DVisualization"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { TrainConsistView } from "@/components/trainScheduling/compositionEditor"; import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid"; @@ -116,6 +117,7 @@ export default function TrainScheduleV2DetailPage() { const [gatepassNotes, setGatepassNotes] = useState(""); const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false); const [switchTarget, setSwitchTarget] = useState(null); + const [visualization3DOpen, setVisualization3DOpen] = useState(false); const autoPreviewedRef = useRef(false); const detailQuery = useQuery( @@ -946,6 +948,18 @@ export default function TrainScheduleV2DetailPage() { + {(schedule.trainSet?.wagons?.length ?? 0) > 0 ? ( + + ) : null} {canPrintMarshalling ? ( -

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() { /> - - + + + + + Company stamp + + Upload your official company stamp or seal as an image. It is + stored on your profile and applied next to your signature on + contracts. + + + + + + + + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index d56272e0c..03a9e515a 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -21,7 +21,9 @@ import AuthShell from "@/components/auth/AuthShell"; import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep"; import PasswordChecklist from "@/components/auth/PasswordChecklist"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; +import { StampUpload } from "@/components/contracts/StampUpload"; import { api } from "@/services/api"; +import { signaturesService } from "@/services/signatures.service"; import { confirmPasswordField, passwordField, @@ -59,6 +61,10 @@ export default function SignupPage() { const navigate = useNavigate(); const { signup } = useAuth(); const [error, setError] = useState(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 ? ( { + 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/services/signatures.service.ts b/apps/edr-freight-web/portal/src/services/signatures.service.ts index 052e26b07..75a216b34 100644 --- a/apps/edr-freight-web/portal/src/services/signatures.service.ts +++ b/apps/edr-freight-web/portal/src/services/signatures.service.ts @@ -10,7 +10,8 @@ export interface SavedSignature { export interface SaveSignaturePayload { signerDisplayName: string; - signatureImageBase64: string; + /** Omit to keep the existing saved signature (stamp-only update). */ + signatureImageBase64?: string; /** Omit to keep the existing saved stamp. */ stampImageBase64?: string; } diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index ffa19a8cc..37997ea18 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -303,6 +303,22 @@ export const TRADE_DIRECTION_LABELS: Record = { DOMESTIC: "Intercity", }; +/** All trade directions a backoffice user can be scoped to. */ +export const ALL_TRADE_DIRECTIONS: ScheduleTradeDirection[] = [ + "IMPORT", + "EXPORT", + "DOMESTIC", +]; + +/** + * Per-user backoffice data scope: which trade directions the user may see. + * A missing config (or all three directions) means unrestricted. + */ +export interface UserTradeAccessDto { + userId: string; + directions: ScheduleTradeDirection[]; +} + export enum TrainCheckpointKind { Departed = "DEPARTED", Passed = "PASSED", @@ -951,6 +967,16 @@ export interface BookingReferenceYard { name: string; code: string; country: string; + /** + * Which freight types this yard can take, per side of the trip. False for a + * yard with no facility record at all. Forms use these to offer a yard as an + * origin or destination only where the selected cargo can actually be + * handled. + */ + hasContainerFacilityOrigin: boolean; + hasBulkFacilityOrigin: boolean; + hasContainerFacilityDestination: boolean; + hasBulkFacilityDestination: boolean; } export interface BookingReferenceContainerType { diff --git a/packages/types/src/freight/notifications.ts b/packages/types/src/freight/notifications.ts index ecd30c715..e3ca5b219 100644 --- a/packages/types/src/freight/notifications.ts +++ b/packages/types/src/freight/notifications.ts @@ -92,6 +92,13 @@ export interface NotificationRecipients { organizationId?: string; /** Backoffice: every current employee across all organizations. */ allBackoffice?: boolean; + /** + * Backoffice: current employees (any org) who hold ANY of these permission + * keys — e.g. notify only marketing, not every employee. Super/org admins + * are not implicitly included; add `allBackoffice`/explicit userIds too if + * admins should also see it. + */ + permissionKeys?: string[]; } /** Input any subsystem passes to `NotificationInboxService.notify(...)`. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7df61a873..4614ef288 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -352,6 +352,9 @@ importers: '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) + '@types/three': + specifier: ^0.185.3 + version: 0.185.3 '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -496,6 +499,9 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 + three: + specifier: ^0.185.1 + version: 0.185.1 tinymce: specifier: ^8.6.0 version: 8.6.0 @@ -1826,6 +1832,9 @@ packages: '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@dotenvx/dotenvx@1.71.0': resolution: {integrity: sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag==} hasBin: true @@ -4728,6 +4737,9 @@ packages: cpu: [arm64] os: [win32] + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -4954,6 +4966,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -4963,6 +4978,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/three@0.185.3': + resolution: {integrity: sha512-8TqTn1+fjPWuJ4mR6Igtg56DCf9b5EeAlwhb5xaa6WlBrsg7SvG0NQbyctGRkHCKwg0uftfCspOEsTXelgktMA==} + '@types/tinymce@4.6.9': resolution: {integrity: sha512-pDxBUlV4v1jgJ97SlnVOSyf3KUy3OQ3s5Ddpfh1L9M5lXlBmX7TJ2OLSozx1WBxp91acHvYPWDwz2U/kMM1oxQ==} @@ -4987,6 +5005,9 @@ packages: '@types/vorpal@1.12.8': resolution: {integrity: sha512-Qt+Yxa1q6QCaYMxZFXlyPOF3ktIscTelNr1AFYuKM7/Dhlki4gvc476uFyA/hYvskSA6V8W+55x9FjlbAPcYdQ==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -9158,6 +9179,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -11305,6 +11329,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three@0.185.1: + resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + throttleit@1.0.1: resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==} @@ -12785,6 +12812,8 @@ snapshots: '@date-fns/tz@1.5.0': {} + '@dimforge/rapier3d-compat@0.12.0': {} + '@dotenvx/dotenvx@1.71.0': dependencies: commander: 11.1.0 @@ -16831,6 +16860,8 @@ snapshots: '@turbo/windows-arm64@2.9.16': optional: true + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -17092,6 +17123,8 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/stats.js@0.17.4': {} + '@types/statuses@2.0.6': {} '@types/superagent@8.1.10': @@ -17106,6 +17139,15 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.10 + '@types/three@0.185.3': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + '@types/tinymce@4.6.9': dependencies: '@types/jquery': 4.0.1 @@ -17125,6 +17167,8 @@ snapshots: '@types/vorpal@1.12.8': {} + '@types/webxr@0.5.24': {} + '@types/ws@8.18.1': dependencies: '@types/node': 20.19.42 @@ -21897,6 +21941,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.1.1: {} + methods@1.1.2: {} micromatch@3.1.10: @@ -24474,6 +24520,8 @@ snapshots: dependencies: any-promise: 1.3.0 + three@0.185.1: {} + throttleit@1.0.1: {} through2@2.0.5: