Merge pull request #151 from Tria-plc/freight_feature/payments

Freight feature/payments
This commit is contained in:
marshal
2026-06-14 02:35:55 +03:00
committed by GitHub
68 changed files with 4994 additions and 535 deletions

View File

@@ -14,4 +14,9 @@ export default registerAs("app", () => ({
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760), maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), 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),
},
})); }));

View File

@@ -0,0 +1,81 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateTrainCompositionRemovalLog1781000000005 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.dropTable('freight.train_composition_removal_logs', true);
}
}

View File

@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class WagonLocomotiveYardLink1782000000000 implements MigrationInterface {
name = 'WagonLocomotiveYardLink1782000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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";`);
}
}

View File

@@ -2,22 +2,24 @@ import { BookingPricingService } from './booking-pricing.service';
import type { Booking } from './entities/booking.entity'; import type { Booking } from './entities/booking.entity';
import type { Rate } from '../rule-engine/entities/rate.entity'; import type { Rate } from '../rule-engine/entities/rate.entity';
const MOCK_CBE_RATE = 130;
describe('BookingPricingService — domestic corridor', () => { describe('BookingPricingService — domestic corridor', () => {
const intercityBulkEtb: Rate = { const intercityBulkUsd: Rate = {
id: 'rate-intercity-bulk-etb', id: 'rate-intercity-bulk-usd',
rateType: 'INTERCITY_BULK', rateType: 'INTERCITY_BULK',
currency: 'ETB', currency: 'USD',
rateValue: 1900, rateValue: 35,
rateUnit: 'PER_TON', rateUnit: 'PER_TON',
status: 'LIVE', status: 'LIVE',
containerTypeId: null, containerTypeId: null,
} as Rate; } as Rate;
const intercityContainerEtb: Rate = { const intercityContainerUsd: Rate = {
id: 'rate-intercity-container-etb', id: 'rate-intercity-container-usd',
rateType: 'INTERCITY_CONTAINER', rateType: 'INTERCITY_CONTAINER',
currency: 'ETB', currency: 'USD',
rateValue: 25000, rateValue: 400,
rateUnit: 'PER_CONTAINER', rateUnit: 'PER_CONTAINER',
status: 'LIVE', status: 'LIVE',
containerTypeId: null, containerTypeId: null,
@@ -26,11 +28,15 @@ describe('BookingPricingService — domestic corridor', () => {
let service: BookingPricingService; let service: BookingPricingService;
let bookingsRepository: { calculateWagonCount: jest.Mock }; let bookingsRepository: { calculateWagonCount: jest.Mock };
let ratesService: { findLiveRates: jest.Mock }; let ratesService: { findLiveRates: jest.Mock };
let cbeExchangeService: { getUsdToEtbRate: jest.Mock };
beforeEach(() => { beforeEach(() => {
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) }; bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
ratesService = { ratesService = {
findLiveRates: jest.fn().mockResolvedValue([intercityBulkEtb, intercityContainerEtb]), findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
};
cbeExchangeService = {
getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
}; };
service = new BookingPricingService( service = new BookingPricingService(
@@ -39,10 +45,11 @@ describe('BookingPricingService — domestic corridor', () => {
{} as never, {} as never,
ratesService as never, ratesService as never,
{} 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 = { const booking = {
id: 'b-1', id: 'b-1',
freightType: 'BULK', freightType: 'BULK',
@@ -57,16 +64,42 @@ describe('BookingPricingService — domestic corridor', () => {
computeBaseRailLinesWithRates: ( computeBaseRailLinesWithRates: (
b: Booking, b: Booking,
input: { containers: [] }, input: { containers: [] },
) => Promise<{ lineItems: Array<{ amount: number; code: string }> }>; ) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
} }
).computeBaseRailLinesWithRates(booking, { containers: [] }); ).computeBaseRailLinesWithRates(booking, { containers: [] });
expect(result.lineItems).toHaveLength(1); expect(result.lineItems).toHaveLength(1);
expect(result.lineItems[0].code).toBe('INTERCITY_BULK'); 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 = { const booking = {
id: 'b-2', id: 'b-2',
freightType: 'CONTAINER', freightType: 'CONTAINER',
@@ -83,12 +116,14 @@ describe('BookingPricingService — domestic corridor', () => {
input: { input: {
containers: Array<{ containerTypeId: string; quantity: number }>; containers: Array<{ containerTypeId: string; quantity: number }>;
}, },
) => Promise<{ lineItems: Array<{ amount: number; code: string }> }>; ) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
} }
).computeBaseRailLinesWithRates(booking, { ).computeBaseRailLinesWithRates(booking, {
containers: [{ containerTypeId: 'ct-20', quantity: 3 }], containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
}); });
expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true); 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');
}); });
}); });

View File

@@ -4,6 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s
import { RatesService } from '../rule-engine/services/rates.service'; import { RatesService } from '../rule-engine/services/rates.service';
import { ServiceTypesService } from '../rule-engine/services/service-types.service'; import { ServiceTypesService } from '../rule-engine/services/service-types.service';
import { Rate } from '../rule-engine/entities/rate.entity'; import { Rate } from '../rule-engine/entities/rate.entity';
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
import { import {
AppliedCargoModifier, AppliedCargoModifier,
BookingEvaluationInput, BookingEvaluationInput,
@@ -40,6 +41,7 @@ export class BookingPricingService {
private readonly containerTypesService: ContainerTypesService, private readonly containerTypesService: ContainerTypesService,
private readonly ratesService: RatesService, private readonly ratesService: RatesService,
private readonly serviceTypesService: ServiceTypesService, private readonly serviceTypesService: ServiceTypesService,
private readonly cbeExchangeService: CbeExchangeService,
) {} ) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> { async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
@@ -80,6 +82,10 @@ export class BookingPricingService {
const evalInput = await this.buildEvalInputForBooking(booking); const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput); 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[] = []; const lineItems: PriceLineItemDto[] = [];
let total = 0; let total = 0;
@@ -95,14 +101,16 @@ export class BookingPricingService {
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r])); const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
for (const mod of ruleResult.appliedModifiers) { for (const mod of ruleResult.appliedModifiers) {
const usdAmount = mod.calculatedAmount;
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const item: PriceLineItemDto = { const item: PriceLineItemDto = {
code: mod.surchargeTypeCode, code: mod.surchargeTypeCode,
description: `Surcharge: ${mod.surchargeTypeCode}`, description: `Surcharge: ${mod.surchargeTypeCode}`,
amount: mod.calculatedAmount, amount: convertedAmount,
currency: mod.currency, currency: paymentCurrency,
}; };
lineItems.push(item); lineItems.push(item);
total += mod.calculatedAmount; total += convertedAmount;
const rate = rateById.get(mod.rateId); const rate = rateById.get(mod.rateId);
if (rate) usedRatesMap.set(rate.id, rate); if (rate) usedRatesMap.set(rate.id, rate);
@@ -263,7 +271,9 @@ export class BookingPricingService {
evalInput: BookingEvaluationInput, evalInput: BookingEvaluationInput,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const liveRates = await this.ratesService.findLiveRates(); 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 isBulk = booking.freightType === 'BULK';
const rateType = const rateType =
@@ -284,34 +294,36 @@ export class BookingPricingService {
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
for (const container of evalInput.containers) { 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; if (!rate) continue;
usedRatesMap.set(rate.id, rate); 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({ lines.push({
code: rateType, code: rateType,
description: `Base rail (${rateType})`, description: `Base rail (${rateType})`,
amount, amount,
currency: rate.currency, currency: paymentCurrency,
}); });
} }
if (lines.length === 0) { if (lines.length === 0) {
const fallback = liveRates.find( 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) { if (fallback) {
usedRatesMap.set(fallback.id, fallback); usedRatesMap.set(fallback.id, fallback);
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0); const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
const quantity = const quantity =
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1; 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({ lines.push({
code: rateType, code: rateType,
description: `Base rail (${rateType})`, description: `Base rail (${rateType})`,
amount, amount,
currency: fallback.currency, currency: paymentCurrency,
}); });
} }
} }

View File

@@ -30,6 +30,7 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { PaymentModule } from '../payment/payment.module'; import { PaymentModule } from '../payment/payment.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
@Module({ @Module({
imports: [ imports: [
@@ -65,6 +66,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
ContractPricingScheduleBuilder, ContractPricingScheduleBuilder,
ContractRendererService, ContractRendererService,
ContractPdfService, ContractPdfService,
CbeExchangeService,
], ],
exports: [BookingsService, BookingsRepository], exports: [BookingsService, BookingsRepository],
}) })

View File

@@ -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<number> {
const now = Date.now();
if (this.cachedRate !== null && now < this.cacheExpiresAt) {
return this.cachedRate;
}
const apiUrl = this.configService.get<string>('app.cbeExchange.apiUrl') ?? '';
const fallbackRate = this.configService.get<number>('app.cbeExchange.fallbackRate') ?? 130;
const cacheTtlMs = this.configService.get<number>('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<string, unknown>)['currency'] === 'USD' ||
(entry as Record<string, unknown>)['Currency'] === 'USD'
),
) as Record<string, unknown> | 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<string, unknown>;
const selling =
obj['selling'] ??
obj['Selling'] ??
obj['sellingRate'] ??
obj['usdToEtb'] ??
obj['rate'];
return selling !== undefined ? Number(selling) : null;
}
return null;
}
}

View File

@@ -1,9 +1,8 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; 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 { import {
LOCOMOTIVE_READINESS_VALUES,
LOCOMOTIVE_STATUSES, LOCOMOTIVE_STATUSES,
LOCOMOTIVE_TYPES, LOCOMOTIVE_TYPES,
} from '../entities/locomotive.entity'; } from '../entities/locomotive.entity';
@@ -28,10 +27,10 @@ export class CreateLocomotiveDto {
@IsIn([...LOCOMOTIVE_STATUSES]) @IsIn([...LOCOMOTIVE_STATUSES])
status!: string; status!: string;
@ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES, default: 'IMPORT_READY' }) @ApiPropertyOptional({ description: 'Current yard location' })
@IsOptional() @IsOptional()
@IsIn([...LOCOMOTIVE_READINESS_VALUES]) @IsUUID()
readiness?: string; currentYardId?: string;
@ApiProperty({ example: 3500 }) @ApiProperty({ example: 3500 })
@Transform(({ value }) => Number(value)) @Transform(({ value }) => Number(value))

View File

@@ -1,8 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator'; import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { import {
LOCOMOTIVE_READINESS_VALUES,
LOCOMOTIVE_STATUSES, LOCOMOTIVE_STATUSES,
LOCOMOTIVE_TYPES, LOCOMOTIVE_TYPES,
} from '../entities/locomotive.entity'; } from '../entities/locomotive.entity';
@@ -18,8 +17,8 @@ export class FilterLocomotivesDto {
@IsIn([...LOCOMOTIVE_TYPES]) @IsIn([...LOCOMOTIVE_TYPES])
locomotiveType?: string; locomotiveType?: string;
@ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES }) @ApiPropertyOptional({ description: 'Filter by current yard' })
@IsOptional() @IsOptional()
@IsIn([...LOCOMOTIVE_READINESS_VALUES]) @IsUUID()
readiness?: string; currentYardId?: string;
} }

View File

@@ -1,8 +1,8 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import { WagonReadiness } from '@edr/types'; import { Column, Entity, Index, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { TrainSet } from '../../train-sets/entities/train-set.entity'; import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
export const LOCOMOTIVE_STATUSES = [ export const LOCOMOTIVE_STATUSES = [
'AVAILABLE', 'AVAILABLE',
@@ -13,20 +13,13 @@ export const LOCOMOTIVE_STATUSES = [
export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const; 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 LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number]; export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
export type LocomotiveReadiness = (typeof LOCOMOTIVE_READINESS_VALUES)[number];
@Entity({ schema: 'freight', name: 'locomotives' }) @Entity({ schema: 'freight', name: 'locomotives' })
@Index(['code']) @Index(['code'])
@Index(['status']) @Index(['status'])
@Index(['readiness']) @Index(['currentYardId'])
export class Locomotive extends BaseEntity { export class Locomotive extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 32, unique: true }) @Column({ name: 'code', type: 'varchar', length: 32, unique: true })
code!: string; code!: string;
@@ -46,8 +39,12 @@ export class Locomotive extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' }) @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
status!: LocomotiveStatus; status!: LocomotiveStatus;
@Column({ name: 'readiness', type: 'varchar', length: 20, default: WagonReadiness.ImportReady }) @Column({ name: 'current_yard_id', type: 'uuid', nullable: true })
readiness!: LocomotiveReadiness; 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 }) @Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
powerKw?: number | null; powerKw?: number | null;

View File

@@ -3,11 +3,9 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
import { WagonReadiness } from '@edr/types';
import { import {
Locomotive, Locomotive,
type LocomotiveReadiness,
type LocomotiveStatus, type LocomotiveStatus,
type LocomotiveType, type LocomotiveType,
} from './entities/locomotive.entity'; } from './entities/locomotive.entity';
@@ -24,8 +22,9 @@ export class LocomotivesService {
...(filter.locomotiveType ...(filter.locomotiveType
? { locomotiveType: filter.locomotiveType as LocomotiveType } ? { locomotiveType: filter.locomotiveType as LocomotiveType }
: {}), : {}),
...(filter.readiness ? { readiness: filter.readiness as LocomotiveReadiness } : {}), ...(filter.currentYardId ? { currentYardId: filter.currentYardId } : {}),
}, },
relations: { currentYard: true },
order: { code: 'ASC' }, order: { code: 'ASC' },
}); });
} }
@@ -42,7 +41,7 @@ export class LocomotivesService {
name: dto.name?.trim() || null, name: dto.name?.trim() || null,
locomotiveType: dto.locomotiveType as LocomotiveType, locomotiveType: dto.locomotiveType as LocomotiveType,
status: dto.status as LocomotiveStatus, status: dto.status as LocomotiveStatus,
readiness: (dto.readiness as LocomotiveReadiness) ?? WagonReadiness.ImportReady, currentYardId: dto.currentYardId ?? null,
maxPullWeightTons: dto.maxPullWeightTons, maxPullWeightTons: dto.maxPullWeightTons,
maxTrainLengthMeters: dto.maxTrainLengthMeters, maxTrainLengthMeters: dto.maxTrainLengthMeters,
powerKw: dto.powerKw ?? null, powerKw: dto.powerKw ?? null,
@@ -52,7 +51,9 @@ export class LocomotivesService {
} }
async findById(id: string): Promise<Locomotive> { async findById(id: string): Promise<Locomotive> {
const locomotive = await this.locomotivesRepository.findById(id); const locomotive = await this.locomotivesRepository.findById(id, {
relations: { currentYard: true },
});
if (!locomotive) { if (!locomotive) {
throw new NotFoundException(`Locomotive ${id} not found`); throw new NotFoundException(`Locomotive ${id} not found`);
@@ -76,10 +77,10 @@ export class LocomotivesService {
locomotiveType: locomotiveType:
dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType, dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus, status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
readiness: currentYardId:
dto.readiness === undefined dto.currentYardId === undefined
? locomotive.readiness ? locomotive.currentYardId
: (dto.readiness as LocomotiveReadiness), : (dto.currentYardId ?? null),
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null, name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null, powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
tractionForceKn: tractionForceKn:

View File

@@ -4,7 +4,7 @@ import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength,
import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity'; import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
const CURRENCIES = ['ETB', 'USD'] as const; const CURRENCIES = ['USD'] as const;
export class CreateRateDto { export class CreateRateDto {
@ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' }) @ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' })

View File

@@ -51,7 +51,7 @@ export class RatesService {
rateType: dto.rateType as Rate['rateType'], rateType: dto.rateType as Rate['rateType'],
containerTypeId: dto.containerTypeId, containerTypeId: dto.containerTypeId,
tradeDirection: dto.tradeDirection, tradeDirection: dto.tradeDirection,
currency: dto.currency, currency: dto.currency ?? 'USD',
rateValue: dto.rateValue, rateValue: dto.rateValue,
rateUnit: dto.rateUnit as Rate['rateUnit'], rateUnit: dto.rateUnit as Rate['rateUnit'],
status: 'DRAFT', status: 'DRAFT',
@@ -71,7 +71,7 @@ export class RatesService {
if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType']; if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType'];
if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId; if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId;
if (dto.tradeDirection !== undefined) updates.tradeDirection = dto.tradeDirection; 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.rateValue !== undefined) updates.rateValue = dto.rateValue;
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit']; if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom); if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom);

View File

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

View File

@@ -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<TrainCompositionRemovalLog> {
constructor(dataSource: DataSource) {
super(dataSource.getRepository(TrainCompositionRemovalLog));
}
async findByScheduleId(scheduleId: string): Promise<TrainCompositionRemovalLog[]> {
return this.findAll({
where: { scheduleId },
order: { removedAt: 'DESC' },
});
}
}

View File

@@ -3,11 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
import { TrainSchedule } from './entities/train-schedule.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 { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity';
import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity'; import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository'; import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
import { TrainSchedulesRepository } from './train-schedules.repository'; import { TrainSchedulesRepository } from './train-schedules.repository';
import { TrainCompositionRemovalLogRepository } from './train-composition-removal-log.repository';
import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository'; import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository'; import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository'; import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
@@ -17,6 +19,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([
TrainSchedule, TrainSchedule,
TrainScheduleBooking, TrainScheduleBooking,
TrainCompositionRemovalLog,
WagonBookingAllocation, WagonBookingAllocation,
WagonAllocationContainerItem, WagonAllocationContainerItem,
WagonAllocationBulkLoad, WagonAllocationBulkLoad,
@@ -25,6 +28,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
providers: [ providers: [
TrainSchedulesRepository, TrainSchedulesRepository,
TrainScheduleBookingsRepository, TrainScheduleBookingsRepository,
TrainCompositionRemovalLogRepository,
WagonBookingAllocationsRepository, WagonBookingAllocationsRepository,
WagonAllocationContainerItemsRepository, WagonAllocationContainerItemsRepository,
WagonAllocationBulkLoadsRepository, WagonAllocationBulkLoadsRepository,
@@ -32,6 +36,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
exports: [ exports: [
TrainSchedulesRepository, TrainSchedulesRepository,
TrainScheduleBookingsRepository, TrainScheduleBookingsRepository,
TrainCompositionRemovalLogRepository,
WagonBookingAllocationsRepository, WagonBookingAllocationsRepository,
WagonAllocationContainerItemsRepository, WagonAllocationContainerItemsRepository,
WagonAllocationBulkLoadsRepository, WagonAllocationBulkLoadsRepository,

View File

@@ -3,6 +3,9 @@ import {
listBatchWindowsForDate, listBatchWindowsForDate,
listBatchWindowsForBookings, listBatchWindowsForBookings,
BATCH_WINDOW_START_HOURS, BATCH_WINDOW_START_HOURS,
boardWindowForTimestamp,
listBoardWindowsForRange,
groupBookingsIntoBoardWindows,
} from './batch-window.util'; } from './batch-window.util';
describe('batch-window.util', () => { describe('batch-window.util', () => {
@@ -50,3 +53,84 @@ describe('batch-window.util', () => {
expect(getBatchWindowForTimestamp(fullyExecutedAt).key).toBe(overnight!.key); expect(getBatchWindowForTimestamp(fullyExecutedAt).key).toBe(overnight!.key);
}); });
}); });
describe('batch-window board windows (midnight-based 3h slots)', () => {
it('maps 04:00 EAT to the 03:0006: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:0003: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:0024: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 (0609 slot)
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (1215 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 → 0609 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);
});
});

View File

@@ -159,6 +159,154 @@ export function listBatchWindowsForBookings(
return [...byKey.values()].sort(compareBatchWindows); 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 (0003, 0306, … 2124). */
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<string>();
// 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<T>(
items: T[],
getTimestamp: (item: T) => Date | null | undefined,
openDate: Date,
departureDate: Date,
pendingKey = 'pending-contract',
): Map<string, { window: BoardWindow | null; items: T[] }> {
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
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`. */ /** Group items by batch window key; items without a timestamp go to `pendingKey`. */
export function groupByBatchWindow<T>( export function groupByBatchWindow<T>(
items: T[], items: T[],

View File

@@ -19,9 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service'; import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service'; import { TrainSchedulingService } from './train-scheduling.service';
import { import { groupBookingsIntoBoardWindows } from './batch-window.util';
groupByBatchWindow,
} from './batch-window.util';
import { import {
BATCH_CRON, BATCH_CRON,
BATCH_TIMEZONE, BATCH_TIMEZONE,
@@ -82,6 +80,10 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
export interface BatchWindowGroup { export interface BatchWindowGroup {
key: string; key: string;
label: 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; start: string;
end: string; end: string;
counts: { counts: {
@@ -401,11 +403,15 @@ export class BookingBatchService implements OnModuleInit {
const loco = s.trainSet?.locomotive ?? null; const loco = s.trainSet?.locomotive ?? null;
const referenceDate = s.scheduledDepartureDate ?? new Date(); // Display windows span the whole booking window: from when it opened
const windowBuckets = groupByBatchWindow( // (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, items,
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null), (item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
referenceDate, openDate,
departureDate,
); );
const emptyCounts = () => ({ const emptyCounts = () => ({
@@ -437,6 +443,8 @@ export class BookingBatchService implements OnModuleInit {
windows.push({ windows.push({
key: w.key, key: w.key,
label: w.label, label: w.label,
date: w.date,
dateLabel: w.dateLabel,
start: w.start.toISOString(), start: w.start.toISOString(),
end: w.end.toISOString(), end: w.end.toISOString(),
counts: countFor(bucket.items), counts: countFor(bucket.items),
@@ -477,6 +485,8 @@ export class BookingBatchService implements OnModuleInit {
pendingContract: { pendingContract: {
key: 'pending-contract', key: 'pending-contract',
label: 'Pending contract', label: 'Pending contract',
date: '',
dateLabel: '',
start: '', start: '',
end: '', end: '',
counts: countFor(pendingBookings), counts: countFor(pendingBookings),

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
export class AssignUnassignedBookingDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
bookingId!: string;
}

View File

@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator'; import { IsUUID } from 'class-validator';
export class AvailableLocomotivesQueryDto { 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() @IsUUID()
routeId!: string; routeId!: string;
} }

View File

@@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateContainerItemDto {
@IsString()
@IsOptional()
containerNumber?: string | null;
}

View File

@@ -9,15 +9,20 @@ import {
Post, Post,
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; 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 { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { AssignBookingsDto } from './dto/assign-bookings.dto'; 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 { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto'; import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PinWagonsDto } from './dto/pin-wagons.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 { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
@@ -75,7 +80,7 @@ export class TrainSchedulingController {
@Get('available-locomotives') @Get('available-locomotives')
@TrainSchedulingView() @TrainSchedulingView()
@ApiOperation({ @ApiOperation({
summary: 'List AVAILABLE locomotives filtered by route corridor readiness', summary: 'List AVAILABLE locomotives at the route origin yard',
}) })
getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) { getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) {
return this.trainSchedulingService.getAvailableLocomotivesForRoute(query.routeId); return this.trainSchedulingService.getAvailableLocomotivesForRoute(query.routeId);
@@ -176,8 +181,56 @@ export class TrainSchedulingController {
unassignBooking( unassignBooking(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: 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') @Post('schedules/:id/pin-wagons')
@@ -275,7 +328,7 @@ export class TrainSchedulingController {
@Post('schedules/:id/arrive') @Post('schedules/:id/arrive')
@TrainSchedulingManage() @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) { arriveSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.arriveSchedule(id); return this.trainSchedulingService.arriveSchedule(id);
} }

View File

@@ -1,10 +1,11 @@
import { BadRequestException, ConflictException } from '@nestjs/common'; 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 { Wagon } from '../wagons/entities/wagon.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.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'; import { TrainSchedulingService } from './train-scheduling.service';
const nw5 = { const nw5 = {
@@ -25,7 +26,7 @@ const locomotive = {
maxPullWeightTons: 3500, maxPullWeightTons: 3500,
maxTrainLengthMeters: 760, maxTrainLengthMeters: 760,
status: 'AVAILABLE', status: 'AVAILABLE',
readiness: WagonReadiness.ImportReady, currentYardId: 'yard-origin',
}; };
const cw3 = { const cw3 = {
@@ -96,6 +97,7 @@ describe('TrainSchedulingService', () => {
bookingsRepository = { bookingsRepository = {
findEligibleForScheduling: jest.fn(), findEligibleForScheduling: jest.fn(),
findByIdsForScheduling: jest.fn(), findByIdsForScheduling: jest.fn(),
findAll: jest.fn(),
updateSchedulingFields: jest.fn(), updateSchedulingFields: jest.fn(),
}; };
locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() }; locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() };
@@ -144,6 +146,7 @@ describe('TrainSchedulingService', () => {
wagonAllocationContainerItemsRepository as never, wagonAllocationContainerItemsRepository as never,
wagonAllocationBulkLoadsRepository as never, wagonAllocationBulkLoadsRepository as never,
trainCheckpointEventsRepository as never, trainCheckpointEventsRepository as never,
{} as never, // trainCompositionRemovalLogRepository
); );
const defaultFleetWagons = [ const defaultFleetWagons = [
@@ -151,14 +154,14 @@ describe('TrainSchedulingService', () => {
id: `wagon-nw5-${index}`, id: `wagon-nw5-${index}`,
wagonTypeId: nw5.id, wagonTypeId: nw5.id,
status: WagonStatus.Available, status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady, currentYardId: 'yard-origin',
currentTrainScheduleId: null, currentTrainScheduleId: null,
})), })),
...Array.from({ length: 50 }, (_, index) => ({ ...Array.from({ length: 50 }, (_, index) => ({
id: `wagon-cw3-${index}`, id: `wagon-cw3-${index}`,
wagonTypeId: cw3.id, wagonTypeId: cw3.id,
status: WagonStatus.Available, status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady, currentYardId: 'yard-origin',
currentTrainScheduleId: null, currentTrainScheduleId: null,
})), })),
]; ];
@@ -192,7 +195,7 @@ describe('TrainSchedulingService', () => {
id: `wagon-${index}`, id: `wagon-${index}`,
wagonTypeId: nw5.id, wagonTypeId: nw5.id,
status: WagonStatus.Available, status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady, currentYardId: 'yard-origin',
currentTrainScheduleId: null, currentTrainScheduleId: null,
})); }));
@@ -533,14 +536,14 @@ describe('TrainSchedulingService', () => {
).rejects.toBeInstanceOf(ConflictException); ).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 scheduleId = 'sched-1';
const slotId = 'slot-1'; const slotId = 'slot-1';
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: scheduleId, id: scheduleId,
status: 'DRAFT', status: 'DRAFT',
direction: 'IMPORT', originStationId: 'yard-origin',
trainSet: { trainSet: {
wagons: [{ id: slotId, physicalWagonId: null }], wagons: [{ id: slotId, physicalWagonId: null }],
}, },
@@ -554,7 +557,7 @@ describe('TrainSchedulingService', () => {
id: 'wagon-1', id: 'wagon-1',
wagonNumber: 'WGN-001', wagonNumber: 'WGN-001',
status: WagonStatus.Available, status: WagonStatus.Available,
readiness: WagonReadiness.ExportReady, currentYardId: 'yard-other',
currentTrainScheduleId: null, currentTrainScheduleId: null,
}), }),
update: jest.fn(), update: jest.fn(),
@@ -577,7 +580,7 @@ describe('TrainSchedulingService', () => {
).rejects.toBeInstanceOf(ConflictException); ).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( const exportBooking = makeBooking(
'exp-1', 'exp-1',
'BKG-EXP', 'BKG-EXP',
@@ -597,14 +600,16 @@ describe('TrainSchedulingService', () => {
wagonTypesRepository.findAll.mockResolvedValue([nw5]); wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]); bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); 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}`, id: `wagon-nw5-${index}`,
wagonTypeId: nw5.id, wagonTypeId: nw5.id,
wagonNumber: `WGN-${index}`, wagonNumber: `WGN-${index}`,
status: WagonStatus.Available, status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady, currentYardId: 'yard-djibouti',
currentTrainScheduleId: null, currentTrainScheduleId: null,
})); }));
@@ -613,7 +618,7 @@ describe('TrainSchedulingService', () => {
return { find: jest.fn().mockResolvedValue([]) }; return { find: jest.fn().mockResolvedValue([]) };
} }
if (entity === Wagon) { if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(importOnlyFleet) }; return { find: jest.fn().mockResolvedValue(wrongYardFleet) };
} }
if (entity === WagonType) { if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) }; return { find: jest.fn().mockResolvedValue([nw5]) };
@@ -630,7 +635,7 @@ describe('TrainSchedulingService', () => {
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect( 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); ).toBe(true);
}); });
@@ -722,14 +727,160 @@ describe('TrainSchedulingService', () => {
).rejects.toBeInstanceOf(BadRequestException); ).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', () => { 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 routeId = 'route-export';
const originYardId = 'yard-addis';
const routeRepo = { const routeRepo = {
findOne: jest.fn().mockResolvedValue({ findOne: jest.fn().mockResolvedValue({
id: routeId, id: routeId,
name: 'Addis → Djibouti', name: 'Addis → Djibouti',
isActive: true, isActive: true,
originYardId,
originYard: { country: 'Ethiopia' }, originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Djibouti' }, destinationYard: { country: 'Djibouti' },
}), }),
@@ -739,23 +890,28 @@ describe('TrainSchedulingService', () => {
return { findOne: jest.fn(), update: jest.fn() }; return { findOne: jest.fn(), update: jest.fn() };
}); });
locomotivesRepository.findAll.mockResolvedValue([ locomotivesRepository.findAll.mockResolvedValue([
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady }, { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady },
]); ]);
const result = await service.getAvailableLocomotivesForRoute(routeId); const result = await service.getAvailableLocomotivesForRoute(routeId);
expect(locomotivesRepository.findAll).toHaveBeenCalledWith({
where: { status: 'AVAILABLE', currentYardId: originYardId },
order: { code: 'ASC' },
});
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].code).toBe('EXP'); 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 routeId = 'route-domestic';
const originYardId = 'yard-addis';
const routeRepo = { const routeRepo = {
findOne: jest.fn().mockResolvedValue({ findOne: jest.fn().mockResolvedValue({
id: routeId, id: routeId,
name: 'Addis → Dire Dawa', name: 'Addis → Dire Dawa',
isActive: true, isActive: true,
originYardId,
originYard: { country: 'Ethiopia' }, originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Ethiopia' }, destinationYard: { country: 'Ethiopia' },
}), }),
@@ -765,8 +921,8 @@ describe('TrainSchedulingService', () => {
return { findOne: jest.fn(), update: jest.fn() }; return { findOne: jest.fn(), update: jest.fn() };
}); });
locomotivesRepository.findAll.mockResolvedValue([ locomotivesRepository.findAll.mockResolvedValue([
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady }, { id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId },
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady }, { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
]); ]);
const result = await service.getAvailableLocomotivesForRoute(routeId); const result = await service.getAvailableLocomotivesForRoute(routeId);

View File

@@ -26,9 +26,11 @@ import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.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 { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.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 { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.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 { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PinWagonsDto } from './dto/pin-wagons.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 { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
@@ -52,6 +55,7 @@ import {
selectBookingsWithinFleetCap, selectBookingsWithinFleetCap,
summarizeFleetWarnings, summarizeFleetWarnings,
totalAssignedWeight, totalAssignedWeight,
wagonsRequiredForBooking,
type DeferredBookingRow, type DeferredBookingRow,
type FleetAvailabilityRow, type FleetAvailabilityRow,
} from './fleet-plan.util'; } from './fleet-plan.util';
@@ -75,11 +79,6 @@ import {
pickBulkWagonType, pickBulkWagonType,
} from './wagon-type-resolver.util'; } from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util'; import { deriveScheduleDirection } from './derive-schedule-direction.util';
import {
flipReadiness,
requiredWagonReadiness,
wagonReadinessMatchesSchedule,
} from './wagon-readiness.util';
import { import {
deriveTrainCapacityFromLocomotive, deriveTrainCapacityFromLocomotive,
wagonTypeDimensionsFromEntity, wagonTypeDimensionsFromEntity,
@@ -121,6 +120,26 @@ export interface WagonAllocationAttemptResult {
violations: string[]; 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<TrainLimitConfig> = { const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
maxWeightTons: 3500, maxWeightTons: 3500,
maxLengthMeters: 760, maxLengthMeters: 760,
@@ -143,6 +162,7 @@ export class TrainSchedulingService {
private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository, private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository, private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository, private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
private readonly configService?: ConfigService, private readonly configService?: ConfigService,
) {} ) {}
@@ -269,9 +289,9 @@ export class TrainSchedulingService {
route.originYard ?? { country: null }, route.originYard ?? { country: null },
route.destinationYard ?? { country: null }, route.destinationYard ?? { country: null },
); );
if (!wagonReadinessMatchesSchedule(lockedLocomotive.readiness, direction)) { if (lockedLocomotive.currentYardId !== route.originYardId) {
throw new ConflictException( 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( await this.autoPinWagonsForSchedule(
manager, manager,
scheduleId, scheduleId,
schedule.direction ?? null, schedule.originStationId,
savedWagons, savedWagons,
); );
}); });
@@ -467,7 +487,7 @@ export class TrainSchedulingService {
return { ...detail, warnings, deferredBookings }; 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); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) { if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`); 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`); 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) => { await this.dataSource.transaction(async (manager) => {
const allocationIds = (schedule.trainSet?.wagons ?? []) const allocationIds = (schedule.trainSet?.wagons ?? [])
.flatMap((w) => w.allocations ?? []) .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); return this.getTrainScheduleById(scheduleId);
} }
@@ -565,9 +600,9 @@ export class TrainSchedulingService {
`Wagon ${physicalWagon.wagonNumber} is not available`, `Wagon ${physicalWagon.wagonNumber} is not available`,
); );
} }
if (!wagonReadinessMatchesSchedule(physicalWagon.readiness, schedule.direction)) { if (physicalWagon.currentYardId !== schedule.originStationId) {
throw new ConflictException( 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 * Mark a dispatched train arrived: close out the schedule, move the locomotive
* locomotive + wagons (they have repositioned), and free the assets for re-use. * and wagons to the destination yard, and free the assets for re-use.
*/ */
async arriveSchedule(scheduleId: string) { async arriveSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
@@ -839,7 +874,6 @@ export class TrainSchedulingService {
throw new BadRequestException('Only DISPATCHED trains can arrive'); throw new BadRequestException('Only DISPATCHED trains can arrive');
} }
const isDomestic = schedule.direction === 'DOMESTIC';
const now = new Date(); const now = new Date();
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
@@ -863,7 +897,7 @@ export class TrainSchedulingService {
if (loco) { if (loco) {
await manager.getRepository(Locomotive).update(loco.id, { await manager.getRepository(Locomotive).update(loco.id, {
status: 'AVAILABLE', status: 'AVAILABLE',
readiness: isDomestic ? loco.readiness : flipReadiness(loco.readiness), currentYardId: schedule.destinationStationId,
}); });
} }
} }
@@ -878,7 +912,7 @@ export class TrainSchedulingService {
currentTrainScheduleId: null, currentTrainScheduleId: null,
trainSetWagonId: null, trainSetWagonId: null,
status: WagonStatus.Available, status: WagonStatus.Available,
readiness: isDomestic ? wagon.readiness : flipReadiness(wagon.readiness), currentYardId: schedule.destinationStationId,
}); });
} }
@@ -1028,11 +1062,15 @@ export class TrainSchedulingService {
} }
if ( if (
bookings.some( bookings.some((b) => {
(b) => if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
return false;
}
return (
b.originYardId !== dto.originStationId || 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'); violations.push('Selected bookings must share the same origin and destination as the schedule');
} }
@@ -1083,8 +1121,8 @@ export class TrainSchedulingService {
: buildBulkWagonPlan(bookings, wagonType); : buildBulkWagonPlan(bookings, wagonType);
} }
const scheduleDirection = await this.resolveScheduleDirection(targetScheduleId, bookings); const originYardId = dto.originStationId;
const fleetCounts = await this.countFleetAvailability(scheduleDirection, targetScheduleId); const fleetCounts = await this.countFleetAvailability(originYardId, targetScheduleId);
const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available])); const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available]));
fleetAvailability = computeFleetAvailability( fleetAvailability = computeFleetAvailability(
demandPlan, demandPlan,
@@ -1113,7 +1151,7 @@ export class TrainSchedulingService {
violations.push( violations.push(
...(await this.validatePhysicalFleetForPlan( ...(await this.validatePhysicalFleetForPlan(
wagonPlan, wagonPlan,
scheduleDirection, originYardId,
targetScheduleId, targetScheduleId,
)), )),
); );
@@ -1170,26 +1208,43 @@ export class TrainSchedulingService {
} }
} }
const availableLocomotives = ( let assignedLocomotive: Locomotive | null = null;
await this.locomotivesRepository.findAll({ if (targetScheduleId) {
where: { status: 'AVAILABLE' }, const targetSchedule =
}) await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
).filter((l) => wagonReadinessMatchesSchedule(l.readiness, scheduleDirection)); assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null;
if (!availableLocomotives.length) { }
const readinessHint = requiredWagonReadiness(scheduleDirection);
violations.push( if (assignedLocomotive) {
readinessHint if (assignedLocomotive.currentYardId !== originYardId) {
? `No available ${readinessHint} locomotive exists for this ${scheduleDirection} schedule` violations.push(
: 'No available locomotive exists for scheduling', `Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`,
); );
} else if ( } else if (
!availableLocomotives.some( Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons ||
(l) => Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters
Number(l.maxPullWeightTons) >= totalWeightTons && ) {
Number(l.maxTrainLengthMeters) >= totalLengthMeters, violations.push(
) 'Assigned locomotive cannot support the total train weight and length',
) { );
violations.push('No available locomotive can 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 { return {
@@ -1334,26 +1389,8 @@ export class TrainSchedulingService {
]; ];
} }
private async resolveScheduleDirection(
targetScheduleId: string | undefined,
bookings: Booking[],
): Promise<string | null> {
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( private async countFleetAvailability(
scheduleDirection: string | null, originYardId: string,
targetScheduleId?: string, targetScheduleId?: string,
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> { ): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
const [wagons, wagonTypes] = await Promise.all([ const [wagons, wagonTypes] = await Promise.all([
@@ -1368,7 +1405,7 @@ export class TrainSchedulingService {
? wagon.currentTrainScheduleId === targetScheduleId ? wagon.currentTrainScheduleId === targetScheduleId
: false; : false;
if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue; if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue;
if (!wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection)) continue; if (wagon.currentYardId !== originYardId) continue;
const typeId = wagon.wagonTypeId; const typeId = wagon.wagonTypeId;
const code = typeCodeById.get(typeId) ?? typeId; const code = typeCodeById.get(typeId) ?? typeId;
@@ -1399,7 +1436,7 @@ export class TrainSchedulingService {
private async autoPinWagonsForSchedule( private async autoPinWagonsForSchedule(
manager: EntityManager, manager: EntityManager,
scheduleId: string, scheduleId: string,
scheduleDirection: string | null, originYardId: string,
slots: TrainSetWagon[], slots: TrainSetWagon[],
) { ) {
const wagons = await manager.getRepository(Wagon).find(); const wagons = await manager.getRepository(Wagon).find();
@@ -1419,7 +1456,7 @@ export class TrainSchedulingService {
planSlots, planSlots,
wagons, wagons,
scheduleId, scheduleId,
scheduleDirection, originYardId,
); );
if (unpinnable.length) { if (unpinnable.length) {
throw new BadRequestException({ throw new BadRequestException({
@@ -1434,7 +1471,7 @@ export class TrainSchedulingService {
slot, slot,
wagons, wagons,
scheduleId, scheduleId,
scheduleDirection, originYardId,
assignedPhysicalIds, assignedPhysicalIds,
); );
if (!physical) continue; if (!physical) continue;
@@ -1455,7 +1492,7 @@ export class TrainSchedulingService {
/** Pre-assign check: every planned slot must have a matching physical wagon. */ /** Pre-assign check: every planned slot must have a matching physical wagon. */
private async validatePhysicalFleetForPlan( private async validatePhysicalFleetForPlan(
wagonPlan: WagonPlanSlot[], wagonPlan: WagonPlanSlot[],
scheduleDirection: string | null, originYardId: string,
targetScheduleId?: string, targetScheduleId?: string,
): Promise<string[]> { ): Promise<string[]> {
if (!wagonPlan.length) return []; if (!wagonPlan.length) return [];
@@ -1469,7 +1506,7 @@ export class TrainSchedulingService {
})), })),
wagons, wagons,
targetScheduleId, targetScheduleId,
scheduleDirection, originYardId,
); );
} }
@@ -1477,24 +1514,22 @@ export class TrainSchedulingService {
slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>, slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>,
wagons: Wagon[], wagons: Wagon[],
scheduleId: string | undefined, scheduleId: string | undefined,
scheduleDirection: string | null, originYardId: string,
): string[] { ): string[] {
const violations: string[] = []; const violations: string[] = [];
const assignedPhysicalIds = new Set<string>(); const assignedPhysicalIds = new Set<string>();
const required = requiredWagonReadiness(scheduleDirection);
const readinessLabel = required ?? 'any readiness';
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) { for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
const physical = this.pickPhysicalWagonForSlot( const physical = this.pickPhysicalWagonForSlot(
slot, slot,
wagons, wagons,
scheduleId, scheduleId,
scheduleDirection, originYardId,
assignedPhysicalIds, assignedPhysicalIds,
); );
if (!physical) { if (!physical) {
violations.push( violations.push(
`No ${readinessLabel} ${slot.wagonTypeCode} wagon available for slot #${slot.sequenceNo}`, `No ${slot.wagonTypeCode} wagon available at yard for slot #${slot.sequenceNo}`,
); );
continue; continue;
} }
@@ -1508,7 +1543,7 @@ export class TrainSchedulingService {
slot: { wagonTypeId: string }, slot: { wagonTypeId: string },
wagons: Wagon[], wagons: Wagon[],
scheduleId: string | undefined, scheduleId: string | undefined,
scheduleDirection: string | null, originYardId: string,
assignedPhysicalIds: Set<string>, assignedPhysicalIds: Set<string>,
): Wagon | undefined { ): Wagon | undefined {
return wagons.find((wagon) => { return wagons.find((wagon) => {
@@ -1518,7 +1553,7 @@ export class TrainSchedulingService {
? wagon.currentTrainScheduleId === scheduleId ? wagon.currentTrainScheduleId === scheduleId
: false; : false;
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return 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, id: schedule.trainSet.locomotive.id,
code: schedule.trainSet.locomotive.code, code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name ?? null, name: schedule.trainSet.locomotive.name ?? null,
readiness: schedule.trainSet.locomotive.readiness ?? null, currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
} }
: null, : null,
wagonCount: schedule.trainSet?.wagonCount ?? 0, 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<Locomotive[]> { async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
const route = await this.getActiveRoute(routeId); 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({ const locomotives = await this.locomotivesRepository.findAll({
where: { status: 'AVAILABLE' }, where: { status: 'AVAILABLE', currentYardId: route.originYardId },
order: { code: 'ASC' }, order: { code: 'ASC' },
}); });
if (!requiredReadiness) { return locomotives;
return locomotives;
}
return locomotives.filter((l) => wagonReadinessMatchesSchedule(l.readiness, direction));
} }
/** 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) { async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
const schedules = await this.trainSchedulesRepository.findAll({ const schedules = await this.trainSchedulesRepository.findAll({
where: { where: {
bookingWindowStatus: 'OPEN', bookingWindowStatus: 'OPEN',
...(originYardId ? { originStationId: originYardId } : {}),
...(destinationYardId ? { destinationStationId: destinationYardId } : {}),
}, },
relations: { relations: {
trainSet: { locomotive: true }, trainSet: { locomotive: true },
route: true, route: { milestones: true },
originStation: true, originStation: true,
destinationStation: true, destinationStation: true,
scheduleBookings: { booking: true }, scheduleBookings: { booking: true },
}, },
order: { scheduledDepartureDate: 'ASC' }, order: { scheduledDepartureDate: 'ASC' },
}); });
return schedules
const filteredSchedules = schedules
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status)) .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)); .map((s) => this.mapScheduleListItem(s));
return filteredSchedules;
} }
private async mapScheduleDetail( private async mapScheduleDetail(
@@ -1952,7 +2020,7 @@ export class TrainSchedulingService {
code: schedule.trainSet.locomotive.code, code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name, name: schedule.trainSet.locomotive.name,
status: schedule.trainSet.locomotive.status, status: schedule.trainSet.locomotive.status,
readiness: schedule.trainSet.locomotive.readiness ?? null, currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
maxPullWeightTons: roundTons( maxPullWeightTons: roundTons(
Number(schedule.trainSet.locomotive.maxPullWeightTons), Number(schedule.trainSet.locomotive.maxPullWeightTons),
), ),
@@ -2031,6 +2099,102 @@ export class TrainSchedulingService {
return SchedulingStatus.Eligible; 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. */ /** Preview wagon allocation issues per linked booking without mutating the schedule. */
async previewAllocationForSchedule( async previewAllocationForSchedule(
scheduleId: string, scheduleId: string,
@@ -2236,6 +2400,303 @@ export class TrainSchedulingService {
return result; return result;
} }
async removeTrainSetWagonSlot(scheduleId: string, trainSetWagonId: string): Promise<any> {
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<UnassignedBookingsResponse> {
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<string>,
booking: Booking,
fleetByTypeId: Map<string, { code: string; available: number }>,
): 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<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
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<any[]> {
return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
}
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> { private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id); const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);

View File

@@ -1,5 +1,6 @@
import { WagonReadiness, type ScheduleTradeDirection } from '@edr/types'; import { WagonReadiness, type ScheduleTradeDirection } from '@edr/types';
/** @deprecated Replaced by yard-based fleet filtering via `currentYardId`. */
export function requiredWagonReadiness( export function requiredWagonReadiness(
direction: ScheduleTradeDirection | string | null | undefined, direction: ScheduleTradeDirection | string | null | undefined,
): WagonReadiness | null { ): WagonReadiness | null {
@@ -8,6 +9,7 @@ export function requiredWagonReadiness(
return null; return null;
} }
/** @deprecated Replaced by `wagon.currentYardId === originYardId` checks. */
export function wagonReadinessMatchesSchedule( export function wagonReadinessMatchesSchedule(
wagonReadiness: WagonReadiness | string, wagonReadiness: WagonReadiness | string,
direction: ScheduleTradeDirection | string | null | undefined, direction: ScheduleTradeDirection | string | null | undefined,
@@ -18,9 +20,7 @@ export function wagonReadinessMatchesSchedule(
} }
/** /**
* Toggle a readiness value (IMPORT_READY ↔ EXPORT_READY). Used when a train * @deprecated Replaced by setting `currentYardId = schedule.destinationStationId` on arrival.
* reaches its destination: the asset has repositioned, so it is now ready for
* the opposite direction. Direction-agnostic so it handles round trips.
*/ */
export function flipReadiness( export function flipReadiness(
readiness: WagonReadiness | string, readiness: WagonReadiness | string,

View File

@@ -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'; import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator';
export class CreateWagonDto { export class CreateWagonDto {
@@ -30,8 +30,8 @@ export class CreateWagonDto {
status?: WagonStatus; status?: WagonStatus;
@IsOptional() @IsOptional()
@IsEnum(WagonReadiness) @IsUUID()
readiness?: WagonReadiness; currentYardId?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()

View File

@@ -1,4 +1,4 @@
import { WagonReadiness, WagonStatus } from '@edr/types'; import { WagonStatus } from '@edr/types';
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
@@ -14,10 +14,10 @@ export class ListWagonsQueryDto {
@IsEnum(WagonStatus) @IsEnum(WagonStatus)
status?: WagonStatus; status?: WagonStatus;
@ApiPropertyOptional({ enum: WagonReadiness }) @ApiPropertyOptional({ description: 'Filter by current yard' })
@IsOptional() @IsOptional()
@IsEnum(WagonReadiness) @IsUUID()
readiness?: WagonReadiness; currentYardId?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()

View File

@@ -1,11 +1,12 @@
// apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts // 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 { Entity, Column, ManyToOne, OneToMany, JoinColumn, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import { Train } from '../../trains/entities/train.entity'; import { Train } from '../../trains/entities/train.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { Container } from '../../container-management/entities/container.entity'; import { Container } from '../../container-management/entities/container.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
export const WAGON_STATUSES = [ export const WAGON_STATUSES = [
WagonStatus.Available, WagonStatus.Available,
@@ -14,16 +15,10 @@ export const WAGON_STATUSES = [
WagonStatus.Retired, WagonStatus.Retired,
] as const; ] as const;
export const WAGON_READINESS_VALUES = [
WagonReadiness.ImportReady,
WagonReadiness.ExportReady,
] as const;
export type WagonStatusType = (typeof WAGON_STATUSES)[number]; export type WagonStatusType = (typeof WAGON_STATUSES)[number];
export type WagonReadinessType = (typeof WAGON_READINESS_VALUES)[number];
@Entity({ name: 'wagons', schema: 'freight' }) @Entity({ name: 'wagons', schema: 'freight' })
@Index(['readiness']) @Index(['currentYardId'])
export class Wagon extends BaseEntity { export class Wagon extends BaseEntity {
@Column({ unique: true, name: 'wagon_number' }) @Column({ unique: true, name: 'wagon_number' })
wagonNumber!: string; wagonNumber!: string;
@@ -46,8 +41,12 @@ export class Wagon extends BaseEntity {
@Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) @Column({ type: 'varchar', length: 20, default: WagonStatus.Available })
status!: WagonStatusType; status!: WagonStatusType;
@Column({ type: 'varchar', length: 20, default: WagonReadiness.ImportReady }) @Column({ name: 'current_yard_id', type: 'uuid', nullable: true })
readiness!: WagonReadinessType; currentYardId!: string | null;
@ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'current_yard_id' })
currentYard?: Yard | null;
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
notes!: string | null; notes!: string | null;

View File

@@ -1,4 +1,4 @@
import { WagonReadiness, WagonStatus } from '@edr/types'; import { WagonStatus } from '@edr/types';
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm'; import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
@@ -24,11 +24,11 @@ export class WagonsService {
const wagon = this.wagonRepo.create({ const wagon = this.wagonRepo.create({
...dto, ...dto,
status: dto.status ?? WagonStatus.Available, status: dto.status ?? WagonStatus.Available,
readiness: dto.readiness ?? WagonReadiness.ImportReady,
}); });
// Convert undefined to null for nullable fields // Convert undefined to null for nullable fields
if (dto.trainId === undefined) wagon.trainId = null; if (dto.trainId === undefined) wagon.trainId = null;
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
if (dto.currentYardId === undefined) wagon.currentYardId = null;
return this.wagonRepo.save(wagon); return this.wagonRepo.save(wagon);
} }
@@ -39,7 +39,7 @@ export class WagonsService {
const wagonTypeId = query.wagonTypeId?.trim(); const wagonTypeId = query.wagonTypeId?.trim();
const filters: FindOptionsWhere<Wagon> = { const filters: FindOptionsWhere<Wagon> = {
...(query.status ? { status: query.status } : {}), ...(query.status ? { status: query.status } : {}),
...(query.readiness ? { readiness: query.readiness } : {}), ...(query.currentYardId ? { currentYardId: query.currentYardId } : {}),
...(trainId ? { trainId } : {}), ...(trainId ? { trainId } : {}),
...(wagonTypeId ? { wagonTypeId } : {}), ...(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) ? (query.sortBy as keyof Wagon)
: 'wagonNumber'; : 'wagonNumber';
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
return this.wagonRepo.find({ return this.wagonRepo.find({
where: search ? where : filters, where: search ? where : filters,
relations: { currentYard: true },
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>, order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
take: query.limit ? Number(query.limit) : undefined, take: query.limit ? Number(query.limit) : undefined,
@@ -65,7 +66,10 @@ export class WagonsService {
} }
async findById(id: string): Promise<Wagon> { async findById(id: string): Promise<Wagon> {
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`); if (!wagon) throw new NotFoundException(`Wagon ${id} not found`);
return wagon; return wagon;
} }

View File

@@ -19,7 +19,7 @@ import { Container } from "../modules/container-management/entities/container.en
import { Route } from "../modules/routes/entities/route.entity"; import { Route } from "../modules/routes/entities/route.entity";
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity"; import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
import { Wagon } from "../modules/wagons/entities/wagon.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"; const SEED_FLAG = "SEED_DEMO_BOOKINGS";
@@ -499,7 +499,7 @@ export class DemoBookingsSeeder {
} }
const nw5 = await manager.getRepository(WagonType).findOneBy({ code: "NW5" }); const nw5 = await manager.getRepository(WagonType).findOneBy({ code: "NW5" });
if (nw5) { if (nw5 && djibouti && addis) {
await manager.getRepository(Wagon).upsert( await manager.getRepository(Wagon).upsert(
Array.from({ length: 20 }, (_, index) => ({ Array.from({ length: 20 }, (_, index) => ({
wagonNumber: `WGN-DEMO-${String(index + 1).padStart(3, "0")}`, wagonNumber: `WGN-DEMO-${String(index + 1).padStart(3, "0")}`,
@@ -509,10 +509,7 @@ export class DemoBookingsSeeder {
tareWeight: 20, tareWeight: 20,
maxPayloadWeight: 70, maxPayloadWeight: 70,
status: WagonStatus.Available, status: WagonStatus.Available,
readiness: currentYardId: index % 2 === 0 ? djibouti.id : addis.id,
index % 2 === 0
? WagonReadiness.ImportReady
: WagonReadiness.ExportReady,
notes: "Demo wagon for train scheduling", notes: "Demo wagon for train scheduling",
trainSetWagonId: null, trainSetWagonId: null,
currentTrainScheduleId: 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 ft20 = containerTypeByCode.get("20FT");
const ft40 = containerTypeByCode.get("40FT"); const ft40 = containerTypeByCode.get("40FT");
if (ft20 && ft40) { if (ft20 && ft40) {

View File

@@ -441,20 +441,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
rateValue: 1200, rateValue: 1200,
rateUnit: "PER_CONTAINER", 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", rateType: "CONTAINER_EXPORT",
containerTypeId: ctByCode.get("20FT")!.id, containerTypeId: ctByCode.get("20FT")!.id,
@@ -469,34 +455,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
rateValue: 900, rateValue: 900,
rateUnit: "PER_CONTAINER", 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", rateType: "CONTAINER_IMPORT",
containerTypeId: null, containerTypeId: null,
@@ -504,13 +462,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
rateValue: 1000, rateValue: 1000,
rateUnit: "PER_CONTAINER", rateUnit: "PER_CONTAINER",
}, },
{
rateType: "CONTAINER_IMPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 56000,
rateUnit: "PER_CONTAINER",
},
{ {
rateType: "CONTAINER_EXPORT", rateType: "CONTAINER_EXPORT",
containerTypeId: null, containerTypeId: null,
@@ -519,17 +470,24 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
rateUnit: "PER_CONTAINER", rateUnit: "PER_CONTAINER",
}, },
{ {
rateType: "CONTAINER_EXPORT", rateType: "INTERCITY_CONTAINER",
containerTypeId: null, containerTypeId: ctByCode.get("20FT")!.id,
currency: "ETB", currency: "USD",
rateValue: 42000, rateValue: 350,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_CONTAINER",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "USD",
rateValue: 550,
rateUnit: "PER_CONTAINER", rateUnit: "PER_CONTAINER",
}, },
{ {
rateType: "INTERCITY_CONTAINER", rateType: "INTERCITY_CONTAINER",
containerTypeId: null, containerTypeId: null,
currency: "ETB", currency: "USD",
rateValue: 25000, rateValue: 400,
rateUnit: "PER_CONTAINER", rateUnit: "PER_CONTAINER",
}, },
{ {
@@ -539,13 +497,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
rateValue: 35, rateValue: 35,
rateUnit: "PER_TON", rateUnit: "PER_TON",
}, },
{
rateType: "INTERCITY_BULK",
containerTypeId: null,
currency: "ETB",
rateValue: 1900,
rateUnit: "PER_TON",
},
{ {
rateType: "BULK_IMPORT", rateType: "BULK_IMPORT",
containerTypeId: null, containerTypeId: null,
@@ -553,13 +504,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
rateValue: 50, rateValue: 50,
rateUnit: "PER_TON", rateUnit: "PER_TON",
}, },
{
rateType: "BULK_IMPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 2800,
rateUnit: "PER_TON",
},
{ {
rateType: "BULK_EXPORT", rateType: "BULK_EXPORT",
containerTypeId: null, containerTypeId: null,
@@ -567,13 +511,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
rateValue: 40, rateValue: 40,
rateUnit: "PER_TON", rateUnit: "PER_TON",
}, },
{
rateType: "BULK_EXPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 2200,
rateUnit: "PER_TON",
},
{ {
rateType: "OVERWEIGHT_PER_TON", rateType: "OVERWEIGHT_PER_TON",
containerTypeId: null, containerTypeId: null,
@@ -581,13 +518,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
rateValue: 25, rateValue: 25,
rateUnit: "PER_TON", rateUnit: "PER_TON",
}, },
{
rateType: "OVERWEIGHT_PER_TON",
containerTypeId: null,
currency: "ETB",
rateValue: 1400,
rateUnit: "PER_TON",
},
{ {
rateType: "HAZARD_SURCHARGE", rateType: "HAZARD_SURCHARGE",
containerTypeId: null, containerTypeId: null,
@@ -595,13 +525,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
rateValue: 150, rateValue: 150,
rateUnit: "FLAT", rateUnit: "FLAT",
}, },
{
rateType: "HAZARD_SURCHARGE",
containerTypeId: null,
currency: "ETB",
rateValue: 8500,
rateUnit: "FLAT",
},
{ {
rateType: "REEFER_SURCHARGE", rateType: "REEFER_SURCHARGE",
containerTypeId: null, containerTypeId: null,
@@ -609,13 +532,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
rateValue: 200, rateValue: 200,
rateUnit: "FLAT", rateUnit: "FLAT",
}, },
{
rateType: "REEFER_SURCHARGE",
containerTypeId: null,
currency: "ETB",
rateValue: 11000,
rateUnit: "FLAT",
},
{ {
rateType: "DOUBLE_HANDLING", rateType: "DOUBLE_HANDLING",
containerTypeId: null, containerTypeId: null,
@@ -623,13 +539,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
rateValue: 100, rateValue: 100,
rateUnit: "PER_CONTAINER", rateUnit: "PER_CONTAINER",
}, },
{
rateType: "DOUBLE_HANDLING",
containerTypeId: null,
currency: "ETB",
rateValue: 5500,
rateUnit: "PER_CONTAINER",
},
{ {
rateType: "LASHING", rateType: "LASHING",
containerTypeId: null, containerTypeId: null,
@@ -637,13 +546,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
rateValue: 50, rateValue: 50,
rateUnit: "PER_CONTAINER", rateUnit: "PER_CONTAINER",
}, },
{
rateType: "LASHING",
containerTypeId: null,
currency: "ETB",
rateValue: 2800,
rateUnit: "PER_CONTAINER",
},
]; ];
const entities = rateData.map((d) => const entities = rateData.map((d) =>
@@ -673,15 +575,10 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
}; };
const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD"); const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD");
const hazardRateEtb = findRate("HAZARD_SURCHARGE", "ETB");
const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD"); const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD");
const reeferRateEtb = findRate("REEFER_SURCHARGE", "ETB");
const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD"); const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD");
const overweightRateEtb = findRate("OVERWEIGHT_PER_TON", "ETB");
const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD"); const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD");
const shipLineRateEtb = findRate("DOUBLE_HANDLING", "ETB");
const consolidRateUsd = findRate("LASHING", "USD"); const consolidRateUsd = findRate("LASHING", "USD");
const consolidRateEtb = findRate("LASHING", "ETB");
await surRepo.createQueryBuilder().delete().execute(); await surRepo.createQueryBuilder().delete().execute();
await surRepo.save([ await surRepo.save([
@@ -689,35 +586,35 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
code: "HAZARDOUS_CARGO", code: "HAZARDOUS_CARGO",
label: "Hazardous Cargo", label: "Hazardous Cargo",
triggerCondition: "CARGO_FLAG_HAZARDOUS", triggerCondition: "CARGO_FLAG_HAZARDOUS",
rateId: hazardRateUsd?.id ?? hazardRateEtb?.id, rateId: hazardRateUsd?.id,
isActive: true, isActive: true,
}), }),
surRepo.create({ surRepo.create({
code: "REEFER_CARGO", code: "REEFER_CARGO",
label: "Reefer Cargo", label: "Reefer Cargo",
triggerCondition: "CARGO_FLAG_REEFER", triggerCondition: "CARGO_FLAG_REEFER",
rateId: reeferRateUsd?.id ?? reeferRateEtb?.id, rateId: reeferRateUsd?.id,
isActive: true, isActive: true,
}), }),
surRepo.create({ surRepo.create({
code: "OVERWEIGHT_CARGO", code: "OVERWEIGHT_CARGO",
label: "Overweight Cargo", label: "Overweight Cargo",
triggerCondition: "VGM_EXCEEDS_LIMIT", triggerCondition: "VGM_EXCEEDS_LIMIT",
rateId: overweightRateUsd?.id ?? overweightRateEtb?.id, rateId: overweightRateUsd?.id,
isActive: true, isActive: true,
}), }),
surRepo.create({ surRepo.create({
code: "SHIPPING_LINE_FEE", code: "SHIPPING_LINE_FEE",
label: "Shipping Line Fee", label: "Shipping Line Fee",
triggerCondition: "SHIPPING_LINE_MAPPED", triggerCondition: "SHIPPING_LINE_MAPPED",
rateId: shipLineRateUsd?.id ?? shipLineRateEtb?.id, rateId: shipLineRateUsd?.id,
isActive: true, isActive: true,
}), }),
surRepo.create({ surRepo.create({
code: "CONSOLIDATION_FEE", code: "CONSOLIDATION_FEE",
label: "Consolidation Fee", label: "Consolidation Fee",
triggerCondition: "CONSOLIDATION_ENABLED", triggerCondition: "CONSOLIDATION_ENABLED",
rateId: consolidRateUsd?.id ?? consolidRateEtb?.id, rateId: consolidRateUsd?.id,
isActive: true, isActive: true,
}), }),
]); ]);
@@ -784,10 +681,10 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
}, },
{ {
reference: "BKG-PRICE-003", reference: "BKG-PRICE-003",
description: "20FT container import + shipping line (ETB)", description: "20FT container import + shipping line (USD)",
freightType: "CONTAINER" as const, freightType: "CONTAINER" as const,
tradeDirection: "IMPORT", tradeDirection: "IMPORT",
paymentCurrency: "ETB", paymentCurrency: "USD",
serviceTypeId: railContainer.id, serviceTypeId: railContainer.id,
originYardId: djibouti.id, originYardId: djibouti.id,
destinationYardId: addis.id, destinationYardId: addis.id,
@@ -799,7 +696,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
containers: [ containers: [
{ containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 }, { containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 },
], ],
expectedBaseRate: 45000, expectedBaseRate: 800,
expectedSurcharges: ["SHIPPING_LINE_FEE"], expectedSurcharges: ["SHIPPING_LINE_FEE"],
}, },
{ {

View File

@@ -111,7 +111,10 @@ const FleetCardGrid = ({
</Text> </Text>
{subtitle != null && subtitle !== "" ? ( {subtitle != null && subtitle !== "" ? (
<Text size="xs" c="dimmed" lineClamp={1}> <Text size="xs" c="dimmed" lineClamp={1}>
{String(subtitle)} {presentation.subtitleKey === "currentYard" ||
presentation.subtitleKey === "currentYardId"
? formatFleetCell(subtitle, "entityLabel", presentation.subtitleKey)
: String(subtitle)}
</Text> </Text>
) : null} ) : null}
</Stack> </Stack>

View File

@@ -6,7 +6,7 @@ export type FleetViewMode = "table" | "cards";
const STORAGE_PREFIX = "edr-freight-fleet-view:"; 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 => { const readStored = (slug: ViewModeSlug): FleetViewMode => {
try { try {

View File

@@ -522,9 +522,7 @@ export function AllocateBookingWizard({
placeholder={routeId ? "Select locomotive" : "Select a route first"} placeholder={routeId ? "Select locomotive" : "Select a route first"}
data={(locomotivesQuery.data ?? []).map((l) => ({ data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id, value: l.id,
label: `${l.code} · ${ label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
}`,
}))} }))}
value={locomotiveId || null} value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")} onChange={(v) => setLocomotiveId(v ?? "")}

View File

@@ -18,7 +18,7 @@ import { Freight } from "@edr/types";
import type { PinWagonAssignment, TrainScheduleDetail } from "@/types/trainScheduling"; import type { PinWagonAssignment, TrainScheduleDetail } from "@/types/trainScheduling";
import type { Wagon } from "@/services/wagon.service"; import type { Wagon } from "@/services/wagon.service";
import { wagonMatchesScheduleDirection } from "@/utils/wagonAvailability"; import { wagonMatchesScheduleOrigin } from "@/utils/wagonAvailability";
import { autoFillWagonAssignments, countFilledSlots } from "./pinWagons.util"; import { autoFillWagonAssignments, countFilledSlots } from "./pinWagons.util";
@@ -35,6 +35,7 @@ export function PinWagonsForm({
onSubmit: (assignments: PinWagonAssignment[]) => void; onSubmit: (assignments: PinWagonAssignment[]) => void;
autoFillOnMount?: boolean; autoFillOnMount?: boolean;
}) { }) {
const originYardId = schedule.originStation?.id;
const slots = schedule.trainSet?.wagons ?? []; const slots = schedule.trainSet?.wagons ?? [];
const [assignments, setAssignments] = useState<Record<string, string>>({}); const [assignments, setAssignments] = useState<Record<string, string>>({});
@@ -43,7 +44,7 @@ export function PinWagonsForm({
for (const wagon of availableWagons) { for (const wagon of availableWagons) {
const isPinnedOnSlot = slots.some((s) => s.physicalWagonId === wagon.id); const isPinnedOnSlot = slots.some((s) => s.physicalWagonId === wagon.id);
if ( if (
!wagonMatchesScheduleDirection(wagon, schedule.direction, { !wagonMatchesScheduleOrigin(wagon, originYardId, {
allowPinned: isPinnedOnSlot, allowPinned: isPinnedOnSlot,
}) })
) { ) {
@@ -58,7 +59,7 @@ export function PinWagonsForm({
map.set(typeId, list); map.set(typeId, list);
} }
return map; return map;
}, [availableWagons, schedule.direction, slots]); }, [availableWagons, originYardId, slots]);
const runAutoFill = useCallback( const runAutoFill = useCallback(
(preserveManual = false) => { (preserveManual = false) => {

View File

@@ -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<RemovalTarget | null>(null);
const wagons = scheduleDetail.trainSet?.wagons ?? [];
const wagonCountByBooking = new Map<string, number>();
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 (
<Stack align="center" gap="xs" py="xl">
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
<Package size={20} />
</ThemeIcon>
<Text size="sm" fw={600} c="gray.7">
No assigned bookings
</Text>
<Text size="xs" c="dimmed" ta="center" maw={220}>
Assign a paid booking from the Unassigned tab to load it onto a wagon.
</Text>
</Stack>
);
}
return (
<>
<Stack gap="xs">
{assignedBookings.map((booking) => {
const isActive = selectedBookingId === booking.id;
return (
<Card
key={booking.id}
padding="xs"
radius="md"
withBorder
onClick={() =>
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",
}}
>
<Group gap={8} wrap="nowrap" align="flex-start">
<ThemeIcon size={30} radius="md" variant="light" color="green">
<Package size={16} />
</ThemeIcon>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap" gap={4}>
<Text size="sm" fw={700} truncate>
{booking.reference}
</Text>
<Group gap={4} wrap="nowrap">
<Badge
size="xs"
variant="light"
color="green"
leftSection={<TrainFront size={9} />}
>
{wagonCountByBooking.get(booking.id)}
</Badge>
{!isDispatched ? (
<Tooltip label="Remove from train" withArrow>
<ActionIcon
size="sm"
variant="subtle"
color="red"
loading={unassign.isPending && unassign.variables?.bookingId === booking.id}
onClick={(e) => {
e.stopPropagation();
setRemovalTarget({
bookingId: booking.id,
reference: booking.reference,
company: booking.customer,
weightTons: booking.weightTons ?? null,
wagonCount: wagonCountByBooking.get(booking.id) ?? 0,
});
}}
>
<X size={14} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
</Group>
{booking.customer ? (
<Group gap={4} wrap="nowrap">
<Building2 size={11} color="var(--mantine-color-gray-6)" />
<Text size="11px" c="dimmed" truncate>
{booking.customer}
</Text>
</Group>
) : null}
<Group gap={4} wrap="nowrap" mt={2}>
<Weight size={11} color="var(--mantine-color-gray-6)" />
<Text size="11px" c="dimmed">
{(booking.weightTons ?? 0).toFixed(1)} T
</Text>
</Group>
</Box>
</Group>
</Card>
);
})}
</Stack>
<RemoveBookingConfirmModal
opened={Boolean(removalTarget)}
onClose={() => setRemovalTarget(null)}
onConfirm={handleConfirmRemove}
isLoading={unassign.isPending}
target={removalTarget}
/>
</>
);
};

View File

@@ -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 (
<Stack align="center" gap="xs" py="xl">
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
<Icon size={20} />
</ThemeIcon>
<Text size="sm" fw={600} c="gray.7">
{emptyTitle}
</Text>
<Text size="xs" c="dimmed" ta="center" maw={220}>
{emptyHint}
</Text>
</Stack>
);
}
return (
<Stack gap="xs">
{bookings.map((booking) => {
const isActive = selectedBookingId === booking.id;
const deadline = fmtDateTime(booking.paymentDeadline);
return (
<Card
key={booking.id}
padding="xs"
radius="md"
withBorder
onClick={() =>
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,
}}
>
<Group gap={8} wrap="nowrap" align="flex-start">
<ThemeIcon size={30} radius="md" variant="light" color={accent}>
<Icon size={16} />
</ThemeIcon>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap" gap={4}>
<Text size="sm" fw={700} truncate>
{booking.reference}
</Text>
{booking.isGovernment ? (
<Badge
size="xs"
variant="light"
color="grape"
leftSection={<Landmark size={9} />}
>
Gov
</Badge>
) : null}
</Group>
{booking.company ? (
<Group gap={4} wrap="nowrap">
<Building2 size={11} color="var(--mantine-color-gray-6)" />
<Text size="11px" c="dimmed" truncate>
{booking.company}
</Text>
</Group>
) : null}
<Group justify="space-between" wrap="nowrap" mt={2} gap={6}>
<Group gap={4} wrap="nowrap">
<Weight size={11} color="var(--mantine-color-gray-6)" />
<Text size="11px" c="dimmed">
{(booking.weightTons ?? 0).toFixed(1)} T · {booking.wagons}w
</Text>
</Group>
{variant === "payment" && deadline ? (
<Text size="10px" fw={700} c="orange.7" style={{ whiteSpace: "nowrap" }}>
Pay by {deadline}
</Text>
) : variant === "expired" ? (
<Badge size="xs" variant="light" color="red">
Expired
</Badge>
) : null}
</Group>
</Box>
</Group>
</Card>
);
})}
</Stack>
);
};

View File

@@ -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 (
<Group justify="space-between" wrap="nowrap" gap="md">
<Group gap={8} wrap="nowrap">
<ThemeIcon size={28} radius="md" variant="light" color="green">
{icon}
</ThemeIcon>
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Box style={{ textAlign: "right" }}>{value}</Box>
</Group>
);
}
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 (
<Modal
opened={opened}
onClose={onClose}
centered
size="md"
radius="lg"
title={
<Group gap={10} wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: freightBrand.gradient,
color: "white",
}}
>
<Package size={20} />
</Box>
<div>
<Text fw={800}>{booking.reference ?? "Booking"}</Text>
<Text size="xs" c="dimmed">
Booking details
</Text>
</div>
</Group>
}
>
<Stack gap="md">
<Stack gap="sm">
{booking.company ? (
<InfoRow
icon={<Building2 size={15} />}
label="Company"
value={
<Text size="sm" fw={700}>
{booking.company}
</Text>
}
/>
) : null}
<InfoRow
icon={isBulk ? <Fuel size={15} /> : <ContainerIcon size={15} />}
label="Freight type"
value={
<Badge variant="light" color={isBulk ? "orange" : "cyan"}>
{booking.freightType ?? (isBulk ? "BULK" : "CONTAINER")}
</Badge>
}
/>
<InfoRow
icon={<Weight size={15} />}
label="Weight"
value={
<Text size="sm" fw={700}>
{booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"}
</Text>
}
/>
<InfoRow
icon={<TrainFront size={15} />}
label="Wagons"
value={
bookingWagons.length ? (
<Group gap={4} justify="flex-end">
{bookingWagons.map((w) => (
<Badge key={w.id} size="sm" variant="outline" color="green" radius="sm">
#{w.sequenceNo}
</Badge>
))}
</Group>
) : (
<Text size="sm" c="dimmed">
Not assigned to a wagon
</Text>
)
}
/>
{booking.status ? (
<InfoRow
icon={<MapPin size={15} />}
label="Status"
value={
<Badge variant="light" color="gray">
{booking.status}
</Badge>
}
/>
) : null}
</Stack>
{containers.length ? (
<>
<Divider
label={
<Group gap={6}>
<ContainerIcon size={13} />
<Text size="xs" fw={700}>
Containers ({containers.length})
</Text>
</Group>
}
/>
<Stack gap={6}>
{containers.map((c, i) => (
<Group
key={c.id}
justify="space-between"
wrap="nowrap"
p="xs"
style={{
borderRadius: 8,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap={8} wrap="nowrap">
<ContainerIcon size={14} color="var(--mantine-color-cyan-7)" />
<Text size="sm" fw={600}>
{c.containerNumber?.trim() || `Container ${i + 1}`}
</Text>
</Group>
{c.grossWeightTons != null ? (
<Text size="xs" c="dimmed">
{Number(c.grossWeightTons).toFixed(1)} T
</Text>
) : null}
</Group>
))}
</Stack>
</>
) : null}
</Stack>
</Modal>
);
};

View File

@@ -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<TabKey, { label: string; icon: LucideIcon; color: string }> = {
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<BookingDetailData | null>(null);
const [tab, setTab] = useState<TabKey>("assigned");
const unassignedQuery = useUnassignedBookings(scheduleId);
const removalsQuery = useCompositionRemovals(scheduleId);
const { assignedCount } = useMemo(() => {
const wagons = scheduleDetail.trainSet?.wagons ?? [];
const ids = new Set<string>();
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<TabKey, number> = {
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 (
<Tooltip label={meta.label} withArrow position="top">
<Tabs.Tab value={value} px={6}>
<Group gap={5} wrap="nowrap" justify="center">
<Icon size={15} />
<Badge
size="xs"
circle
variant={active ? "filled" : "light"}
color={active ? meta.color : "gray"}
>
{count}
</Badge>
</Group>
</Tabs.Tab>
</Tooltip>
);
};
return (
<>
<Paper
radius="lg"
withBorder
style={{
height: "100%",
overflow: "hidden",
display: "flex",
flexDirection: "column",
borderColor: "var(--mantine-color-gray-2)",
}}
>
{/* Header — reflects the active tab */}
<Group
justify="space-between"
wrap="nowrap"
px="md"
py="sm"
style={{
background: `linear-gradient(135deg, ${freightBrand.mutedBg}, white)`,
borderBottom: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap={10} wrap="nowrap">
<Box
style={{
width: 34,
height: 34,
borderRadius: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: freightBrand.gradient,
color: "white",
}}
>
<Layers size={18} />
</Box>
<div>
<Text fw={800} size="sm">
{TAB_META[tab].label}
</Text>
<Text size="11px" c="dimmed">
{counts[tab]} booking{counts[tab] === 1 ? "" : "s"}
</Text>
</div>
</Group>
</Group>
<Tabs
value={tab}
onChange={(v) => v && setTab(v as TabKey)}
variant="default"
color="green"
style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}
>
<Tabs.List grow>
<TabButton value="assigned" />
<TabButton value="unassigned" />
<TabButton value="payment" />
<TabButton value="expired" />
<TabButton value="removed" />
</Tabs.List>
<ScrollArea style={{ flex: 1 }} type="auto" offsetScrollbars>
<Tabs.Panel value="assigned" p="md">
<AssignedBookingsPanel
scheduleDetail={scheduleDetail}
scheduleId={scheduleId}
selectedBookingId={selectedBookingId}
onSelect={handleSelect}
/>
</Tabs.Panel>
<Tabs.Panel value="unassigned" p="md">
<UnassignedBookingsPanel
scheduleId={scheduleId}
selectedBookingId={selectedBookingId}
onSelect={handleSelect}
/>
</Tabs.Panel>
<Tabs.Panel value="payment" p="md">
<BatchBookingList
bookings={awaitingPayment}
variant="payment"
selectedBookingId={selectedBookingId}
onSelect={handleSelect}
emptyTitle="No bookings awaiting payment"
emptyHint="Bookings selected for this batch with a payment notification sent will appear here."
/>
</Tabs.Panel>
<Tabs.Panel value="expired" p="md">
<BatchBookingList
bookings={expired}
variant="expired"
selectedBookingId={selectedBookingId}
onSelect={handleSelect}
emptyTitle="No expired bookings"
emptyHint="Bookings whose payment window lapsed will appear here."
/>
</Tabs.Panel>
<Tabs.Panel value="removed" p="md">
<RemovalLogPanel scheduleId={scheduleId} />
</Tabs.Panel>
</ScrollArea>
</Tabs>
{/* Footer summary */}
<Group
justify="space-between"
px="md"
py="xs"
style={{
borderTop: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-gray-0)",
}}
>
<Group gap={5} wrap="nowrap">
<PackageCheck size={13} color="var(--mantine-color-green-7)" />
<Text size="xs" c="dimmed">
{assignedCount} on train
</Text>
</Group>
<Group gap={5} wrap="nowrap">
<CreditCard size={13} color="var(--mantine-color-orange-6)" />
<Text size="xs" c="dimmed">
{counts.payment} to pay
</Text>
</Group>
<Group gap={5} wrap="nowrap">
<XCircle size={13} color="var(--mantine-color-red-6)" />
<Text size="xs" c="dimmed">
{counts.expired} expired
</Text>
</Group>
</Group>
</Paper>
<BookingDetailModal
opened={Boolean(detailBooking)}
onClose={() => setDetailBooking(null)}
booking={detailBooking}
wagons={scheduleDetail.trainSet?.wagons ?? []}
/>
</>
);
};

View File

@@ -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<string | null>(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 <Text size="sm">{value || "TBD"}</Text>;
}
if (isEditing) {
return (
<Group gap={4}>
<TextInput
size="xs"
value={inputValue}
onChange={(e) => setInputValue(e.currentTarget.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
autoFocus
disabled={isLoading}
placeholder="Container #"
style={{ flex: 1 }}
/>
{error && <Text size="xs" c="red">{error}</Text>}
</Group>
);
}
return (
<Text
size="sm"
onClick={() => setIsEditing(true)}
style={{ cursor: "pointer", textDecoration: "underline" }}
title="Click to edit"
>
{value || "TBD"}
</Text>
);
};

View File

@@ -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<TrainScheduleDetail["trainSet"]>["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 (
<Group gap={count > 2 ? 10 : 18} justify="center" wrap="nowrap" mt={2}>
{Array.from({ length: count }).map((_, i) => (
<Box
key={i}
style={{
width: 12,
height: 12,
borderRadius: "50%",
background: dark
? "radial-gradient(circle at 35% 35%, #2c4a3a, #0f291b)"
: "radial-gradient(circle at 35% 35%, var(--mantine-color-gray-5), var(--mantine-color-gray-8))",
border: "2px solid var(--mantine-color-gray-4)",
boxShadow: "inset 0 0 0 2px rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.2)",
}}
/>
))}
</Group>
);
}
function Coupler() {
return (
<Box style={{ width: 12, height: 70, display: "flex", alignItems: "center", flexShrink: 0 }}>
<Box
style={{
width: "100%",
height: 5,
borderRadius: 3,
background:
"linear-gradient(90deg, var(--mantine-color-gray-4), var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
}}
/>
</Box>
);
}
function LocomotiveCar({ locomotive }: { locomotive: Locomotive }) {
const code = locomotive?.code ?? "LOCO";
return (
<Box style={{ width: 120, flexShrink: 0 }}>
<Box
style={{
position: "relative",
height: 70,
borderRadius: "12px 22px 9px 9px",
background: `linear-gradient(160deg, ${freightBrand.primaryLight} 0%, ${freightBrand.primary} 45%, ${freightBrand.primaryDark} 100%)`,
boxShadow: `${freightBrand.shadowSm}, inset 0 1px 0 rgba(255,255,255,0.25)`,
border: "1px solid rgba(0,0,0,0.1)",
overflow: "hidden",
padding: "8px 9px 7px",
color: "white",
}}
>
{/* cab windows */}
<Box style={{ position: "absolute", top: 9, right: 9, display: "flex", gap: 4 }}>
<Box
style={{
width: 13,
height: 11,
borderRadius: "3px 5px 3px 3px",
background: "linear-gradient(135deg, #E8FBFF 0%, #9ED9E8 100%)",
}}
/>
</Box>
{/* headlight */}
<Box
style={{
position: "absolute",
bottom: 12,
right: 5,
width: 7,
height: 7,
borderRadius: "50%",
background: "#fde68a",
boxShadow: "0 0 9px 3px rgba(253,230,138,0.9)",
}}
/>
{/* hazard stripe */}
<Box
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 5,
background: "repeating-linear-gradient(45deg, #fbbf24 0 6px, #1f2937 6px 12px)",
opacity: 0.9,
}}
/>
<Group gap={5} wrap="nowrap" align="center">
<TrainFront size={16} />
<Text size="sm" fw={800} style={{ letterSpacing: 0.4 }}>
{code}
</Text>
</Group>
{locomotive?.maxPullWeightTons ? (
<Group gap={3} wrap="nowrap" mt={3} style={{ opacity: 0.95 }}>
<Gauge size={10} />
<Text size="9px" fw={700}>
{locomotive.maxPullWeightTons}T pull
</Text>
</Group>
) : null}
</Box>
<Wheels count={3} dark />
<Text size="9px" ta="center" c="dimmed" mt={2} fw={700} style={{ letterSpacing: 1 }}>
HEAD
</Text>
</Box>
);
}
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 (
<HoverCard width={280} shadow="lg" radius="md" position="top" withArrow openDelay={120}>
<HoverCard.Target>
<Box
onClick={onSelect}
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
>
<Box
style={{
position: "relative",
height: 70,
borderRadius: 11,
background: isEmpty
? "var(--mantine-color-gray-0)"
: "linear-gradient(180deg, white, var(--mantine-color-gray-0))",
border: isEmpty
? "1.5px dashed var(--mantine-color-gray-4)"
: "1px solid var(--mantine-color-gray-3)",
boxShadow:
ringColor !== "transparent"
? `0 0 0 3px ${ringColor}, 0 4px 12px rgba(15,41,27,0.12)`
: isEmpty
? "none"
: "0 3px 10px rgba(15,41,27,0.08)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
transition: "box-shadow 120ms ease",
}}
>
{/* top accent strip */}
<Box
style={{
height: 4,
background: isEmpty
? "var(--mantine-color-gray-3)"
: `linear-gradient(90deg, ${accentVar}, var(--mantine-color-${accent}-4))`,
}}
/>
{/* header */}
<Group justify="space-between" px={7} pt={3} wrap="nowrap">
<Text size="10px" fw={800} c="gray.7">
#{wagon.sequenceNo}
</Text>
{isEmpty ? (
<Text size="8px" c="dimmed" fw={700} style={{ letterSpacing: 0.5 }}>
EMPTY
</Text>
) : (
<Group gap={2} wrap="nowrap">
{isBulk ? (
<Fuel size={10} color={accentVar} />
) : (
<ContainerIcon size={10} color={accentVar} />
)}
<Text size="8px" fw={700} c={`${accent}.7`} style={{ letterSpacing: 0.3 }}>
{isBulk ? "BULK" : "CONT"}
</Text>
</Group>
)}
</Group>
{/* body */}
<Box style={{ flex: 1, padding: "3px 7px", display: "flex", alignItems: "center" }}>
{isEmpty ? (
<Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}>
Available
</Text>
) : isBulk ? (
<Stack gap={2} style={{ width: "100%" }}>
<Box
style={{
height: 16,
borderRadius: 5,
background: "var(--mantine-color-orange-0)",
border: "1px solid var(--mantine-color-orange-2)",
overflow: "hidden",
position: "relative",
}}
>
<Box
style={{
position: "absolute",
inset: 0,
width: `${utilization}%`,
background:
"linear-gradient(90deg, var(--mantine-color-orange-6), var(--mantine-color-orange-4))",
}}
/>
</Box>
</Stack>
) : (
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
<Box
key={i}
style={{
flex: 1,
minWidth: 0,
height: 26,
borderRadius: 4,
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 2px",
}}
>
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
{cn}
</Text>
</Box>
))}
</Group>
)}
</Box>
{/* footer */}
<Box
style={{
borderTop: "1px solid var(--mantine-color-gray-1)",
padding: "2px 7px",
background: isEmpty ? "transparent" : "var(--mantine-color-gray-0)",
}}
>
<Group justify="space-between" wrap="nowrap" gap={3}>
<Text size="8px" c="dimmed" fw={600} truncate>
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Wagon"}
</Text>
{!isEmpty ? (
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
{assigned}T
</Text>
) : null}
</Group>
</Box>
</Box>
<Wheels count={2} />
</Box>
</HoverCard.Target>
<HoverCard.Dropdown p="sm">
<Stack gap={8}>
<Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<Box
style={{
width: 26,
height: 26,
borderRadius: 7,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: isEmpty
? "var(--mantine-color-gray-1)"
: freightBrand.gradient,
color: isEmpty ? "var(--mantine-color-gray-6)" : "white",
}}
>
<TrainFront size={15} />
</Box>
<div>
<Text size="sm" fw={800}>
Wagon #{wagon.sequenceNo}
</Text>
<Text size="10px" c="dimmed">
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Unassigned"}
</Text>
</div>
</Group>
{!isEmpty ? (
<Badge size="xs" variant="light" color={isBulk ? "orange" : "cyan"}>
{isBulk ? "Bulk" : "Container"}
</Badge>
) : null}
</Group>
{isEmpty ? (
<Text size="xs" c="dimmed">
Empty slot available for allocation.
</Text>
) : (
<Stack gap={6}>
{company ? (
<Group gap={6} wrap="nowrap">
<Building2 size={13} color={freightBrand.primary} />
<Text size="xs" fw={700} truncate>
{company}
</Text>
</Group>
) : null}
<Group gap={6} wrap="nowrap">
<Package size={13} color="var(--mantine-color-gray-6)" />
<Text size="xs" c="dimmed">
{allocation?.bookingReference ?? "Unknown booking"}
</Text>
</Group>
{containerNumbers.length ? (
<div>
<Text size="10px" c="dimmed" fw={700} mb={3} tt="uppercase">
Containers
</Text>
<Group gap={4}>
{containerNumbers.map((cn, i) => (
<Badge key={i} size="xs" variant="outline" color="cyan" radius="sm">
{cn}
</Badge>
))}
</Group>
</div>
) : null}
{isBulk && allocation?.bulkLoad?.cargoDescription ? (
<Text size="xs" c="dimmed">
{allocation.bulkLoad.cargoDescription}
</Text>
) : null}
<Group gap={6} wrap="nowrap">
<Weight size={13} color="var(--mantine-color-gray-6)" />
<Text size="xs" c="dimmed">
{assigned}T / {capacity}T ({utilization}%)
</Text>
</Group>
<Box
style={{
height: 5,
borderRadius: 3,
background: "var(--mantine-color-gray-1)",
overflow: "hidden",
}}
>
<Box
style={{
width: `${utilization}%`,
height: "100%",
background:
utilization >= 100
? "var(--mantine-color-red-5)"
: `var(--mantine-color-${accent}-5)`,
}}
/>
</Box>
<Text size="10px" c="dimmed" ta="center">
Click the wagon to edit or remove
</Text>
</Stack>
)}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}
export const InteractiveTrainConsist = ({
wagons,
locomotive,
getCompany,
selectedWagonId,
onSelectWagon,
highlightBookingId,
}: InteractiveTrainConsistProps) => {
return (
<Box
style={{
position: "relative",
padding: "8px 12px 18px",
borderRadius: 14,
background: "linear-gradient(180deg, var(--mantine-color-gray-0), white)",
border: "1px solid var(--mantine-color-gray-2)",
overflowX: "auto",
}}
>
<Group gap={0} wrap="nowrap" align="flex-start" style={{ minWidth: "min-content" }}>
{locomotive ? <LocomotiveCar locomotive={locomotive} /> : null}
{wagons.length === 0 ? (
<Text size="sm" c="dimmed" pl="md" pt="lg">
No wagons assigned
</Text>
) : (
wagons.map((wagon, i) => {
const bookingId = wagon.allocations?.[0]?.bookingId;
return (
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-start">
{i > 0 || locomotive ? <Coupler /> : null}
<WagonCar
wagon={wagon}
company={getCompany(bookingId)}
selected={selectedWagonId === wagon.id}
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)}
onSelect={() => onSelectWagon(wagon)}
/>
</Group>
);
})
)}
</Group>
{/* track bed under the whole consist */}
<Box
style={{
position: "absolute",
left: 12,
right: 12,
bottom: 8,
height: 8,
}}
>
<Box
style={{
position: "absolute",
inset: 0,
background:
"repeating-linear-gradient(90deg, var(--mantine-color-gray-4) 0 5px, transparent 5px 20px)",
opacity: 0.5,
borderRadius: 2,
}}
/>
<Box
style={{
position: "absolute",
left: 0,
right: 0,
top: 1,
height: 2,
borderRadius: 1,
background: "var(--mantine-color-gray-5)",
}}
/>
<Box
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 1,
height: 2,
borderRadius: 1,
background: "var(--mantine-color-gray-5)",
}}
/>
</Box>
</Box>
);
};

View File

@@ -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 (
<Text size="sm" c="dimmed">
Loading...
</Text>
);
}
const removals = removalQuery.data ?? [];
if (removals.length === 0) {
return (
<Stack align="center" gap="xs" py="xl">
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
<History size={20} />
</ThemeIcon>
<Text size="sm" fw={600} c="gray.7">
No removals yet
</Text>
<Text size="xs" c="dimmed" ta="center" maw={220}>
Bookings removed from this train will appear here for audit.
</Text>
</Stack>
);
}
return (
<Stack gap="xs">
{removals.map((removal) => (
<Card key={removal.id} padding="xs" radius="md" withBorder>
<Group gap={8} wrap="nowrap" align="flex-start">
<ThemeIcon size={30} radius="md" variant="light" color="red">
<PackageX size={16} />
</ThemeIcon>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={700} truncate>
{removal.bookingReference || "Unknown booking"}
</Text>
<Text size="11px" c="dimmed">
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
</Text>
{removal.notes ? (
<Text size="11px" c="dimmed" mt={2} lineClamp={2}>
{removal.notes}
</Text>
) : null}
</Box>
</Group>
</Card>
))}
</Stack>
);
};

View File

@@ -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 (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="md"
withCloseButton={false}
title={
<Group gap={10} wrap="nowrap">
<ThemeIcon size={40} radius="md" variant="light" color="red">
<PackageX size={21} />
</ThemeIcon>
<div>
<Text fw={800}>Remove booking from train?</Text>
<Text size="xs" c="dimmed">
This change is logged and the customer is notified
</Text>
</div>
</Group>
}
>
<Stack gap="md">
{/* Booking summary */}
<Box
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Text fw={800} size="sm">
{target?.reference ?? "Booking"}
</Text>
<Badge variant="light" color="green" leftSection={<TrainFront size={10} />}>
{target?.wagonCount ?? 0} wagon{target?.wagonCount === 1 ? "" : "s"}
</Badge>
</Group>
<Group gap="lg" mt={6} wrap="wrap">
{target?.company ? (
<Group gap={5} wrap="nowrap">
<Building2 size={13} color="var(--mantine-color-gray-6)" />
<Text size="xs" c="dimmed">
{target.company}
</Text>
</Group>
) : null}
<Group gap={5} wrap="nowrap">
<Weight size={13} color="var(--mantine-color-gray-6)" />
<Text size="xs" c="dimmed">
{(target?.weightTons ?? 0).toFixed(1)} T
</Text>
</Group>
</Group>
</Box>
{/* What happens */}
<Box
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-orange-0)",
border: "1px solid var(--mantine-color-orange-2)",
}}
>
<Group gap={6} mb={6} wrap="nowrap">
<AlertTriangle size={14} color="var(--mantine-color-orange-7)" />
<Text size="xs" fw={700} c="orange.8">
Removing this booking will:
</Text>
</Group>
<List spacing={6} size="xs" center>
<List.Item
icon={
<ThemeIcon size={18} radius="xl" variant="light" color="orange">
<Undo2 size={11} />
</ThemeIcon>
}
>
Return it to the unassigned pool
</List.Item>
<List.Item
icon={
<ThemeIcon size={18} radius="xl" variant="light" color="orange">
<FileClock size={11} />
</ThemeIcon>
}
>
Create a removal log entry for audit
</List.Item>
<List.Item
icon={
<ThemeIcon size={18} radius="xl" variant="light" color="orange">
<Bell size={11} />
</ThemeIcon>
}
>
Notify the customer to reschedule or cancel
</List.Item>
</List>
</Box>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button
color="red"
radius="md"
leftSection={<PackageX size={16} />}
loading={isLoading}
onClick={onConfirm}
>
Remove booking
</Button>
</Group>
</Stack>
</Modal>
);
};

View File

@@ -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 (
<Modal opened={opened} onClose={onClose} title="Confirm Booking Removal" centered>
<Stack gap="md">
<div>
<Text size="sm" fw={500} mb={4}>
Booking Details
</Text>
<Stack gap={4}>
<Text size="sm">
<strong>Reference:</strong> {booking?.reference || "N/A"}
</Text>
<Text size="sm">
<strong>Freight Type:</strong>{" "}
<Badge size="sm" variant="light">
{booking?.freightType || "N/A"}
</Badge>
</Text>
<Text size="sm">
<strong>Weight:</strong> {allocation.allocatedWeightTons?.toFixed(2) || 0} T
</Text>
<Text size="sm">
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}
</Text>
</Stack>
</div>
<div>
<Text size="sm" c="orange" fw={500}>
Warning: Removing this booking will:
</Text>
<ul style={{ marginTop: 8, marginBottom: 0 }}>
<li>Move the booking back to the unassigned pool</li>
<li>Create a removal log for audit</li>
<li>Notify the customer to reschedule or cancel</li>
</ul>
</div>
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={isLoading}>
Cancel
</Button>
<Button color="red" onClick={onConfirm} loading={isLoading}>
Remove Booking
</Button>
</Group>
</Stack>
</Modal>
);
};

View File

@@ -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 (
<Group gap={5} wrap="nowrap">
<Box
style={{
width: 10,
height: 10,
borderRadius: 3,
background: dashed ? "var(--mantine-color-gray-1)" : `var(--mantine-color-${color}-5)`,
border: dashed ? "1.5px dashed var(--mantine-color-gray-4)" : "none",
}}
/>
<Text size="xs" c="dimmed">
{label}
</Text>
</Group>
);
}
export const TrainConsistView = ({
scheduleDetail,
scheduleId,
maxWagons,
highlightBookingId,
}: TrainConsistViewProps) => {
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(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<string, string>();
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 (
<Stack gap="md" style={{ width: "100%" }}>
<TrainStatsBar
weightUsed={weightUsed}
weightMax={scheduleDetail.locomotive?.maxWeightTons ?? trainSet?.locomotive?.maxPullWeightTons ?? null}
lengthUsed={lengthUsed}
lengthMax={scheduleDetail.locomotive?.maxLengthMeters ?? trainSet?.locomotive?.maxTrainLengthMeters ?? null}
wagonCount={wagons.length}
wagonMax={maxWagons}
/>
{/* Consist panel */}
<Paper
radius="lg"
withBorder
style={{ borderColor: "var(--mantine-color-gray-2)", overflow: "hidden" }}
>
<Group
justify="space-between"
wrap="nowrap"
px="md"
py="sm"
style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}
>
<Group gap={10} wrap="nowrap">
<Box
style={{
width: 34,
height: 34,
borderRadius: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: freightBrand.gradient,
color: "white",
}}
>
<TrainFront size={18} />
</Box>
<div>
<Text fw={800} size="sm">
Train consist
</Text>
<Text size="11px" c="dimmed">
{wagons.length} wagons · {loadedCount} loaded · {wagons.length - loadedCount} empty
</Text>
</div>
</Group>
<Group gap="md" wrap="nowrap" visibleFrom="sm">
<LegendDot color="cyan" label="Container" />
<LegendDot color="orange" label="Bulk" />
<LegendDot color="gray" label="Empty" dashed />
</Group>
</Group>
<Box p="md">
<InteractiveTrainConsist
wagons={wagons}
locomotive={trainSet?.locomotive}
getCompany={(bookingId) => (bookingId ? companyByBooking.get(bookingId) ?? null : null)}
selectedWagonId={selectedWagonId}
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
highlightBookingId={highlightBookingId}
/>
</Box>
</Paper>
{/* Selected wagon — editable detail card */}
{selectedWagon ? (
<Box>
<Group gap={6} mb={6} wrap="nowrap">
<Badge variant="light" color="green" radius="sm">
Editing wagon #{selectedWagon.sequenceNo}
</Badge>
<Text size="xs" c="dimmed">
Update container numbers or remove the booking
</Text>
</Group>
<WagonCard
wagon={selectedWagon}
company={
selectedWagon.allocations?.[0]?.bookingId
? companyByBooking.get(selectedWagon.allocations[0].bookingId) ?? null
: null
}
scheduleId={scheduleId}
scheduleStatus={scheduleDetail.status}
onRemoveBooking={handleRemoveBooking}
onRemoveWagon={handleRemoveWagon}
/>
</Box>
) : wagons.length ? (
<Paper
radius="md"
py="sm"
px="md"
style={{
background: "var(--mantine-color-gray-0)",
border: "1px dashed var(--mantine-color-gray-3)",
}}
>
<Group gap={8} justify="center" c="dimmed">
<ThemeIcon size={24} radius="xl" variant="light" color="gray">
<MousePointerClick size={13} />
</ThemeIcon>
<Text size="xs" c="dimmed">
Click a wagon in the train to edit container numbers or remove its booking.
</Text>
</Group>
</Paper>
) : null}
<RemoveBookingModal
opened={removeModalOpen}
onClose={() => {
setRemoveModalOpen(false);
}}
wagon={selectedWagon}
onConfirm={handleConfirmRemoveBooking}
isLoading={unassignMutation.isPending}
/>
</Stack>
);
};

View File

@@ -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 (
<Group gap="sm" wrap="nowrap" align="center">
<RingProgress
size={62}
thickness={6}
roundCaps
sections={[{ value: clamped, color }]}
rootColor="var(--mantine-color-gray-1)"
label={
<Group justify="center">
<ThemeIcon size={26} radius="xl" variant="transparent" style={{ color }}>
{icon}
</ThemeIcon>
</Group>
}
/>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="10px" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.6 }}>
{label}
</Text>
<Group gap={5} align="baseline" wrap="nowrap">
<Text size="lg" fw={800} lh={1.1} c="dark.5" style={{ whiteSpace: "nowrap" }}>
{current}
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
/ {max} {unit}
</Text>
</Group>
{pct != null ? (
<Text size="10px" fw={700} style={{ color }}>
{Math.round(pct)}% utilized
</Text>
) : (
<Text size="10px" c="dimmed">
no limit set
</Text>
)}
</Stack>
</Group>
);
}
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 (
<Paper
p="md"
radius="lg"
withBorder
style={{ borderColor: "var(--mantine-color-gray-2)", background: "white" }}
>
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg">
<StatTile
icon={<Weight size={15} />}
label="Weight"
pct={weightPct}
current={weightUsed.toFixed(1)}
max={weightMax?.toFixed(1) ?? "∞"}
unit="T"
/>
<Box
px={{ base: 0, xs: "lg" }}
style={{
borderLeft: "1px solid var(--mantine-color-gray-2)",
borderRight: "1px solid var(--mantine-color-gray-2)",
}}
>
<StatTile
icon={<Ruler size={15} />}
label="Length"
pct={lengthPct}
current={lengthUsed.toFixed(1)}
max={lengthMax?.toFixed(1) ?? "∞"}
unit="m"
/>
</Box>
<StatTile
icon={<Train size={15} />}
label="Wagons"
pct={wagonPct}
current={String(wagonCount)}
max={String(wagonMax)}
unit=""
/>
</SimpleGrid>
</Paper>
);
};

View File

@@ -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 (
<Group gap={5} wrap="nowrap" px="sm" py={6} style={{ borderRadius: 8, background: "var(--mantine-color-red-0)", border: "1px solid var(--mantine-color-red-2)" }}>
<MapPin size={13} color="var(--mantine-color-red-6)" />
<Text size="xs" fw={700} c="red.7">
No wagons at origin yard
</Text>
</Group>
);
}
return (
<Group
gap="xs"
wrap="wrap"
px="sm"
py={6}
style={{
borderRadius: 8,
background: "var(--mantine-color-green-0)",
border: "1px solid var(--mantine-color-green-1)",
}}
>
<Group gap={5} wrap="nowrap">
<MapPin size={13} color="var(--mantine-color-green-7)" />
<Text size="xs" fw={700} c="green.8">
Origin yard
</Text>
</Group>
{fleetAtOrigin.map((row) => (
<Badge key={row.wagonTypeId} size="sm" variant="light" color="green">
{row.wagonTypeCode}: {row.available}
</Badge>
))}
</Group>
);
};
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 (
<Text size="sm" c="dimmed">
Loading...
</Text>
);
}
const bookings = unassignedQuery.data?.bookings ?? [];
const fleetAtOrigin = unassignedQuery.data?.fleetAtOrigin ?? [];
if (bookings.length === 0) {
return (
<Stack align="center" gap="xs" py="xl">
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
<ContainerIcon size={20} />
</ThemeIcon>
<Text size="sm" fw={600} c="gray.7">
No unassigned bookings
</Text>
<Text size="xs" c="dimmed" ta="center" maw={220}>
Paid bookings waiting for a wagon will appear here.
</Text>
</Stack>
);
}
return (
<Stack gap="xs">
<YardFleetBanner fleetAtOrigin={fleetAtOrigin} />
{bookings.map((booking) => {
const isActive = selectedBookingId === booking.id;
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
const fits = booking.canAssign;
const blockReason = booking.blockReason;
return (
<Card
key={booking.id}
padding="xs"
radius="md"
withBorder
onClick={() =>
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,
}}
>
<Stack gap={6}>
<Group gap={8} wrap="nowrap" align="flex-start">
<ThemeIcon size={30} radius="md" variant="light" color="orange">
<ContainerIcon size={16} />
</ThemeIcon>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap" gap={4}>
<Text size="sm" fw={700} truncate>
{booking.reference}
</Text>
{booking.priorityScore ? (
<Badge size="xs" color="green">
P{booking.priorityScore}
</Badge>
) : null}
</Group>
<Group gap={6} wrap="nowrap" mt={2}>
<Badge size="xs" variant="light" color="gray">
{booking.freightType}
</Badge>
<Group gap={3} wrap="nowrap">
<TrainFront size={11} color="var(--mantine-color-gray-6)" />
<Text size="11px" c="dimmed">
{booking.wagonsRequired}× {booking.requiredWagonTypeCode}
</Text>
</Group>
</Group>
</Box>
</Group>
{blockReason ? (
<Group gap={5} wrap="nowrap">
<AlertTriangle size={12} color="var(--mantine-color-red-6)" />
<Text size="10px" c="red.7" fw={600}>
{blockReason}
</Text>
</Group>
) : null}
<Tooltip label={blockReason} disabled={fits} withArrow position="bottom">
<Button
size="xs"
variant="light"
color="green"
disabled={!fits}
onClick={(e) => {
e.stopPropagation();
void handleAssign(booking.id, booking.reference);
}}
loading={
assignMutation.isPending &&
assignMutation.variables?.bookingId === booking.id
}
leftSection={<Plus size={12} />}
>
Assign to train
</Button>
</Tooltip>
</Stack>
</Card>
);
})}
</Stack>
);
};

View File

@@ -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 (
<Card padding="sm" radius="md" withBorder style={{ borderColor: freightBrand.mutedBorder }}>
<Card.Section withBorder inheritPadding py="xs" style={{ background: freightBrand.mutedBg }}>
<Group justify="space-between">
<Group gap={6}>
<ThemeIcon size={28} radius="md" variant="white" color="green">
<TrainFront size={16} />
</ThemeIcon>
<div>
<Group gap={4}>
<Text size="sm" fw={800}>
Wagon #{wagon.sequenceNo}
</Text>
<Badge size="xs" variant="light" color="green">
{wagonType}
</Badge>
</Group>
{wagon.physicalWagonNumber || wagon.physicalWagonId ? (
<Text size="10px" c="dimmed">
{wagon.physicalWagonNumber || wagon.physicalWagonId?.slice(0, 8)}
</Text>
) : null}
</div>
</Group>
{hasAllocations ? (
<Badge
size="sm"
variant="light"
color={isBulk ? "orange" : "cyan"}
leftSection={isBulk ? <Fuel size={11} /> : <ContainerIcon size={11} />}
>
{isBulk ? "Bulk" : "Container"}
</Badge>
) : null}
</Group>
</Card.Section>
<Stack gap="sm" mt="sm">
{hasAllocations && allocation ? (
<>
{company ? (
<Group gap={6} wrap="nowrap">
<Building2 size={14} color={freightBrand.primary} />
<Text size="sm" fw={700} truncate>
{company}
</Text>
</Group>
) : null}
<Group gap={6} wrap="nowrap">
<Package size={14} color="var(--mantine-color-gray-6)" />
<Text size="sm" c="dimmed">
{allocation.bookingReference || "Unknown booking"}
</Text>
</Group>
{allocation.loadType === "CONTAINER" && allocation.containerItems?.length ? (
<Box>
<Text size="10px" c="dimmed" fw={700} tt="uppercase" mb={4}>
Containers
</Text>
<Stack gap={6}>
{allocation.containerItems.map((item, idx) => (
<Group key={item.id} gap={8} wrap="nowrap">
<ContainerIcon size={13} color="var(--mantine-color-cyan-7)" />
<Text size="xs" c="dimmed">
#{idx + 1}
</Text>
<ContainerNumberInput
value={item.containerNumber ?? null}
itemId={item.id}
scheduleId={scheduleId}
disabled={isDispatched}
/>
</Group>
))}
</Stack>
</Box>
) : null}
{isBulk ? (
<Group gap={6} wrap="nowrap">
<Fuel size={14} color="var(--mantine-color-orange-6)" />
<Text size="xs" c="dimmed">
{allocation.bulkLoad?.cargoDescription || "Bulk load"}
</Text>
</Group>
) : null}
<Box>
<Group justify="space-between" mb={4}>
<Text size="xs" c="dimmed" fw={600}>
Weight
</Text>
<Text size="xs" c="dimmed">
{weightUsed.toFixed(1)} / {weightMax.toFixed(1)} T
</Text>
</Group>
<Progress
value={Math.min(weightPercent, 100)}
color={weightPercent > 90 ? "red" : weightPercent > 75 ? "orange" : "green"}
size="sm"
radius="xl"
/>
</Box>
{!isDispatched ? (
<Button
variant="light"
color="red"
size="xs"
leftSection={<X size={14} />}
onClick={() => onRemoveBooking(wagon)}
fullWidth
>
Remove booking
</Button>
) : null}
</>
) : (
<Stack gap="xs" align="center" py="sm">
<ThemeIcon size={36} radius="xl" variant="light" color="gray">
<TrainFront size={18} />
</ThemeIcon>
<Text size="sm" c="dimmed">
Empty slot
</Text>
{!isDispatched ? (
<Button
variant="subtle"
color="gray"
size="xs"
leftSection={<Trash2 size={14} />}
onClick={() => onRemoveWagon(wagon.id)}
>
Remove wagon
</Button>
) : null}
</Stack>
)}
</Stack>
</Card>
);
};

View File

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

View File

@@ -1,10 +1,11 @@
import { useState } from "react"; import { useState, useMemo } from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { Button, Group, Modal, NumberInput, Select, Stack, Text } from "@mantine/core"; import { Button, Group, Modal, NumberInput, Select, Stack, Text } from "@mantine/core";
import { Freight } from "@edr/types"; import { Freight } from "@edr/types";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useRouteYards } from "@/hooks/useRoutes";
import { useAssignWagonToTrain, useWagons } from "@/hooks/useWagons"; import { useAssignWagonToTrain, useWagons } from "@/hooks/useWagons";
export function AssignWagonDialog({ trainId }: { trainId: string }) { export function AssignWagonDialog({ trainId }: { trainId: string }) {
@@ -12,6 +13,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
const [wagonId, setWagonId] = useState<string | null>(null); const [wagonId, setWagonId] = useState<string | null>(null);
const [sequence, setSequence] = useState<number | "">(""); const [sequence, setSequence] = useState<number | "">("");
const { data: wagons } = useWagons(); const { data: wagons } = useWagons();
const { data: yards = [] } = useRouteYards();
const assign = useAssignWagonToTrain(); const assign = useAssignWagonToTrain();
const { toast } = useToast(); const { toast } = useToast();
@@ -19,9 +21,19 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
(w) => w.status === Freight.WagonStatus.Available || !w.trainId, (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) => ({ const wagonOptions = available.map((w) => ({
value: w.id, 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 () => { const handleAssign = async () => {

View File

@@ -52,6 +52,10 @@ export const QUERY_KEYS = {
batchBoard: () => ["train-scheduling", "batch-board"] as const, batchBoard: () => ["train-scheduling", "batch-board"] as const,
batchBoardDetail: (scheduleId: string) => batchBoardDetail: (scheduleId: string) =>
["train-scheduling", "batch-board", scheduleId] as const, ["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: { FLEET: {

View File

@@ -144,6 +144,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/batch-board/${scheduleId}`, `/train-scheduling/batch-board/${scheduleId}`,
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`, RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`, 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`, BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
MARK_BOOKING_PAID: (bookingId: string) => MARK_BOOKING_PAID: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/mark-paid`, `/train-scheduling/bookings/${bookingId}/mark-paid`,
@@ -190,6 +192,14 @@ export const URL_CONSTANTS = {
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`, SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
CANCEL_SCHEDULE: (id: string) => CANCEL_SCHEDULE: (id: string) =>
`/train-scheduling/container/schedules/${id}/cancel`, `/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: { RULE_ENGINE: {

View File

@@ -153,6 +153,12 @@ export const useScheduleMutations = (scheduleId?: string) => {
void qc.invalidateQueries({ void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(scheduleId), 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 }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
}; };
@@ -191,6 +197,12 @@ export const useScheduleMutations = (scheduleId?: string) => {
onSuccess: invalidate, onSuccess: invalidate,
}); });
const assignUnassigned = useMutation({
mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) =>
trainSchedulingService.assignUnassignedBooking(id, bookingId),
onSuccess: invalidate,
});
const unassign = useMutation({ const unassign = useMutation({
mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) => mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) =>
trainSchedulingService.unassignBooking(id, bookingId), trainSchedulingService.unassignBooking(id, bookingId),
@@ -234,6 +246,7 @@ export const useScheduleMutations = (scheduleId?: string) => {
create, create,
preview, preview,
assign, assign,
assignUnassigned,
unassign, unassign,
pin, pin,
finalize, finalize,
@@ -244,3 +257,46 @@ export const useScheduleMutations = (scheduleId?: string) => {
invalidate, 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),
});
},
});
};

View File

@@ -16,6 +16,7 @@ import { useWagonTypes } from "@/hooks/use-wagon-types";
import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet"; import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
import { useContainers } from "@/hooks/useContainers"; import { useContainers } from "@/hooks/useContainers";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useRouteYards } from "@/hooks/useRoutes";
import { useWagons } from "@/hooks/useWagons"; import { useWagons } from "@/hooks/useWagons";
import type { FleetListFilters } from "@/services/fleet/fleet.service"; import type { FleetListFilters } from "@/services/fleet/fleet.service";
import { import {
@@ -49,12 +50,12 @@ const FleetResourcePage = () => {
if (slug !== "wagons" && slug !== "locomotives") return undefined; if (slug !== "wagons" && slug !== "locomotives") return undefined;
const filters: FleetListFilters = {}; const filters: FleetListFilters = {};
const status = listFilterValues.status; const status = listFilterValues.status;
const readiness = listFilterValues.readiness; const currentYardId = listFilterValues.currentYardId;
if (status && status !== "ALL") { if (status && status !== "ALL") {
filters.status = status as FleetListFilters["status"]; (filters as { status?: string }).status = status;
} }
if (readiness && readiness !== "ALL") { if (currentYardId && currentYardId !== "ALL") {
filters.readiness = readiness as FleetListFilters["readiness"]; filters.currentYardId = currentYardId;
} }
if (slug === "wagons" && search.trim()) { if (slug === "wagons" && search.trim()) {
filters.search = search.trim(); filters.search = search.trim();
@@ -70,6 +71,7 @@ const FleetResourcePage = () => {
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useCargoTypes(); const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useCargoTypes();
const { data: wagons = [], isLoading: wagonsLoading } = useWagons(); const { data: wagons = [], isLoading: wagonsLoading } = useWagons();
const { data: containers = [], isLoading: containersLoading } = useContainers(); const { data: containers = [], isLoading: containersLoading } = useContainers();
const { data: yards = [], isLoading: yardsLoading } = useRouteYards();
useEffect(() => { useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
@@ -98,18 +100,6 @@ const FleetResourcePage = () => {
]; ];
}, [allRows, hasStatusColumn, usesServerListFilters]); }, [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 dynamicOptions = useMemo(() => {
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map( const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
(t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }), (t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }),
@@ -128,14 +118,41 @@ const FleetResourcePage = () => {
(c) => ({ value: c.id, label: c.containerNumber }), (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 { return {
wagonTypes: wagonTypeOpts, wagonTypes: wagonTypeOpts,
containerTypes: containerTypeOpts, containerTypes: containerTypeOpts,
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts], cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts], wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
containers: containerOpts, 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(() => { useEffect(() => {
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes); registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
@@ -146,6 +163,7 @@ const FleetResourcePage = () => {
); );
registerFleetOptionLabels("wagonId", dynamicOptions.wagons); registerFleetOptionLabels("wagonId", dynamicOptions.wagons);
registerFleetOptionLabels("containerId", dynamicOptions.containers); registerFleetOptionLabels("containerId", dynamicOptions.containers);
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
}, [dynamicOptions]); }, [dynamicOptions]);
const formFields = useMemo((): FleetFormFieldDef[] => { const formFields = useMemo((): FleetFormFieldDef[] => {
@@ -158,7 +176,12 @@ const FleetResourcePage = () => {
}, [config, dynamicOptions]); }, [config, dynamicOptions]);
const selectOptionsLoading = const selectOptionsLoading =
wagonTypesLoading || containerTypesLoading || cargoTypesLoading || wagonsLoading || containersLoading; wagonTypesLoading ||
containerTypesLoading ||
cargoTypesLoading ||
wagonsLoading ||
containersLoading ||
yardsLoading;
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
if (!config) return allRows; if (!config) return allRows;
@@ -222,7 +245,7 @@ const FleetResourcePage = () => {
}); });
return base; return base;
}, [config]); }, [config, dynamicOptions.yards]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; const tableStatus = isLoading ? "loading" : isError ? "error" : "success";

View File

@@ -1,5 +1,4 @@
import { Freight } from "@edr/types"; import { Freight } from "@edr/types";
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources"; import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
export type FleetResourceSlug = export type FleetResourceSlug =
@@ -20,7 +19,8 @@ export type FleetDynamicOptions =
| "containerTypes" | "containerTypes"
| "cargoTypes" | "cargoTypes"
| "wagons" | "wagons"
| "containers"; | "containers"
| "yards";
export interface FleetResourceColumn { export interface FleetResourceColumn {
id: string; id: string;
@@ -35,10 +35,11 @@ export interface FleetFormFieldDef extends FormFieldDef {
} }
export interface FleetListFilterDef { export interface FleetListFilterDef {
key: "status" | "readiness" | "wagonTypeId" | "trainId"; key: "status" | "currentYardId" | "wagonTypeId" | "trainId";
label: string; label: string;
options: Array<{ value: string; label: string }>; options?: Array<{ value: string; label: string }>;
allLabel?: string; allLabel?: string;
dynamicOptions?: FleetDynamicOptions;
} }
export interface FleetResourceConfig { export interface FleetResourceConfig {
@@ -93,10 +94,6 @@ const WAGON_STATUS_OPTIONS = [
{ label: "Retired", value: Freight.WagonStatus.Retired }, { 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[] = [ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ {
@@ -122,19 +119,19 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
options: LOCOMOTIVE_STATUS_OPTIONS, options: LOCOMOTIVE_STATUS_OPTIONS,
}, },
{ {
key: "readiness", key: "currentYardId",
label: "Readiness", label: "Current Yard",
allLabel: "All readiness", allLabel: "All yards",
options: WAGON_READINESS_OPTIONS, dynamicOptions: "yards",
}, },
], ],
cardSubtitleKey: "readiness", cardSubtitleKey: "currentYard",
searchKeys: ["code", "name", "locomotiveType", "status", "readiness"], searchKeys: ["code", "name", "locomotiveType", "status", "currentYardId"],
columns: [ columns: [
{ id: "code", header: "Code", accessorKey: "code", format: "code" }, { id: "code", header: "Code", accessorKey: "code", format: "code" },
{ id: "name", header: "Name", accessorKey: "name" }, { id: "name", header: "Name", accessorKey: "name" },
{ id: "locomotiveType", header: "Type", accessorKey: "locomotiveType" }, { 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: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" }, { id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", 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: "name", label: "Name", type: "text" },
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS }, { 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: "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: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true }, { name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
{ name: "powerKw", label: "Power (kW)", type: "number" }, { name: "powerKw", label: "Power (kW)", type: "number" },
@@ -156,7 +153,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
name: "", name: "",
locomotiveType: "DIESEL", locomotiveType: "DIESEL",
status: "AVAILABLE", status: "AVAILABLE",
readiness: Freight.WagonReadiness.ImportReady, currentYardId: "",
maxPullWeightTons: 0, maxPullWeightTons: 0,
maxTrainLengthMeters: 760, maxTrainLengthMeters: 760,
powerKw: "", powerKw: "",
@@ -225,20 +222,20 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
options: WAGON_STATUS_OPTIONS, options: WAGON_STATUS_OPTIONS,
}, },
{ {
key: "readiness", key: "currentYardId",
label: "Readiness", label: "Current Yard",
allLabel: "All readiness", allLabel: "All yards",
options: WAGON_READINESS_OPTIONS, dynamicOptions: "yards",
}, },
], ],
cardTitleKey: "wagonNumber", cardTitleKey: "wagonNumber",
cardSubtitleKey: "readiness", cardSubtitleKey: "currentYard",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "readiness"], searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"],
columns: [ columns: [
{ id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" }, { id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" }, { id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
{ id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" }, { 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" }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
], ],
formFields: [ formFields: [
@@ -246,7 +243,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
{ name: "tareWeight", label: "Tare weight", type: "number", required: true }, { name: "tareWeight", label: "Tare weight", type: "number", required: true },
{ name: "maxPayloadWeight", label: "Max payload 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: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
{ name: "notes", label: "Notes", type: "textarea" }, { name: "notes", label: "Notes", type: "textarea" },
], ],
@@ -255,7 +252,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
wagonTypeId: "", wagonTypeId: "",
tareWeight: 0, tareWeight: 0,
maxPayloadWeight: 0, maxPayloadWeight: 0,
readiness: Freight.WagonReadiness.ImportReady, currentYardId: "",
status: Freight.WagonStatus.Available, status: Freight.WagonStatus.Available,
notes: "", notes: "",
}, },

View File

@@ -296,9 +296,12 @@ const RuleEngineResourcePage = () => {
}; };
const handleFormSubmit = (values: Record<string, unknown>) => { const handleFormSubmit = (values: Record<string, unknown>) => {
const payload =
config.slug === "rates" ? { ...values, currency: "USD" } : values;
if (editing?.id) { if (editing?.id) {
update.mutate( update.mutate(
{ id: editing.id, payload: values }, { id: editing.id, payload },
{ {
onSuccess: () => { onSuccess: () => {
setFormOpen(false); setFormOpen(false);
@@ -307,7 +310,7 @@ const RuleEngineResourcePage = () => {
}, },
); );
} else { } else {
create.mutate(values, { create.mutate(payload, {
onSuccess: () => { onSuccess: () => {
setFormOpen(false); setFormOpen(false);
setEditing(null); setEditing(null);

View File

@@ -110,10 +110,7 @@ const RATE_UNITS = ["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "FLAT"].m
value: v, value: v,
})); }));
const CURRENCIES = [ const CURRENCIES = [{ label: "USD", value: "USD" }];
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
];
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({ const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
id: key, id: key,
@@ -430,7 +427,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select", type: "select",
options: TRADE_DIRECTIONS, options: TRADE_DIRECTIONS,
}, },
{ name: "currency", label: "Currency", type: "select", required: true, options: CURRENCIES },
{ name: "rateValue", label: "Rate value", type: "number", required: true }, { name: "rateValue", label: "Rate value", type: "number", required: true },
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS }, { name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true }, { name: "effectiveFrom", label: "Effective from", type: "date", required: true },

View File

@@ -1,13 +1,15 @@
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
Alert, Alert,
Box, Box,
Button, Button,
Card,
Container, Container,
Group, Group,
Paper, Paper,
RingProgress, RingProgress,
Select,
SimpleGrid, SimpleGrid,
Skeleton, Skeleton,
Stack, Stack,
@@ -17,17 +19,21 @@ import {
import { import {
AlertTriangle, AlertTriangle,
ArrowRight, ArrowRight,
CalendarClock,
CalendarDays, CalendarDays,
Inbox, Inbox,
Package, Package,
RefreshCw,
Ruler, Ruler,
Train, Train,
TrainFront, TrainFront,
Weight, Weight,
} from "lucide-react"; } 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 Breadcrumbs from "@/components/ui/Breadcrumbs";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { import {
BookingPipeline, BookingPipeline,
HeroChip, HeroChip,
@@ -35,6 +41,7 @@ import {
WindowStatusPill, WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals"; } from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals"; import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand"; import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling"; import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
import type { BatchBoardSchedule } from "@/types/trainScheduling"; import type { BatchBoardSchedule } from "@/types/trainScheduling";
@@ -58,6 +65,27 @@ const fmtScheduleDate = (iso: string | null) =>
}).format(new Date(iso)) + " EAT" }).format(new Date(iso)) + " EAT"
: "No date"; : "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. */ /** Capacity ring color: gold normally, red once over capacity. */
function ringColor(pct: number) { function ringColor(pct: number) {
if (pct >= 100) return "#fa5252"; 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 (
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 8,
background: over ? "var(--mantine-color-red-0)" : "var(--mantine-color-gray-1)",
border: `1px solid ${over ? "var(--mantine-color-red-2)" : "var(--mantine-color-gray-2)"}`,
}}
>
<Icon size={12} color={over ? "var(--mantine-color-red-6)" : "var(--mantine-color-gray-6)"} />
<Text size="xs" fw={700} c={over ? "red.7" : "gray.7"} lh={1.2}>
{pct != null ? `${Math.round(pct)}%` : "—"}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
{text}
</Text>
</Group>
);
}
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 }) { function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { capacity, counts, locomotive } = schedule; const { capacity, counts, locomotive } = schedule;
const lengthPct = const lengthPct = lengthPctOf(schedule);
capacity.maxLengthMeters && capacity.maxLengthMeters > 0 const weightPct = weightPctOf(schedule);
? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100
: null;
const weightPct =
capacity.maxWeightTons && capacity.maxWeightTons > 0
? (capacity.usedWeightTons / capacity.maxWeightTons) * 100
: null;
const totalBookings = totalBookingCount(counts); const totalBookings = totalBookingCount(counts);
@@ -298,7 +364,13 @@ function CardSkeleton() {
} }
export default function BatchBoardPage() { export default function BatchBoardPage() {
const navigate = useNavigate();
const { data, isLoading, isFetching, refetch } = useBatchBoard(); 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 schedules = data ?? [];
const summary = useMemo(() => { const summary = useMemo(() => {
@@ -308,22 +380,199 @@ export default function BatchBoardPage() {
return { openWindows, totalBookings, totalWagons }; return { openWindows, totalBookings, totalWagons };
}, [schedules]); }, [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<BatchBoardSchedule>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "train",
header: "Train / Route",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={34} radius="md" variant="light" color="#F2A516">
<Train size={17} />
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={700} lh={1.2} truncate>
{row.original.trainNumber ?? row.original.routeName ?? "Schedule"}
</Text>
<Box maw={220}>
<RouteCorridor
origin={row.original.origin}
destination={row.original.destination}
variant="compact"
/>
</Box>
</Stack>
</Group>
),
},
{
id: "date",
header: "Departure",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const { day, time } = splitDate(row.original.scheduleDate);
return (
<Group gap="sm" wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 9,
background: "var(--mantine-color-orange-0)",
color: "#B26C09",
flexShrink: 0,
}}
>
<CalendarClock size={16} />
</Box>
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{day}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{time || "—"}
</Text>
</Stack>
</Group>
);
},
},
{
id: "window",
header: "Window",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <WindowStatusPill status={row.original.bookingWindowStatus} />,
},
{
id: "loco",
header: "Locomotive",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
row.original.locomotive ? (
<Group gap={6} wrap="nowrap">
<TrainFront size={14} color="var(--mantine-color-gray-5)" />
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{row.original.locomotive.code}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
{fmtTons(row.original.locomotive.maxPullWeightTons)} pull
</Text>
</Stack>
</Group>
) : (
<Text size="xs" c="red.6" fw={600}>
No loco
</Text>
),
},
{
id: "capacity",
header: "Capacity",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="wt" />
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 8,
background: "var(--mantine-color-green-0)",
border: "1px solid var(--mantine-color-green-1)",
}}
>
<Package size={12} color="var(--mantine-color-green-7)" />
<Text size="xs" fw={700} c="green.8" lh={1.2}>
{row.original.capacity.allocatedWagons}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
wgn
</Text>
</Group>
</Group>
),
},
{
id: "bookings",
header: "Bookings",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const total = totalBookingCount(row.original.counts);
return (
<Stack gap={4} style={{ minWidth: 130 }}>
<Text size="sm" fw={700} c="dark.4" lh={1.2}>
{total} booking{total === 1 ? "" : "s"}
</Text>
<BookingPipeline counts={row.original.counts} size={10} />
</Stack>
);
},
},
{
id: "actions",
header: "",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<Group justify="flex-end" wrap="nowrap">
<Button
variant="light"
color="green"
size="compact-sm"
rightSection={<ArrowRight size={14} />}
onClick={() =>
navigate(`/dashboard/operations/batch-board/${row.original.scheduleId}`)
}
>
View windows
</Button>
</Group>
),
},
];
}, [navigate]);
const tableStatus = isLoading ? "loading" : "success";
return ( return (
<Container fluid py="lg" px="xl"> <Container fluid py="lg" px="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} /> <Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
<Group justify="flex-end" mt="md">
<Button
variant="default"
radius="lg"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
</Group>
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="md" mt="md"> <SimpleGrid cols={{ base: 1, xs: 3 }} spacing="md" mt="md">
<StatTile <StatTile
icon={Train} icon={Train}
@@ -359,46 +608,121 @@ export default function BatchBoardPage() {
/> />
</SimpleGrid> </SimpleGrid>
{isLoading ? ( <Card
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg"> radius="lg"
<CardSkeleton /> padding={0}
<CardSkeleton /> withBorder
<CardSkeleton /> mt="lg"
</SimpleGrid> style={{ borderColor: "var(--mantine-color-gray-2)" }}
) : schedules.length === 0 ? ( >
<Paper radius="lg" withBorder p={48} mt="lg" bg="gray.0"> <Stack gap={0}>
<Stack align="center" gap="sm"> <Box px="md" pt="md" pb="sm" w="100%">
<Box <FleetToolbar
style={{ search={search}
display: "flex", onSearchChange={setSearch}
alignItems: "center", searchPlaceholder="Search schedules…"
justifyContent: "center", viewMode={viewMode}
width: 64, onViewModeChange={setViewMode}
height: 64, filters={
borderRadius: 20, <Select
background: "white", size="sm"
border: "1px solid var(--mantine-color-gray-2)", radius="lg"
boxShadow: "0 4px 12px rgba(15,23,42,0.06)", value={windowFilter}
onChange={(v) => 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)" } }}
/>
}
/>
</Box>
{viewMode === "table" ? (
<DataTable
columns={columns}
data={paged}
status={tableStatus}
emptyMessage="No active schedules"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filtered.length,
}} }}
> tableOptions={{
<Inbox size={28} color="var(--mantine-color-gray-5)" /> manualPagination: true,
</Box> pageCount,
<Text fw={700} c="gray.7"> state: { pagination },
No active schedules onPaginationChange: setPagination,
</Text> }}
<Text size="sm" c="dimmed" ta="center" maw={380}> containerClassName="border-0 shadow-none bg-transparent"
Schedules with an open booking window appear here as cards. Create or activate footer={({ table, pagination: footerPagination }) => (
a schedule to get started. <DataTableFooter
</Text> table={table}
</Stack> pagination={footerPagination}
</Paper> options={{ labels: { items: "schedules" } }}
) : ( />
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg"> )}
{schedules.map((s) => ( />
<ScheduleCard key={s.scheduleId} schedule={s} /> ) : isLoading ? (
))} <SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
</SimpleGrid> <CardSkeleton />
)} <CardSkeleton />
<CardSkeleton />
</SimpleGrid>
) : filtered.length === 0 ? (
<Paper radius="lg" p={48} m="md" bg="gray.0">
<Stack align="center" gap="sm">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 20,
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 4px 12px rgba(15,23,42,0.06)",
}}
>
<Inbox size={28} color="var(--mantine-color-gray-5)" />
</Box>
<Text fw={700} c="gray.7">
No active schedules
</Text>
<Text size="sm" c="dimmed" ta="center" maw={380}>
Schedules with an open booking window appear here. Create or activate a
schedule to get started.
</Text>
</Stack>
</Paper>
) : (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
{filtered.map((s) => (
<ScheduleCard key={s.scheduleId} schedule={s} />
))}
</SimpleGrid>
)}
</Stack>
</Card>
<Group justify="flex-end" mt="md">
<Button
variant="subtle"
color="gray"
size="xs"
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
</Group>
</Container> </Container>
); );
} }

View File

@@ -1,8 +1,9 @@
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { import {
Accordion, Accordion,
ActionIcon,
Alert, Alert,
Badge, Badge,
Box, Box,
@@ -14,6 +15,7 @@ import {
SimpleGrid, SimpleGrid,
Stack, Stack,
Table, Table,
Tabs,
Text, Text,
ThemeIcon, ThemeIcon,
Title, Title,
@@ -25,6 +27,8 @@ import {
Boxes, Boxes,
CalendarDays, CalendarDays,
CheckCircle2, CheckCircle2,
ChevronLeft,
ChevronRight,
Clock, Clock,
FileSignature, FileSignature,
Hourglass, Hourglass,
@@ -41,6 +45,7 @@ import type { LucideIcon } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { TrainConsistView, CompositionBookingTabs } from "@/components/trainScheduling/compositionEditor";
import { import {
BookingPipeline, BookingPipeline,
HeroChip, 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 }) { function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
const total = window.bookings.length; const total = window.bookings.length;
const hasIssues = window.bookings.some( const hasIssues = window.bookings.some(
@@ -347,7 +386,7 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
</Box> </Box>
<Box style={{ minWidth: 0 }}> <Box style={{ minWidth: 0 }}>
<Text fw={700} size="sm" truncate> <Text fw={700} size="sm" truncate>
{window.label} {timeLabelOf(window.label)}
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"} {total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"}
@@ -454,17 +493,110 @@ export default function BatchScheduleDetailPage() {
[data], [data],
); );
const scheduleDetailQuery = useScheduleDetail( const scheduleDetailQuery = useScheduleDetail(scheduleId, "CONTAINER");
hasAssignedWagons ? scheduleId : undefined,
"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(() => { const todayEat = useMemo(
if (!data) return []; () =>
const withBookings = data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key); new Intl.DateTimeFormat("en-CA", {
if (data.pendingContract.bookings.length) withBookings.push("pending-contract"); timeZone: "Africa/Addis_Ababa",
return withBookings.length ? withBookings : [data.windows[0]?.key].filter(Boolean); year: "numeric",
}, [data]); 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<string | null>(null);
const [activeTab, setActiveTab] = useState<string | null>("overview");
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(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 = () => { const handleRunAllocation = () => {
runAllocation runAllocation
@@ -518,8 +650,17 @@ export default function BatchScheduleDetailPage() {
]} ]}
/> />
<Stack gap="lg" mt="md"> <Tabs value={activeTab} onChange={setActiveTab} mt="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md"> <Tabs.List>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="composition">
Train Composition {scheduleDetailQuery.data?.trainSet?.wagons && scheduleDetailQuery.data.trainSet.wagons.length > 0 && `(${scheduleDetailQuery.data.trainSet.wagons.length})`}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="lg">
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={9} style={{ minWidth: 0 }}> <Stack gap={9} style={{ minWidth: 0 }}>
<Group gap="sm" wrap="wrap" align="center"> <Group gap="sm" wrap="wrap" align="center">
<Button <Button
@@ -637,7 +778,6 @@ export default function BatchScheduleDetailPage() {
variant="line" variant="line"
/> />
</SimpleGrid> </SimpleGrid>
</Stack>
{/* Booking pipeline */} {/* Booking pipeline */}
<Paper <Paper
@@ -706,24 +846,126 @@ export default function BatchScheduleDetailPage() {
<Box> <Box>
<Title order={4}>Batch windows (EAT)</Title> <Title order={4}>Batch windows (EAT)</Title>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Bookings grouped by contract signing time in 3-hour windows expand one to 3-hour windows for every day from when the booking window opened through the
see bookings and wagon allocation issues. departure date. Bookings appear under the date their contract was signed open a
day to see its windows.
</Text> </Text>
</Box> </Box>
</Group> </Group>
<Accordion {dayGroups.length && selectedDay ? (
multiple <>
defaultValue={defaultOpen} {/* Date stepper — page back/forward through each day in the range */}
variant="separated" <Group justify="center" align="center" wrap="nowrap" gap="md" mt="md">
radius="md" <ActionIcon
mt="md" variant="light"
className="bb-window-accordion" color="#F2A516"
> size="xl"
{data.windows.map((window) => ( radius="xl"
<WindowAccordionItem key={window.key} window={window} /> aria-label="Previous day"
))} disabled={selectedIndex <= 0}
{data.pendingContract.bookings.length ? ( onClick={() => setSelectedDate(dayGroups[selectedIndex - 1]?.date ?? null)}
>
<ChevronLeft size={20} />
</ActionIcon>
<Paper
withBorder
radius="xl"
px="xl"
py="xs"
style={{
flex: 1,
maxWidth: 360,
textAlign: "center",
background: selectedDay.totalBookings ? "#FEF1D5" : "white",
borderColor: selectedDay.totalBookings
? "#FBD171"
: "var(--mantine-color-gray-2)",
}}
>
<Group justify="center" gap={8} wrap="nowrap">
<CalendarDays size={15} color="#B26C09" />
<Text
fw={800}
style={{ color: selectedDay.totalBookings ? "#8A5304" : "#0f172a" }}
>
{selectedDay.dateLabel}
</Text>
{selectedDay.date === todayEat ? (
<Badge size="xs" variant="light" color="#F2A516">
Today
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" mt={2}>
{selectedDay.totalBookings
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
: `${selectedDay.windows.length} windows · no bookings`}
</Text>
</Paper>
<ActionIcon
variant="light"
color="#F2A516"
size="xl"
radius="xl"
aria-label="Next day"
disabled={selectedIndex >= dayGroups.length - 1}
onClick={() => setSelectedDate(dayGroups[selectedIndex + 1]?.date ?? null)}
>
<ChevronRight size={20} />
</ActionIcon>
</Group>
<Group justify="space-between" align="center" mt="sm">
<Text size="xs" c="dimmed">
Day {selectedIndex + 1} of {dayGroups.length}
</Text>
<Group gap={6} wrap="nowrap">
{selectedDay.hasIssues ? (
<Badge
variant="light"
color="red"
size="sm"
leftSection={<AlertTriangle size={10} />}
>
Issues
</Badge>
) : null}
<WindowCountChips counts={selectedDay.counts} />
</Group>
</Group>
<Accordion
key={selectedDay.date}
multiple
defaultValue={openWindowKeys}
variant="separated"
radius="md"
mt="md"
className="bb-window-accordion"
>
{selectedDay.windows.map((window) => (
<WindowAccordionItem key={window.key} window={window} />
))}
</Accordion>
</>
) : (
<Text size="sm" c="dimmed" ta="center" py="lg">
No batch windows for this schedule.
</Text>
)}
{data.pendingContract.bookings.length ? (
<Accordion
multiple
defaultValue={["pending-contract"]}
variant="separated"
radius="md"
mt="md"
className="bb-window-accordion"
>
<Accordion.Item value="pending-contract"> <Accordion.Item value="pending-contract">
<Accordion.Control> <Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm"> <Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
@@ -763,11 +1005,11 @@ export default function BatchScheduleDetailPage() {
<BookingTable bookings={data.pendingContract.bookings} /> <BookingTable bookings={data.pendingContract.bookings} />
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
) : null} </Accordion>
</Accordion> ) : null}
</Paper> </Paper>
{/* Train composition */} {/* Train composition diagram */}
{hasAssignedWagons && scheduleDetailQuery.data ? ( {hasAssignedWagons && scheduleDetailQuery.data ? (
<Box mt="lg"> <Box mt="lg">
<TrainCompositionDiagram <TrainCompositionDiagram
@@ -779,6 +1021,48 @@ export default function BatchScheduleDetailPage() {
/> />
</Box> </Box>
) : null} ) : null}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="composition" pt="lg">
{scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? (
<Group align="stretch" gap="md" wrap="nowrap">
<Box style={{ flex: 1, minWidth: 0 }}>
<TrainConsistView
scheduleDetail={scheduleDetailQuery.data}
scheduleId={scheduleId ?? ""}
maxWagons={53}
highlightBookingId={selectedBookingId}
/>
</Box>
<Box style={{ width: 380, flexShrink: 0, minHeight: 520 }}>
<CompositionBookingTabs
scheduleDetail={scheduleDetailQuery.data}
scheduleId={scheduleId ?? ""}
awaitingPayment={batchBookings.awaitingPayment}
expired={batchBookings.expired}
selectedBookingId={selectedBookingId}
onSelectBooking={setSelectedBookingId}
/>
</Box>
</Group>
) : (
<Paper
radius="lg"
withBorder
py={64}
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group justify="center">
<Loader color="green" size="sm" />
<Text size="sm" c="dimmed">
Loading train composition
</Text>
</Group>
</Paper>
)}
</Tabs.Panel>
</Tabs>
</Container> </Container>
); );
} }

View File

@@ -90,7 +90,7 @@ export default function TrainScheduleTrackPage() {
onSuccess: () => { onSuccess: () => {
toast({ toast({
title: isFinal title: isFinal
? "Train arrived — assets freed, readiness flipped" ? "Train arrived — assets freed, moved to destination yard"
: "Checkpoint logged", : "Checkpoint logged",
}); });
}, },

View File

@@ -785,11 +785,11 @@ export default function TrainScheduleV2DetailPage() {
label="Locomotive" label="Locomotive"
value={schedule.trainSet?.locomotive?.code ?? "—"} value={schedule.trainSet?.locomotive?.code ?? "—"}
hint={ hint={
schedule.trainSet?.locomotive?.readiness === "EXPORT_READY" schedule.trainSet?.locomotive?.currentYardId
? "Export-ready" ? schedule.trainSet.locomotive.currentYardId === schedule.originStation?.id
: schedule.trainSet?.locomotive?.readiness === "IMPORT_READY" ? `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? "origin yard"}`
? "Import-ready" : "Not at schedule origin yard"
: undefined : "No current yard set"
} }
accent="#F2A516" accent="#F2A516"
graph="area" graph="area"

View File

@@ -89,15 +89,13 @@ export default function TrainScheduleV2ListPage() {
const selectedRoute = activeRoutes.find((r) => r.id === routeId); const selectedRoute = activeRoutes.find((r) => r.id === routeId);
const locomotiveReadinessHint = useMemo(() => { const locomotiveYardHint = useMemo(() => {
if (!selectedRoute) return "Select a route first"; if (!selectedRoute) return "Select a route first";
const origin = selectedRoute.originYard?.country?.trim(); const originLabel =
const dest = selectedRoute.destinationYard?.country?.trim(); selectedRoute.originYard?.label ??
if (origin === "Djibouti") return "Import corridor — import-ready locomotives only"; selectedRoute.originYard?.code ??
if (dest === "Djibouti" && origin !== "Djibouti") { "the route origin yard";
return "Export corridor — export-ready locomotives only"; return `Only locomotives currently at ${originLabel} are shown`;
}
return "Domestic corridor — any readiness";
}, [selectedRoute]); }, [selectedRoute]);
useEffect(() => { useEffect(() => {
@@ -525,7 +523,7 @@ export default function TrainScheduleV2ListPage() {
/> />
{routeId ? ( {routeId ? (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{locomotiveReadinessHint} {locomotiveYardHint}
</Text> </Text>
) : null} ) : null}
<TextInput <TextInput
@@ -542,9 +540,7 @@ export default function TrainScheduleV2ListPage() {
placeholder={routeId ? "Select locomotive" : "Select a route first"} placeholder={routeId ? "Select locomotive" : "Select a route first"}
data={(locomotivesQuery.data ?? []).map((l) => ({ data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id, value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""} · ${ label: `${l.code}${l.name ? `${l.name}` : ""}`,
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
}`,
}))} }))}
value={locomotiveId || null} value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")} onChange={(v) => setLocomotiveId(v ?? "")}

View File

@@ -1,5 +1,3 @@
import type { Freight } from '@edr/types';
import { api as apiClient } from '../auth/http'; import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS'; import { URL_CONSTANTS } from '@/constants/URLS';
@@ -13,7 +11,7 @@ export type LocomotiveStatus =
export interface LocomotiveListFilters { export interface LocomotiveListFilters {
status?: LocomotiveStatus; status?: LocomotiveStatus;
readiness?: Freight.WagonReadiness; currentYardId?: string;
} }
export interface Locomotive { export interface Locomotive {
@@ -22,7 +20,8 @@ export interface Locomotive {
name?: string | null; name?: string | null;
locomotiveType: LocomotiveType; locomotiveType: LocomotiveType;
status: LocomotiveStatus; status: LocomotiveStatus;
readiness: Freight.WagonReadiness; currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null;
maxPullWeightTons: number; maxPullWeightTons: number;
maxTrainLengthMeters: number; maxTrainLengthMeters: number;
powerKw?: number | null; powerKw?: number | null;
@@ -41,7 +40,7 @@ export const locomotivesService = {
getAll: (filters: LocomotiveListFilters = {}) => { getAll: (filters: LocomotiveListFilters = {}) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status); if (filters.status) params.set('status', filters.status);
if (filters.readiness) params.set('readiness', filters.readiness); if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
const qs = params.toString(); const qs = params.toString();
return apiClient.get<Locomotive[]>( return apiClient.get<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`, `${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,

View File

@@ -6,6 +6,9 @@ import type {
BatchBoardScheduleDetail, BatchBoardScheduleDetail,
BookableSchedule, BookableSchedule,
AssignBookingsPayload, AssignBookingsPayload,
CompositionRemovalEntry,
CompositionUnassignedBooking,
UnassignedBookingsResponse,
CreateTrainSchedulePayload, CreateTrainSchedulePayload,
EligibleContainerBookingsResponse, EligibleContainerBookingsResponse,
FreightType, FreightType,
@@ -160,6 +163,17 @@ export const trainSchedulingService = {
return unwrap(response.data); return unwrap(response.data);
}, },
assignUnassignedBooking: async (
scheduleId: string,
bookingId: string,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.ASSIGN_UNASSIGNED_BOOKING(scheduleId),
{ bookingId },
);
return unwrap(response.data);
},
assignBookings: async ( assignBookings: async (
scheduleId: string, scheduleId: string,
payload: AssignBookingsPayload, payload: AssignBookingsPayload,
@@ -350,4 +364,41 @@ export const trainSchedulingService = {
country: yard.country, country: yard.country,
})); }));
}, },
removeWagonSlot: async (scheduleId: string, wagonId: string): Promise<TrainScheduleDetail> => {
const response = await client.delete<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.REMOVE_WAGON_SLOT(scheduleId, wagonId),
);
return unwrap(response.data);
},
updateContainerItem: async (
scheduleId: string,
itemId: string,
payload: { containerNumber: string | null },
): Promise<{ id: string; containerNumber: string | null }> => {
const response = await client.patch<{ id: string; containerNumber: string | null }>(
URL_CONSTANTS.TRAIN_SCHEDULING.UPDATE_CONTAINER_ITEM(scheduleId, itemId),
payload,
);
return unwrap(response.data);
},
getUnassignedBookings: async (
scheduleId: string,
): Promise<UnassignedBookingsResponse> => {
const response = await client.get<UnassignedBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.UNASSIGNED_BOOKINGS(scheduleId),
);
return unwrap(response.data);
},
getCompositionRemovals: async (
scheduleId: string,
): Promise<CompositionRemovalEntry[]> => {
const response = await client.get<CompositionRemovalEntry[]>(
URL_CONSTANTS.TRAIN_SCHEDULING.COMPOSITION_REMOVALS(scheduleId),
);
return unwrap(response.data);
},
}; };

View File

@@ -11,14 +11,15 @@ export interface Wagon {
tareWeight: number; tareWeight: number;
maxPayloadWeight: number; maxPayloadWeight: number;
status: Freight.WagonStatus; status: Freight.WagonStatus;
readiness: Freight.WagonReadiness; currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null;
notes?: string; notes?: string;
} }
export interface WagonListFilters { export interface WagonListFilters {
search?: string; search?: string;
status?: Freight.WagonStatus; status?: Freight.WagonStatus;
readiness?: Freight.WagonReadiness; currentYardId?: string;
wagonTypeId?: string; wagonTypeId?: string;
trainId?: string; trainId?: string;
} }
@@ -28,7 +29,7 @@ export const wagonService = {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filters.search?.trim()) params.set('search', filters.search.trim()); if (filters.search?.trim()) params.set('search', filters.search.trim());
if (filters.status) params.set('status', filters.status); if (filters.status) params.set('status', filters.status);
if (filters.readiness) params.set('readiness', filters.readiness); if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId); if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId);
if (filters.trainId) params.set('trainId', filters.trainId); if (filters.trainId) params.set('trainId', filters.trainId);
const qs = params.toString(); const qs = params.toString();

View File

@@ -127,8 +127,6 @@ export interface TrainSchedulePreviewResponse {
containerSlotSequenceNos?: number[]; containerSlotSequenceNos?: number[];
} }
export type Readiness = "IMPORT_READY" | "EXPORT_READY";
export interface LocomotiveRecord { export interface LocomotiveRecord {
id: string; id: string;
code: string; code: string;
@@ -136,7 +134,7 @@ export interface LocomotiveRecord {
maxPullWeightTons: number; maxPullWeightTons: number;
maxTrainLengthMeters: number; maxTrainLengthMeters: number;
status: "AVAILABLE" | "ASSIGNED" | "MAINTENANCE" | "OUT_OF_SERVICE"; status: "AVAILABLE" | "ASSIGNED" | "MAINTENANCE" | "OUT_OF_SERVICE";
readiness?: Readiness | null; currentYardId?: string | null;
locomotiveType?: "DIESEL" | "ELECTRIC"; locomotiveType?: "DIESEL" | "ELECTRIC";
} }
@@ -153,7 +151,7 @@ export interface TrainScheduleListItem {
id: string; id: string;
code: string; code: string;
name?: string | null; name?: string | null;
readiness?: Readiness | null; currentYardId?: string | null;
} }
| null; | null;
wagonCount: number; wagonCount: number;
@@ -247,6 +245,10 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
export interface BatchWindowGroup { export interface BatchWindowGroup {
key: string; key: string;
label: 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; start: string;
end: string; end: string;
counts: { counts: {
@@ -348,7 +350,7 @@ export interface TrainScheduleDetail {
code: string; code: string;
name?: string | null; name?: string | null;
status: string; status: string;
readiness?: Readiness | null; currentYardId?: string | null;
maxPullWeightTons: number; maxPullWeightTons: number;
maxTrainLengthMeters?: number; maxTrainLengthMeters?: number;
} | null; } | null;
@@ -483,3 +485,33 @@ export interface PinWagonAssignment {
export interface PinWagonsPayload { export interface PinWagonsPayload {
assignments: PinWagonAssignment[]; assignments: PinWagonAssignment[];
} }
export interface CompositionUnassignedBooking {
id: string;
reference: string | null;
freightType: FreightType | null;
priorityScore: number;
cargoTotalWeightVgm: number;
status: string | null;
schedulingStatus: SchedulingStatus | null;
wagonsRequired: number;
requiredWagonTypeCode: string;
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
}
export interface UnassignedBookingsResponse {
fleetAtOrigin: FleetAvailabilityRow[];
bookings: CompositionUnassignedBooking[];
}
export interface CompositionRemovalEntry {
id: string;
scheduleId: string;
bookingId: string;
bookingReference: string | null;
removedByUserId: string | null;
removedAt: string;
notes: string | null;
}

View File

@@ -2,31 +2,30 @@ import { Freight } from "@edr/types";
import type { Wagon } from "@/services/wagon.service"; import type { Wagon } from "@/services/wagon.service";
export function wagonMatchesScheduleDirection( /** Check if a wagon is available for a schedule based on its current yard.
wagon: Pick<Wagon, "status" | "readiness">, * A wagon is eligible if:
scheduleDirection?: string | null, * 1. It is available (or pinned to this schedule)
* 2. It is physically located at the schedule's origin yard
*/
export function wagonMatchesScheduleOrigin(
wagon: Pick<Wagon, "id" | "status" | "currentYardId">,
originYardId?: string | null,
options?: { allowPinned?: boolean }, options?: { allowPinned?: boolean },
): boolean { ): boolean {
if (options?.allowPinned) return true; if (options?.allowPinned) return true;
if (wagon.status !== Freight.WagonStatus.Available) return false; if (wagon.status !== Freight.WagonStatus.Available) return false;
if (!scheduleDirection || scheduleDirection === "DOMESTIC") return true; if (!originYardId) return true;
if (scheduleDirection === "IMPORT") { return wagon.currentYardId === originYardId;
return wagon.readiness === Freight.WagonReadiness.ImportReady;
}
if (scheduleDirection === "EXPORT") {
return wagon.readiness === Freight.WagonReadiness.ExportReady;
}
return true;
} }
export function filterWagonsForSchedule( export function filterWagonsForSchedule(
wagons: Wagon[], wagons: Wagon[],
scheduleDirection?: string | null, originYardId?: string | null,
pinnedWagonIds?: Set<string>, pinnedWagonIds?: Set<string>,
): Wagon[] { ): Wagon[] {
return wagons.filter((wagon) => { return wagons.filter((wagon) => {
const isPinned = pinnedWagonIds?.has(wagon.id) ?? false; const isPinned = pinnedWagonIds?.has(wagon.id) ?? false;
return wagonMatchesScheduleDirection(wagon, scheduleDirection, { return wagonMatchesScheduleOrigin(wagon, originYardId, {
allowPinned: isPinned, allowPinned: isPinned,
}); });
}); });