per-user trade-direction access scope

This commit is contained in:
Marshal
2026-08-02 22:29:58 +00:00
parent c055abe8c1
commit f4fd469643
47 changed files with 1451 additions and 107 deletions

View File

@@ -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,

View File

@@ -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<void> {
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<void> {
await queryRunner.dropTable('freight.user_trade_access', true);
}
}

View File

@@ -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<void> {
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<void> {
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
`);
}
}

View File

@@ -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")

View File

@@ -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],

View File

@@ -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 };
}

View File

@@ -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<BookingReferenceDataDto> {
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 => ({

View File

@@ -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

View File

@@ -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,

View File

@@ -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<Booking> {
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,

View File

@@ -1619,6 +1619,7 @@ export class BookingsService {
filter: FilterBookingDto,
forceCompanyId?: string,
forceCompanyProfileId?: string,
tradeDirections?: string[],
): Promise<PaginatedBookings> {
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,

View File

@@ -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 {

View File

@@ -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');

View File

@@ -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,

View File

@@ -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<Contract> {
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,

View File

@@ -748,6 +748,7 @@ export class ContractsService {
filter: FilterContractDto,
forceCompanyId?: string,
forceCompanyProfileId?: string,
tradeDirections?: string[],
): Promise<PaginatedContracts> {
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,

View File

@@ -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<OverviewResponseDto> {
return this.overviewService.getDashboard(query.range ?? '30d');
async getDashboard(
@Query() query: OverviewQueryDto,
@CurrentUser() user: TCurrentUser,
): Promise<OverviewResponseDto> {
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<OverviewBookingsTabDto> {
return this.overviewService.getBookingsTab(query.range ?? '30d');
async getBookingsTab(
@Query() query: OverviewQueryDto,
@CurrentUser() user: TCurrentUser,
): Promise<OverviewBookingsTabDto> {
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<OverviewContractsTabDto> {
return this.overviewService.getContractsTab(query.range ?? '30d');
async getContractsTab(
@Query() query: OverviewQueryDto,
@CurrentUser() user: TCurrentUser,
): Promise<OverviewContractsTabDto> {
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<OverviewBillingTabDto> {
return this.overviewService.getBillingTab(query.range ?? '30d');
async getBillingTab(
@Query() query: OverviewQueryDto,
@CurrentUser() user: TCurrentUser,
): Promise<OverviewBillingTabDto> {
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<OverviewCustomersTabDto> {
return this.overviewService.getCustomersTab(query.range ?? '30d');
async getCustomersTab(
@Query() query: OverviewQueryDto,
@CurrentUser() user: TCurrentUser,
): Promise<OverviewCustomersTabDto> {
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.overviewService.getCustomersTab(
query.range ?? '30d',
allowed ?? undefined,
);
}
@Get('staff')

View File

@@ -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],

View File

@@ -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<User>,
) { }
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
async getBookingKpis(dirs?: string[]): Promise<OverviewBookingKpisRow> {
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<Record<string, string>>();
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<Record<string, number>> {
async getStatusCounts(dirs?: string[]): Promise<Record<string, number>> {
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<OverviewRecentBookingRow[]> {
async getRecentBookings(
limit: number,
dirs?: string[],
): Promise<OverviewRecentBookingRow[]> {
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<OverviewContractKpisRow> {
async getContractKpis(dirs?: string[]): Promise<OverviewContractKpisRow> {
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<Record<string, number>> {
async getContractStatusCounts(
dirs?: string[],
): Promise<Record<string, number>> {
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<OverviewRecentContractRow[]> {
async getRecentContracts(
limit: number,
dirs?: string[],
): Promise<OverviewRecentContractRow[]> {
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<{

View File

@@ -40,7 +40,10 @@ export class OverviewService {
return { bookingsByPipeline, bookingsByStatus };
}
async getDashboard(range: OverviewRangeQuery = '30d'): Promise<OverviewResponseDto> {
async getDashboard(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewResponseDto> {
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<OverviewBookingsTabDto> {
async getBookingsTab(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewBookingsTabDto> {
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<OverviewContractsTabDto> {
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<OverviewBillingTabDto> {
async getBillingTab(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewBillingTabDto> {
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<OverviewCustomersTabDto> {
async getCustomersTab(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewCustomersTabDto> {
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 {

View File

@@ -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,
});
}

View File

@@ -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,

View File

@@ -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")

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -1437,6 +1437,7 @@ export class BookingBatchService implements OnModuleInit {
*/
async getBatchBoard(
query: BatchBoardQueryDto = {},
allowedDirections?: string[],
): Promise<BatchBoardListResponse> {
// 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<string>(BATCH_BOARD_STATUSES);

View File

@@ -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")

View File

@@ -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,

View File

@@ -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<TrainSchedule> = {};
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;

View File

@@ -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[];
}

View File

@@ -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[])
: [];
}
}

View File

@@ -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<T extends WhereExpressionBuilder>(
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<string, unknown> } {
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<string, unknown> } {
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<T>(
qb: SelectQueryBuilder<T & object>,
refColumn: string,
dirs: string[] | null,
): SelectQueryBuilder<T & object> {
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;
}

View File

@@ -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',
);
}
}
}

View File

@@ -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 {}

View File

@@ -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<UserTradeAccess> {
constructor(
@InjectRepository(UserTradeAccess) repository: Repository<UserTradeAccess>,
) {
super(repository);
}
findByUserId(userId: string): Promise<UserTradeAccess | null> {
return this.repository.findOne({ where: { userId } });
}
findAllConfigs(): Promise<UserTradeAccess[]> {
return this.repository.find({ order: { updatedAt: 'DESC' } });
}
}

View File

@@ -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<UserTradeAccessView[]> {
const rows = await this.repository.findAllConfigs();
return rows.map((r) => this.toView(r));
}
async upsert(
userId: string,
directions: Freight.ScheduleTradeDirection[],
actorId?: string | null,
): Promise<UserTradeAccessView> {
// 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<Freight.ScheduleTradeDirection[] | null> {
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,
};
}
}

View File

@@ -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`],
);

View File

@@ -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 = () => {
</RequirePermission>
}
/>
<Route
path="configuration/trade-access"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<TradeAccessPage />
</RequirePermission>
}
/>
{/* <Route
path="configuration/contract-validity-periods"
element={

View File

@@ -1,5 +1,5 @@
import { Badge, Box, Button, CloseButton, Divider, Group, Paper, ScrollArea, Stack, Text } from "@mantine/core";
import { ChevronLeft, ChevronRight, Maximize2, Minimize2, Train, TrainFront, X } from "lucide-react";
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";
@@ -319,7 +319,11 @@ function addWheels(group: THREE.Group, length: number, wheels: THREE.Mesh[]) {
}
}
function buildLocomotive(loco: { code: string } | undefined, wheels: THREE.Mesh[]): THREE.Group {
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 });
@@ -345,7 +349,12 @@ function buildLocomotive(loco: { code: string } | undefined, wheels: THREE.Mesh[
g.add(spot, spot.target);
addWheels(g, len, wheels);
g.userData.length = len;
g.userData.locoCode = loco?.code;
const locoId = loco?.id ?? loco?.code ?? "loco";
g.userData.locoId = locoId;
g.traverse((o) => {
o.userData.locoId = locoId;
});
pickables.push(g);
return g;
}
@@ -457,6 +466,119 @@ function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3
return g;
}
function InfoRow({ label, value }: { label: string; value: string | number | null | undefined }) {
if (value === null || value === undefined || value === "") return null;
return (
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" c="gray.5">{label}</Text>
<Text size="xs" c="gray.1" fw={600} ta="right">{value}</Text>
</Group>
);
}
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 (
<Paper
radius="lg"
p="md"
style={{
position: "absolute",
top: 60,
right: 16,
width: 340,
maxHeight: "calc(100% - 76px)",
background: "rgba(13, 17, 23, 0.92)",
border: "1px solid rgba(255,255,255,0.15)",
color: "#e6edf3",
zIndex: 10,
}}
>
<Group justify="space-between" mb="xs">
<Text fw={700}>Locomotive {loco?.code ?? ""}</Text>
<CloseButton variant="transparent" c="gray.4" onClick={onClose} />
</Group>
<Button fullWidth color="orange" size="xs" mb="sm" leftSection={<Gauge size={14} />} onClick={onDrive}>
Drive this locomotive
</Button>
<ScrollArea.Autosize mah="70vh">
<Stack gap="xs">
{loco ? (
<>
{loco.name ? <Text size="sm" c="gray.3">{loco.name}</Text> : null}
<Group gap="xs">
<Badge color="yellow" variant="light">{loco.status}</Badge>
</Group>
<InfoRow label="Max pull weight" value={`${loco.maxPullWeightTons} t`} />
{loco.maxTrainLengthMeters ? (
<InfoRow label="Max train length" value={`${loco.maxTrainLengthMeters} m`} />
) : null}
</>
) : (
<Text size="sm" c="gray.5">No locomotive assigned yet.</Text>
)}
<Divider color="rgba(255,255,255,0.1)" label="Train" labelPosition="left" />
<InfoRow label="Voyage / reference" value={schedule.reference} />
<InfoRow label="Train number" value={schedule.trainNumber} />
<InfoRow
label="Train"
value={schedule.train ? `${schedule.train.code}${schedule.train.trainName ? ` · ${schedule.train.trainName}` : ""}` : null}
/>
<InfoRow label="Status" value={String(schedule.status)} />
<InfoRow label="Direction" value={schedule.direction} />
<InfoRow label="Route" value={schedule.route?.name} />
<InfoRow
label="From → To"
value={
schedule.originStation || schedule.destinationStation
? `${schedule.originStation?.label ?? schedule.originStation?.code ?? "?"}${schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? "?"}`
: null
}
/>
<InfoRow
label="Departure"
value={schedule.scheduledDepartureDate ? new Date(schedule.scheduledDepartureDate).toLocaleString() : null}
/>
<InfoRow
label="Arrival"
value={schedule.scheduledArrivalDate ? new Date(schedule.scheduledArrivalDate).toLocaleString() : null}
/>
{ts ? (
<>
<Divider color="rgba(255,255,255,0.1)" label="Consist" labelPosition="left" />
<InfoRow label="Wagons" value={ts.wagonCount} />
<InfoRow label="Total weight" value={`${ts.totalWeightTons} t`} />
<InfoRow label="Total length" value={`${ts.totalLengthMeters} m`} />
{ts.heaviestLeg ? (
<InfoRow
label="Heaviest leg"
value={`${ts.heaviestLeg.grossWeightTons} t · ${ts.heaviestLeg.lengthMeters} m`}
/>
) : null}
</>
) : null}
</Stack>
</ScrollArea.Autosize>
</Paper>
);
}
function WagonDetailPanel({ wagon, schedule, onClose }: { wagon: Wagon; schedule: TrainScheduleDetail; onClose: () => void }) {
const bookings = schedule.bookings ?? [];
return (
@@ -547,6 +669,8 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
const containerRef = useRef<HTMLDivElement>(null);
const canvasHostRef = useRef<HTMLDivElement>(null);
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
const [selectedLocoId, setSelectedLocoId] = useState<string | null>(null);
const [driveMode, setDriveMode] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const cameraApiRef = useRef<{
overview: () => void;
@@ -556,7 +680,9 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
} | null>(null);
const wagons = schedule.trainSet?.wagons ?? [];
const moving = String(schedule.status).toUpperCase() === "DISPATCHED";
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),
@@ -572,10 +698,16 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
: 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;
@@ -619,11 +751,13 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
: 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);
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 }> = [];
@@ -650,6 +784,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
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;
@@ -692,6 +827,20 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
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();
@@ -714,11 +863,21 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
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) : null;
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);
@@ -732,20 +891,27 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
let raf = 0;
let last = performance.now();
const speed = 8; // m/s visual speed when dispatched
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;
if (train.position.x > trainLen / 2 + 120) train.position.x = trainLen / 2 - 120;
// 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 * 3.2);
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
@@ -772,12 +938,14 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
});
};
// rebuild scene only when schedule identity/status changes
}, [schedule.id, schedule.status, moving, wagons.length]); // eslint-disable-line react-hooks/exhaustive-deps
}, [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) onClose();
if (e.key !== "Escape" || document.fullscreenElement) return;
if (driveMode) setDriveMode(false);
else onClose();
};
document.addEventListener("fullscreenchange", onFsChange);
window.addEventListener("keydown", onKey);
@@ -785,7 +953,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
document.removeEventListener("fullscreenchange", onFsChange);
window.removeEventListener("keydown", onKey);
};
}, [onClose]);
}, [onClose, driveMode]);
const toggleFullscreen = () => {
if (document.fullscreenElement) void document.exitFullscreen();
@@ -807,8 +975,12 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
gap="xs"
style={{ position: "absolute", top: 16, left: 16, zIndex: 10 }}
>
<Badge size="lg" variant="filled" color={moving ? "green" : "gray"}>
{moving ? "DISPATCHED — TRAIN IN MOTION" : `${schedule.status} — TRAIN STOPPED`}
<Badge size="lg" variant="filled" color={driveMode ? "orange" : dispatched ? "green" : "gray"}>
{driveMode
? "DRIVER VIEW — SIMULATION"
: dispatched
? "DISPATCHED — TRAIN IN MOTION"
: `${schedule.status} — TRAIN STOPPED`}
</Badge>
<Badge size="lg" variant="light" color="yellow">
{schedule.route?.name ?? schedule.reference ?? "Train"} · {wagons.length} wagons
@@ -839,13 +1011,26 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
size="compact-sm"
variant="default"
leftSection={<TrainFront size={14} />}
onClick={() => cameraApiRef.current?.front()}
onClick={() => {
setSelectedWagonId(null);
setSelectedLocoId(firstLocoId);
cameraApiRef.current?.front();
}}
>
Locomotive
</Button>
<Button size="compact-sm" variant="default" onClick={() => cameraApiRef.current?.rear()}>
Last wagon
</Button>
<Button
size="compact-sm"
variant={driveMode ? "filled" : "default"}
color={driveMode ? "orange" : undefined}
leftSection={<Gauge size={14} />}
onClick={() => setDriveMode((d) => !d)}
>
{driveMode ? "Exit drive" : "Drive"}
</Button>
<Divider orientation="vertical" color="rgba(255,255,255,0.2)" />
<Button size="compact-sm" variant="default" onClick={() => stepWagon(-1)} px={8}>
<ChevronLeft size={16} />
@@ -885,6 +1070,13 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
</Group>
{selectedWagon ? (
<WagonDetailPanel wagon={selectedWagon} schedule={schedule} onClose={() => setSelectedWagonId(null)} />
) : selectedLocoId ? (
<LocoDetailPanel
locoId={selectedLocoId}
schedule={schedule}
onClose={() => setSelectedLocoId(null)}
onDrive={() => setDriveMode(true)}
/>
) : null}
</Box>
);

View File

@@ -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: <T extends { value: string }>(options: T[]): T[] =>
options.filter((o) => directions.includes(o.value as TradeDirection)),
};
}

View File

@@ -1,3 +1,4 @@
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
import {
ActionIcon,
Box,
@@ -139,6 +140,7 @@ export default function BookingRequestsPage() {
const [statusFilter, setStatusFilter] = useState<string[]>(() =>
paramStatuses.split(",").filter(Boolean),
);
const { filterOptions } = useMyTradeAccess();
const [directionFilter, setDirectionFilter] = useState<string | null>(
paramDirection,
);
@@ -602,7 +604,7 @@ export default function BookingRequestsPage() {
/>
<Select
placeholder="All directions"
data={TRADE_DIRECTION_OPTIONS}
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);

View File

@@ -0,0 +1,200 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { useAuth } from "@/auth/useAuth";
import { useEmployees } from "@/user-management/hooks/useEmployees";
import {
ALL_TRADE_DIRECTIONS,
TRADE_DIRECTION_LABELS,
userTradeAccessService,
type TradeDirection,
} from "@/services/userTradeAccess.service";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const QUERY_KEY = ["user-trade-access", "list"] as const;
type EmployeeRow = {
userId: string;
name: string;
email: string;
};
/**
* Per-user trade-direction access (Import / Export / Intercity checkboxes).
* All three checked (or never configured) = unrestricted; unchecking limits
* the user's contracts, bookings, schedules, batch board, payments, invoices
* and overview to the checked directions. Admins always bypass the scope.
*/
export default function TradeAccessPage() {
const { user } = useAuth();
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const organizationId =
user?.employee && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
const { employeesResponseByOrg, isLoadingEmployeesByOrg } = useEmployees({
organizationId,
});
const { data: configs, isLoading: configsLoading } = useQuery({
queryKey: QUERY_KEY,
queryFn: userTradeAccessService.list,
});
const saveMutation = useMutation({
mutationFn: ({
userId,
directions,
}: {
userId: string;
directions: TradeDirection[];
}) => userTradeAccessService.set(userId, directions),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
void queryClient.invalidateQueries({
queryKey: ["user-trade-access", "me"],
});
toast.success("Trade access updated");
},
});
const configByUser = useMemo(() => {
const map = new Map<string, TradeDirection[]>();
for (const row of configs ?? []) map.set(row.userId, row.directions);
return map;
}, [configs]);
const rows: EmployeeRow[] = useMemo(() => {
const items = employeesResponseByOrg?.items ?? [];
const mapped = items
.map((item: { user?: { id?: string; name?: { en?: string }; email?: string; username?: string } }) => ({
userId: item.user?.id ?? "",
name: item.user?.name?.en ?? item.user?.username ?? "—",
email: item.user?.email ?? "",
}))
.filter((r: EmployeeRow) => r.userId);
const term = search.trim().toLowerCase();
if (!term) return mapped;
return mapped.filter(
(r: EmployeeRow) =>
r.name.toLowerCase().includes(term) ||
r.email.toLowerCase().includes(term),
);
}, [employeesResponseByOrg, search]);
// No row yet = unrestricted, so render as all three checked.
const directionsFor = (userId: string): TradeDirection[] =>
configByUser.get(userId) ?? [...ALL_TRADE_DIRECTIONS];
const toggle = (userId: string, direction: TradeDirection) => {
const current = directionsFor(userId);
const next = current.includes(direction)
? current.filter((d) => d !== direction)
: [...current, direction];
saveMutation.mutate({ userId, directions: next });
};
const loading = isLoadingEmployeesByOrg || configsLoading;
return (
<div className="space-y-4 p-4">
<div>
<h1 className="text-xl font-bold">Trade direction access</h1>
<p className="text-sm text-muted-foreground">
Choose which trade directions each backoffice user can see. This
filters their contracts, bookings, schedules, batch board, payments,
invoices and overview. All three checked means full access; super and
organization admins are never restricted.
</p>
</div>
<input
type="search"
value={search}
onChange={(e) => 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 ? (
<p className="text-sm text-muted-foreground">Loading users</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
{ALL_TRADE_DIRECTIONS.map((d) => (
<TableHead key={d} className="text-center">
{TRADE_DIRECTION_LABELS[d]}
</TableHead>
))}
<TableHead>Access</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const dirs = directionsFor(row.userId);
const unrestricted = dirs.length === ALL_TRADE_DIRECTIONS.length;
return (
<TableRow key={row.userId}>
<TableCell className="font-medium">{row.name}</TableCell>
<TableCell>{row.email}</TableCell>
{ALL_TRADE_DIRECTIONS.map((d) => (
<TableCell key={d} className="text-center">
<input
type="checkbox"
className="h-4 w-4 accent-primary"
checked={dirs.includes(d)}
disabled={saveMutation.isPending}
onChange={() => toggle(row.userId, d)}
aria-label={`${row.name}${TRADE_DIRECTION_LABELS[d]}`}
/>
</TableCell>
))}
<TableCell>
{unrestricted ? (
<span className="text-xs text-muted-foreground">
Full access
</span>
) : dirs.length === 0 ? (
<span className="text-xs font-medium text-red-600">
No data
</span>
) : (
<span className="text-xs font-medium text-amber-600">
{dirs.map((d) => TRADE_DIRECTION_LABELS[d]).join(" + ")}{" "}
only
</span>
)}
</TableCell>
</TableRow>
);
})}
{rows.length === 0 && (
<TableRow>
<TableCell
colSpan={3 + ALL_TRADE_DIRECTIONS.length}
className="text-center text-sm text-muted-foreground"
>
No users found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
);
}

View File

@@ -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<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
@@ -309,7 +311,7 @@ export default function ClearanceDocumentsPage() {
<Group gap="sm" mt="sm" wrap="wrap">
<Select
placeholder="Direction"
data={TRADE_DIRECTION_OPTIONS}
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);

View File

@@ -1,4 +1,5 @@
import { directionLabel } from "@/lib/utils";
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
import {
ActionIcon,
Box,
@@ -156,6 +157,7 @@ export default function ContractRequestsPage() {
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
// Filter controls (empty/null = "all").
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const { filterOptions } = useMyTradeAccess();
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
null,
@@ -561,7 +563,7 @@ export default function ContractRequestsPage() {
/>
<Select
placeholder="All directions"
data={TRADE_DIRECTION_OPTIONS}
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);

View File

@@ -0,0 +1,43 @@
import { api as client } from "../auth/http";
export type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
export const ALL_TRADE_DIRECTIONS: TradeDirection[] = [
"IMPORT",
"EXPORT",
"DOMESTIC",
];
export const TRADE_DIRECTION_LABELS: Record<TradeDirection, string> = {
IMPORT: "Import",
EXPORT: "Export",
DOMESTIC: "Intercity",
};
export interface UserTradeAccessRow {
userId: string;
directions: TradeDirection[];
updatedAt: string;
}
export interface MyTradeAccess {
restricted: boolean;
directions: TradeDirection[];
}
export const userTradeAccessService = {
/** All configured per-user scopes (admin only). */
list: async (): Promise<UserTradeAccessRow[]> =>
(await client.get("/user-trade-access")).data,
/** Current user's effective scope. */
me: async (): Promise<MyTradeAccess> =>
(await client.get("/user-trade-access/me")).data,
/** Set the directions a user may see (admin only). */
set: async (
userId: string,
directions: TradeDirection[],
): Promise<UserTradeAccessRow> =>
(await client.put(`/user-trade-access/${userId}`, { directions })).data,
};

View File

@@ -25,6 +25,28 @@ export function Step4Route({
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const operationType = form.watch("operationType");
const cargoType = form.watch("cargoType");
// A yard is only offerable for the side it can actually work: loading cargo
// onto a train and receiving it off one need different equipment, and a yard
// with no facility at all reports false on every flag.
const yardHandlesSide = useCallback(
(
yard: Freight.BookingReferenceYard | undefined,
side: "origin" | "destination",
) => {
if (!yard) return false;
if (side === "origin") {
return cargoType === "bulk"
? yard.hasBulkFacilityOrigin
: yard.hasContainerFacilityOrigin;
}
return cargoType === "bulk"
? yard.hasBulkFacilityDestination
: yard.hasContainerFacilityDestination;
},
[cargoType],
);
const { originCountry, destinationCountry } = useMemo(() => {
switch (operationType) {
@@ -47,23 +69,28 @@ export function Step4Route({
}, [referenceData]);
const yardsForSide = useCallback(
(country: string | null, excludeYardId: string) =>
(
country: string | null,
excludeYardId: string,
side: "origin" | "destination",
) =>
yardOptions
.filter((o) => o.value !== excludeYardId)
.filter((o) => {
if (!country) return true;
const yard = referenceData?.yard.find((y) => y.id === o.value);
if (!yardHandlesSide(yard, side)) return false;
if (!country) return true;
return yard?.country === country;
}),
[yardOptions, referenceData],
[yardOptions, referenceData, yardHandlesSide],
);
const originData = useMemo(
() => yardsForSide(originCountry, destinationYard),
() => yardsForSide(originCountry, destinationYard, "origin"),
[yardsForSide, originCountry, destinationYard],
);
const destData = useMemo(
() => yardsForSide(destinationCountry, originYard),
() => yardsForSide(destinationCountry, originYard, "destination"),
[yardsForSide, destinationCountry, originYard],
);
@@ -88,6 +115,18 @@ export function Step4Route({
}
}, [destinationCountry, dest, form]);
// Switching cargo type can strand an already-picked yard that has no facility
// for the new type on that side. Same pristine-hydration guard as above.
useEffect(() => {
if (!form.formState.isDirty) return;
if (origin && !yardHandlesSide(origin, "origin")) {
form.setValue("originYard", "");
}
if (dest && !yardHandlesSide(dest, "destination")) {
form.setValue("destinationYard", "");
}
}, [origin, dest, yardHandlesSide, form]);
const directionStyle: Record<string, string> = {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",

View File

@@ -303,6 +303,22 @@ export const TRADE_DIRECTION_LABELS: Record<ScheduleTradeDirection, string> = {
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 {