diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index 4f7ec23bb..e659b950a 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -14,4 +14,9 @@ export default registerAs("app", () => ({ maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760), maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), }, + cbeExchange: { + apiUrl: process.env.CBE_EXCHANGE_API_URL ?? "", + fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130), + cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000), + }, })); diff --git a/apps/edr-freight-api/src/migrations/1781000000005-CreateTrainCompositionRemovalLog.ts b/apps/edr-freight-api/src/migrations/1781000000005-CreateTrainCompositionRemovalLog.ts new file mode 100644 index 000000000..475a52574 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000005-CreateTrainCompositionRemovalLog.ts @@ -0,0 +1,81 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateTrainCompositionRemovalLog1781000000005 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'train_composition_removal_logs', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'uuid_generate_v4()', + }, + { + name: 'schedule_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'booking_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'booking_reference', + type: 'varchar', + length: '64', + isNullable: true, + }, + { + name: 'removed_by_user_id', + type: 'uuid', + isNullable: true, + }, + { + name: 'removed_at', + type: 'timestamptz', + default: 'NOW()', + isNullable: false, + }, + { + name: 'notes', + type: 'text', + isNullable: true, + }, + { + name: 'created_at', + type: 'timestamptz', + default: 'NOW()', + isNullable: false, + }, + { + name: 'updated_at', + type: 'timestamptz', + default: 'NOW()', + isNullable: false, + }, + { + name: 'deleted_at', + type: 'timestamptz', + isNullable: true, + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.train_composition_removal_logs', + new TableIndex({ + columnNames: ['schedule_id'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.train_composition_removal_logs', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1782000000000-WagonLocomotiveYardLink.ts b/apps/edr-freight-api/src/migrations/1782000000000-WagonLocomotiveYardLink.ts new file mode 100644 index 000000000..68100f367 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1782000000000-WagonLocomotiveYardLink.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class WagonLocomotiveYardLink1782000000000 implements MigrationInterface { + name = 'WagonLocomotiveYardLink1782000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS "current_yard_id" UUID NULL; + `); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_wagon_current_yard' + ) THEN + ALTER TABLE freight.wagons + ADD CONSTRAINT "FK_wagon_current_yard" + FOREIGN KEY ("current_yard_id") REFERENCES freight.yards(id) ON DELETE SET NULL; + END IF; + END $$; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_wagon_current_yard_id" + ON freight.wagons ("current_yard_id"); + `); + + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`); + await queryRunner.query(`ALTER TABLE freight.wagons DROP COLUMN IF EXISTS readiness;`); + + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS "current_yard_id" UUID NULL; + `); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_locomotive_current_yard' + ) THEN + ALTER TABLE freight.locomotives + ADD CONSTRAINT "FK_locomotive_current_yard" + FOREIGN KEY ("current_yard_id") REFERENCES freight.yards(id) ON DELETE SET NULL; + END IF; + END $$; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_locomotive_current_yard_id" + ON freight.locomotives ("current_yard_id"); + `); + + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`); + await queryRunner.query(`ALTER TABLE freight.locomotives DROP COLUMN IF EXISTS readiness;`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'; + `); + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagons_readiness + ON freight.wagons (readiness) + WHERE deleted_at IS NULL; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_locomotives_readiness + ON freight.locomotives (readiness) + WHERE deleted_at IS NULL; + `); + + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_wagon_current_yard_id"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_locomotive_current_yard_id"`); + await queryRunner.query(` + ALTER TABLE freight.wagons DROP CONSTRAINT IF EXISTS "FK_wagon_current_yard"; + `); + await queryRunner.query(` + ALTER TABLE freight.locomotives DROP CONSTRAINT IF EXISTS "FK_locomotive_current_yard"; + `); + await queryRunner.query(`ALTER TABLE freight.wagons DROP COLUMN IF EXISTS "current_yard_id";`); + await queryRunner.query(`ALTER TABLE freight.locomotives DROP COLUMN IF EXISTS "current_yard_id";`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 8240105c9..4ba93626f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -2,22 +2,24 @@ import { BookingPricingService } from './booking-pricing.service'; import type { Booking } from './entities/booking.entity'; import type { Rate } from '../rule-engine/entities/rate.entity'; +const MOCK_CBE_RATE = 130; + describe('BookingPricingService — domestic corridor', () => { - const intercityBulkEtb: Rate = { - id: 'rate-intercity-bulk-etb', + const intercityBulkUsd: Rate = { + id: 'rate-intercity-bulk-usd', rateType: 'INTERCITY_BULK', - currency: 'ETB', - rateValue: 1900, + currency: 'USD', + rateValue: 35, rateUnit: 'PER_TON', status: 'LIVE', containerTypeId: null, } as Rate; - const intercityContainerEtb: Rate = { - id: 'rate-intercity-container-etb', + const intercityContainerUsd: Rate = { + id: 'rate-intercity-container-usd', rateType: 'INTERCITY_CONTAINER', - currency: 'ETB', - rateValue: 25000, + currency: 'USD', + rateValue: 400, rateUnit: 'PER_CONTAINER', status: 'LIVE', containerTypeId: null, @@ -26,11 +28,15 @@ describe('BookingPricingService — domestic corridor', () => { let service: BookingPricingService; let bookingsRepository: { calculateWagonCount: jest.Mock }; let ratesService: { findLiveRates: jest.Mock }; + let cbeExchangeService: { getUsdToEtbRate: jest.Mock }; beforeEach(() => { bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) }; ratesService = { - findLiveRates: jest.fn().mockResolvedValue([intercityBulkEtb, intercityContainerEtb]), + findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]), + }; + cbeExchangeService = { + getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), }; service = new BookingPricingService( @@ -39,10 +45,11 @@ describe('BookingPricingService — domestic corridor', () => { {} as never, ratesService as never, {} as never, + cbeExchangeService as never, ); }); - it('prices domestic bulk using INTERCITY_BULK and cargo tons', async () => { + it('prices domestic bulk in ETB using INTERCITY_BULK USD rate × CBE exchange rate', async () => { const booking = { id: 'b-1', freightType: 'BULK', @@ -57,16 +64,42 @@ describe('BookingPricingService — domestic corridor', () => { computeBaseRailLinesWithRates: ( b: Booking, input: { containers: [] }, - ) => Promise<{ lineItems: Array<{ amount: number; code: string }> }>; + ) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>; } ).computeBaseRailLinesWithRates(booking, { containers: [] }); expect(result.lineItems).toHaveLength(1); expect(result.lineItems[0].code).toBe('INTERCITY_BULK'); - expect(result.lineItems[0].amount).toBe(1900 * 120); + expect(result.lineItems[0].currency).toBe('ETB'); + expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE)); }); - it('prices domestic container using INTERCITY_CONTAINER fallback', async () => { + it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => { + const booking = { + id: 'b-1-usd', + freightType: 'BULK', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + cargoTotalWeightVgm: 120, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { containers: [] }, + ) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>; + } + ).computeBaseRailLinesWithRates(booking, { containers: [] }); + + expect(result.lineItems).toHaveLength(1); + expect(result.lineItems[0].code).toBe('INTERCITY_BULK'); + expect(result.lineItems[0].currency).toBe('USD'); + expect(result.lineItems[0].amount).toBe(35 * 120); + }); + + it('prices domestic container in ETB using INTERCITY_CONTAINER USD fallback × CBE rate', async () => { const booking = { id: 'b-2', freightType: 'CONTAINER', @@ -83,12 +116,14 @@ describe('BookingPricingService — domestic corridor', () => { input: { containers: Array<{ containerTypeId: string; quantity: number }>; }, - ) => Promise<{ lineItems: Array<{ amount: number; code: string }> }>; + ) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>; } ).computeBaseRailLinesWithRates(booking, { containers: [{ containerTypeId: 'ct-20', quantity: 3 }], }); expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true); + const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!; + expect(line.currency).toBe('ETB'); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index e5f63eb27..7c6d363da 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -4,6 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s import { RatesService } from '../rule-engine/services/rates.service'; import { ServiceTypesService } from '../rule-engine/services/service-types.service'; import { Rate } from '../rule-engine/entities/rate.entity'; +import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; import { AppliedCargoModifier, BookingEvaluationInput, @@ -40,6 +41,7 @@ export class BookingPricingService { private readonly containerTypesService: ContainerTypesService, private readonly ratesService: RatesService, private readonly serviceTypesService: ServiceTypesService, + private readonly cbeExchangeService: CbeExchangeService, ) {} async generatePrice(bookingId: string): Promise { @@ -80,6 +82,10 @@ export class BookingPricingService { const evalInput = await this.buildEvalInputForBooking(booking); const ruleResult = await this.ruleEngineService.evaluate(evalInput); + const paymentCurrency = booking.paymentCurrency; + const isEtbBooking = paymentCurrency === 'ETB'; + const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1; + const lineItems: PriceLineItemDto[] = []; let total = 0; @@ -95,14 +101,16 @@ export class BookingPricingService { const usedRatesMap = new Map(baseRates.map((r) => [r.id, r])); for (const mod of ruleResult.appliedModifiers) { + const usdAmount = mod.calculatedAmount; + const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; const item: PriceLineItemDto = { code: mod.surchargeTypeCode, description: `Surcharge: ${mod.surchargeTypeCode}`, - amount: mod.calculatedAmount, - currency: mod.currency, + amount: convertedAmount, + currency: paymentCurrency, }; lineItems.push(item); - total += mod.calculatedAmount; + total += convertedAmount; const rate = rateById.get(mod.rateId); if (rate) usedRatesMap.set(rate.id, rate); @@ -263,7 +271,9 @@ export class BookingPricingService { evalInput: BookingEvaluationInput, ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { const liveRates = await this.ratesService.findLiveRates(); - const currency = booking.paymentCurrency; + const paymentCurrency = booking.paymentCurrency; + const isEtbBooking = paymentCurrency === 'ETB'; + const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1; const isBulk = booking.freightType === 'BULK'; const rateType = @@ -284,34 +294,36 @@ export class BookingPricingService { const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); for (const container of evalInput.containers) { - const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency); + const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD'); if (!rate) continue; usedRatesMap.set(rate.id, rate); - const amount = this.amountForRate(rate, container.quantity, wagonCount); + const usdAmount = this.amountForRate(rate, container.quantity, wagonCount); + const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; lines.push({ code: rateType, description: `Base rail (${rateType})`, amount, - currency: rate.currency, + currency: paymentCurrency, }); } if (lines.length === 0) { const fallback = liveRates.find( - (r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE', + (r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE', ); if (fallback) { usedRatesMap.set(fallback.id, fallback); const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0); const quantity = isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1; - const amount = this.amountForRate(fallback, quantity, wagonCount); + const usdAmount = this.amountForRate(fallback, quantity, wagonCount); + const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; lines.push({ code: rateType, description: `Base rail (${rateType})`, amount, - currency: fallback.currency, + currency: paymentCurrency, }); } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index cdffe49f5..cb5d87714 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -30,6 +30,7 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; import { PaymentModule } from '../payment/payment.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; @Module({ imports: [ @@ -65,6 +66,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu ContractPricingScheduleBuilder, ContractRendererService, ContractPdfService, + CbeExchangeService, ], exports: [BookingsService, BookingsRepository], }) diff --git a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts b/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts new file mode 100644 index 000000000..0f596831f --- /dev/null +++ b/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts @@ -0,0 +1,115 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class CbeExchangeService { + private readonly logger = new Logger(CbeExchangeService.name); + private cachedRate: number | null = null; + private cacheExpiresAt = 0; + + constructor(private readonly configService: ConfigService) {} + + /** + * Returns the current CBE USD→ETB exchange rate. + * Fetches live from CBE_EXCHANGE_API_URL, caches for CBE_EXCHANGE_CACHE_TTL_MS, + * and falls back to CBE_EXCHANGE_FALLBACK_RATE when the API is unreachable. + */ + async getUsdToEtbRate(): Promise { + const now = Date.now(); + + if (this.cachedRate !== null && now < this.cacheExpiresAt) { + return this.cachedRate; + } + + const apiUrl = this.configService.get('app.cbeExchange.apiUrl') ?? ''; + const fallbackRate = this.configService.get('app.cbeExchange.fallbackRate') ?? 130; + const cacheTtlMs = this.configService.get('app.cbeExchange.cacheTtlMs') ?? 3_600_000; + + if (!apiUrl) { + this.logger.warn( + `CBE_EXCHANGE_API_URL not configured — using fallback rate ${fallbackRate} ETB/USD`, + ); + return fallbackRate; + } + + try { + const response = await fetch(apiUrl, { + signal: AbortSignal.timeout(8_000), + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`CBE API responded with status ${response.status}`); + } + + const json = await response.json(); + const rate = this.parseRate(json); + + if (!rate || !Number.isFinite(rate) || rate <= 0) { + throw new Error(`Invalid rate value parsed from CBE API response: ${rate}`); + } + + this.cachedRate = rate; + this.cacheExpiresAt = now + cacheTtlMs; + this.logger.log(`CBE USD→ETB rate refreshed: ${rate}`); + return rate; + } catch (err) { + this.logger.error( + `Failed to fetch CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`, + ); + + if (this.cachedRate !== null) { + this.logger.warn(`Using previously cached CBE rate: ${this.cachedRate}`); + return this.cachedRate; + } + + return fallbackRate; + } + } + + /** + * Parses the USD→ETB selling rate from the CBE API JSON response. + * CBE API typically returns an array of currency objects. + * Adjust this method if the API shape differs. + * + * Expected shape (one common format): + * [ { currency: "USD", selling: "130.50", ... }, ... ] + */ + private parseRate(json: unknown): number | null { + if (Array.isArray(json)) { + const usdEntry = json.find( + (entry: unknown) => + typeof entry === 'object' && + entry !== null && + ( + (entry as Record)['currency'] === 'USD' || + (entry as Record)['Currency'] === 'USD' + ), + ) as Record | undefined; + + if (!usdEntry) return null; + + const selling = + usdEntry['selling'] ?? + usdEntry['Selling'] ?? + usdEntry['sellingRate'] ?? + usdEntry['rate'] ?? + usdEntry['Rate']; + + return selling !== undefined ? Number(selling) : null; + } + + if (typeof json === 'object' && json !== null) { + const obj = json as Record; + const selling = + obj['selling'] ?? + obj['Selling'] ?? + obj['sellingRate'] ?? + obj['usdToEtb'] ?? + obj['rate']; + return selling !== undefined ? Number(selling) : null; + } + + return null; + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts index 41ce09f26..5745d3037 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts @@ -1,9 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min, IsUUID } from 'class-validator'; import { - LOCOMOTIVE_READINESS_VALUES, LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES, } from '../entities/locomotive.entity'; @@ -28,10 +27,10 @@ export class CreateLocomotiveDto { @IsIn([...LOCOMOTIVE_STATUSES]) status!: string; - @ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES, default: 'IMPORT_READY' }) + @ApiPropertyOptional({ description: 'Current yard location' }) @IsOptional() - @IsIn([...LOCOMOTIVE_READINESS_VALUES]) - readiness?: string; + @IsUUID() + currentYardId?: string; @ApiProperty({ example: 3500 }) @Transform(({ value }) => Number(value)) diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts index e8684f460..c634d5efb 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -1,8 +1,7 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional } from 'class-validator'; +import { IsIn, IsOptional, IsUUID } from 'class-validator'; import { - LOCOMOTIVE_READINESS_VALUES, LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES, } from '../entities/locomotive.entity'; @@ -18,8 +17,8 @@ export class FilterLocomotivesDto { @IsIn([...LOCOMOTIVE_TYPES]) locomotiveType?: string; - @ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES }) + @ApiPropertyOptional({ description: 'Filter by current yard' }) @IsOptional() - @IsIn([...LOCOMOTIVE_READINESS_VALUES]) - readiness?: string; + @IsUUID() + currentYardId?: string; } diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index 40e00aa68..25d32f106 100644 --- a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -1,8 +1,8 @@ import { BaseEntity } from '@edr/api-common'; -import { WagonReadiness } from '@edr/types'; -import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { Column, Entity, Index, OneToMany, ManyToOne, JoinColumn } from 'typeorm'; import { TrainSet } from '../../train-sets/entities/train-set.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; export const LOCOMOTIVE_STATUSES = [ 'AVAILABLE', @@ -13,20 +13,13 @@ export const LOCOMOTIVE_STATUSES = [ export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const; -/** Locomotives reuse the wagon readiness values (IMPORT_READY / EXPORT_READY). */ -export const LOCOMOTIVE_READINESS_VALUES = [ - WagonReadiness.ImportReady, - WagonReadiness.ExportReady, -] as const; - export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number]; export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number]; -export type LocomotiveReadiness = (typeof LOCOMOTIVE_READINESS_VALUES)[number]; @Entity({ schema: 'freight', name: 'locomotives' }) @Index(['code']) @Index(['status']) -@Index(['readiness']) +@Index(['currentYardId']) export class Locomotive extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) code!: string; @@ -46,8 +39,12 @@ export class Locomotive extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' }) status!: LocomotiveStatus; - @Column({ name: 'readiness', type: 'varchar', length: 20, default: WagonReadiness.ImportReady }) - readiness!: LocomotiveReadiness; + @Column({ name: 'current_yard_id', type: 'uuid', nullable: true }) + currentYardId!: string | null; + + @ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'current_yard_id' }) + currentYard?: Yard | null; @Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true }) powerKw?: number | null; diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index 09c4c717f..ebcb09bce 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -3,11 +3,9 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; -import { WagonReadiness } from '@edr/types'; import { Locomotive, - type LocomotiveReadiness, type LocomotiveStatus, type LocomotiveType, } from './entities/locomotive.entity'; @@ -24,8 +22,9 @@ export class LocomotivesService { ...(filter.locomotiveType ? { locomotiveType: filter.locomotiveType as LocomotiveType } : {}), - ...(filter.readiness ? { readiness: filter.readiness as LocomotiveReadiness } : {}), + ...(filter.currentYardId ? { currentYardId: filter.currentYardId } : {}), }, + relations: { currentYard: true }, order: { code: 'ASC' }, }); } @@ -42,7 +41,7 @@ export class LocomotivesService { name: dto.name?.trim() || null, locomotiveType: dto.locomotiveType as LocomotiveType, status: dto.status as LocomotiveStatus, - readiness: (dto.readiness as LocomotiveReadiness) ?? WagonReadiness.ImportReady, + currentYardId: dto.currentYardId ?? null, maxPullWeightTons: dto.maxPullWeightTons, maxTrainLengthMeters: dto.maxTrainLengthMeters, powerKw: dto.powerKw ?? null, @@ -52,7 +51,9 @@ export class LocomotivesService { } async findById(id: string): Promise { - const locomotive = await this.locomotivesRepository.findById(id); + const locomotive = await this.locomotivesRepository.findById(id, { + relations: { currentYard: true }, + }); if (!locomotive) { throw new NotFoundException(`Locomotive ${id} not found`); @@ -76,10 +77,10 @@ export class LocomotivesService { locomotiveType: dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType, status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus, - readiness: - dto.readiness === undefined - ? locomotive.readiness - : (dto.readiness as LocomotiveReadiness), + currentYardId: + dto.currentYardId === undefined + ? locomotive.currentYardId + : (dto.currentYardId ?? null), name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null, powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null, tractionForceKn: diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 2f73780d9..969c08876 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -4,7 +4,7 @@ import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; -const CURRENCIES = ['ETB', 'USD'] as const; +const CURRENCIES = ['USD'] as const; export class CreateRateDto { @ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 525185058..291d1959d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -51,7 +51,7 @@ export class RatesService { rateType: dto.rateType as Rate['rateType'], containerTypeId: dto.containerTypeId, tradeDirection: dto.tradeDirection, - currency: dto.currency, + currency: dto.currency ?? 'USD', rateValue: dto.rateValue, rateUnit: dto.rateUnit as Rate['rateUnit'], status: 'DRAFT', @@ -71,7 +71,7 @@ export class RatesService { if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType']; if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId; if (dto.tradeDirection !== undefined) updates.tradeDirection = dto.tradeDirection; - if (dto.currency) updates.currency = dto.currency; + updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit']; if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom); diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-composition-removal-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-composition-removal-log.entity.ts new file mode 100644 index 000000000..b32b42148 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-composition-removal-log.entity.ts @@ -0,0 +1,22 @@ +import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +@Entity({ schema: 'freight', name: 'train_composition_removal_logs' }) +@Index(['scheduleId']) +export class TrainCompositionRemovalLog extends BaseEntity { + @Column({ name: 'schedule_id', type: 'uuid' }) scheduleId!: string; + + @Column({ name: 'booking_id', type: 'uuid' }) bookingId!: string; + + @Column({ name: 'booking_reference', type: 'varchar', length: 64, nullable: true }) + bookingReference?: string | null; + + @Column({ name: 'removed_by_user_id', type: 'uuid', nullable: true }) + removedByUserId?: string | null; + + @Column({ name: 'removed_at', type: 'timestamptz', default: () => 'NOW()' }) + removedAt!: Date; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-composition-removal-log.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-composition-removal-log.repository.ts new file mode 100644 index 000000000..c8e0d6053 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-composition-removal-log.repository.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { TrainCompositionRemovalLog } from './entities/train-composition-removal-log.entity'; + +@Injectable() +export class TrainCompositionRemovalLogRepository extends BaseRepository { + constructor(dataSource: DataSource) { + super(dataSource.getRepository(TrainCompositionRemovalLog)); + } + + async findByScheduleId(scheduleId: string): Promise { + return this.findAll({ + where: { scheduleId }, + order: { removedAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts index f9ce84f7d..bb7b41d90 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts @@ -3,11 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; import { TrainSchedule } from './entities/train-schedule.entity'; +import { TrainCompositionRemovalLog } from './entities/train-composition-removal-log.entity'; import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity'; import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository'; import { TrainSchedulesRepository } from './train-schedules.repository'; +import { TrainCompositionRemovalLogRepository } from './train-composition-removal-log.repository'; import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository'; import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository'; @@ -17,6 +19,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r TypeOrmModule.forFeature([ TrainSchedule, TrainScheduleBooking, + TrainCompositionRemovalLog, WagonBookingAllocation, WagonAllocationContainerItem, WagonAllocationBulkLoad, @@ -25,6 +28,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r providers: [ TrainSchedulesRepository, TrainScheduleBookingsRepository, + TrainCompositionRemovalLogRepository, WagonBookingAllocationsRepository, WagonAllocationContainerItemsRepository, WagonAllocationBulkLoadsRepository, @@ -32,6 +36,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r exports: [ TrainSchedulesRepository, TrainScheduleBookingsRepository, + TrainCompositionRemovalLogRepository, WagonBookingAllocationsRepository, WagonAllocationContainerItemsRepository, WagonAllocationBulkLoadsRepository, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index f2d3eda25..e765e5694 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -3,6 +3,9 @@ import { listBatchWindowsForDate, listBatchWindowsForBookings, BATCH_WINDOW_START_HOURS, + boardWindowForTimestamp, + listBoardWindowsForRange, + groupBookingsIntoBoardWindows, } from './batch-window.util'; describe('batch-window.util', () => { @@ -50,3 +53,84 @@ describe('batch-window.util', () => { expect(getBatchWindowForTimestamp(fullyExecutedAt).key).toBe(overnight!.key); }); }); + +describe('batch-window board windows (midnight-based 3h slots)', () => { + it('maps 04:00 EAT to the 03:00–06:00 slot', () => { + // 01:00 UTC = 04:00 EAT on 11 Jun + const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z')); + expect(w.label).toContain('03:00'); + expect(w.label).toContain('06:00'); + expect(w.date).toBe('2026-06-11'); + expect(w.dateLabel).toContain('11 Jun'); + }); + + it('maps 00:30 EAT to the 00:00–03:00 slot of that EAT day', () => { + // 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun + const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z')); + expect(w.label).toContain('00:00'); + expect(w.label).toContain('03:00'); + expect(w.date).toBe('2026-06-11'); + }); + + it('maps 23:00 EAT to the final 21:00–24:00 slot', () => { + // 20:00 UTC = 23:00 EAT on 11 Jun + const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z')); + expect(w.label).toContain('21:00'); + expect(w.label).toContain('24:00'); + expect(w.date).toBe('2026-06-11'); + }); + + it('lists a continuous range open→departure clamped at both ends', () => { + // open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC) + const open = new Date('2026-06-05T05:00:00.000Z'); + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listBoardWindowsForRange(open, departure); + + // Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5 + expect(windows).toHaveLength(6 + 8 + 8 + 5); + expect(windows[0].date).toBe('2026-06-05'); + expect(windows[0].label).toContain('06:00'); + expect(windows[0].label).toContain('09:00'); + const last = windows[windows.length - 1]; + expect(last.date).toBe('2026-06-08'); + expect(last.label).toContain('12:00'); + expect(last.label).toContain('15:00'); + // chronological + unique keys + const keys = windows.map((w) => w.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('handles a same-day open→departure range', () => { + const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (06–09 slot) + const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 slot) + const windows = listBoardWindowsForRange(open, departure); + // 06,09,12 = 3 slots + expect(windows).toHaveLength(3); + expect(windows.every((w) => w.date === '2026-06-05')).toBe(true); + }); + + it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => { + const open = new Date('2026-06-05T05:00:00.000Z'); + const departure = new Date('2026-06-06T11:00:00.000Z'); + const items = [ + { id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 06–09 on 5th + { id: 'b', ts: null }, // pending + ]; + const map = groupBookingsIntoBoardWindows( + items, + (i) => i.ts, + open, + departure, + 'pending-contract', + ); + const pending = map.get('pending-contract'); + expect(pending?.items.map((i) => i.id)).toEqual(['b']); + const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a')); + expect(withA?.window?.date).toBe('2026-06-05'); + // empty slots are retained for the UI + const emptyCount = [...map.values()].filter( + (b) => b.window && b.items.length === 0, + ).length; + expect(emptyCount).toBeGreaterThan(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index e837a6d48..38ab407bc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -159,6 +159,154 @@ export function listBatchWindowsForBookings( return [...byKey.values()].sort(compareBatchWindows); } +// --------------------------------------------------------------------------- +// Board-display windows: full-day, midnight-based 3h slots over a date range. +// These are used ONLY for the batch-board UI grouping (not persisted, and +// independent of the cron intake hours above). +// --------------------------------------------------------------------------- + +/** Midnight-based 3-hour slot starts (00–03, 03–06, … 21–24). */ +export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const; + +/** A board window carries an EAT calendar date in addition to the slot times. */ +export interface BoardWindow extends BatchWindow { + /** EAT calendar day as ISO `YYYY-MM-DD`. */ + date: string; + /** Human label for the day, e.g. `Thu, 05 Jun`. */ + dateLabel: string; +} + +const dayLabelFmt = new Intl.DateTimeFormat('en-GB', { + weekday: 'short', + day: '2-digit', + month: 'short', + timeZone: BATCH_TIMEZONE, +}); + +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */ +function boardWindowFromEatStart( + year: number, + month: number, + day: number, + startHour: number, +): BoardWindow { + const start = eatToUtc(year, month, day, startHour); + const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over) + const end = eatToUtc(year, month, day, endHour); + const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`; + return { + key: start.toISOString(), + start, + end, + label: formatWindowLabel(start, end, endLabel), + date: `${year}-${pad2(month)}-${pad2(day)}`, + dateLabel: dayLabelFmt.format(start), + }; +} + +/** Which midnight-based 3h EAT slot a timestamp falls in. */ +export function boardWindowForTimestamp(date: Date): BoardWindow { + const { year, month, day, hour } = eatParts(date); + let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0; + for (const h of BOARD_WINDOW_HOURS) { + if (hour >= h) startHour = h; + } + return boardWindowFromEatStart(year, month, day, startHour); +} + +/** + * Continuous list of board windows from `openDate` to `departureDate` (inclusive), + * clamped to the slot containing `openDate` on the first day and the slot + * containing `departureDate` on the last day. Returned in chronological order. + */ +export function listBoardWindowsForRange( + openDate: Date, + departureDate: Date, +): BoardWindow[] { + const startWin = boardWindowForTimestamp(openDate); + const endWin = boardWindowForTimestamp(departureDate); + // Guard against an inverted range (departure before open). + if (endWin.start.getTime() < startWin.start.getTime()) { + return [startWin]; + } + + const windows: BoardWindow[] = []; + const seen = new Set(); + // Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to + // avoid any boundary ambiguity, then filter to [startWin.start, endWin.start]. + let cursor = new Date(eatToUtc( + Number(startWin.date.slice(0, 4)), + Number(startWin.date.slice(5, 7)), + Number(startWin.date.slice(8, 10)), + 12, + )); + const lastDayMs = eatToUtc( + Number(endWin.date.slice(0, 4)), + Number(endWin.date.slice(5, 7)), + Number(endWin.date.slice(8, 10)), + 12, + ).getTime(); + + while (cursor.getTime() <= lastDayMs) { + const { year, month, day } = eatParts(cursor); + for (const h of BOARD_WINDOW_HOURS) { + const w = boardWindowFromEatStart(year, month, day, h); + if ( + w.start.getTime() >= startWin.start.getTime() && + w.start.getTime() <= endWin.start.getTime() && + !seen.has(w.key) + ) { + seen.add(w.key); + windows.push(w); + } + } + cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000); + } + + windows.sort(compareBatchWindows); + return windows; +} + +/** + * Group items into board windows spanning [openDate, departureDate]. Empty + * windows are kept so the UI shows every slot. Items whose timestamp falls + * outside the range still get their own window (nothing hidden). Items without + * a timestamp go to `pendingKey`. + */ +export function groupBookingsIntoBoardWindows( + items: T[], + getTimestamp: (item: T) => Date | null | undefined, + openDate: Date, + departureDate: Date, + pendingKey = 'pending-contract', +): Map { + const map = new Map(); + + for (const w of listBoardWindowsForRange(openDate, departureDate)) { + map.set(w.key, { window: w, items: [] }); + } + map.set(pendingKey, { window: null, items: [] }); + + for (const item of items) { + const ts = getTimestamp(item); + if (!ts) { + map.get(pendingKey)!.items.push(item); + continue; + } + const w = boardWindowForTimestamp(ts); + if (!map.has(w.key)) { + map.set(w.key, { window: w, items: [] }); + } + map.get(w.key)!.items.push(item); + } + + return map; +} + /** Group items by batch window key; items without a timestamp go to `pendingKey`. */ export function groupByBatchWindow( items: T[], diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 8170c3bcf..771422fa2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -19,9 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; -import { - groupByBatchWindow, -} from './batch-window.util'; +import { groupBookingsIntoBoardWindows } from './batch-window.util'; import { BATCH_CRON, BATCH_TIMEZONE, @@ -82,6 +80,10 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking { export interface BatchWindowGroup { key: string; label: string; + /** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */ + date: string; + /** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */ + dateLabel: string; start: string; end: string; counts: { @@ -401,11 +403,15 @@ export class BookingBatchService implements OnModuleInit { const loco = s.trainSet?.locomotive ?? null; - const referenceDate = s.scheduledDepartureDate ?? new Date(); - const windowBuckets = groupByBatchWindow( + // Display windows span the whole booking window: from when it opened + // (schedule creation) through the scheduled departure, in 3-hour EAT slots. + const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date(); + const departureDate = s.scheduledDepartureDate ?? new Date(); + const windowBuckets = groupBookingsIntoBoardWindows( items, (item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null), - referenceDate, + openDate, + departureDate, ); const emptyCounts = () => ({ @@ -437,6 +443,8 @@ export class BookingBatchService implements OnModuleInit { windows.push({ key: w.key, label: w.label, + date: w.date, + dateLabel: w.dateLabel, start: w.start.toISOString(), end: w.end.toISOString(), counts: countFor(bucket.items), @@ -477,6 +485,8 @@ export class BookingBatchService implements OnModuleInit { pendingContract: { key: 'pending-contract', label: 'Pending contract', + date: '', + dateLabel: '', start: '', end: '', counts: countFor(pendingBookings), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-unassigned-booking.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-unassigned-booking.dto.ts new file mode 100644 index 000000000..1db03c429 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-unassigned-booking.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class AssignUnassignedBookingDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + bookingId!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-locomotives-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-locomotives-query.dto.ts index 470794322..705e505df 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-locomotives-query.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-locomotives-query.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsUUID } from 'class-validator'; export class AvailableLocomotivesQueryDto { - @ApiProperty({ format: 'uuid', description: 'Route used to derive import/export/domestic readiness' }) + @ApiProperty({ format: 'uuid', description: 'Route used to filter locomotives at the origin yard' }) @IsUUID() routeId!: string; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-container-item.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-container-item.dto.ts new file mode 100644 index 000000000..37710e003 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-container-item.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateContainerItemDto { + @IsString() + @IsOptional() + containerNumber?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 8056f4cdc..3c191c713 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -9,15 +9,20 @@ import { Post, Query, } from '@nestjs/common'; +import { CurrentUser } from '@edr/api-common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { AuthUserPayload } from '../../common/resolve-auth-user-id'; +import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards'; import { AssignBookingsDto } from './dto/assign-bookings.dto'; +import { AssignUnassignedBookingDto } from './dto/assign-unassigned-booking.dto'; import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto'; import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; +import { UpdateContainerItemDto } from './dto/update-container-item.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; @@ -75,7 +80,7 @@ export class TrainSchedulingController { @Get('available-locomotives') @TrainSchedulingView() @ApiOperation({ - summary: 'List AVAILABLE locomotives filtered by route corridor readiness', + summary: 'List AVAILABLE locomotives at the route origin yard', }) getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) { return this.trainSchedulingService.getAvailableLocomotivesForRoute(query.routeId); @@ -176,8 +181,56 @@ export class TrainSchedulingController { unassignBooking( @Param('id', ParseUUIDPipe) id: string, @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, ) { - return this.trainSchedulingService.unassignBooking(id, bookingId); + return this.trainSchedulingService.unassignBooking(id, bookingId, resolveAuthUserId(user)); + } + + @Delete('schedules/:id/wagons/:trainSetWagonId') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Remove an empty wagon slot from a train' }) + removeWagonSlot( + @Param('id', ParseUUIDPipe) id: string, + @Param('trainSetWagonId', ParseUUIDPipe) trainSetWagonId: string, + ) { + return this.trainSchedulingService.removeTrainSetWagonSlot(id, trainSetWagonId); + } + + @Patch('schedules/:id/container-items/:itemId') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Update a container number on a wagon slot' }) + updateContainerItem( + @Param('id', ParseUUIDPipe) id: string, + @Param('itemId', ParseUUIDPipe) itemId: string, + @Body() dto: UpdateContainerItemDto, + ) { + return this.trainSchedulingService.updateContainerItem(id, itemId, dto); + } + + @Get('schedules/:id/unassigned-bookings') + @TrainSchedulingView() + @ApiOperation({ summary: 'Get unassigned bookings for a schedule' }) + getUnassignedBookings(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getUnassignedBookings(id); + } + + @Post('schedules/:id/assign-unassigned-booking') + @TrainSchedulingManage() + @ApiOperation({ + summary: 'Assign one linked unallocated booking to wagons (preserves existing assignments)', + }) + assignUnassignedBooking( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignUnassignedBookingDto, + ) { + return this.trainSchedulingService.assignUnassignedBookingToWagons(id, dto.bookingId); + } + + @Get('schedules/:id/composition-removals') + @TrainSchedulingView() + @ApiOperation({ summary: 'Get removal log for a schedule' }) + getCompositionRemovals(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getCompositionRemovals(id); } @Post('schedules/:id/pin-wagons') @@ -275,7 +328,7 @@ export class TrainSchedulingController { @Post('schedules/:id/arrive') @TrainSchedulingManage() - @ApiOperation({ summary: 'Mark a dispatched train arrived (flip readiness, free assets)' }) + @ApiOperation({ summary: 'Mark a dispatched train arrived (move assets to destination yard, free assets)' }) arriveSchedule(@Param('id', ParseUUIDPipe) id: string) { return this.trainSchedulingService.arriveSchedule(id); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 9127cbc61..163008ecc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -1,10 +1,11 @@ import { BadRequestException, ConflictException } from '@nestjs/common'; -import { WagonReadiness, WagonStatus } from '@edr/types'; +import { WagonStatus } from '@edr/types'; import { Wagon } from '../wagons/entities/wagon.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { TrainSchedulingService } from './train-scheduling.service'; const nw5 = { @@ -25,7 +26,7 @@ const locomotive = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, status: 'AVAILABLE', - readiness: WagonReadiness.ImportReady, + currentYardId: 'yard-origin', }; const cw3 = { @@ -96,6 +97,7 @@ describe('TrainSchedulingService', () => { bookingsRepository = { findEligibleForScheduling: jest.fn(), findByIdsForScheduling: jest.fn(), + findAll: jest.fn(), updateSchedulingFields: jest.fn(), }; locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() }; @@ -144,6 +146,7 @@ describe('TrainSchedulingService', () => { wagonAllocationContainerItemsRepository as never, wagonAllocationBulkLoadsRepository as never, trainCheckpointEventsRepository as never, + {} as never, // trainCompositionRemovalLogRepository ); const defaultFleetWagons = [ @@ -151,14 +154,14 @@ describe('TrainSchedulingService', () => { id: `wagon-nw5-${index}`, wagonTypeId: nw5.id, status: WagonStatus.Available, - readiness: WagonReadiness.ImportReady, + currentYardId: 'yard-origin', currentTrainScheduleId: null, })), ...Array.from({ length: 50 }, (_, index) => ({ id: `wagon-cw3-${index}`, wagonTypeId: cw3.id, status: WagonStatus.Available, - readiness: WagonReadiness.ImportReady, + currentYardId: 'yard-origin', currentTrainScheduleId: null, })), ]; @@ -192,7 +195,7 @@ describe('TrainSchedulingService', () => { id: `wagon-${index}`, wagonTypeId: nw5.id, status: WagonStatus.Available, - readiness: WagonReadiness.ImportReady, + currentYardId: 'yard-origin', currentTrainScheduleId: null, })); @@ -533,14 +536,14 @@ describe('TrainSchedulingService', () => { ).rejects.toBeInstanceOf(ConflictException); }); - it('rejects pin when wagon readiness does not match schedule direction', async () => { + it('rejects pin when wagon is not at the schedule origin yard', async () => { const scheduleId = 'sched-1'; const slotId = 'slot-1'; trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: scheduleId, status: 'DRAFT', - direction: 'IMPORT', + originStationId: 'yard-origin', trainSet: { wagons: [{ id: slotId, physicalWagonId: null }], }, @@ -554,7 +557,7 @@ describe('TrainSchedulingService', () => { id: 'wagon-1', wagonNumber: 'WGN-001', status: WagonStatus.Available, - readiness: WagonReadiness.ExportReady, + currentYardId: 'yard-other', currentTrainScheduleId: null, }), update: jest.fn(), @@ -577,7 +580,7 @@ describe('TrainSchedulingService', () => { ).rejects.toBeInstanceOf(ConflictException); }); - it('flags physical fleet shortfall when export schedule lacks EXPORT_READY wagons', async () => { + it('flags physical fleet shortfall when wagons are not at the origin yard', async () => { const exportBooking = makeBooking( 'exp-1', 'BKG-EXP', @@ -597,14 +600,16 @@ describe('TrainSchedulingService', () => { wagonTypesRepository.findAll.mockResolvedValue([nw5]); bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]); trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); - locomotivesRepository.findAll.mockResolvedValue([locomotive]); + locomotivesRepository.findAll.mockResolvedValue([ + { ...locomotive, currentYardId: 'yard-addis' }, + ]); - const importOnlyFleet = Array.from({ length: 5 }, (_, index) => ({ + const wrongYardFleet = Array.from({ length: 5 }, (_, index) => ({ id: `wagon-nw5-${index}`, wagonTypeId: nw5.id, wagonNumber: `WGN-${index}`, status: WagonStatus.Available, - readiness: WagonReadiness.ImportReady, + currentYardId: 'yard-djibouti', currentTrainScheduleId: null, })); @@ -613,7 +618,7 @@ describe('TrainSchedulingService', () => { return { find: jest.fn().mockResolvedValue([]) }; } if (entity === Wagon) { - return { find: jest.fn().mockResolvedValue(importOnlyFleet) }; + return { find: jest.fn().mockResolvedValue(wrongYardFleet) }; } if (entity === WagonType) { return { find: jest.fn().mockResolvedValue([nw5]) }; @@ -630,7 +635,7 @@ describe('TrainSchedulingService', () => { expect(result.valid).toBe(false); expect( - result.violations.some((v) => v.includes('EXPORT_READY') && v.includes('NW5')), + result.violations.some((v) => v.includes('available at yard') && v.includes('NW5')), ).toBe(true); }); @@ -722,14 +727,160 @@ describe('TrainSchedulingService', () => { ).rejects.toBeInstanceOf(BadRequestException); }); + describe('getUnassignedBookings', () => { + const scheduleId = 'sched-unassigned-1'; + const trainSetId = 'train-set-unassigned'; + const assignedBooking = makeBooking('b-assigned', 'BKG-ASSIGNED', 50, 1, '40FT', 1); + const unassignedBooking = makeBooking('b-unassigned', 'BKG-UNASSIGNED', 60, 1, '40FT', 1); + + const buildScheduleGraph = () => ({ + id: scheduleId, + status: 'DRAFT', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + trainSet: { + id: trainSetId, + locomotive: { ...locomotive, status: 'ASSIGNED', currentYardId: 'yard-origin' }, + wagons: [{ id: 'slot-1', sequenceNo: 1, wagonTypeId: nw5.id, allocations: [] }], + }, + scheduleBookings: [], + }); + + beforeEach(() => { + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + bookingsRepository.findAll.mockResolvedValue([ + { + ...assignedBooking, + trainScheduleId: scheduleId, + paymentStatus: 'PAID', + isGovernment: false, + }, + { + ...unassignedBooking, + trainScheduleId: scheduleId, + paymentStatus: 'PAID', + isGovernment: false, + }, + ]); + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(buildScheduleGraph()); + }); + + it('allows assign when train slots are full but origin yard has matching wagons', async () => { + const yardFleet = [ + { + id: 'wagon-pinned', + wagonTypeId: nw5.id, + status: WagonStatus.Assigned, + currentYardId: 'yard-origin', + currentTrainScheduleId: scheduleId, + }, + ...Array.from({ length: 2 }, (_, index) => ({ + id: `wagon-yard-${index}`, + wagonTypeId: nw5.id, + status: WagonStatus.Available, + currentYardId: 'yard-origin', + currentTrainScheduleId: null, + })), + ]; + + bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => { + const map = new Map([ + [assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }], + [unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }], + ]); + return ids.map((id) => map.get(id)).filter(Boolean); + }); + + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity === Wagon) { + return { find: jest.fn().mockResolvedValue(yardFleet) }; + } + if (entity === WagonType) { + return { find: jest.fn().mockResolvedValue([nw5]) }; + } + if (entity === WagonBookingAllocation) { + return { + find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]), + }; + } + return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) }; + }); + + const result = await service.getUnassignedBookings(scheduleId); + + expect(result.bookings).toHaveLength(1); + expect(result.bookings[0].id).toBe(unassignedBooking.id); + expect(result.bookings[0].canAssign).toBe(true); + expect(result.bookings[0].blockReason).toBeNull(); + expect( + result.fleetAtOrigin.some( + (row: { wagonTypeCode: string; available: number }) => + row.wagonTypeCode === 'NW5' && row.available >= 2, + ), + ).toBe(true); + }); + + it('blocks assign when origin yard lacks wagons of the required type', async () => { + const yardFleet = [ + { + id: 'wagon-pinned', + wagonTypeId: nw5.id, + status: WagonStatus.Assigned, + currentYardId: 'yard-origin', + currentTrainScheduleId: scheduleId, + }, + ]; + + bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => { + const map = new Map([ + [assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }], + [unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }], + ]); + return ids.map((id) => map.get(id)).filter(Boolean); + }); + + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity === Wagon) { + return { find: jest.fn().mockResolvedValue(yardFleet) }; + } + if (entity === WagonType) { + return { find: jest.fn().mockResolvedValue([nw5]) }; + } + if (entity === WagonBookingAllocation) { + return { + find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]), + }; + } + return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) }; + }); + + const result = await service.getUnassignedBookings(scheduleId); + + expect(result.bookings).toHaveLength(1); + expect(result.bookings[0].canAssign).toBe(false); + expect(result.bookings[0].blockReason).toBeTruthy(); + }); + }); + describe('getAvailableLocomotivesForRoute', () => { - it('filters to export-ready locomotives on Ethiopia → Djibouti routes', async () => { + it('returns locomotives at the route origin yard', async () => { const routeId = 'route-export'; + const originYardId = 'yard-addis'; const routeRepo = { findOne: jest.fn().mockResolvedValue({ id: routeId, name: 'Addis → Djibouti', isActive: true, + originYardId, originYard: { country: 'Ethiopia' }, destinationYard: { country: 'Djibouti' }, }), @@ -739,23 +890,28 @@ describe('TrainSchedulingService', () => { return { findOne: jest.fn(), update: jest.fn() }; }); locomotivesRepository.findAll.mockResolvedValue([ - { id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady }, - { id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady }, + { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId }, ]); const result = await service.getAvailableLocomotivesForRoute(routeId); + expect(locomotivesRepository.findAll).toHaveBeenCalledWith({ + where: { status: 'AVAILABLE', currentYardId: originYardId }, + order: { code: 'ASC' }, + }); expect(result).toHaveLength(1); expect(result[0].code).toBe('EXP'); }); - it('returns all available locomotives on domestic routes', async () => { + it('returns all locomotives returned by the repository for domestic routes', async () => { const routeId = 'route-domestic'; + const originYardId = 'yard-addis'; const routeRepo = { findOne: jest.fn().mockResolvedValue({ id: routeId, name: 'Addis → Dire Dawa', isActive: true, + originYardId, originYard: { country: 'Ethiopia' }, destinationYard: { country: 'Ethiopia' }, }), @@ -765,8 +921,8 @@ describe('TrainSchedulingService', () => { return { findOne: jest.fn(), update: jest.fn() }; }); locomotivesRepository.findAll.mockResolvedValue([ - { id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady }, - { id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady }, + { id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId }, + { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId }, ]); const result = await service.getAvailableLocomotivesForRoute(routeId); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 6b4004cfb..8a448c917 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -26,9 +26,11 @@ import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository'; import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository'; import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; @@ -41,6 +43,7 @@ import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto'; import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; +import { UpdateContainerItemDto } from './dto/update-container-item.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; @@ -52,6 +55,7 @@ import { selectBookingsWithinFleetCap, summarizeFleetWarnings, totalAssignedWeight, + wagonsRequiredForBooking, type DeferredBookingRow, type FleetAvailabilityRow, } from './fleet-plan.util'; @@ -75,11 +79,6 @@ import { pickBulkWagonType, } from './wagon-type-resolver.util'; import { deriveScheduleDirection } from './derive-schedule-direction.util'; -import { - flipReadiness, - requiredWagonReadiness, - wagonReadinessMatchesSchedule, -} from './wagon-readiness.util'; import { deriveTrainCapacityFromLocomotive, wagonTypeDimensionsFromEntity, @@ -121,6 +120,26 @@ export interface WagonAllocationAttemptResult { violations: string[]; } +export interface CompositionUnassignedBookingRow { + id: string; + reference: string | null; + freightType: string | null; + priorityScore: number; + cargoTotalWeightVgm: number; + status: string | null; + schedulingStatus: string | null; + wagonsRequired: number; + requiredWagonTypeCode: string; + yardWagonsAvailable: number; + canAssign: boolean; + blockReason: string | null; +} + +export interface UnassignedBookingsResponse { + fleetAtOrigin: FleetAvailabilityRow[]; + bookings: CompositionUnassignedBookingRow[]; +} + const DEFAULT_TRAIN_LIMITS: Required = { maxWeightTons: 3500, maxLengthMeters: 760, @@ -143,6 +162,7 @@ export class TrainSchedulingService { private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository, private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository, private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository, + private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository, private readonly configService?: ConfigService, ) {} @@ -269,9 +289,9 @@ export class TrainSchedulingService { route.originYard ?? { country: null }, route.destinationYard ?? { country: null }, ); - if (!wagonReadinessMatchesSchedule(lockedLocomotive.readiness, direction)) { + if (lockedLocomotive.currentYardId !== route.originYardId) { throw new ConflictException( - `Locomotive ${lockedLocomotive.code} is ${lockedLocomotive.readiness} and cannot run a ${direction} schedule`, + `Locomotive ${lockedLocomotive.code} is at yard ${lockedLocomotive.currentYardId} but schedule originates from ${route.originYardId}`, ); } @@ -458,7 +478,7 @@ export class TrainSchedulingService { await this.autoPinWagonsForSchedule( manager, scheduleId, - schedule.direction ?? null, + schedule.originStationId, savedWagons, ); }); @@ -467,7 +487,7 @@ export class TrainSchedulingService { return { ...detail, warnings, deferredBookings }; } - async unassignBooking(scheduleId: string, bookingId: string) { + async unassignBooking(scheduleId: string, bookingId: string, userId?: string) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -481,6 +501,9 @@ export class TrainSchedulingService { throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`); } + const booking = await this.bookingsRepository.findById(bookingId); + const bookingReference = booking?.reference ?? null; + await this.dataSource.transaction(async (manager) => { const allocationIds = (schedule.trainSet?.wagons ?? []) .flatMap((w) => w.allocations ?? []) @@ -529,6 +552,18 @@ export class TrainSchedulingService { } }); + await this.trainCompositionRemovalLogRepository.create({ + scheduleId, + bookingId, + bookingReference, + removedByUserId: userId ?? null, + removedAt: new Date(), + }); + + console.log( + `[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`, + ); + return this.getTrainScheduleById(scheduleId); } @@ -565,9 +600,9 @@ export class TrainSchedulingService { `Wagon ${physicalWagon.wagonNumber} is not available`, ); } - if (!wagonReadinessMatchesSchedule(physicalWagon.readiness, schedule.direction)) { + if (physicalWagon.currentYardId !== schedule.originStationId) { throw new ConflictException( - `Wagon ${physicalWagon.wagonNumber} is ${physicalWagon.readiness} but schedule is ${schedule.direction ?? 'unknown'}`, + `Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`, ); } @@ -827,8 +862,8 @@ export class TrainSchedulingService { } /** - * Mark a dispatched train arrived: close out the schedule, flip readiness on the - * locomotive + wagons (they have repositioned), and free the assets for re-use. + * Mark a dispatched train arrived: close out the schedule, move the locomotive + * and wagons to the destination yard, and free the assets for re-use. */ async arriveSchedule(scheduleId: string) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); @@ -839,7 +874,6 @@ export class TrainSchedulingService { throw new BadRequestException('Only DISPATCHED trains can arrive'); } - const isDomestic = schedule.direction === 'DOMESTIC'; const now = new Date(); await this.dataSource.transaction(async (manager) => { @@ -863,7 +897,7 @@ export class TrainSchedulingService { if (loco) { await manager.getRepository(Locomotive).update(loco.id, { status: 'AVAILABLE', - readiness: isDomestic ? loco.readiness : flipReadiness(loco.readiness), + currentYardId: schedule.destinationStationId, }); } } @@ -878,7 +912,7 @@ export class TrainSchedulingService { currentTrainScheduleId: null, trainSetWagonId: null, status: WagonStatus.Available, - readiness: isDomestic ? wagon.readiness : flipReadiness(wagon.readiness), + currentYardId: schedule.destinationStationId, }); } @@ -1028,11 +1062,15 @@ export class TrainSchedulingService { } if ( - bookings.some( - (b) => + bookings.some((b) => { + if (targetScheduleId && b.trainScheduleId === targetScheduleId) { + return false; + } + return ( b.originYardId !== dto.originStationId || - b.destinationYardId !== dto.destinationStationId, - ) + b.destinationYardId !== dto.destinationStationId + ); + }) ) { violations.push('Selected bookings must share the same origin and destination as the schedule'); } @@ -1083,8 +1121,8 @@ export class TrainSchedulingService { : buildBulkWagonPlan(bookings, wagonType); } - const scheduleDirection = await this.resolveScheduleDirection(targetScheduleId, bookings); - const fleetCounts = await this.countFleetAvailability(scheduleDirection, targetScheduleId); + const originYardId = dto.originStationId; + const fleetCounts = await this.countFleetAvailability(originYardId, targetScheduleId); const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available])); fleetAvailability = computeFleetAvailability( demandPlan, @@ -1113,7 +1151,7 @@ export class TrainSchedulingService { violations.push( ...(await this.validatePhysicalFleetForPlan( wagonPlan, - scheduleDirection, + originYardId, targetScheduleId, )), ); @@ -1170,26 +1208,43 @@ export class TrainSchedulingService { } } - const availableLocomotives = ( - await this.locomotivesRepository.findAll({ - where: { status: 'AVAILABLE' }, - }) - ).filter((l) => wagonReadinessMatchesSchedule(l.readiness, scheduleDirection)); - if (!availableLocomotives.length) { - const readinessHint = requiredWagonReadiness(scheduleDirection); - violations.push( - readinessHint - ? `No available ${readinessHint} locomotive exists for this ${scheduleDirection} schedule` - : 'No available locomotive exists for scheduling', - ); - } else if ( - !availableLocomotives.some( - (l) => - Number(l.maxPullWeightTons) >= totalWeightTons && - Number(l.maxTrainLengthMeters) >= totalLengthMeters, - ) - ) { - violations.push('No available locomotive can support the total train weight and length'); + let assignedLocomotive: Locomotive | null = null; + if (targetScheduleId) { + const targetSchedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId); + assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null; + } + + if (assignedLocomotive) { + if (assignedLocomotive.currentYardId !== originYardId) { + violations.push( + `Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`, + ); + } else if ( + Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons || + Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters + ) { + violations.push( + 'Assigned locomotive cannot support the total train weight and length', + ); + } + } else { + const availableLocomotives = ( + await this.locomotivesRepository.findAll({ + where: { status: 'AVAILABLE' }, + }) + ).filter((l) => l.currentYardId === originYardId); + if (!availableLocomotives.length) { + violations.push('No available locomotive at the schedule origin yard'); + } else if ( + !availableLocomotives.some( + (l) => + Number(l.maxPullWeightTons) >= totalWeightTons && + Number(l.maxTrainLengthMeters) >= totalLengthMeters, + ) + ) { + violations.push('No available locomotive can support the total train weight and length'); + } } return { @@ -1334,26 +1389,8 @@ export class TrainSchedulingService { ]; } - private async resolveScheduleDirection( - targetScheduleId: string | undefined, - bookings: Booking[], - ): Promise { - if (targetScheduleId) { - const schedule = await this.trainSchedulesRepository.findById(targetScheduleId); - if (schedule?.direction) return schedule.direction; - } - - const booking = bookings[0]; - if (!booking) return null; - - return deriveScheduleDirection( - booking.originYard ?? { country: null }, - booking.destinationYard ?? { country: null }, - ); - } - private async countFleetAvailability( - scheduleDirection: string | null, + originYardId: string, targetScheduleId?: string, ): Promise> { const [wagons, wagonTypes] = await Promise.all([ @@ -1368,7 +1405,7 @@ export class TrainSchedulingService { ? wagon.currentTrainScheduleId === targetScheduleId : false; if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue; - if (!wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection)) continue; + if (wagon.currentYardId !== originYardId) continue; const typeId = wagon.wagonTypeId; const code = typeCodeById.get(typeId) ?? typeId; @@ -1399,7 +1436,7 @@ export class TrainSchedulingService { private async autoPinWagonsForSchedule( manager: EntityManager, scheduleId: string, - scheduleDirection: string | null, + originYardId: string, slots: TrainSetWagon[], ) { const wagons = await manager.getRepository(Wagon).find(); @@ -1419,7 +1456,7 @@ export class TrainSchedulingService { planSlots, wagons, scheduleId, - scheduleDirection, + originYardId, ); if (unpinnable.length) { throw new BadRequestException({ @@ -1434,7 +1471,7 @@ export class TrainSchedulingService { slot, wagons, scheduleId, - scheduleDirection, + originYardId, assignedPhysicalIds, ); if (!physical) continue; @@ -1455,7 +1492,7 @@ export class TrainSchedulingService { /** Pre-assign check: every planned slot must have a matching physical wagon. */ private async validatePhysicalFleetForPlan( wagonPlan: WagonPlanSlot[], - scheduleDirection: string | null, + originYardId: string, targetScheduleId?: string, ): Promise { if (!wagonPlan.length) return []; @@ -1469,7 +1506,7 @@ export class TrainSchedulingService { })), wagons, targetScheduleId, - scheduleDirection, + originYardId, ); } @@ -1477,24 +1514,22 @@ export class TrainSchedulingService { slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>, wagons: Wagon[], scheduleId: string | undefined, - scheduleDirection: string | null, + originYardId: string, ): string[] { const violations: string[] = []; const assignedPhysicalIds = new Set(); - const required = requiredWagonReadiness(scheduleDirection); - const readinessLabel = required ?? 'any readiness'; for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) { const physical = this.pickPhysicalWagonForSlot( slot, wagons, scheduleId, - scheduleDirection, + originYardId, assignedPhysicalIds, ); if (!physical) { violations.push( - `No ${readinessLabel} ${slot.wagonTypeCode} wagon available for slot #${slot.sequenceNo}`, + `No ${slot.wagonTypeCode} wagon available at yard for slot #${slot.sequenceNo}`, ); continue; } @@ -1508,7 +1543,7 @@ export class TrainSchedulingService { slot: { wagonTypeId: string }, wagons: Wagon[], scheduleId: string | undefined, - scheduleDirection: string | null, + originYardId: string, assignedPhysicalIds: Set, ): Wagon | undefined { return wagons.find((wagon) => { @@ -1518,7 +1553,7 @@ export class TrainSchedulingService { ? wagon.currentTrainScheduleId === scheduleId : false; if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false; - return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection); + return wagon.currentYardId === originYardId; }); } @@ -1834,7 +1869,7 @@ export class TrainSchedulingService { id: schedule.trainSet.locomotive.id, code: schedule.trainSet.locomotive.code, name: schedule.trainSet.locomotive.name ?? null, - readiness: schedule.trainSet.locomotive.readiness ?? null, + currentYardId: schedule.trainSet.locomotive.currentYardId ?? null, } : null, wagonCount: schedule.trainSet?.wagonCount ?? 0, @@ -1852,47 +1887,80 @@ export class TrainSchedulingService { }; } - /** AVAILABLE locomotives whose readiness matches the corridor implied by the route. */ + /** AVAILABLE locomotives at the route's origin yard. */ async getAvailableLocomotivesForRoute(routeId: string): Promise { const route = await this.getActiveRoute(routeId); - const direction = deriveScheduleDirection( - route.originYard ?? { country: null }, - route.destinationYard ?? { country: null }, - ); - const requiredReadiness = requiredWagonReadiness(direction); const locomotives = await this.locomotivesRepository.findAll({ - where: { status: 'AVAILABLE' }, + where: { status: 'AVAILABLE', currentYardId: route.originYardId }, order: { code: 'ASC' }, }); - if (!requiredReadiness) { - return locomotives; - } - - return locomotives.filter((l) => wagonReadinessMatchesSchedule(l.readiness, direction)); + return locomotives; } - /** OPEN, same-route schedules a new booking may target (with rough remaining capacity). */ + /** OPEN schedules a new booking may target (with rough remaining capacity). + * Supports sub-route matching: if originYardId and/or destinationYardId are provided, + * returns schedules whose route passes through both yards in the correct order. + */ async getBookableSchedules(originYardId?: string, destinationYardId?: string) { const schedules = await this.trainSchedulesRepository.findAll({ where: { bookingWindowStatus: 'OPEN', - ...(originYardId ? { originStationId: originYardId } : {}), - ...(destinationYardId ? { destinationStationId: destinationYardId } : {}), }, relations: { trainSet: { locomotive: true }, - route: true, + route: { milestones: true }, originStation: true, destinationStation: true, scheduleBookings: { booking: true }, }, order: { scheduledDepartureDate: 'ASC' }, }); - return schedules + + const filteredSchedules = schedules .filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status)) + .filter((s) => { + // Build the full stop list: origin -> milestones (ordered) -> destination + const milestones = s.route?.milestones ?? []; + const sortedMilestones = [...milestones].sort((a, b) => a.sequenceNo - b.sequenceNo); + const stopYardIds = [s.originStationId, ...sortedMilestones.map((m) => m.yardId), s.destinationStationId]; + + // Remove duplicates while preserving order (in case origin/destination appears in milestones) + const uniqueStopYardIds: string[] = []; + for (const yardId of stopYardIds) { + if (!uniqueStopYardIds.includes(yardId)) { + uniqueStopYardIds.push(yardId); + } + } + + // Check origin yard filter + if (originYardId) { + if (!uniqueStopYardIds.includes(originYardId)) { + return false; + } + } + + // Check destination yard filter + if (destinationYardId) { + if (!uniqueStopYardIds.includes(destinationYardId)) { + return false; + } + // Ensure destination comes after origin (if both are specified) + if (originYardId) { + const originIndex = uniqueStopYardIds.indexOf(originYardId); + const destIndex = uniqueStopYardIds.indexOf(destinationYardId); + if (destIndex <= originIndex) { + return false; + } + } + } + + return true; + }) .map((s) => this.mapScheduleListItem(s)); + + return filteredSchedules; } private async mapScheduleDetail( @@ -1952,7 +2020,7 @@ export class TrainSchedulingService { code: schedule.trainSet.locomotive.code, name: schedule.trainSet.locomotive.name, status: schedule.trainSet.locomotive.status, - readiness: schedule.trainSet.locomotive.readiness ?? null, + currentYardId: schedule.trainSet.locomotive.currentYardId ?? null, maxPullWeightTons: roundTons( Number(schedule.trainSet.locomotive.maxPullWeightTons), ), @@ -2031,6 +2099,102 @@ export class TrainSchedulingService { return SchedulingStatus.Eligible; } + /** Assign one linked-but-unallocated booking onto wagons, preserving existing wagon assignments. */ + async assignUnassignedBookingToWagons(scheduleId: string, bookingId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!schedule.trainSet?.locomotive) { + throw new BadRequestException('Schedule has no locomotive — cannot assign booking'); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + `Cannot assign bookings to schedule in status ${schedule.status}`, + ); + } + + const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + if (booking.trainScheduleId !== scheduleId) { + throw new BadRequestException('Booking is not linked to this schedule'); + } + if (!this.isReadyToLoadBooking(booking)) { + throw new BadRequestException('Booking is not paid and ready to load'); + } + + const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); + if (wagonAssignedIds.has(bookingId)) { + throw new BadRequestException('Booking is already assigned to a wagon'); + } + + const allBookingIds = [...wagonAssignedIds, bookingId]; + const previewDto = { + bookingIds: allBookingIds, + scheduleDate: schedule.scheduledDepartureDate.toISOString(), + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }; + const limits = await this.resolveTrainLimitConfig(undefined, schedule.trainSet.locomotive); + + const validation = await this.validateBookingsForScheduling( + previewDto, + null, + false, + [], + false, + limits, + scheduleId, + ); + + if (!validation.valid) { + throw new BadRequestException({ + message: 'Booking validation failed', + violations: validation.violations, + warnings: validation.warnings, + }); + } + + if (!validation.bookings.some((b) => b.id === bookingId)) { + const deferred = validation.deferredBookings.find((d) => d.id === bookingId); + throw new BadRequestException({ + message: deferred?.reason ?? 'Booking does not fit on available fleet wagons', + violations: validation.violations, + warnings: validation.warnings, + deferredBookings: validation.deferredBookings, + }); + } + + const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); + const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings); + const slots = getContainerSlotSequenceNos(validation.wagonPlan); + const placements = autoFillPlacements(units, slots); + const missingForBooking = findMissingContainerNumberIssues(units, placements).find( + (m) => m.bookingId === bookingId, + ); + if (missingForBooking) { + throw new BadRequestException({ + message: missingForBooking.issue, + violations: [missingForBooking.issue], + }); + } + + const assignableSet = new Set(validation.bookings.map((b) => b.id)); + const assignPlacements = placementsForBookings(placements, assignableSet, units); + const needsPlacements = containerBookings.length > 0; + + return this.assignBookingsToSchedule( + scheduleId, + { + bookingIds: validation.bookings.map((b) => b.id), + containerPlacements: needsPlacements ? assignPlacements : undefined, + }, + undefined, + ); + } + /** Preview wagon allocation issues per linked booking without mutating the schedule. */ async previewAllocationForSchedule( scheduleId: string, @@ -2236,6 +2400,303 @@ export class TrainSchedulingService { return result; } + async removeTrainSetWagonSlot(scheduleId: string, trainSetWagonId: string): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException('Cannot remove wagon slots from a finalized or dispatched schedule'); + } + + const wagon = (schedule.trainSet?.wagons ?? []).find((w) => w.id === trainSetWagonId); + if (!wagon) { + throw new NotFoundException(`Train set wagon ${trainSetWagonId} not found in this schedule`); + } + + if ((wagon.allocations ?? []).length > 0) { + throw new BadRequestException( + 'Cannot remove a wagon slot that has active allocations; remove the booking first', + ); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(TrainSetWagon).delete(trainSetWagonId); + await manager.getRepository(TrainSet).update(schedule.trainSetId, { + wagonCount: Math.max(0, (schedule.trainSet?.wagonCount ?? 0) - 1), + totalLengthMeters: Math.max(0, (schedule.trainSet?.totalLengthMeters ?? 0) - (wagon.lengthMeters ?? 0)), + }); + }); + + return this.getTrainScheduleById(scheduleId); + } + + async updateContainerItem( + scheduleId: string, + itemId: string, + dto: UpdateContainerItemDto, + ): Promise<{ id: string; containerNumber: string | null }> { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status === 'DISPATCHED') { + throw new BadRequestException('Cannot edit a dispatched schedule'); + } + + const item = await this.dataSource.getRepository(WagonAllocationContainerItem).findOne({ + where: { id: itemId }, + relations: ['wagonBookingAllocation', 'wagonBookingAllocation.trainSetWagon'], + }); + + if (!item) { + throw new NotFoundException(`Container item ${itemId} not found`); + } + + const wagonId = item.wagonBookingAllocationId; + const wagonAllocation = await this.dataSource.getRepository(WagonBookingAllocation).findOne({ + where: { id: wagonId }, + relations: ['trainSetWagon'], + }); + + if (!wagonAllocation?.trainSetWagon) { + throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`); + } + + const trainSetWagonId = wagonAllocation.trainSetWagon.id; + const wagonIds = (schedule.trainSet?.wagons ?? []).map((w) => w.id); + if (!wagonIds.includes(trainSetWagonId)) { + throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`); + } + + await this.dataSource.getRepository(WagonAllocationContainerItem).update(itemId, { + containerNumber: dto.containerNumber ?? null, + }); + + return { id: itemId, containerNumber: dto.containerNumber ?? null }; + } + + async getUnassignedBookings(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const allBookings = await this.bookingsRepository.findAll({ + where: { trainScheduleId: scheduleId }, + select: [ + 'id', + 'reference', + 'freightType', + 'priorityScore', + 'cargoTotalWeightVgm', + 'status', + 'schedulingStatus', + 'paymentStatus', + 'isGovernment', + ], + }); + + const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); + + const unassigned = allBookings + .filter((b) => !wagonAssignedIds.has(b.id) && this.isReadyToLoadBooking(b)) + .sort((a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0)); + + const fleetCounts = await this.countFleetAvailability( + schedule.originStationId, + scheduleId, + ); + const fleetByTypeId = new Map( + fleetCounts.map((row) => [ + row.wagonTypeId, + { code: row.wagonTypeCode, available: row.available }, + ]), + ); + const fleetAtOrigin: FleetAvailabilityRow[] = fleetCounts.map((row) => ({ + wagonTypeId: row.wagonTypeId, + wagonTypeCode: row.wagonTypeCode, + needed: 0, + available: row.available, + shortfall: 0, + })); + + const bookings = await Promise.all( + unassigned.map(async (b) => { + const assignability = await this.previewUnassignedBookingAssignability( + schedule, + wagonAssignedIds, + b as Booking, + fleetByTypeId, + ); + return { + id: b.id, + reference: b.reference ?? null, + freightType: b.freightType ?? null, + priorityScore: b.priorityScore ?? 0, + cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0), + status: b.status ?? null, + schedulingStatus: b.schedulingStatus ?? null, + ...assignability, + }; + }), + ); + + return { fleetAtOrigin, bookings }; + } + + private async previewUnassignedBookingAssignability( + schedule: TrainSchedule, + wagonAssignedIds: Set, + booking: Booking, + fleetByTypeId: Map, + ): Promise<{ + wagonsRequired: number; + requiredWagonTypeCode: string; + yardWagonsAvailable: number; + canAssign: boolean; + blockReason: string | null; + }> { + if (!schedule.trainSet?.locomotive) { + return { + wagonsRequired: 0, + requiredWagonTypeCode: '', + yardWagonsAvailable: 0, + canAssign: false, + blockReason: 'Schedule has no locomotive', + }; + } + + const freightType = booking.freightType === 'BULK' ? 'BULK' : 'CONTAINER'; + let wagonType: WagonType; + try { + wagonType = await this.resolveWagonType(freightType, [booking.id]); + } catch { + return { + wagonsRequired: 0, + requiredWagonTypeCode: '', + yardWagonsAvailable: 0, + canAssign: false, + blockReason: 'No suitable wagon type found', + }; + } + + const bulkCapacity = + freightType === 'BULK' ? Number(wagonType.capacityTons) : undefined; + const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]); + const resolvedBooking = fullBooking ?? booking; + const wagonsRequired = wagonsRequiredForBooking(resolvedBooking, bulkCapacity); + const yardWagonsAvailable = fleetByTypeId.get(wagonType.id)?.available ?? 0; + + const allBookingIds = [...wagonAssignedIds, booking.id]; + const previewDto = { + bookingIds: allBookingIds, + scheduleDate: schedule.scheduledDepartureDate.toISOString(), + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }; + const limits = await this.resolveTrainLimitConfig( + undefined, + schedule.trainSet.locomotive, + ); + + let validation: Awaited>; + try { + validation = await this.validateBookingsForScheduling( + previewDto, + null, + false, + [], + false, + limits, + schedule.id, + ); + } catch (err) { + return { + wagonsRequired, + requiredWagonTypeCode: wagonType.code, + yardWagonsAvailable, + canAssign: false, + blockReason: err instanceof Error ? err.message : 'Validation failed', + }; + } + + if (!validation.valid) { + return { + wagonsRequired, + requiredWagonTypeCode: wagonType.code, + yardWagonsAvailable, + canAssign: false, + blockReason: validation.violations[0] ?? 'Booking validation failed', + }; + } + + const fittingIds = new Set(validation.bookings.map((b) => b.id)); + if (!fittingIds.has(booking.id)) { + const deferred = validation.deferredBookings.find((d) => d.id === booking.id); + const yardShortfall = + yardWagonsAvailable < wagonsRequired + ? `No ${wagonType.code} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)` + : null; + return { + wagonsRequired, + requiredWagonTypeCode: wagonType.code, + yardWagonsAvailable, + canAssign: false, + blockReason: + deferred?.reason ?? + yardShortfall ?? + `Need ${wagonsRequired} ${wagonType.code} wagon(s) at origin yard`, + }; + } + + const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); + if (containerBookings.some((b) => b.id === booking.id)) { + const units = expandBookingContainerUnits(containerBookings); + const slots = getContainerSlotSequenceNos(validation.wagonPlan); + const placements = autoFillPlacements(units, slots); + const missing = findMissingContainerNumberIssues(units, placements).find( + (m) => m.bookingId === booking.id, + ); + if (missing) { + return { + wagonsRequired, + requiredWagonTypeCode: wagonType.code, + yardWagonsAvailable, + canAssign: false, + blockReason: missing.issue, + }; + } + } + + return { + wagonsRequired, + requiredWagonTypeCode: wagonType.code, + yardWagonsAvailable, + canAssign: true, + blockReason: null, + }; + } + + /** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */ + private isReadyToLoadBooking(booking: { + status: string; + paymentStatus?: string | null; + isGovernment?: boolean; + }): boolean { + if (booking.status === 'EXPIRED') return false; + if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { + return false; + } + if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true; + if (booking.isGovernment) return true; + return false; + } + + async getCompositionRemovals(scheduleId: string): Promise { + return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId); + } + private async getWagonAssignedBookingIds(scheduleId: string): Promise> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts index bda854d58..e4ee03a2c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts @@ -1,5 +1,6 @@ import { WagonReadiness, type ScheduleTradeDirection } from '@edr/types'; +/** @deprecated Replaced by yard-based fleet filtering via `currentYardId`. */ export function requiredWagonReadiness( direction: ScheduleTradeDirection | string | null | undefined, ): WagonReadiness | null { @@ -8,6 +9,7 @@ export function requiredWagonReadiness( return null; } +/** @deprecated Replaced by `wagon.currentYardId === originYardId` checks. */ export function wagonReadinessMatchesSchedule( wagonReadiness: WagonReadiness | string, direction: ScheduleTradeDirection | string | null | undefined, @@ -18,9 +20,7 @@ export function wagonReadinessMatchesSchedule( } /** - * Toggle a readiness value (IMPORT_READY ↔ EXPORT_READY). Used when a train - * reaches its destination: the asset has repositioned, so it is now ready for - * the opposite direction. Direction-agnostic so it handles round trips. + * @deprecated Replaced by setting `currentYardId = schedule.destinationStationId` on arrival. */ export function flipReadiness( readiness: WagonReadiness | string, diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts index 06072ae02..03a930b11 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -1,4 +1,4 @@ -import { WagonReadiness, WagonStatus } from '@edr/types'; +import { WagonStatus } from '@edr/types'; import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator'; export class CreateWagonDto { @@ -30,8 +30,8 @@ export class CreateWagonDto { status?: WagonStatus; @IsOptional() - @IsEnum(WagonReadiness) - readiness?: WagonReadiness; + @IsUUID() + currentYardId?: string; @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index 7670d7d34..23b517785 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -1,4 +1,4 @@ -import { WagonReadiness, WagonStatus } from '@edr/types'; +import { WagonStatus } from '@edr/types'; import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; @@ -14,10 +14,10 @@ export class ListWagonsQueryDto { @IsEnum(WagonStatus) status?: WagonStatus; - @ApiPropertyOptional({ enum: WagonReadiness }) + @ApiPropertyOptional({ description: 'Filter by current yard' }) @IsOptional() - @IsEnum(WagonReadiness) - readiness?: WagonReadiness; + @IsUUID() + currentYardId?: string; @ApiPropertyOptional() @IsOptional() diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index e2c33c66f..42db52231 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -1,11 +1,12 @@ // apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts -import { WagonReadiness, WagonStatus } from '@edr/types'; +import { WagonStatus } from '@edr/types'; import { Entity, Column, ManyToOne, OneToMany, JoinColumn, Index } from 'typeorm'; import { BaseEntity } from '@edr/api-common'; import { Train } from '../../trains/entities/train.entity'; import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; import { Container } from '../../container-management/entities/container.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; export const WAGON_STATUSES = [ WagonStatus.Available, @@ -14,16 +15,10 @@ export const WAGON_STATUSES = [ WagonStatus.Retired, ] as const; -export const WAGON_READINESS_VALUES = [ - WagonReadiness.ImportReady, - WagonReadiness.ExportReady, -] as const; - export type WagonStatusType = (typeof WAGON_STATUSES)[number]; -export type WagonReadinessType = (typeof WAGON_READINESS_VALUES)[number]; @Entity({ name: 'wagons', schema: 'freight' }) -@Index(['readiness']) +@Index(['currentYardId']) export class Wagon extends BaseEntity { @Column({ unique: true, name: 'wagon_number' }) wagonNumber!: string; @@ -46,8 +41,12 @@ export class Wagon extends BaseEntity { @Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) status!: WagonStatusType; - @Column({ type: 'varchar', length: 20, default: WagonReadiness.ImportReady }) - readiness!: WagonReadinessType; + @Column({ name: 'current_yard_id', type: 'uuid', nullable: true }) + currentYardId!: string | null; + + @ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'current_yard_id' }) + currentYard?: Yard | null; @Column({ type: 'text', nullable: true }) notes!: string | null; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 7ab6a67e6..b2b1df275 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -1,4 +1,4 @@ -import { WagonReadiness, WagonStatus } from '@edr/types'; +import { WagonStatus } from '@edr/types'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm'; @@ -24,11 +24,11 @@ export class WagonsService { const wagon = this.wagonRepo.create({ ...dto, status: dto.status ?? WagonStatus.Available, - readiness: dto.readiness ?? WagonReadiness.ImportReady, }); // Convert undefined to null for nullable fields if (dto.trainId === undefined) wagon.trainId = null; if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; + if (dto.currentYardId === undefined) wagon.currentYardId = null; return this.wagonRepo.save(wagon); } @@ -39,7 +39,7 @@ export class WagonsService { const wagonTypeId = query.wagonTypeId?.trim(); const filters: FindOptionsWhere = { ...(query.status ? { status: query.status } : {}), - ...(query.readiness ? { readiness: query.readiness } : {}), + ...(query.currentYardId ? { currentYardId: query.currentYardId } : {}), ...(trainId ? { trainId } : {}), ...(wagonTypeId ? { wagonTypeId } : {}), }; @@ -51,13 +51,14 @@ export class WagonsService { }); } - const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'readiness', 'sequenceNumber'].includes(query.sortBy ?? '') + const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'currentYardId', 'sequenceNumber'].includes(query.sortBy ?? '') ? (query.sortBy as keyof Wagon) : 'wagonNumber'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; return this.wagonRepo.find({ where: search ? where : filters, + relations: { currentYard: true }, order: { [sortBy]: sortOrder } as FindOptionsOrder, skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, take: query.limit ? Number(query.limit) : undefined, @@ -65,7 +66,10 @@ export class WagonsService { } async findById(id: string): Promise { - const wagon = await this.wagonRepo.findOne({ where: { id } }); + const wagon = await this.wagonRepo.findOne({ + where: { id }, + relations: { currentYard: true }, + }); if (!wagon) throw new NotFoundException(`Wagon ${id} not found`); return wagon; } diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 23c5862ac..5cb640b39 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -19,7 +19,7 @@ import { Container } from "../modules/container-management/entities/container.en import { Route } from "../modules/routes/entities/route.entity"; import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity"; import { Wagon } from "../modules/wagons/entities/wagon.entity"; -import { WagonReadiness, WagonStatus } from "@edr/types"; +import { WagonStatus } from "@edr/types"; const SEED_FLAG = "SEED_DEMO_BOOKINGS"; @@ -499,7 +499,7 @@ export class DemoBookingsSeeder { } const nw5 = await manager.getRepository(WagonType).findOneBy({ code: "NW5" }); - if (nw5) { + if (nw5 && djibouti && addis) { await manager.getRepository(Wagon).upsert( Array.from({ length: 20 }, (_, index) => ({ wagonNumber: `WGN-DEMO-${String(index + 1).padStart(3, "0")}`, @@ -509,10 +509,7 @@ export class DemoBookingsSeeder { tareWeight: 20, maxPayloadWeight: 70, status: WagonStatus.Available, - readiness: - index % 2 === 0 - ? WagonReadiness.ImportReady - : WagonReadiness.ExportReady, + currentYardId: index % 2 === 0 ? djibouti.id : addis.id, notes: "Demo wagon for train scheduling", trainSetWagonId: null, currentTrainScheduleId: null, @@ -521,6 +518,19 @@ export class DemoBookingsSeeder { ); } + if (djibouti) { + await manager.getRepository(Locomotive).update( + { code: "LOC-001" }, + { currentYardId: djibouti.id }, + ); + } + if (addis) { + await manager.getRepository(Locomotive).update( + { code: "LOC-002" }, + { currentYardId: addis.id }, + ); + } + const ft20 = containerTypeByCode.get("20FT"); const ft40 = containerTypeByCode.get("40FT"); if (ft20 && ft40) { diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 5357b7916..830225f80 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -441,20 +441,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { rateValue: 1200, rateUnit: "PER_CONTAINER", }, - { - rateType: "CONTAINER_IMPORT", - containerTypeId: ctByCode.get("20FT")!.id, - currency: "ETB", - rateValue: 45000, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "CONTAINER_IMPORT", - containerTypeId: ctByCode.get("40FT")!.id, - currency: "ETB", - rateValue: 67000, - rateUnit: "PER_CONTAINER", - }, { rateType: "CONTAINER_EXPORT", containerTypeId: ctByCode.get("20FT")!.id, @@ -469,34 +455,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { rateValue: 900, rateUnit: "PER_CONTAINER", }, - { - rateType: "CONTAINER_EXPORT", - containerTypeId: ctByCode.get("20FT")!.id, - currency: "ETB", - rateValue: 34000, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "CONTAINER_EXPORT", - containerTypeId: ctByCode.get("40FT")!.id, - currency: "ETB", - rateValue: 50000, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "INTERCITY_CONTAINER", - containerTypeId: ctByCode.get("20FT")!.id, - currency: "ETB", - rateValue: 20000, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "INTERCITY_CONTAINER", - containerTypeId: ctByCode.get("40FT")!.id, - currency: "ETB", - rateValue: 30000, - rateUnit: "PER_CONTAINER", - }, { rateType: "CONTAINER_IMPORT", containerTypeId: null, @@ -504,13 +462,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { rateValue: 1000, rateUnit: "PER_CONTAINER", }, - { - rateType: "CONTAINER_IMPORT", - containerTypeId: null, - currency: "ETB", - rateValue: 56000, - rateUnit: "PER_CONTAINER", - }, { rateType: "CONTAINER_EXPORT", containerTypeId: null, @@ -519,17 +470,24 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { rateUnit: "PER_CONTAINER", }, { - rateType: "CONTAINER_EXPORT", - containerTypeId: null, - currency: "ETB", - rateValue: 42000, + rateType: "INTERCITY_CONTAINER", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "USD", + rateValue: 350, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "USD", + rateValue: 550, rateUnit: "PER_CONTAINER", }, { rateType: "INTERCITY_CONTAINER", containerTypeId: null, - currency: "ETB", - rateValue: 25000, + currency: "USD", + rateValue: 400, rateUnit: "PER_CONTAINER", }, { @@ -539,13 +497,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { rateValue: 35, rateUnit: "PER_TON", }, - { - rateType: "INTERCITY_BULK", - containerTypeId: null, - currency: "ETB", - rateValue: 1900, - rateUnit: "PER_TON", - }, { rateType: "BULK_IMPORT", containerTypeId: null, @@ -553,13 +504,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { rateValue: 50, rateUnit: "PER_TON", }, - { - rateType: "BULK_IMPORT", - containerTypeId: null, - currency: "ETB", - rateValue: 2800, - rateUnit: "PER_TON", - }, { rateType: "BULK_EXPORT", containerTypeId: null, @@ -567,13 +511,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { rateValue: 40, rateUnit: "PER_TON", }, - { - rateType: "BULK_EXPORT", - containerTypeId: null, - currency: "ETB", - rateValue: 2200, - rateUnit: "PER_TON", - }, { rateType: "OVERWEIGHT_PER_TON", containerTypeId: null, @@ -581,13 +518,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { rateValue: 25, rateUnit: "PER_TON", }, - { - rateType: "OVERWEIGHT_PER_TON", - containerTypeId: null, - currency: "ETB", - rateValue: 1400, - rateUnit: "PER_TON", - }, { rateType: "HAZARD_SURCHARGE", containerTypeId: null, @@ -595,13 +525,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { rateValue: 150, rateUnit: "FLAT", }, - { - rateType: "HAZARD_SURCHARGE", - containerTypeId: null, - currency: "ETB", - rateValue: 8500, - rateUnit: "FLAT", - }, { rateType: "REEFER_SURCHARGE", containerTypeId: null, @@ -609,13 +532,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { rateValue: 200, rateUnit: "FLAT", }, - { - rateType: "REEFER_SURCHARGE", - containerTypeId: null, - currency: "ETB", - rateValue: 11000, - rateUnit: "FLAT", - }, { rateType: "DOUBLE_HANDLING", containerTypeId: null, @@ -623,13 +539,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { rateValue: 100, rateUnit: "PER_CONTAINER", }, - { - rateType: "DOUBLE_HANDLING", - containerTypeId: null, - currency: "ETB", - rateValue: 5500, - rateUnit: "PER_CONTAINER", - }, { rateType: "LASHING", containerTypeId: null, @@ -637,13 +546,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { rateValue: 50, rateUnit: "PER_CONTAINER", }, - { - rateType: "LASHING", - containerTypeId: null, - currency: "ETB", - rateValue: 2800, - rateUnit: "PER_CONTAINER", - }, ]; const entities = rateData.map((d) => @@ -673,15 +575,10 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { }; const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD"); - const hazardRateEtb = findRate("HAZARD_SURCHARGE", "ETB"); const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD"); - const reeferRateEtb = findRate("REEFER_SURCHARGE", "ETB"); const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD"); - const overweightRateEtb = findRate("OVERWEIGHT_PER_TON", "ETB"); const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD"); - const shipLineRateEtb = findRate("DOUBLE_HANDLING", "ETB"); const consolidRateUsd = findRate("LASHING", "USD"); - const consolidRateEtb = findRate("LASHING", "ETB"); await surRepo.createQueryBuilder().delete().execute(); await surRepo.save([ @@ -689,35 +586,35 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { code: "HAZARDOUS_CARGO", label: "Hazardous Cargo", triggerCondition: "CARGO_FLAG_HAZARDOUS", - rateId: hazardRateUsd?.id ?? hazardRateEtb?.id, + rateId: hazardRateUsd?.id, isActive: true, }), surRepo.create({ code: "REEFER_CARGO", label: "Reefer Cargo", triggerCondition: "CARGO_FLAG_REEFER", - rateId: reeferRateUsd?.id ?? reeferRateEtb?.id, + rateId: reeferRateUsd?.id, isActive: true, }), surRepo.create({ code: "OVERWEIGHT_CARGO", label: "Overweight Cargo", triggerCondition: "VGM_EXCEEDS_LIMIT", - rateId: overweightRateUsd?.id ?? overweightRateEtb?.id, + rateId: overweightRateUsd?.id, isActive: true, }), surRepo.create({ code: "SHIPPING_LINE_FEE", label: "Shipping Line Fee", triggerCondition: "SHIPPING_LINE_MAPPED", - rateId: shipLineRateUsd?.id ?? shipLineRateEtb?.id, + rateId: shipLineRateUsd?.id, isActive: true, }), surRepo.create({ code: "CONSOLIDATION_FEE", label: "Consolidation Fee", triggerCondition: "CONSOLIDATION_ENABLED", - rateId: consolidRateUsd?.id ?? consolidRateEtb?.id, + rateId: consolidRateUsd?.id, isActive: true, }), ]); @@ -784,10 +681,10 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { }, { reference: "BKG-PRICE-003", - description: "20FT container import + shipping line (ETB)", + description: "20FT container import + shipping line (USD)", freightType: "CONTAINER" as const, tradeDirection: "IMPORT", - paymentCurrency: "ETB", + paymentCurrency: "USD", serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, @@ -799,7 +696,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { containers: [ { containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 }, ], - expectedBaseRate: 45000, + expectedBaseRate: 800, expectedSurcharges: ["SHIPPING_LINE_FEE"], }, { diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx index 85c79bdaa..4eedb52b4 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetCardGrid.tsx @@ -111,7 +111,10 @@ const FleetCardGrid = ({ {subtitle != null && subtitle !== "" ? ( - {String(subtitle)} + {presentation.subtitleKey === "currentYard" || + presentation.subtitleKey === "currentYardId" + ? formatFleetCell(subtitle, "entityLabel", presentation.subtitleKey) + : String(subtitle)} ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/useFleetViewMode.ts b/apps/edr-freight-web/backoffice/src/components/fleet/useFleetViewMode.ts index 244e03841..9dd571271 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/useFleetViewMode.ts +++ b/apps/edr-freight-web/backoffice/src/components/fleet/useFleetViewMode.ts @@ -6,7 +6,7 @@ export type FleetViewMode = "table" | "cards"; const STORAGE_PREFIX = "edr-freight-fleet-view:"; -type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2"; +type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2" | "batch-board"; const readStored = (slug: ViewModeSlug): FleetViewMode => { try { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx index 3892fbf81..5bc884966 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx @@ -522,9 +522,7 @@ export function AllocateBookingWizard({ placeholder={routeId ? "Select locomotive" : "Select a route first"} data={(locomotivesQuery.data ?? []).map((l) => ({ value: l.id, - label: `${l.code} · ${ - l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready" - }`, + label: `${l.code}${l.name ? ` · ${l.name}` : ""}`, }))} value={locomotiveId || null} onChange={(v) => setLocomotiveId(v ?? "")} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx index e9b387407..80f72d9d0 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx @@ -18,7 +18,7 @@ import { Freight } from "@edr/types"; import type { PinWagonAssignment, TrainScheduleDetail } from "@/types/trainScheduling"; import type { Wagon } from "@/services/wagon.service"; -import { wagonMatchesScheduleDirection } from "@/utils/wagonAvailability"; +import { wagonMatchesScheduleOrigin } from "@/utils/wagonAvailability"; import { autoFillWagonAssignments, countFilledSlots } from "./pinWagons.util"; @@ -35,6 +35,7 @@ export function PinWagonsForm({ onSubmit: (assignments: PinWagonAssignment[]) => void; autoFillOnMount?: boolean; }) { + const originYardId = schedule.originStation?.id; const slots = schedule.trainSet?.wagons ?? []; const [assignments, setAssignments] = useState>({}); @@ -43,7 +44,7 @@ export function PinWagonsForm({ for (const wagon of availableWagons) { const isPinnedOnSlot = slots.some((s) => s.physicalWagonId === wagon.id); if ( - !wagonMatchesScheduleDirection(wagon, schedule.direction, { + !wagonMatchesScheduleOrigin(wagon, originYardId, { allowPinned: isPinnedOnSlot, }) ) { @@ -58,7 +59,7 @@ export function PinWagonsForm({ map.set(typeId, list); } return map; - }, [availableWagons, schedule.direction, slots]); + }, [availableWagons, originYardId, slots]); const runAutoFill = useCallback( (preserveManual = false) => { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx new file mode 100644 index 000000000..126701b0b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx @@ -0,0 +1,169 @@ +import { useState } from "react"; +import { ActionIcon, Badge, Box, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; +import { Building2, Package, TrainFront, Weight, X } from "lucide-react"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; +import type { BookingDetailData } from "./BookingDetailModal"; +import { RemoveBookingConfirmModal, type RemovalTarget } from "./RemoveBookingConfirmModal"; +import { useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useToast } from "@/hooks/use-toast"; +import { freightBrand } from "@/theme/freight-brand"; + +interface AssignedBookingsPanelProps { + scheduleDetail: TrainScheduleDetail; + scheduleId: string; + selectedBookingId?: string | null; + onSelect: (booking: BookingDetailData) => void; +} + +export const AssignedBookingsPanel = ({ + scheduleDetail, + scheduleId, + selectedBookingId, + onSelect, +}: AssignedBookingsPanelProps) => { + const { toast } = useToast(); + const unassign = useScheduleMutations(scheduleId).unassign; + const isDispatched = scheduleDetail.status === "DISPATCHED"; + const [removalTarget, setRemovalTarget] = useState(null); + + const wagons = scheduleDetail.trainSet?.wagons ?? []; + const wagonCountByBooking = new Map(); + for (const w of wagons) { + for (const a of w.allocations ?? []) { + wagonCountByBooking.set(a.bookingId, (wagonCountByBooking.get(a.bookingId) ?? 0) + 1); + } + } + + const assignedBookings = (scheduleDetail.bookings ?? []).filter((b) => + wagonCountByBooking.has(b.id), + ); + + const handleConfirmRemove = async () => { + if (!removalTarget) return; + try { + await unassign.mutateAsync({ id: scheduleId, bookingId: removalTarget.bookingId }); + toast({ title: "Booking removed from train" }); + setRemovalTarget(null); + } catch { + toast({ title: "Could not remove booking", variant: "destructive" }); + } + }; + + if (assignedBookings.length === 0) { + return ( + + + + + + No assigned bookings + + + Assign a paid booking from the Unassigned tab to load it onto a wagon. + + + ); + } + + return ( + <> + + {assignedBookings.map((booking) => { + const isActive = selectedBookingId === booking.id; + return ( + + onSelect({ + bookingId: booking.id, + reference: booking.reference, + company: booking.customer, + freightType: scheduleDetail.freightType ?? null, + weightTons: booking.weightTons ?? null, + status: booking.status, + }) + } + style={{ + cursor: "pointer", + borderColor: isActive ? freightBrand.primary : undefined, + boxShadow: isActive ? `0 0 0 2px ${freightBrand.ring}` : undefined, + background: isActive ? freightBrand.mutedBg : undefined, + transition: "box-shadow 120ms ease", + }} + > + + + + + + + + {booking.reference} + + + } + > + {wagonCountByBooking.get(booking.id)} + + {!isDispatched ? ( + + { + e.stopPropagation(); + setRemovalTarget({ + bookingId: booking.id, + reference: booking.reference, + company: booking.customer, + weightTons: booking.weightTons ?? null, + wagonCount: wagonCountByBooking.get(booking.id) ?? 0, + }); + }} + > + + + + ) : null} + + + {booking.customer ? ( + + + + {booking.customer} + + + ) : null} + + + + {(booking.weightTons ?? 0).toFixed(1)} T + + + + + + ); + })} + + + setRemovalTarget(null)} + onConfirm={handleConfirmRemove} + isLoading={unassign.isPending} + target={removalTarget} + /> + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BatchBookingList.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BatchBookingList.tsx new file mode 100644 index 000000000..ab2b13a20 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BatchBookingList.tsx @@ -0,0 +1,132 @@ +import { Badge, Box, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import { Building2, CreditCard, Landmark, Weight, XCircle } from "lucide-react"; +import type { BatchBoardBookingDetail } from "@/types/trainScheduling"; +import type { BookingDetailData } from "./BookingDetailModal"; + +interface BatchBookingListProps { + bookings: BatchBoardBookingDetail[]; + variant: "payment" | "expired"; + selectedBookingId?: string | null; + onSelect: (booking: BookingDetailData) => void; + emptyTitle: string; + emptyHint: string; +} + +const fmtDateTime = (iso: string | null) => + iso + ? new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Africa/Addis_Ababa", + }).format(new Date(iso)) + : null; + +export const BatchBookingList = ({ + bookings, + variant, + selectedBookingId, + onSelect, + emptyTitle, + emptyHint, +}: BatchBookingListProps) => { + const accent = variant === "payment" ? "orange" : "red"; + const Icon = variant === "payment" ? CreditCard : XCircle; + + if (bookings.length === 0) { + return ( + + + + + + {emptyTitle} + + + {emptyHint} + + + ); + } + + return ( + + {bookings.map((booking) => { + const isActive = selectedBookingId === booking.id; + const deadline = fmtDateTime(booking.paymentDeadline); + return ( + + onSelect({ + bookingId: booking.id, + reference: booking.reference, + company: booking.company, + freightType: null, + weightTons: booking.weightTons ?? null, + status: variant === "payment" ? "Awaiting payment" : "Expired", + }) + } + style={{ + cursor: "pointer", + borderColor: isActive ? `var(--mantine-color-${accent}-5)` : undefined, + }} + > + + + + + + + + {booking.reference} + + {booking.isGovernment ? ( + } + > + Gov + + ) : null} + + {booking.company ? ( + + + + {booking.company} + + + ) : null} + + + + + {(booking.weightTons ?? 0).toFixed(1)} T · {booking.wagons}w + + + {variant === "payment" && deadline ? ( + + Pay by {deadline} + + ) : variant === "expired" ? ( + + Expired + + ) : null} + + + + + ); + })} + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx new file mode 100644 index 000000000..e1fd4977d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx @@ -0,0 +1,218 @@ +import { Badge, Box, Divider, Group, Modal, Stack, Text, ThemeIcon } from "@mantine/core"; +import { + Building2, + Container as ContainerIcon, + Fuel, + MapPin, + Package, + TrainFront, + Weight, +} from "lucide-react"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; +import { freightBrand } from "@/theme/freight-brand"; + +type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; + +export interface BookingDetailData { + bookingId: string; + reference: string | null; + company: string | null; + freightType: string | null; + weightTons: number | null; + status: string | null; + priorityScore?: number | null; +} + +interface BookingDetailModalProps { + opened: boolean; + onClose: () => void; + booking: BookingDetailData | null; + /** All wagons in the consist — used to show where this booking sits. */ + wagons: Wagon[]; +} + +function InfoRow({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: React.ReactNode; +}) { + return ( + + + + {icon} + + + {label} + + + {value} + + ); +} + +export const BookingDetailModal = ({ + opened, + onClose, + booking, + wagons, +}: BookingDetailModalProps) => { + if (!booking) return null; + + const bookingWagons = wagons.filter((w) => + (w.allocations ?? []).some((a) => a.bookingId === booking.bookingId), + ); + const allocations = bookingWagons.flatMap((w) => + (w.allocations ?? []) + .filter((a) => a.bookingId === booking.bookingId) + .map((a) => ({ wagon: w, allocation: a })), + ); + const containers = allocations.flatMap(({ allocation }) => allocation.containerItems ?? []); + const isBulk = allocations.some(({ allocation }) => + (allocation.loadType ?? "").toUpperCase().includes("BULK"), + ); + + return ( + + + + +
+ {booking.reference ?? "Booking"} + + Booking details + +
+ + } + > + + + {booking.company ? ( + } + label="Company" + value={ + + {booking.company} + + } + /> + ) : null} + : } + label="Freight type" + value={ + + {booking.freightType ?? (isBulk ? "BULK" : "CONTAINER")} + + } + /> + } + label="Weight" + value={ + + {booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"} + + } + /> + } + label="Wagons" + value={ + bookingWagons.length ? ( + + {bookingWagons.map((w) => ( + + #{w.sequenceNo} + + ))} + + ) : ( + + Not assigned to a wagon + + ) + } + /> + {booking.status ? ( + } + label="Status" + value={ + + {booking.status} + + } + /> + ) : null} + + + {containers.length ? ( + <> + + + + Containers ({containers.length}) + + + } + /> + + {containers.map((c, i) => ( + + + + + {c.containerNumber?.trim() || `Container ${i + 1}`} + + + {c.grossWeightTons != null ? ( + + {Number(c.grossWeightTons).toFixed(1)} T + + ) : null} + + ))} + + + ) : null} + +
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx new file mode 100644 index 000000000..13b0f92aa --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx @@ -0,0 +1,251 @@ +import { useMemo, useState } from "react"; +import { Badge, Box, Group, Paper, ScrollArea, Tabs, Text, Tooltip } from "@mantine/core"; +import { CreditCard, History, Layers, PackageCheck, PackagePlus, XCircle } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import type { BatchBoardBookingDetail, TrainScheduleDetail } from "@/types/trainScheduling"; +import { AssignedBookingsPanel } from "./AssignedBookingsPanel"; +import { UnassignedBookingsPanel } from "./UnassignedBookingsPanel"; +import { RemovalLogPanel } from "./RemovalLogPanel"; +import { BatchBookingList } from "./BatchBookingList"; +import { BookingDetailModal, type BookingDetailData } from "./BookingDetailModal"; +import { + useCompositionRemovals, + useUnassignedBookings, +} from "@/hooks/trainScheduling/useTrainScheduling"; +import { freightBrand } from "@/theme/freight-brand"; + +interface CompositionBookingTabsProps { + scheduleDetail: TrainScheduleDetail; + scheduleId: string; + /** Bookings selected for batch with a payment notification sent (awaiting payment). */ + awaitingPayment?: BatchBoardBookingDetail[]; + /** Bookings whose payment window expired. */ + expired?: BatchBoardBookingDetail[]; + /** Booking id highlighted in the train consist (lifted to the page). */ + selectedBookingId?: string | null; + onSelectBooking?: (bookingId: string | null) => void; +} + +type TabKey = "assigned" | "unassigned" | "payment" | "expired" | "removed"; + +const TAB_META: Record = { + assigned: { label: "Assigned to train", icon: PackageCheck, color: "green" }, + unassigned: { label: "Unassigned (ready to load)", icon: PackagePlus, color: "orange" }, + payment: { label: "Awaiting payment", icon: CreditCard, color: "orange" }, + expired: { label: "Expired bookings", icon: XCircle, color: "red" }, + removed: { label: "Removed from train", icon: History, color: "gray" }, +}; + +export const CompositionBookingTabs = ({ + scheduleDetail, + scheduleId, + awaitingPayment = [], + expired = [], + selectedBookingId, + onSelectBooking, +}: CompositionBookingTabsProps) => { + const [detailBooking, setDetailBooking] = useState(null); + const [tab, setTab] = useState("assigned"); + + const unassignedQuery = useUnassignedBookings(scheduleId); + const removalsQuery = useCompositionRemovals(scheduleId); + + const { assignedCount } = useMemo(() => { + const wagons = scheduleDetail.trainSet?.wagons ?? []; + const ids = new Set(); + for (const w of wagons) { + for (const a of w.allocations ?? []) { + ids.add(a.bookingId); + } + } + return { assignedCount: ids.size }; + }, [scheduleDetail.trainSet?.wagons]); + + const counts: Record = { + assigned: assignedCount, + unassigned: unassignedQuery.data?.bookings?.length ?? 0, + payment: awaitingPayment.length, + expired: expired.length, + removed: removalsQuery.data?.length ?? 0, + }; + + const handleSelect = (booking: BookingDetailData) => { + setDetailBooking(booking); + onSelectBooking?.(booking.bookingId); + }; + + const TabButton = ({ value }: { value: TabKey }) => { + const meta = TAB_META[value]; + const Icon = meta.icon; + const active = tab === value; + const count = counts[value]; + return ( + + + + + + {count} + + + + + ); + }; + + return ( + <> + + {/* Header — reflects the active tab */} + + + + + +
+ + {TAB_META[tab].label} + + + {counts[tab]} booking{counts[tab] === 1 ? "" : "s"} + +
+
+
+ + v && setTab(v as TabKey)} + variant="default" + color="green" + style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Footer summary */} + + + + + {assignedCount} on train + + + + + + {counts.payment} to pay + + + + + + {counts.expired} expired + + + +
+ + setDetailBooking(null)} + booking={detailBooking} + wagons={scheduleDetail.trainSet?.wagons ?? []} + /> + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx new file mode 100644 index 000000000..5629ba698 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { Group, TextInput, Text } from "@mantine/core"; +import { useUpdateContainerItem } from "@/hooks/trainScheduling/useTrainScheduling"; + +interface ContainerNumberInputProps { + value: string | null; + itemId: string; + scheduleId: string; + disabled: boolean; +} + +export const ContainerNumberInput = ({ + value, + itemId, + scheduleId, + disabled, +}: ContainerNumberInputProps) => { + const [isEditing, setIsEditing] = useState(false); + const [inputValue, setInputValue] = useState(value ?? ""); + const [error, setError] = useState(null); + + const updateMutation = useUpdateContainerItem(scheduleId); + const isLoading = updateMutation.isPending; + + const handleSave = async () => { + try { + setError(null); + await updateMutation.mutateAsync({ + itemId, + containerNumber: inputValue || null, + }); + setIsEditing(false); + } catch (err) { + setError("Failed to save"); + setInputValue(value ?? ""); + } + }; + + const handleBlur = () => { + if (inputValue !== value) { + handleSave(); + } else { + setIsEditing(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSave(); + } else if (e.key === "Escape") { + setInputValue(value ?? ""); + setIsEditing(false); + } + }; + + if (disabled) { + return {value || "TBD"}; + } + + if (isEditing) { + return ( + + setInputValue(e.currentTarget.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + autoFocus + disabled={isLoading} + placeholder="Container #" + style={{ flex: 1 }} + /> + {error && {error}} + + ); + } + + return ( + setIsEditing(true)} + style={{ cursor: "pointer", textDecoration: "underline" }} + title="Click to edit" + > + {value || "TBD"} + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx new file mode 100644 index 000000000..297ecc48b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -0,0 +1,527 @@ +import { Badge, Box, Group, HoverCard, Stack, Text } from "@mantine/core"; +import { + Building2, + Container as ContainerIcon, + Fuel, + Gauge, + Package, + TrainFront, + Weight, +} from "lucide-react"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; +import { freightBrand } from "@/theme/freight-brand"; + +type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; +type Locomotive = NonNullable["locomotive"]; + +interface InteractiveTrainConsistProps { + wagons: Wagon[]; + locomotive: Locomotive | null | undefined; + /** Resolve the customer/company name for a booking id (joined from schedule bookings). */ + getCompany: (bookingId: string | undefined) => string | null; + selectedWagonId: string | null; + onSelectWagon: (wagon: Wagon) => void; + /** Booking id to highlight across the train (e.g. selected in the side panel). */ + highlightBookingId?: string | null; +} + +const CONTAINER_GRADIENTS = [ + "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))", + "linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))", +]; +const CONTAINER_BORDERS = ["var(--mantine-color-cyan-8)", "var(--mantine-color-blue-8)"]; + +function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean }) { + return ( + 2 ? 10 : 18} justify="center" wrap="nowrap" mt={2}> + {Array.from({ length: count }).map((_, i) => ( + + ))} + + ); +} + +function Coupler() { + return ( + + + + ); +} + +function LocomotiveCar({ locomotive }: { locomotive: Locomotive }) { + const code = locomotive?.code ?? "LOCO"; + return ( + + + {/* cab windows */} + + + + {/* headlight */} + + {/* hazard stripe */} + + + + + {code} + + + {locomotive?.maxPullWeightTons ? ( + + + + {locomotive.maxPullWeightTons}T pull + + + ) : null} + + + + HEAD + + + ); +} + +function WagonCar({ + wagon, + company, + selected, + highlighted, + onSelect, +}: { + wagon: Wagon; + company: string | null; + selected: boolean; + highlighted: boolean; + onSelect: () => void; +}) { + const allocation = wagon.allocations?.[0]; + const isEmpty = !allocation; + const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK"); + const assigned = allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0; + const capacity = wagon.capacityTons ?? 0; + const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0; + const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan"; + const accentVar = `var(--mantine-color-${accent}-6)`; + + const containerNumbers = (allocation?.containerItems ?? []).map( + (c) => c.containerNumber?.trim() || "—", + ); + const blocks = containerNumbers.slice(0, 2); + + const ringColor = selected + ? freightBrand.primary + : highlighted + ? "var(--mantine-color-yellow-5)" + : "transparent"; + + return ( + + + + + {/* top accent strip */} + + {/* header */} + + + #{wagon.sequenceNo} + + {isEmpty ? ( + + EMPTY + + ) : ( + + {isBulk ? ( + + ) : ( + + )} + + {isBulk ? "BULK" : "CONT"} + + + )} + + + {/* body */} + + {isEmpty ? ( + + Available + + ) : isBulk ? ( + + + + + + ) : ( + + {(blocks.length ? blocks : ["—"]).map((cn, i) => ( + + + {cn} + + + ))} + + )} + + + {/* footer */} + + + + {wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Wagon"} + + {!isEmpty ? ( + + {assigned}T + + ) : null} + + + + + + + + + + + + + + +
+ + Wagon #{wagon.sequenceNo} + + + {wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Unassigned"} + +
+
+ {!isEmpty ? ( + + {isBulk ? "Bulk" : "Container"} + + ) : null} +
+ + {isEmpty ? ( + + Empty slot — available for allocation. + + ) : ( + + {company ? ( + + + + {company} + + + ) : null} + + + + {allocation?.bookingReference ?? "Unknown booking"} + + + + {containerNumbers.length ? ( +
+ + Containers + + + {containerNumbers.map((cn, i) => ( + + {cn} + + ))} + +
+ ) : null} + + {isBulk && allocation?.bulkLoad?.cargoDescription ? ( + + {allocation.bulkLoad.cargoDescription} + + ) : null} + + + + + {assigned}T / {capacity}T ({utilization}%) + + + + = 100 + ? "var(--mantine-color-red-5)" + : `var(--mantine-color-${accent}-5)`, + }} + /> + + + Click the wagon to edit or remove + +
+ )} +
+
+
+ ); +} + +export const InteractiveTrainConsist = ({ + wagons, + locomotive, + getCompany, + selectedWagonId, + onSelectWagon, + highlightBookingId, +}: InteractiveTrainConsistProps) => { + return ( + + + {locomotive ? : null} + {wagons.length === 0 ? ( + + No wagons assigned + + ) : ( + wagons.map((wagon, i) => { + const bookingId = wagon.allocations?.[0]?.bookingId; + return ( + + {i > 0 || locomotive ? : null} + onSelectWagon(wagon)} + /> + + ); + }) + )} + + + {/* track bed under the whole consist */} + + + + + + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx new file mode 100644 index 000000000..05f2a8328 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx @@ -0,0 +1,73 @@ +import { Box, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import { History, PackageX } from "lucide-react"; +import { useCompositionRemovals } from "@/hooks/trainScheduling/useTrainScheduling"; + +interface RemovalLogPanelProps { + scheduleId: string; +} + +export const RemovalLogPanel = ({ scheduleId }: RemovalLogPanelProps) => { + const removalQuery = useCompositionRemovals(scheduleId); + + if (removalQuery.isLoading) { + return ( + + Loading... + + ); + } + + const removals = removalQuery.data ?? []; + + if (removals.length === 0) { + return ( + + + + + + No removals yet + + + Bookings removed from this train will appear here for audit. + + + ); + } + + return ( + + {removals.map((removal) => ( + + + + + + + + {removal.bookingReference || "Unknown booking"} + + + Removed{" "} + {new Date(removal.removedAt).toLocaleString("en-GB", { + timeZone: "Africa/Addis_Ababa", + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + })}{" "} + EAT + + {removal.notes ? ( + + {removal.notes} + + ) : null} + + + + ))} + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingConfirmModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingConfirmModal.tsx new file mode 100644 index 000000000..03301154b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingConfirmModal.tsx @@ -0,0 +1,148 @@ +import { Badge, Box, Button, Group, List, Modal, Stack, Text, ThemeIcon } from "@mantine/core"; +import { AlertTriangle, Bell, Building2, FileClock, PackageX, TrainFront, Undo2, Weight } from "lucide-react"; + +export interface RemovalTarget { + bookingId: string; + reference: string | null; + company: string | null; + weightTons: number | null; + wagonCount: number; +} + +interface RemoveBookingConfirmModalProps { + opened: boolean; + onClose: () => void; + onConfirm: () => void; + isLoading: boolean; + target: RemovalTarget | null; +} + +export const RemoveBookingConfirmModal = ({ + opened, + onClose, + onConfirm, + isLoading, + target, +}: RemoveBookingConfirmModalProps) => { + return ( + + + + +
+ Remove booking from train? + + This change is logged and the customer is notified + +
+ + } + > + + {/* Booking summary */} + + + + {target?.reference ?? "Booking"} + + }> + {target?.wagonCount ?? 0} wagon{target?.wagonCount === 1 ? "" : "s"} + + + + {target?.company ? ( + + + + {target.company} + + + ) : null} + + + + {(target?.weightTons ?? 0).toFixed(1)} T + + + + + + {/* What happens */} + + + + + Removing this booking will: + + + + + + + } + > + Return it to the unassigned pool + + + + + } + > + Create a removal log entry for audit + + + + + } + > + Notify the customer to reschedule or cancel + + + + + + + + + +
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx new file mode 100644 index 000000000..151a796d8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx @@ -0,0 +1,74 @@ +import { Button, Group, Modal, Stack, Text, Badge } from "@mantine/core"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; + +type WagonWithAllocation = TrainScheduleDetail["trainSet"]["wagons"][number]; + +interface RemoveBookingModalProps { + opened: boolean; + onClose: () => void; + wagon: WagonWithAllocation | null; + onConfirm: () => void; + isLoading: boolean; +} + +export const RemoveBookingModal = ({ + opened, + onClose, + wagon, + onConfirm, + isLoading, +}: RemoveBookingModalProps) => { + if (!wagon || !wagon.allocations?.[0]) return null; + + const allocation = wagon.allocations[0]; + const booking = allocation.booking; + + return ( + + +
+ + Booking Details + + + + Reference: {booking?.reference || "N/A"} + + + Freight Type:{" "} + + {booking?.freightType || "N/A"} + + + + Weight: {allocation.allocatedWeightTons?.toFixed(2) || 0} T + + + Wagon Slot: #{wagon.sequenceNo} + + +
+ +
+ + ⚠️ Warning: Removing this booking will: + +
    +
  • Move the booking back to the unassigned pool
  • +
  • Create a removal log for audit
  • +
  • Notify the customer to reschedule or cancel
  • +
+
+ + + + + +
+
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx new file mode 100644 index 000000000..49898cbcd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -0,0 +1,220 @@ +import { useMemo, useState } from "react"; +import { Badge, Box, Group, Paper, Stack, Text, ThemeIcon } from "@mantine/core"; +import { MousePointerClick, TrainFront } from "lucide-react"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; +import { TrainStatsBar } from "./TrainStatsBar"; +import { WagonCard } from "./WagonCard"; +import { InteractiveTrainConsist } from "./InteractiveTrainConsist"; +import { RemoveBookingModal } from "./RemoveBookingModal"; +import { useScheduleMutations, useRemoveWagonSlot } from "@/hooks/trainScheduling/useTrainScheduling"; +import { freightBrand } from "@/theme/freight-brand"; + +type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; + +interface TrainConsistViewProps { + scheduleDetail: TrainScheduleDetail; + scheduleId: string; + maxWagons: number; + /** Booking id selected in the side panel — highlights its wagons in the consist. */ + highlightBookingId?: string | null; +} + +function LegendDot({ color, label, dashed }: { color: string; label: string; dashed?: boolean }) { + return ( + + + + {label} + + + ); +} + +export const TrainConsistView = ({ + scheduleDetail, + scheduleId, + maxWagons, + highlightBookingId, +}: TrainConsistViewProps) => { + const [selectedWagonId, setSelectedWagonId] = useState(null); + const [removeModalOpen, setRemoveModalOpen] = useState(false); + + const unassignMutation = useScheduleMutations(scheduleId).unassign; + const removeWagonMutation = useRemoveWagonSlot(scheduleId); + + const trainSet = scheduleDetail.trainSet; + const wagons = trainSet?.wagons ?? []; + + // Join company/customer name from schedule bookings by booking id. + const companyByBooking = useMemo(() => { + const map = new Map(); + for (const b of scheduleDetail.bookings ?? []) { + if (b.id && b.customer) map.set(b.id, b.customer); + } + return map; + }, [scheduleDetail.bookings]); + + const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null; + const loadedCount = wagons.filter((w) => (w.allocations?.length ?? 0) > 0).length; + + const handleRemoveBooking = (wagon: Wagon) => { + setSelectedWagonId(wagon.id); + setRemoveModalOpen(true); + }; + + const handleConfirmRemoveBooking = async () => { + if (selectedWagon?.allocations?.[0]?.bookingId) { + await unassignMutation.mutateAsync({ + id: scheduleId, + bookingId: selectedWagon.allocations[0].bookingId, + }); + setRemoveModalOpen(false); + setSelectedWagonId(null); + } + }; + + const handleRemoveWagon = async (wagonId: string) => { + if (confirm("Are you sure you want to remove this wagon slot?")) { + await removeWagonMutation.mutateAsync(wagonId); + setSelectedWagonId(null); + } + }; + + const weightUsed = wagons.reduce( + (sum, w) => sum + (w.allocations?.[0]?.allocatedWeightTons ?? 0), + 0, + ); + const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0); + + return ( + + + + {/* Consist panel */} + + + + + + +
+ + Train consist + + + {wagons.length} wagons · {loadedCount} loaded · {wagons.length - loadedCount} empty + +
+
+ + + + + +
+ + + (bookingId ? companyByBooking.get(bookingId) ?? null : null)} + selectedWagonId={selectedWagonId} + onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))} + highlightBookingId={highlightBookingId} + /> + +
+ + {/* Selected wagon — editable detail card */} + {selectedWagon ? ( + + + + Editing wagon #{selectedWagon.sequenceNo} + + + Update container numbers or remove the booking + + + + + ) : wagons.length ? ( + + + + + + + Click a wagon in the train to edit container numbers or remove its booking. + + + + ) : null} + + { + setRemoveModalOpen(false); + }} + wagon={selectedWagon} + onConfirm={handleConfirmRemoveBooking} + isLoading={unassignMutation.isPending} + /> +
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx new file mode 100644 index 000000000..8ec1c7f4e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx @@ -0,0 +1,134 @@ +import { Box, Group, Paper, RingProgress, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core"; +import { Ruler, Train, Weight } from "lucide-react"; +import { freightBrand } from "@/theme/freight-brand"; + +interface TrainStatsBarProps { + weightUsed: number; + weightMax: number | null; + lengthUsed: number; + lengthMax: number | null; + wagonCount: number; + wagonMax: number; +} + +function pctColor(pct: number) { + if (pct >= 100) return "#fa5252"; + if (pct >= 85) return "#FB8C2E"; + return freightBrand.primary; +} + +function StatTile({ + icon, + label, + pct, + current, + max, + unit, +}: { + icon: React.ReactNode; + label: string; + pct: number | null; + current: string; + max: string; + unit: string; +}) { + const color = pct != null ? pctColor(pct) : freightBrand.primary; + const clamped = pct != null ? Math.min(100, Math.max(0, pct)) : 0; + return ( + + + + {icon} + + + } + /> + + + {label} + + + + {current} + + + / {max} {unit} + + + {pct != null ? ( + + {Math.round(pct)}% utilized + + ) : ( + + no limit set + + )} + + + ); +} + +export const TrainStatsBar = ({ + weightUsed, + weightMax, + lengthUsed, + lengthMax, + wagonCount, + wagonMax, +}: TrainStatsBarProps) => { + const weightPct = weightMax ? (weightUsed / weightMax) * 100 : null; + const lengthPct = lengthMax ? (lengthUsed / lengthMax) * 100 : null; + const wagonPct = wagonMax ? (wagonCount / wagonMax) * 100 : null; + + return ( + + + } + label="Weight" + pct={weightPct} + current={weightUsed.toFixed(1)} + max={weightMax?.toFixed(1) ?? "∞"} + unit="T" + /> + + } + label="Length" + pct={lengthPct} + current={lengthUsed.toFixed(1)} + max={lengthMax?.toFixed(1) ?? "∞"} + unit="m" + /> + + } + label="Wagons" + pct={wagonPct} + current={String(wagonCount)} + max={String(wagonMax)} + unit="" + /> + + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx new file mode 100644 index 000000000..11d6c13d3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx @@ -0,0 +1,218 @@ +import { Badge, Box, Button, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; +import { AlertTriangle, Container as ContainerIcon, MapPin, Plus, TrainFront } from "lucide-react"; +import { + useUnassignedBookings, + useScheduleMutations, +} from "@/hooks/trainScheduling/useTrainScheduling"; +import { useToast } from "@/hooks/use-toast"; +import type { FleetAvailabilityRow } from "@/types/trainScheduling"; +import type { BookingDetailData } from "./BookingDetailModal"; + +interface UnassignedBookingsPanelProps { + scheduleId: string; + selectedBookingId?: string | null; + onSelect: (booking: BookingDetailData) => void; +} + +const parseError = (error: unknown): string | null => { + if (error && typeof error === "object" && "response" in error) { + const resp = (error as { + response?: { data?: { message?: unknown; violations?: string[] } }; + }).response; + const violations = resp?.data?.violations; + if (Array.isArray(violations) && violations.length) return violations.join("; "); + const msg = resp?.data?.message; + if (Array.isArray(msg)) return msg.join(", "); + if (typeof msg === "string") return msg; + } + return null; +}; + +const YardFleetBanner = ({ fleetAtOrigin }: { fleetAtOrigin: FleetAvailabilityRow[] }) => { + if (!fleetAtOrigin.length) { + return ( + + + + No wagons at origin yard + + + ); + } + + return ( + + + + + Origin yard + + + {fleetAtOrigin.map((row) => ( + + {row.wagonTypeCode}: {row.available} + + ))} + + ); +}; + +export const UnassignedBookingsPanel = ({ + scheduleId, + selectedBookingId, + onSelect, +}: UnassignedBookingsPanelProps) => { + const { toast } = useToast(); + const unassignedQuery = useUnassignedBookings(scheduleId); + const assignMutation = useScheduleMutations(scheduleId).assignUnassigned; + + const handleAssign = async (bookingId: string, reference: string | null) => { + try { + await assignMutation.mutateAsync({ + id: scheduleId, + bookingId, + }); + toast({ title: `Assigned ${reference ?? "booking"} to the train` }); + } catch (err) { + toast({ + title: "Could not assign booking", + description: parseError(err) ?? "Assignment failed — check yard fleet and train limits.", + variant: "destructive", + }); + } + }; + + if (unassignedQuery.isLoading) { + return ( + + Loading... + + ); + } + + const bookings = unassignedQuery.data?.bookings ?? []; + const fleetAtOrigin = unassignedQuery.data?.fleetAtOrigin ?? []; + + if (bookings.length === 0) { + return ( + + + + + + No unassigned bookings + + + Paid bookings waiting for a wagon will appear here. + + + ); + } + + return ( + + + + {bookings.map((booking) => { + const isActive = selectedBookingId === booking.id; + const weight = Number(booking.cargoTotalWeightVgm ?? 0); + const fits = booking.canAssign; + const blockReason = booking.blockReason; + + return ( + + onSelect({ + bookingId: booking.id, + reference: booking.reference, + company: null, + freightType: booking.freightType, + weightTons: Number.isFinite(weight) ? weight : null, + status: booking.status, + priorityScore: booking.priorityScore, + }) + } + style={{ + cursor: "pointer", + borderColor: isActive ? "var(--mantine-color-green-5)" : undefined, + }} + > + + + + + + + + + {booking.reference} + + {booking.priorityScore ? ( + + P{booking.priorityScore} + + ) : null} + + + + {booking.freightType} + + + + + {booking.wagonsRequired}× {booking.requiredWagonTypeCode} + + + + + + + {blockReason ? ( + + + + {blockReason} + + + ) : null} + + + + + + + ); + })} + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx new file mode 100644 index 000000000..bda0b1356 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx @@ -0,0 +1,188 @@ +import { Badge, Box, Button, Card, Group, Progress, Stack, Text, ThemeIcon } from "@mantine/core"; +import { + Building2, + Container as ContainerIcon, + Fuel, + Package, + TrainFront, + Trash2, + X, +} from "lucide-react"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; +import { ContainerNumberInput } from "./ContainerNumberInput"; +import { freightBrand } from "@/theme/freight-brand"; + +type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; + +interface WagonCardProps { + wagon: Wagon; + company?: string | null; + scheduleId: string; + scheduleStatus?: string; + onRemoveBooking: (wagon: Wagon) => void; + onRemoveWagon: (wagonId: string) => void; +} + +export const WagonCard = ({ + wagon, + company, + scheduleId, + scheduleStatus, + onRemoveBooking, + onRemoveWagon, +}: WagonCardProps) => { + const isDispatched = scheduleStatus === "DISPATCHED"; + const allocation = wagon.allocations?.[0]; + const hasAllocations = Boolean(allocation); + const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK"); + + const weightUsed = allocation?.allocatedWeightTons ?? 0; + const weightMax = wagon.capacityTons ?? 0; + const weightPercent = weightMax ? (weightUsed / weightMax) * 100 : 0; + + const wagonType = wagon.wagonType?.code || "UNKNOWN"; + + return ( + + + + + + + +
+ + + Wagon #{wagon.sequenceNo} + + + {wagonType} + + + {wagon.physicalWagonNumber || wagon.physicalWagonId ? ( + + {wagon.physicalWagonNumber || wagon.physicalWagonId?.slice(0, 8)} + + ) : null} +
+
+ {hasAllocations ? ( + : } + > + {isBulk ? "Bulk" : "Container"} + + ) : null} +
+
+ + + {hasAllocations && allocation ? ( + <> + {company ? ( + + + + {company} + + + ) : null} + + + + + {allocation.bookingReference || "Unknown booking"} + + + + {allocation.loadType === "CONTAINER" && allocation.containerItems?.length ? ( + + + Containers + + + {allocation.containerItems.map((item, idx) => ( + + + + #{idx + 1} + + + + ))} + + + ) : null} + + {isBulk ? ( + + + + {allocation.bulkLoad?.cargoDescription || "Bulk load"} + + + ) : null} + + + + + Weight + + + {weightUsed.toFixed(1)} / {weightMax.toFixed(1)} T + + + 90 ? "red" : weightPercent > 75 ? "orange" : "green"} + size="sm" + radius="xl" + /> + + + {!isDispatched ? ( + + ) : null} + + ) : ( + + + + + + Empty slot + + {!isDispatched ? ( + + ) : null} + + )} + +
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/index.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/index.ts new file mode 100644 index 000000000..c0cf7bc96 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/index.ts @@ -0,0 +1,13 @@ +export { TrainStatsBar } from "./TrainStatsBar"; +export { ContainerNumberInput } from "./ContainerNumberInput"; +export { RemoveBookingModal } from "./RemoveBookingModal"; +export { WagonCard } from "./WagonCard"; +export { TrainConsistView } from "./TrainConsistView"; +export { InteractiveTrainConsist } from "./InteractiveTrainConsist"; +export { BookingDetailModal } from "./BookingDetailModal"; +export { BatchBookingList } from "./BatchBookingList"; +export { RemoveBookingConfirmModal } from "./RemoveBookingConfirmModal"; +export { AssignedBookingsPanel } from "./AssignedBookingsPanel"; +export { UnassignedBookingsPanel } from "./UnassignedBookingsPanel"; +export { RemovalLogPanel } from "./RemovalLogPanel"; +export { CompositionBookingTabs } from "./CompositionBookingTabs"; diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx index 3c826b055..b0ec3228c 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx @@ -1,10 +1,11 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Plus } from "lucide-react"; import { Button, Group, Modal, NumberInput, Select, Stack, Text } from "@mantine/core"; import { Freight } from "@edr/types"; import { useToast } from "@/hooks/use-toast"; +import { useRouteYards } from "@/hooks/useRoutes"; import { useAssignWagonToTrain, useWagons } from "@/hooks/useWagons"; export function AssignWagonDialog({ trainId }: { trainId: string }) { @@ -12,6 +13,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) { const [wagonId, setWagonId] = useState(null); const [sequence, setSequence] = useState(""); const { data: wagons } = useWagons(); + const { data: yards = [] } = useRouteYards(); const assign = useAssignWagonToTrain(); const { toast } = useToast(); @@ -19,9 +21,19 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) { (w) => w.status === Freight.WagonStatus.Available || !w.trainId, ); + const yardLabelById = useMemo( + () => + new Map( + yards.map((y) => [y.id, y.label ?? y.code ?? y.id]), + ), + [yards], + ); + const wagonOptions = available.map((w) => ({ value: w.id, - label: `${w.wagonNumber} (${w.readiness.replace("_", " ").toLowerCase()})`, + label: w.currentYardId + ? `${w.wagonNumber} (${yardLabelById.get(w.currentYardId) ?? "yard"})` + : `${w.wagonNumber} (no yard)`, })); const handleAssign = async () => { diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 35f566519..e3e1921de 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -52,6 +52,10 @@ export const QUERY_KEYS = { batchBoard: () => ["train-scheduling", "batch-board"] as const, batchBoardDetail: (scheduleId: string) => ["train-scheduling", "batch-board", scheduleId] as const, + unassignedBookings: (id: string) => + ["train-scheduling", "unassigned", id] as const, + compositionRemovals: (id: string) => + ["train-scheduling", "removals", id] as const, }, FLEET: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 5dd820edb..7449ae7f3 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -144,6 +144,8 @@ export const URL_CONSTANTS = { `/train-scheduling/batch-board/${scheduleId}`, RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`, RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`, + ASSIGN_UNASSIGNED_BOOKING: (id: string) => + `/train-scheduling/schedules/${id}/assign-unassigned-booking`, BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`, MARK_BOOKING_PAID: (bookingId: string) => `/train-scheduling/bookings/${bookingId}/mark-paid`, @@ -190,6 +192,14 @@ export const URL_CONSTANTS = { SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`, CANCEL_SCHEDULE: (id: string) => `/train-scheduling/container/schedules/${id}/cancel`, + REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) => + `/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`, + UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) => + `/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`, + UNASSIGNED_BOOKINGS: (scheduleId: string) => + `/train-scheduling/schedules/${scheduleId}/unassigned-bookings`, + COMPOSITION_REMOVALS: (scheduleId: string) => + `/train-scheduling/schedules/${scheduleId}/composition-removals`, }, RULE_ENGINE: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts index 8ea385316..8c6536c5c 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts @@ -153,6 +153,12 @@ export const useScheduleMutations = (scheduleId?: string) => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(scheduleId), }); + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId), + }); + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId), + }); } void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT }); }; @@ -191,6 +197,12 @@ export const useScheduleMutations = (scheduleId?: string) => { onSuccess: invalidate, }); + const assignUnassigned = useMutation({ + mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) => + trainSchedulingService.assignUnassignedBooking(id, bookingId), + onSuccess: invalidate, + }); + const unassign = useMutation({ mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) => trainSchedulingService.unassignBooking(id, bookingId), @@ -234,6 +246,7 @@ export const useScheduleMutations = (scheduleId?: string) => { create, preview, assign, + assignUnassigned, unassign, pin, finalize, @@ -244,3 +257,46 @@ export const useScheduleMutations = (scheduleId?: string) => { invalidate, }; }; + +export const useUnassignedBookings = (scheduleId: string | undefined) => + useQuery({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId ?? ""), + queryFn: () => trainSchedulingService.getUnassignedBookings(scheduleId!), + enabled: Boolean(scheduleId), + }); + +export const useCompositionRemovals = (scheduleId: string | undefined) => + useQuery({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId ?? ""), + queryFn: () => trainSchedulingService.getCompositionRemovals(scheduleId!), + enabled: Boolean(scheduleId), + }); + +export const useRemoveWagonSlot = (scheduleId: string) => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (wagonId: string) => + trainSchedulingService.removeWagonSlot(scheduleId, wagonId), + onSuccess: () => { + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId), + }); + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId), + }); + }, + }); +}; + +export const useUpdateContainerItem = (scheduleId: string) => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ itemId, containerNumber }: { itemId: string; containerNumber: string | null }) => + trainSchedulingService.updateContainerItem(scheduleId, itemId, { containerNumber }), + onSuccess: () => { + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId), + }); + }, + }); +}; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 67694c9f4..f246ad194 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -16,6 +16,7 @@ import { useWagonTypes } from "@/hooks/use-wagon-types"; import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet"; import { useContainers } from "@/hooks/useContainers"; import { useToast } from "@/hooks/use-toast"; +import { useRouteYards } from "@/hooks/useRoutes"; import { useWagons } from "@/hooks/useWagons"; import type { FleetListFilters } from "@/services/fleet/fleet.service"; import { @@ -49,12 +50,12 @@ const FleetResourcePage = () => { if (slug !== "wagons" && slug !== "locomotives") return undefined; const filters: FleetListFilters = {}; const status = listFilterValues.status; - const readiness = listFilterValues.readiness; + const currentYardId = listFilterValues.currentYardId; if (status && status !== "ALL") { - filters.status = status as FleetListFilters["status"]; + (filters as { status?: string }).status = status; } - if (readiness && readiness !== "ALL") { - filters.readiness = readiness as FleetListFilters["readiness"]; + if (currentYardId && currentYardId !== "ALL") { + filters.currentYardId = currentYardId; } if (slug === "wagons" && search.trim()) { filters.search = search.trim(); @@ -70,6 +71,7 @@ const FleetResourcePage = () => { const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useCargoTypes(); const { data: wagons = [], isLoading: wagonsLoading } = useWagons(); const { data: containers = [], isLoading: containersLoading } = useContainers(); + const { data: yards = [], isLoading: yardsLoading } = useRouteYards(); useEffect(() => { setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); @@ -98,18 +100,6 @@ const FleetResourcePage = () => { ]; }, [allRows, hasStatusColumn, usesServerListFilters]); - const listFilterSelects = useMemo(() => { - if (!config?.listFilters?.length) return null; - return config.listFilters.map((filter) => ({ - ...filter, - value: listFilterValues[filter.key] ?? "ALL", - data: [ - { value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` }, - ...filter.options.map((opt) => ({ value: opt.value, label: opt.label })), - ], - })); - }, [config?.listFilters, listFilterValues]); - const dynamicOptions = useMemo(() => { const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map( (t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }), @@ -128,14 +118,41 @@ const FleetResourcePage = () => { (c) => ({ value: c.id, label: c.containerNumber }), ); + const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map( + (y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }), + ); + + registerFleetOptionLabels("currentYardId", yardOpts); + return { wagonTypes: wagonTypeOpts, containerTypes: containerTypeOpts, cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts], wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts], containers: containerOpts, + yards: yardOpts, }; - }, [wagonTypes, containerTypes, cargoTypes, wagons, containers]); + }, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]); + + const listFilterSelects = useMemo(() => { + if (!config?.listFilters?.length) return null; + return config.listFilters.map((filter) => { + const dynamicOpts = filter.dynamicOptions + ? (dynamicOptions[filter.dynamicOptions] ?? []) + : []; + const staticOpts = + filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? []; + const opts = filter.dynamicOptions ? dynamicOpts : staticOpts; + return { + ...filter, + value: listFilterValues[filter.key] ?? "ALL", + data: [ + { value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` }, + ...opts, + ], + }; + }); + }, [config?.listFilters, listFilterValues, dynamicOptions]); useEffect(() => { registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes); @@ -146,6 +163,7 @@ const FleetResourcePage = () => { ); registerFleetOptionLabels("wagonId", dynamicOptions.wagons); registerFleetOptionLabels("containerId", dynamicOptions.containers); + registerFleetOptionLabels("currentYardId", dynamicOptions.yards); }, [dynamicOptions]); const formFields = useMemo((): FleetFormFieldDef[] => { @@ -158,7 +176,12 @@ const FleetResourcePage = () => { }, [config, dynamicOptions]); const selectOptionsLoading = - wagonTypesLoading || containerTypesLoading || cargoTypesLoading || wagonsLoading || containersLoading; + wagonTypesLoading || + containerTypesLoading || + cargoTypesLoading || + wagonsLoading || + containersLoading || + yardsLoading; const filteredRows = useMemo(() => { if (!config) return allRows; @@ -222,7 +245,7 @@ const FleetResourcePage = () => { }); return base; - }, [config]); + }, [config, dynamicOptions.yards]); const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 44849b2c4..649b2bc8b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -1,5 +1,4 @@ import { Freight } from "@edr/types"; - import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources"; export type FleetResourceSlug = @@ -20,7 +19,8 @@ export type FleetDynamicOptions = | "containerTypes" | "cargoTypes" | "wagons" - | "containers"; + | "containers" + | "yards"; export interface FleetResourceColumn { id: string; @@ -35,10 +35,11 @@ export interface FleetFormFieldDef extends FormFieldDef { } export interface FleetListFilterDef { - key: "status" | "readiness" | "wagonTypeId" | "trainId"; + key: "status" | "currentYardId" | "wagonTypeId" | "trainId"; label: string; - options: Array<{ value: string; label: string }>; + options?: Array<{ value: string; label: string }>; allLabel?: string; + dynamicOptions?: FleetDynamicOptions; } export interface FleetResourceConfig { @@ -93,10 +94,6 @@ const WAGON_STATUS_OPTIONS = [ { label: "Retired", value: Freight.WagonStatus.Retired }, ]; -const WAGON_READINESS_OPTIONS = [ - { label: "Import ready", value: Freight.WagonReadiness.ImportReady }, - { label: "Export ready", value: Freight.WagonReadiness.ExportReady }, -]; export const FLEET_RESOURCES: FleetResourceConfig[] = [ { @@ -122,19 +119,19 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ options: LOCOMOTIVE_STATUS_OPTIONS, }, { - key: "readiness", - label: "Readiness", - allLabel: "All readiness", - options: WAGON_READINESS_OPTIONS, + key: "currentYardId", + label: "Current Yard", + allLabel: "All yards", + dynamicOptions: "yards", }, ], - cardSubtitleKey: "readiness", - searchKeys: ["code", "name", "locomotiveType", "status", "readiness"], + cardSubtitleKey: "currentYard", + searchKeys: ["code", "name", "locomotiveType", "status", "currentYardId"], columns: [ { id: "code", header: "Code", accessorKey: "code", format: "code" }, { id: "name", header: "Name", accessorKey: "name" }, { id: "locomotiveType", header: "Type", accessorKey: "locomotiveType" }, - { id: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" }, + { id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, { id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" }, { id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" }, @@ -144,7 +141,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { name: "name", label: "Name", type: "text" }, { name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS }, { name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS }, - { name: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS }, + { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, { name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true }, { name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true }, { name: "powerKw", label: "Power (kW)", type: "number" }, @@ -156,7 +153,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ name: "", locomotiveType: "DIESEL", status: "AVAILABLE", - readiness: Freight.WagonReadiness.ImportReady, + currentYardId: "", maxPullWeightTons: 0, maxTrainLengthMeters: 760, powerKw: "", @@ -225,20 +222,20 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ options: WAGON_STATUS_OPTIONS, }, { - key: "readiness", - label: "Readiness", - allLabel: "All readiness", - options: WAGON_READINESS_OPTIONS, + key: "currentYardId", + label: "Current Yard", + allLabel: "All yards", + dynamicOptions: "yards", }, ], cardTitleKey: "wagonNumber", - cardSubtitleKey: "readiness", - searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "readiness"], + cardSubtitleKey: "currentYard", + searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"], columns: [ { id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" }, { id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" }, { id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" }, - { id: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" }, + { id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, ], formFields: [ @@ -246,7 +243,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, { name: "tareWeight", label: "Tare weight", type: "number", required: true }, { name: "maxPayloadWeight", label: "Max payload weight", type: "number", required: true }, - { name: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS }, + { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, { name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS }, { name: "notes", label: "Notes", type: "textarea" }, ], @@ -255,7 +252,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ wagonTypeId: "", tareWeight: 0, maxPayloadWeight: 0, - readiness: Freight.WagonReadiness.ImportReady, + currentYardId: "", status: Freight.WagonStatus.Available, notes: "", }, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 57c06a42c..64e79f8e7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -296,9 +296,12 @@ const RuleEngineResourcePage = () => { }; const handleFormSubmit = (values: Record) => { + const payload = + config.slug === "rates" ? { ...values, currency: "USD" } : values; + if (editing?.id) { update.mutate( - { id: editing.id, payload: values }, + { id: editing.id, payload }, { onSuccess: () => { setFormOpen(false); @@ -307,7 +310,7 @@ const RuleEngineResourcePage = () => { }, ); } else { - create.mutate(values, { + create.mutate(payload, { onSuccess: () => { setFormOpen(false); setEditing(null); diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index f7f301a00..689f994c0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -110,10 +110,7 @@ const RATE_UNITS = ["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "FLAT"].m value: v, })); -const CURRENCIES = [ - { label: "ETB", value: "ETB" }, - { label: "USD", value: "USD" }, -]; +const CURRENCIES = [{ label: "USD", value: "USD" }]; const codeColumn = (key: string, header = "Code"): ResourceColumn => ({ id: key, @@ -430,7 +427,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ type: "select", options: TRADE_DIRECTIONS, }, - { name: "currency", label: "Currency", type: "select", required: true, options: CURRENCIES }, { name: "rateValue", label: "Rate value", type: "number", required: true }, { name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS }, { name: "effectiveFrom", label: "Effective from", type: "date", required: true }, diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx index 0dec57af5..4ee2a7d96 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx @@ -1,13 +1,15 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Alert, Box, Button, + Card, Container, Group, Paper, RingProgress, + Select, SimpleGrid, Skeleton, Stack, @@ -17,17 +19,21 @@ import { import { AlertTriangle, ArrowRight, + CalendarClock, CalendarDays, Inbox, Package, - RefreshCw, Ruler, Train, TrainFront, Weight, } from "lucide-react"; +import type { ColumnDef } from "@edr/ui-common"; +import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import FleetToolbar from "@/components/fleet/FleetToolbar"; +import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { BookingPipeline, HeroChip, @@ -35,6 +41,7 @@ import { WindowStatusPill, } from "@/components/trainScheduling/batchVisuals"; import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals"; +import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand"; import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling"; import type { BatchBoardSchedule } from "@/types/trainScheduling"; @@ -58,6 +65,27 @@ const fmtScheduleDate = (iso: string | null) => }).format(new Date(iso)) + " EAT" : "No date"; +const splitDate = (iso: string | null) => { + if (!iso) return { day: "—", time: "" }; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return { day: "—", time: "" }; + return { + day: new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + timeZone: "Africa/Addis_Ababa", + }).format(date), + time: + new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Africa/Addis_Ababa", + }).format(date) + " EAT", + }; +}; + /** Capacity ring color: gold normally, red once over capacity. */ function ringColor(pct: number) { if (pct >= 100) return "#fa5252"; @@ -109,18 +137,56 @@ function CapacityRing({ ); } +/** Small percent chip used in the table's capacity column. */ +function CapacityChip({ + icon: Icon, + pct, + text, +}: { + icon: typeof Weight; + pct: number | null; + text: string; +}) { + const over = pct != null && pct >= 100; + return ( + + + + {pct != null ? `${Math.round(pct)}%` : "—"} + + + {text} + + + ); +} + +function weightPctOf(s: BatchBoardSchedule) { + return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0 + ? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100 + : null; +} +function lengthPctOf(s: BatchBoardSchedule) { + return s.capacity.maxLengthMeters && s.capacity.maxLengthMeters > 0 + ? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100 + : null; +} + function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { const navigate = useNavigate(); const { capacity, counts, locomotive } = schedule; - const lengthPct = - capacity.maxLengthMeters && capacity.maxLengthMeters > 0 - ? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100 - : null; - const weightPct = - capacity.maxWeightTons && capacity.maxWeightTons > 0 - ? (capacity.usedWeightTons / capacity.maxWeightTons) * 100 - : null; + const lengthPct = lengthPctOf(schedule); + const weightPct = weightPctOf(schedule); const totalBookings = totalBookingCount(counts); @@ -298,7 +364,13 @@ function CardSkeleton() { } export default function BatchBoardPage() { + const navigate = useNavigate(); const { data, isLoading, isFetching, refetch } = useBatchBoard(); + const { viewMode, setViewMode } = useFleetViewMode("batch-board"); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [search, setSearch] = useState(""); + const [windowFilter, setWindowFilter] = useState("ALL"); + const schedules = data ?? []; const summary = useMemo(() => { @@ -308,22 +380,199 @@ export default function BatchBoardPage() { return { openWindows, totalBookings, totalWagons }; }, [schedules]); + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + return schedules.filter((s) => { + if (windowFilter !== "ALL" && s.bookingWindowStatus !== windowFilter) return false; + if (!query) return true; + const haystack = [ + s.trainNumber, + s.routeName, + s.origin, + s.destination, + s.locomotive?.code, + s.status, + s.bookingWindowStatus, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(query); + }); + }, [schedules, search, windowFilter]); + + const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize)); + const paged = useMemo(() => { + const start = pagination.pageIndex * pagination.pageSize; + return filtered.slice(start, start + pagination.pageSize); + }, [filtered, pagination]); + + const columns = useMemo((): ColumnDef[] => { + const headerClassName = ruleEngineTable.headerCell; + const cellClassName = ruleEngineTable.bodyCell; + return [ + { + id: "train", + header: "Train / Route", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + + + + + + + {row.original.trainNumber ?? row.original.routeName ?? "Schedule"} + + + + + + + ), + }, + { + id: "date", + header: "Departure", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const { day, time } = splitDate(row.original.scheduleDate); + return ( + + + + + + + {day} + + + {time || "—"} + + + + ); + }, + }, + { + id: "window", + header: "Window", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => , + }, + { + id: "loco", + header: "Locomotive", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => + row.original.locomotive ? ( + + + + + {row.original.locomotive.code} + + + {fmtTons(row.original.locomotive.maxPullWeightTons)} pull + + + + ) : ( + + No loco + + ), + }, + { + id: "capacity", + header: "Capacity", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + + + + + + + {row.original.capacity.allocatedWagons} + + + wgn + + + + ), + }, + { + id: "bookings", + header: "Bookings", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const total = totalBookingCount(row.original.counts); + return ( + + + {total} booking{total === 1 ? "" : "s"} + + + + ); + }, + }, + { + id: "actions", + header: "", + meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, + cell: ({ row }) => ( + + + + ), + }, + ]; + }, [navigate]); + + const tableStatus = isLoading ? "loading" : "success"; + return ( - - - - - {isLoading ? ( - - - - - - ) : schedules.length === 0 ? ( - - - + + + v && setWindowFilter(v)} + data={[ + { value: "ALL", label: "All windows" }, + { value: "OPEN", label: "Open" }, + { value: "FULL", label: "Full" }, + { value: "CLOSED", label: "Closed" }, + ]} + w={150} + styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} + /> + } + /> + + + {viewMode === "table" ? ( + - - - - No active schedules - - - Schedules with an open booking window appear here as cards. Create or activate - a schedule to get started. - - - - ) : ( - - {schedules.map((s) => ( - - ))} - - )} + tableOptions={{ + manualPagination: true, + pageCount, + state: { pagination }, + onPaginationChange: setPagination, + }} + containerClassName="border-0 shadow-none bg-transparent" + footer={({ table, pagination: footerPagination }) => ( + + )} + /> + ) : isLoading ? ( + + + + + + ) : filtered.length === 0 ? ( + + + + + + + No active schedules + + + Schedules with an open booking window appear here. Create or activate a + schedule to get started. + + + + ) : ( + + {filtered.map((s) => ( + + ))} + + )} + + + + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 91849ee3e..311f76124 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -1,8 +1,9 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Accordion, + ActionIcon, Alert, Badge, Box, @@ -14,6 +15,7 @@ import { SimpleGrid, Stack, Table, + Tabs, Text, ThemeIcon, Title, @@ -25,6 +27,8 @@ import { Boxes, CalendarDays, CheckCircle2, + ChevronLeft, + ChevronRight, Clock, FileSignature, Hourglass, @@ -41,6 +45,7 @@ import type { LucideIcon } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; +import { TrainConsistView, CompositionBookingTabs } from "@/components/trainScheduling/compositionEditor"; import { BookingPipeline, HeroChip, @@ -318,6 +323,40 @@ function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) { ); } +/** "05 Jun 2026 · 06:00 – 09:00 EAT" → "06:00 – 09:00 EAT" (date lives in the day header). */ +function timeLabelOf(label: string): string { + const idx = label.indexOf("·"); + return idx >= 0 ? label.slice(idx + 1).trim() : label; +} + +const EAT_TZ = "Africa/Addis_Ababa"; +const dateKeyFmt = new Intl.DateTimeFormat("en-CA", { + timeZone: EAT_TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", +}); +const dateLabelFmt = new Intl.DateTimeFormat("en-GB", { + timeZone: EAT_TZ, + weekday: "short", + day: "2-digit", + month: "short", +}); + +/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */ +function windowDateKey(w: BatchWindowGroup): string { + if (w.date) return w.date; + if (w.start) return dateKeyFmt.format(new Date(w.start)); + return "undated"; +} + +/** Human day label for a window — prefers the API field, falls back to `start`. */ +function windowDateLabel(w: BatchWindowGroup): string { + if (w.dateLabel) return w.dateLabel; + if (w.start) return dateLabelFmt.format(new Date(w.start)); + return "Undated"; +} + function WindowAccordionItem({ window }: { window: BatchWindowGroup }) { const total = window.bookings.length; const hasIssues = window.bookings.some( @@ -347,7 +386,7 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
- {window.label} + {timeLabelOf(window.label)} {total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"} @@ -454,17 +493,110 @@ export default function BatchScheduleDetailPage() { [data], ); - const scheduleDetailQuery = useScheduleDetail( - hasAssignedWagons ? scheduleId : undefined, - "CONTAINER", + const scheduleDetailQuery = useScheduleDetail(scheduleId, "CONTAINER"); + + // Batch bookings by state for the composition side panel (payment / expired lists). + const batchBookings = useMemo(() => { + if (!data) return { awaitingPayment: [], expired: [] }; + const all = [ + ...data.windows.flatMap((w) => w.bookings), + ...data.pendingContract.bookings, + ]; + return { + awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"), + expired: all.filter((b) => b.state === "EXPIRED"), + }; + }, [data]); + + // Group the flat window list into per-day sections (one per EAT calendar date). + const dayGroups = useMemo(() => { + if (!data) return []; + const byDate = new Map< + string, + { + date: string; + dateLabel: string; + windows: BatchWindowGroup[]; + totalBookings: number; + counts: BatchWindowGroup["counts"]; + hasIssues: boolean; + } + >(); + for (const w of data.windows) { + const dateKey = windowDateKey(w); + let group = byDate.get(dateKey); + if (!group) { + group = { + date: dateKey, + dateLabel: windowDateLabel(w), + windows: [], + totalBookings: 0, + counts: { + allocated: 0, + selectedForBatch: 0, + ready: 0, + waiting: 0, + expired: 0, + pendingContract: 0, + }, + hasIssues: false, + }; + byDate.set(dateKey, group); + } + group.windows.push(w); + group.totalBookings += w.bookings.length; + group.counts.allocated += w.counts.allocated; + group.counts.selectedForBatch += w.counts.selectedForBatch; + group.counts.ready += w.counts.ready; + group.counts.waiting += w.counts.waiting; + group.counts.expired += w.counts.expired; + group.counts.pendingContract += w.counts.pendingContract; + group.hasIssues = + group.hasIssues || + w.bookings.some( + (b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED", + ); + } + return [...byDate.values()]; + }, [data]); + + // Windows with bookings open by default (inside an expanded day). + const openWindowKeys = useMemo( + () => (data ? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key) : []), + [data], ); - const defaultOpen = useMemo(() => { - if (!data) return []; - const withBookings = data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key); - if (data.pendingContract.bookings.length) withBookings.push("pending-contract"); - return withBookings.length ? withBookings : [data.windows[0]?.key].filter(Boolean); - }, [data]); + const todayEat = useMemo( + () => + new Intl.DateTimeFormat("en-CA", { + timeZone: "Africa/Addis_Ababa", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()), + [], + ); + + // Date-stepper: which day is currently shown. Default to today, else the first + // day with bookings, else the first day. Keep the selection if still valid. + const [selectedDate, setSelectedDate] = useState(null); + const [activeTab, setActiveTab] = useState("overview"); + const [selectedBookingId, setSelectedBookingId] = useState(null); + useEffect(() => { + if (!dayGroups.length) return; + if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return; + const preferred = + dayGroups.find((d) => d.date === todayEat) ?? + dayGroups.find((d) => d.totalBookings > 0) ?? + dayGroups[0]; + setSelectedDate(preferred.date); + }, [dayGroups, selectedDate, todayEat]); + + const selectedIndex = Math.max( + 0, + dayGroups.findIndex((d) => d.date === selectedDate), + ); + const selectedDay = dayGroups[selectedIndex]; const handleRunAllocation = () => { runAllocation @@ -518,8 +650,17 @@ export default function BatchScheduleDetailPage() { ]} /> - - + + + Overview + + Train Composition {scheduleDetailQuery.data?.trainSet?.wagons && scheduleDetailQuery.data.trainSet.wagons.length > 0 && `(${scheduleDetailQuery.data.trainSet.wagons.length})`} + + + + + +