mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 16:40:56 +00:00
Merge pull request #151 from Tria-plc/freight_feature/payments
Freight feature/payments
This commit is contained in:
@@ -14,4 +14,9 @@ export default registerAs("app", () => ({
|
||||
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
||||
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
|
||||
},
|
||||
cbeExchange: {
|
||||
apiUrl: process.env.CBE_EXCHANGE_API_URL ?? "",
|
||||
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
|
||||
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";`);
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,24 @@ import { BookingPricingService } from './booking-pricing.service';
|
||||
import type { Booking } from './entities/booking.entity';
|
||||
import type { Rate } from '../rule-engine/entities/rate.entity';
|
||||
|
||||
const MOCK_CBE_RATE = 130;
|
||||
|
||||
describe('BookingPricingService — domestic corridor', () => {
|
||||
const intercityBulkEtb: Rate = {
|
||||
id: 'rate-intercity-bulk-etb',
|
||||
const intercityBulkUsd: Rate = {
|
||||
id: 'rate-intercity-bulk-usd',
|
||||
rateType: 'INTERCITY_BULK',
|
||||
currency: 'ETB',
|
||||
rateValue: 1900,
|
||||
currency: 'USD',
|
||||
rateValue: 35,
|
||||
rateUnit: 'PER_TON',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
const intercityContainerEtb: Rate = {
|
||||
id: 'rate-intercity-container-etb',
|
||||
const intercityContainerUsd: Rate = {
|
||||
id: 'rate-intercity-container-usd',
|
||||
rateType: 'INTERCITY_CONTAINER',
|
||||
currency: 'ETB',
|
||||
rateValue: 25000,
|
||||
currency: 'USD',
|
||||
rateValue: 400,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
@@ -26,11 +28,15 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
let service: BookingPricingService;
|
||||
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
||||
let ratesService: { findLiveRates: jest.Mock };
|
||||
let cbeExchangeService: { getUsdToEtbRate: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
||||
ratesService = {
|
||||
findLiveRates: jest.fn().mockResolvedValue([intercityBulkEtb, intercityContainerEtb]),
|
||||
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
|
||||
};
|
||||
cbeExchangeService = {
|
||||
getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
||||
};
|
||||
|
||||
service = new BookingPricingService(
|
||||
@@ -39,10 +45,11 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
{} as never,
|
||||
ratesService as never,
|
||||
{} as never,
|
||||
cbeExchangeService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('prices domestic bulk using INTERCITY_BULK and cargo tons', async () => {
|
||||
it('prices domestic bulk in ETB using INTERCITY_BULK USD rate × CBE exchange rate', async () => {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
freightType: 'BULK',
|
||||
@@ -57,16 +64,42 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string }> }>;
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||||
expect(result.lineItems[0].amount).toBe(1900 * 120);
|
||||
expect(result.lineItems[0].currency).toBe('ETB');
|
||||
expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE));
|
||||
});
|
||||
|
||||
it('prices domestic container using INTERCITY_CONTAINER fallback', async () => {
|
||||
it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => {
|
||||
const booking = {
|
||||
id: 'b-1-usd',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTotalWeightVgm: 120,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||||
expect(result.lineItems[0].currency).toBe('USD');
|
||||
expect(result.lineItems[0].amount).toBe(35 * 120);
|
||||
});
|
||||
|
||||
it('prices domestic container in ETB using INTERCITY_CONTAINER USD fallback × CBE rate', async () => {
|
||||
const booking = {
|
||||
id: 'b-2',
|
||||
freightType: 'CONTAINER',
|
||||
@@ -83,12 +116,14 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
input: {
|
||||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||||
},
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string }> }>;
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, {
|
||||
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
|
||||
});
|
||||
|
||||
expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true);
|
||||
const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!;
|
||||
expect(line.currency).toBe('ETB');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
import {
|
||||
AppliedCargoModifier,
|
||||
BookingEvaluationInput,
|
||||
@@ -40,6 +41,7 @@ export class BookingPricingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly serviceTypesService: ServiceTypesService,
|
||||
private readonly cbeExchangeService: CbeExchangeService,
|
||||
) {}
|
||||
|
||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||
@@ -80,6 +82,10 @@ export class BookingPricingService {
|
||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
|
||||
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
|
||||
@@ -95,14 +101,16 @@ export class BookingPricingService {
|
||||
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
|
||||
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
const usdAmount = mod.calculatedAmount;
|
||||
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const item: PriceLineItemDto = {
|
||||
code: mod.surchargeTypeCode,
|
||||
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
||||
amount: mod.calculatedAmount,
|
||||
currency: mod.currency,
|
||||
amount: convertedAmount,
|
||||
currency: paymentCurrency,
|
||||
};
|
||||
lineItems.push(item);
|
||||
total += mod.calculatedAmount;
|
||||
total += convertedAmount;
|
||||
|
||||
const rate = rateById.get(mod.rateId);
|
||||
if (rate) usedRatesMap.set(rate.id, rate);
|
||||
@@ -263,7 +271,9 @@ export class BookingPricingService {
|
||||
evalInput: BookingEvaluationInput,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const currency = booking.paymentCurrency;
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
|
||||
const isBulk = booking.freightType === 'BULK';
|
||||
|
||||
const rateType =
|
||||
@@ -284,34 +294,36 @@ export class BookingPricingService {
|
||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||
|
||||
for (const container of evalInput.containers) {
|
||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
|
||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
|
||||
if (!rate) continue;
|
||||
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
const amount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
amount,
|
||||
currency: rate.currency,
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
const fallback = liveRates.find(
|
||||
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
|
||||
(r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE',
|
||||
);
|
||||
if (fallback) {
|
||||
usedRatesMap.set(fallback.id, fallback);
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const quantity =
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
const amount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
amount,
|
||||
currency: fallback.currency,
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { PaymentModule } from '../payment/payment.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -65,6 +66,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
CbeExchangeService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min, IsUUID } from 'class-validator';
|
||||
|
||||
import {
|
||||
LOCOMOTIVE_READINESS_VALUES,
|
||||
LOCOMOTIVE_STATUSES,
|
||||
LOCOMOTIVE_TYPES,
|
||||
} from '../entities/locomotive.entity';
|
||||
@@ -28,10 +27,10 @@ export class CreateLocomotiveDto {
|
||||
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||
status!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES, default: 'IMPORT_READY' })
|
||||
@ApiPropertyOptional({ description: 'Current yard location' })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_READINESS_VALUES])
|
||||
readiness?: string;
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
|
||||
@ApiProperty({ example: 3500 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import {
|
||||
LOCOMOTIVE_READINESS_VALUES,
|
||||
LOCOMOTIVE_STATUSES,
|
||||
LOCOMOTIVE_TYPES,
|
||||
} from '../entities/locomotive.entity';
|
||||
@@ -18,8 +17,8 @@ export class FilterLocomotivesDto {
|
||||
@IsIn([...LOCOMOTIVE_TYPES])
|
||||
locomotiveType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES })
|
||||
@ApiPropertyOptional({ description: 'Filter by current yard' })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_READINESS_VALUES])
|
||||
readiness?: string;
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { WagonReadiness } from '@edr/types';
|
||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
import { Column, Entity, Index, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
|
||||
|
||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
|
||||
export const LOCOMOTIVE_STATUSES = [
|
||||
'AVAILABLE',
|
||||
@@ -13,20 +13,13 @@ export const LOCOMOTIVE_STATUSES = [
|
||||
|
||||
export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const;
|
||||
|
||||
/** Locomotives reuse the wagon readiness values (IMPORT_READY / EXPORT_READY). */
|
||||
export const LOCOMOTIVE_READINESS_VALUES = [
|
||||
WagonReadiness.ImportReady,
|
||||
WagonReadiness.ExportReady,
|
||||
] as const;
|
||||
|
||||
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
|
||||
export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
|
||||
export type LocomotiveReadiness = (typeof LOCOMOTIVE_READINESS_VALUES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'locomotives' })
|
||||
@Index(['code'])
|
||||
@Index(['status'])
|
||||
@Index(['readiness'])
|
||||
@Index(['currentYardId'])
|
||||
export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
|
||||
code!: string;
|
||||
@@ -46,8 +39,12 @@ export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
|
||||
status!: LocomotiveStatus;
|
||||
|
||||
@Column({ name: 'readiness', type: 'varchar', length: 20, default: WagonReadiness.ImportReady })
|
||||
readiness!: LocomotiveReadiness;
|
||||
@Column({ name: 'current_yard_id', type: 'uuid', nullable: true })
|
||||
currentYardId!: string | null;
|
||||
|
||||
@ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'current_yard_id' })
|
||||
currentYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
powerKw?: number | null;
|
||||
|
||||
@@ -3,11 +3,9 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
import { WagonReadiness } from '@edr/types';
|
||||
|
||||
import {
|
||||
Locomotive,
|
||||
type LocomotiveReadiness,
|
||||
type LocomotiveStatus,
|
||||
type LocomotiveType,
|
||||
} from './entities/locomotive.entity';
|
||||
@@ -24,8 +22,9 @@ export class LocomotivesService {
|
||||
...(filter.locomotiveType
|
||||
? { locomotiveType: filter.locomotiveType as LocomotiveType }
|
||||
: {}),
|
||||
...(filter.readiness ? { readiness: filter.readiness as LocomotiveReadiness } : {}),
|
||||
...(filter.currentYardId ? { currentYardId: filter.currentYardId } : {}),
|
||||
},
|
||||
relations: { currentYard: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
@@ -42,7 +41,7 @@ export class LocomotivesService {
|
||||
name: dto.name?.trim() || null,
|
||||
locomotiveType: dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status as LocomotiveStatus,
|
||||
readiness: (dto.readiness as LocomotiveReadiness) ?? WagonReadiness.ImportReady,
|
||||
currentYardId: dto.currentYardId ?? null,
|
||||
maxPullWeightTons: dto.maxPullWeightTons,
|
||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||
powerKw: dto.powerKw ?? null,
|
||||
@@ -52,7 +51,9 @@ export class LocomotivesService {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Locomotive> {
|
||||
const locomotive = await this.locomotivesRepository.findById(id);
|
||||
const locomotive = await this.locomotivesRepository.findById(id, {
|
||||
relations: { currentYard: true },
|
||||
});
|
||||
|
||||
if (!locomotive) {
|
||||
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||
@@ -76,10 +77,10 @@ export class LocomotivesService {
|
||||
locomotiveType:
|
||||
dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
|
||||
readiness:
|
||||
dto.readiness === undefined
|
||||
? locomotive.readiness
|
||||
: (dto.readiness as LocomotiveReadiness),
|
||||
currentYardId:
|
||||
dto.currentYardId === undefined
|
||||
? locomotive.currentYardId
|
||||
: (dto.currentYardId ?? null),
|
||||
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
|
||||
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
|
||||
tractionForceKn:
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength,
|
||||
import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||
const CURRENCIES = ['ETB', 'USD'] as const;
|
||||
const CURRENCIES = ['USD'] as const;
|
||||
|
||||
export class CreateRateDto {
|
||||
@ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' })
|
||||
|
||||
@@ -51,7 +51,7 @@ export class RatesService {
|
||||
rateType: dto.rateType as Rate['rateType'],
|
||||
containerTypeId: dto.containerTypeId,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
currency: dto.currency,
|
||||
currency: dto.currency ?? 'USD',
|
||||
rateValue: dto.rateValue,
|
||||
rateUnit: dto.rateUnit as Rate['rateUnit'],
|
||||
status: 'DRAFT',
|
||||
@@ -71,7 +71,7 @@ export class RatesService {
|
||||
if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType'];
|
||||
if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId;
|
||||
if (dto.tradeDirection !== undefined) updates.tradeDirection = dto.tradeDirection;
|
||||
if (dto.currency) updates.currency = dto.currency;
|
||||
updates.currency = dto.currency ?? existing.currency ?? 'USD';
|
||||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
|
||||
if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from './entities/train-schedule.entity';
|
||||
import { TrainCompositionRemovalLog } from './entities/train-composition-removal-log.entity';
|
||||
import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity';
|
||||
import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
|
||||
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
|
||||
import { TrainSchedulesRepository } from './train-schedules.repository';
|
||||
import { TrainCompositionRemovalLogRepository } from './train-composition-removal-log.repository';
|
||||
import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository';
|
||||
import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository';
|
||||
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
|
||||
@@ -17,6 +19,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
|
||||
TypeOrmModule.forFeature([
|
||||
TrainSchedule,
|
||||
TrainScheduleBooking,
|
||||
TrainCompositionRemovalLog,
|
||||
WagonBookingAllocation,
|
||||
WagonAllocationContainerItem,
|
||||
WagonAllocationBulkLoad,
|
||||
@@ -25,6 +28,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
|
||||
providers: [
|
||||
TrainSchedulesRepository,
|
||||
TrainScheduleBookingsRepository,
|
||||
TrainCompositionRemovalLogRepository,
|
||||
WagonBookingAllocationsRepository,
|
||||
WagonAllocationContainerItemsRepository,
|
||||
WagonAllocationBulkLoadsRepository,
|
||||
@@ -32,6 +36,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
|
||||
exports: [
|
||||
TrainSchedulesRepository,
|
||||
TrainScheduleBookingsRepository,
|
||||
TrainCompositionRemovalLogRepository,
|
||||
WagonBookingAllocationsRepository,
|
||||
WagonAllocationContainerItemsRepository,
|
||||
WagonAllocationBulkLoadsRepository,
|
||||
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
listBatchWindowsForDate,
|
||||
listBatchWindowsForBookings,
|
||||
BATCH_WINDOW_START_HOURS,
|
||||
boardWindowForTimestamp,
|
||||
listBoardWindowsForRange,
|
||||
groupBookingsIntoBoardWindows,
|
||||
} from './batch-window.util';
|
||||
|
||||
describe('batch-window.util', () => {
|
||||
@@ -50,3 +53,84 @@ describe('batch-window.util', () => {
|
||||
expect(getBatchWindowForTimestamp(fullyExecutedAt).key).toBe(overnight!.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch-window board windows (midnight-based 3h slots)', () => {
|
||||
it('maps 04:00 EAT to the 03:00–06:00 slot', () => {
|
||||
// 01:00 UTC = 04:00 EAT on 11 Jun
|
||||
const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z'));
|
||||
expect(w.label).toContain('03:00');
|
||||
expect(w.label).toContain('06:00');
|
||||
expect(w.date).toBe('2026-06-11');
|
||||
expect(w.dateLabel).toContain('11 Jun');
|
||||
});
|
||||
|
||||
it('maps 00:30 EAT to the 00:00–03:00 slot of that EAT day', () => {
|
||||
// 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun
|
||||
const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z'));
|
||||
expect(w.label).toContain('00:00');
|
||||
expect(w.label).toContain('03:00');
|
||||
expect(w.date).toBe('2026-06-11');
|
||||
});
|
||||
|
||||
it('maps 23:00 EAT to the final 21:00–24:00 slot', () => {
|
||||
// 20:00 UTC = 23:00 EAT on 11 Jun
|
||||
const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z'));
|
||||
expect(w.label).toContain('21:00');
|
||||
expect(w.label).toContain('24:00');
|
||||
expect(w.date).toBe('2026-06-11');
|
||||
});
|
||||
|
||||
it('lists a continuous range open→departure clamped at both ends', () => {
|
||||
// open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC)
|
||||
const open = new Date('2026-06-05T05:00:00.000Z');
|
||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||
const windows = listBoardWindowsForRange(open, departure);
|
||||
|
||||
// Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5
|
||||
expect(windows).toHaveLength(6 + 8 + 8 + 5);
|
||||
expect(windows[0].date).toBe('2026-06-05');
|
||||
expect(windows[0].label).toContain('06:00');
|
||||
expect(windows[0].label).toContain('09:00');
|
||||
const last = windows[windows.length - 1];
|
||||
expect(last.date).toBe('2026-06-08');
|
||||
expect(last.label).toContain('12:00');
|
||||
expect(last.label).toContain('15:00');
|
||||
// chronological + unique keys
|
||||
const keys = windows.map((w) => w.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it('handles a same-day open→departure range', () => {
|
||||
const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (06–09 slot)
|
||||
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 slot)
|
||||
const windows = listBoardWindowsForRange(open, departure);
|
||||
// 06,09,12 = 3 slots
|
||||
expect(windows).toHaveLength(3);
|
||||
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
|
||||
});
|
||||
|
||||
it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => {
|
||||
const open = new Date('2026-06-05T05:00:00.000Z');
|
||||
const departure = new Date('2026-06-06T11:00:00.000Z');
|
||||
const items = [
|
||||
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 06–09 on 5th
|
||||
{ id: 'b', ts: null }, // pending
|
||||
];
|
||||
const map = groupBookingsIntoBoardWindows(
|
||||
items,
|
||||
(i) => i.ts,
|
||||
open,
|
||||
departure,
|
||||
'pending-contract',
|
||||
);
|
||||
const pending = map.get('pending-contract');
|
||||
expect(pending?.items.map((i) => i.id)).toEqual(['b']);
|
||||
const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a'));
|
||||
expect(withA?.window?.date).toBe('2026-06-05');
|
||||
// empty slots are retained for the UI
|
||||
const emptyCount = [...map.values()].filter(
|
||||
(b) => b.window && b.items.length === 0,
|
||||
).length;
|
||||
expect(emptyCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,6 +159,154 @@ export function listBatchWindowsForBookings(
|
||||
return [...byKey.values()].sort(compareBatchWindows);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Board-display windows: full-day, midnight-based 3h slots over a date range.
|
||||
// These are used ONLY for the batch-board UI grouping (not persisted, and
|
||||
// independent of the cron intake hours above).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Midnight-based 3-hour slot starts (00–03, 03–06, … 21–24). */
|
||||
export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const;
|
||||
|
||||
/** A board window carries an EAT calendar date in addition to the slot times. */
|
||||
export interface BoardWindow extends BatchWindow {
|
||||
/** EAT calendar day as ISO `YYYY-MM-DD`. */
|
||||
date: string;
|
||||
/** Human label for the day, e.g. `Thu, 05 Jun`. */
|
||||
dateLabel: string;
|
||||
}
|
||||
|
||||
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */
|
||||
function boardWindowFromEatStart(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
startHour: number,
|
||||
): BoardWindow {
|
||||
const start = eatToUtc(year, month, day, startHour);
|
||||
const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over)
|
||||
const end = eatToUtc(year, month, day, endHour);
|
||||
const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`;
|
||||
return {
|
||||
key: start.toISOString(),
|
||||
start,
|
||||
end,
|
||||
label: formatWindowLabel(start, end, endLabel),
|
||||
date: `${year}-${pad2(month)}-${pad2(day)}`,
|
||||
dateLabel: dayLabelFmt.format(start),
|
||||
};
|
||||
}
|
||||
|
||||
/** Which midnight-based 3h EAT slot a timestamp falls in. */
|
||||
export function boardWindowForTimestamp(date: Date): BoardWindow {
|
||||
const { year, month, day, hour } = eatParts(date);
|
||||
let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0;
|
||||
for (const h of BOARD_WINDOW_HOURS) {
|
||||
if (hour >= h) startHour = h;
|
||||
}
|
||||
return boardWindowFromEatStart(year, month, day, startHour);
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuous list of board windows from `openDate` to `departureDate` (inclusive),
|
||||
* clamped to the slot containing `openDate` on the first day and the slot
|
||||
* containing `departureDate` on the last day. Returned in chronological order.
|
||||
*/
|
||||
export function listBoardWindowsForRange(
|
||||
openDate: Date,
|
||||
departureDate: Date,
|
||||
): BoardWindow[] {
|
||||
const startWin = boardWindowForTimestamp(openDate);
|
||||
const endWin = boardWindowForTimestamp(departureDate);
|
||||
// Guard against an inverted range (departure before open).
|
||||
if (endWin.start.getTime() < startWin.start.getTime()) {
|
||||
return [startWin];
|
||||
}
|
||||
|
||||
const windows: BoardWindow[] = [];
|
||||
const seen = new Set<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`. */
|
||||
export function groupByBatchWindow<T>(
|
||||
items: T[],
|
||||
|
||||
@@ -19,9 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import {
|
||||
groupByBatchWindow,
|
||||
} from './batch-window.util';
|
||||
import { groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
@@ -82,6 +80,10 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
|
||||
date: string;
|
||||
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
|
||||
dateLabel: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
@@ -401,11 +403,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
const referenceDate = s.scheduledDepartureDate ?? new Date();
|
||||
const windowBuckets = groupByBatchWindow(
|
||||
// Display windows span the whole booking window: from when it opened
|
||||
// (schedule creation) through the scheduled departure, in 3-hour EAT slots.
|
||||
const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date();
|
||||
const departureDate = s.scheduledDepartureDate ?? new Date();
|
||||
const windowBuckets = groupBookingsIntoBoardWindows(
|
||||
items,
|
||||
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
|
||||
referenceDate,
|
||||
openDate,
|
||||
departureDate,
|
||||
);
|
||||
|
||||
const emptyCounts = () => ({
|
||||
@@ -437,6 +443,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
windows.push({
|
||||
key: w.key,
|
||||
label: w.label,
|
||||
date: w.date,
|
||||
dateLabel: w.dateLabel,
|
||||
start: w.start.toISOString(),
|
||||
end: w.end.toISOString(),
|
||||
counts: countFor(bucket.items),
|
||||
@@ -477,6 +485,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
pendingContract: {
|
||||
key: 'pending-contract',
|
||||
label: 'Pending contract',
|
||||
date: '',
|
||||
dateLabel: '',
|
||||
start: '',
|
||||
end: '',
|
||||
counts: countFor(pendingBookings),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class AssignUnassignedBookingDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
bookingId!: string;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class AvailableLocomotivesQueryDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'Route used to derive import/export/domestic readiness' })
|
||||
@ApiProperty({ format: 'uuid', description: 'Route used to filter locomotives at the origin yard' })
|
||||
@IsUUID()
|
||||
routeId!: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateContainerItemDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
containerNumber?: string | null;
|
||||
}
|
||||
@@ -9,15 +9,20 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
|
||||
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
||||
|
||||
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
|
||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
||||
import { AssignUnassignedBookingDto } from './dto/assign-unassigned-booking.dto';
|
||||
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
|
||||
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
||||
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||
@@ -75,7 +80,7 @@ export class TrainSchedulingController {
|
||||
@Get('available-locomotives')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary: 'List AVAILABLE locomotives filtered by route corridor readiness',
|
||||
summary: 'List AVAILABLE locomotives at the route origin yard',
|
||||
})
|
||||
getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) {
|
||||
return this.trainSchedulingService.getAvailableLocomotivesForRoute(query.routeId);
|
||||
@@ -176,8 +181,56 @@ export class TrainSchedulingController {
|
||||
unassignBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainSchedulingService.unassignBooking(id, bookingId);
|
||||
return this.trainSchedulingService.unassignBooking(id, bookingId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Delete('schedules/:id/wagons/:trainSetWagonId')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Remove an empty wagon slot from a train' })
|
||||
removeWagonSlot(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('trainSetWagonId', ParseUUIDPipe) trainSetWagonId: string,
|
||||
) {
|
||||
return this.trainSchedulingService.removeTrainSetWagonSlot(id, trainSetWagonId);
|
||||
}
|
||||
|
||||
@Patch('schedules/:id/container-items/:itemId')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Update a container number on a wagon slot' })
|
||||
updateContainerItem(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('itemId', ParseUUIDPipe) itemId: string,
|
||||
@Body() dto: UpdateContainerItemDto,
|
||||
) {
|
||||
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
||||
}
|
||||
|
||||
@Get('schedules/:id/unassigned-bookings')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Get unassigned bookings for a schedule' })
|
||||
getUnassignedBookings(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getUnassignedBookings(id);
|
||||
}
|
||||
|
||||
@Post('schedules/:id/assign-unassigned-booking')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary: 'Assign one linked unallocated booking to wagons (preserves existing assignments)',
|
||||
})
|
||||
assignUnassignedBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AssignUnassignedBookingDto,
|
||||
) {
|
||||
return this.trainSchedulingService.assignUnassignedBookingToWagons(id, dto.bookingId);
|
||||
}
|
||||
|
||||
@Get('schedules/:id/composition-removals')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Get removal log for a schedule' })
|
||||
getCompositionRemovals(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getCompositionRemovals(id);
|
||||
}
|
||||
|
||||
@Post('schedules/:id/pin-wagons')
|
||||
@@ -275,7 +328,7 @@ export class TrainSchedulingController {
|
||||
|
||||
@Post('schedules/:id/arrive')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Mark a dispatched train arrived (flip readiness, free assets)' })
|
||||
@ApiOperation({ summary: 'Mark a dispatched train arrived (move assets to destination yard, free assets)' })
|
||||
arriveSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.arriveSchedule(id);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
|
||||
const nw5 = {
|
||||
@@ -25,7 +26,7 @@ const locomotive = {
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'AVAILABLE',
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentYardId: 'yard-origin',
|
||||
};
|
||||
|
||||
const cw3 = {
|
||||
@@ -96,6 +97,7 @@ describe('TrainSchedulingService', () => {
|
||||
bookingsRepository = {
|
||||
findEligibleForScheduling: jest.fn(),
|
||||
findByIdsForScheduling: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
updateSchedulingFields: jest.fn(),
|
||||
};
|
||||
locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() };
|
||||
@@ -144,6 +146,7 @@ describe('TrainSchedulingService', () => {
|
||||
wagonAllocationContainerItemsRepository as never,
|
||||
wagonAllocationBulkLoadsRepository as never,
|
||||
trainCheckpointEventsRepository as never,
|
||||
{} as never, // trainCompositionRemovalLogRepository
|
||||
);
|
||||
|
||||
const defaultFleetWagons = [
|
||||
@@ -151,14 +154,14 @@ describe('TrainSchedulingService', () => {
|
||||
id: `wagon-nw5-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: null,
|
||||
})),
|
||||
...Array.from({ length: 50 }, (_, index) => ({
|
||||
id: `wagon-cw3-${index}`,
|
||||
wagonTypeId: cw3.id,
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: null,
|
||||
})),
|
||||
];
|
||||
@@ -192,7 +195,7 @@ describe('TrainSchedulingService', () => {
|
||||
id: `wagon-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: null,
|
||||
}));
|
||||
|
||||
@@ -533,14 +536,14 @@ describe('TrainSchedulingService', () => {
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('rejects pin when wagon readiness does not match schedule direction', async () => {
|
||||
it('rejects pin when wagon is not at the schedule origin yard', async () => {
|
||||
const scheduleId = 'sched-1';
|
||||
const slotId = 'slot-1';
|
||||
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||
id: scheduleId,
|
||||
status: 'DRAFT',
|
||||
direction: 'IMPORT',
|
||||
originStationId: 'yard-origin',
|
||||
trainSet: {
|
||||
wagons: [{ id: slotId, physicalWagonId: null }],
|
||||
},
|
||||
@@ -554,7 +557,7 @@ describe('TrainSchedulingService', () => {
|
||||
id: 'wagon-1',
|
||||
wagonNumber: 'WGN-001',
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ExportReady,
|
||||
currentYardId: 'yard-other',
|
||||
currentTrainScheduleId: null,
|
||||
}),
|
||||
update: jest.fn(),
|
||||
@@ -577,7 +580,7 @@ describe('TrainSchedulingService', () => {
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('flags physical fleet shortfall when export schedule lacks EXPORT_READY wagons', async () => {
|
||||
it('flags physical fleet shortfall when wagons are not at the origin yard', async () => {
|
||||
const exportBooking = makeBooking(
|
||||
'exp-1',
|
||||
'BKG-EXP',
|
||||
@@ -597,14 +600,16 @@ describe('TrainSchedulingService', () => {
|
||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||
bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]);
|
||||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ ...locomotive, currentYardId: 'yard-addis' },
|
||||
]);
|
||||
|
||||
const importOnlyFleet = Array.from({ length: 5 }, (_, index) => ({
|
||||
const wrongYardFleet = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `wagon-nw5-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
wagonNumber: `WGN-${index}`,
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentYardId: 'yard-djibouti',
|
||||
currentTrainScheduleId: null,
|
||||
}));
|
||||
|
||||
@@ -613,7 +618,7 @@ describe('TrainSchedulingService', () => {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue(importOnlyFleet) };
|
||||
return { find: jest.fn().mockResolvedValue(wrongYardFleet) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
@@ -630,7 +635,7 @@ describe('TrainSchedulingService', () => {
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(
|
||||
result.violations.some((v) => v.includes('EXPORT_READY') && v.includes('NW5')),
|
||||
result.violations.some((v) => v.includes('available at yard') && v.includes('NW5')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -722,14 +727,160 @@ describe('TrainSchedulingService', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
describe('getUnassignedBookings', () => {
|
||||
const scheduleId = 'sched-unassigned-1';
|
||||
const trainSetId = 'train-set-unassigned';
|
||||
const assignedBooking = makeBooking('b-assigned', 'BKG-ASSIGNED', 50, 1, '40FT', 1);
|
||||
const unassignedBooking = makeBooking('b-unassigned', 'BKG-UNASSIGNED', 60, 1, '40FT', 1);
|
||||
|
||||
const buildScheduleGraph = () => ({
|
||||
id: scheduleId,
|
||||
status: 'DRAFT',
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||
trainSet: {
|
||||
id: trainSetId,
|
||||
locomotive: { ...locomotive, status: 'ASSIGNED', currentYardId: 'yard-origin' },
|
||||
wagons: [{ id: 'slot-1', sequenceNo: 1, wagonTypeId: nw5.id, allocations: [] }],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||
bookingsRepository.findAll.mockResolvedValue([
|
||||
{
|
||||
...assignedBooking,
|
||||
trainScheduleId: scheduleId,
|
||||
paymentStatus: 'PAID',
|
||||
isGovernment: false,
|
||||
},
|
||||
{
|
||||
...unassignedBooking,
|
||||
trainScheduleId: scheduleId,
|
||||
paymentStatus: 'PAID',
|
||||
isGovernment: false,
|
||||
},
|
||||
]);
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(buildScheduleGraph());
|
||||
});
|
||||
|
||||
it('allows assign when train slots are full but origin yard has matching wagons', async () => {
|
||||
const yardFleet = [
|
||||
{
|
||||
id: 'wagon-pinned',
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Assigned,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: scheduleId,
|
||||
},
|
||||
...Array.from({ length: 2 }, (_, index) => ({
|
||||
id: `wagon-yard-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Available,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: null,
|
||||
})),
|
||||
];
|
||||
|
||||
bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
|
||||
const map = new Map([
|
||||
[assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
|
||||
[unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
|
||||
]);
|
||||
return ids.map((id) => map.get(id)).filter(Boolean);
|
||||
});
|
||||
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue(yardFleet) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
}
|
||||
if (entity === WagonBookingAllocation) {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
|
||||
};
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const result = await service.getUnassignedBookings(scheduleId);
|
||||
|
||||
expect(result.bookings).toHaveLength(1);
|
||||
expect(result.bookings[0].id).toBe(unassignedBooking.id);
|
||||
expect(result.bookings[0].canAssign).toBe(true);
|
||||
expect(result.bookings[0].blockReason).toBeNull();
|
||||
expect(
|
||||
result.fleetAtOrigin.some(
|
||||
(row: { wagonTypeCode: string; available: number }) =>
|
||||
row.wagonTypeCode === 'NW5' && row.available >= 2,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks assign when origin yard lacks wagons of the required type', async () => {
|
||||
const yardFleet = [
|
||||
{
|
||||
id: 'wagon-pinned',
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Assigned,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: scheduleId,
|
||||
},
|
||||
];
|
||||
|
||||
bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
|
||||
const map = new Map([
|
||||
[assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
|
||||
[unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
|
||||
]);
|
||||
return ids.map((id) => map.get(id)).filter(Boolean);
|
||||
});
|
||||
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue(yardFleet) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
}
|
||||
if (entity === WagonBookingAllocation) {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
|
||||
};
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const result = await service.getUnassignedBookings(scheduleId);
|
||||
|
||||
expect(result.bookings).toHaveLength(1);
|
||||
expect(result.bookings[0].canAssign).toBe(false);
|
||||
expect(result.bookings[0].blockReason).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableLocomotivesForRoute', () => {
|
||||
it('filters to export-ready locomotives on Ethiopia → Djibouti routes', async () => {
|
||||
it('returns locomotives at the route origin yard', async () => {
|
||||
const routeId = 'route-export';
|
||||
const originYardId = 'yard-addis';
|
||||
const routeRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: routeId,
|
||||
name: 'Addis → Djibouti',
|
||||
isActive: true,
|
||||
originYardId,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Djibouti' },
|
||||
}),
|
||||
@@ -739,23 +890,28 @@ describe('TrainSchedulingService', () => {
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
expect(locomotivesRepository.findAll).toHaveBeenCalledWith({
|
||||
where: { status: 'AVAILABLE', currentYardId: originYardId },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].code).toBe('EXP');
|
||||
});
|
||||
|
||||
it('returns all available locomotives on domestic routes', async () => {
|
||||
it('returns all locomotives returned by the repository for domestic routes', async () => {
|
||||
const routeId = 'route-domestic';
|
||||
const originYardId = 'yard-addis';
|
||||
const routeRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: routeId,
|
||||
name: 'Addis → Dire Dawa',
|
||||
isActive: true,
|
||||
originYardId,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Ethiopia' },
|
||||
}),
|
||||
@@ -765,8 +921,8 @@ describe('TrainSchedulingService', () => {
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady },
|
||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
@@ -26,9 +26,11 @@ import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository';
|
||||
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
|
||||
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
||||
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
|
||||
@@ -41,6 +43,7 @@ import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
||||
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||
@@ -52,6 +55,7 @@ import {
|
||||
selectBookingsWithinFleetCap,
|
||||
summarizeFleetWarnings,
|
||||
totalAssignedWeight,
|
||||
wagonsRequiredForBooking,
|
||||
type DeferredBookingRow,
|
||||
type FleetAvailabilityRow,
|
||||
} from './fleet-plan.util';
|
||||
@@ -75,11 +79,6 @@ import {
|
||||
pickBulkWagonType,
|
||||
} from './wagon-type-resolver.util';
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import {
|
||||
flipReadiness,
|
||||
requiredWagonReadiness,
|
||||
wagonReadinessMatchesSchedule,
|
||||
} from './wagon-readiness.util';
|
||||
import {
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
@@ -121,6 +120,26 @@ export interface WagonAllocationAttemptResult {
|
||||
violations: string[];
|
||||
}
|
||||
|
||||
export interface CompositionUnassignedBookingRow {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
freightType: string | null;
|
||||
priorityScore: number;
|
||||
cargoTotalWeightVgm: number;
|
||||
status: string | null;
|
||||
schedulingStatus: string | null;
|
||||
wagonsRequired: number;
|
||||
requiredWagonTypeCode: string;
|
||||
yardWagonsAvailable: number;
|
||||
canAssign: boolean;
|
||||
blockReason: string | null;
|
||||
}
|
||||
|
||||
export interface UnassignedBookingsResponse {
|
||||
fleetAtOrigin: FleetAvailabilityRow[];
|
||||
bookings: CompositionUnassignedBookingRow[];
|
||||
}
|
||||
|
||||
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
||||
maxWeightTons: 3500,
|
||||
maxLengthMeters: 760,
|
||||
@@ -143,6 +162,7 @@ export class TrainSchedulingService {
|
||||
private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
|
||||
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
|
||||
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
|
||||
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
||||
private readonly configService?: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -269,9 +289,9 @@ export class TrainSchedulingService {
|
||||
route.originYard ?? { country: null },
|
||||
route.destinationYard ?? { country: null },
|
||||
);
|
||||
if (!wagonReadinessMatchesSchedule(lockedLocomotive.readiness, direction)) {
|
||||
if (lockedLocomotive.currentYardId !== route.originYardId) {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${lockedLocomotive.code} is ${lockedLocomotive.readiness} and cannot run a ${direction} schedule`,
|
||||
`Locomotive ${lockedLocomotive.code} is at yard ${lockedLocomotive.currentYardId} but schedule originates from ${route.originYardId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -458,7 +478,7 @@ export class TrainSchedulingService {
|
||||
await this.autoPinWagonsForSchedule(
|
||||
manager,
|
||||
scheduleId,
|
||||
schedule.direction ?? null,
|
||||
schedule.originStationId,
|
||||
savedWagons,
|
||||
);
|
||||
});
|
||||
@@ -467,7 +487,7 @@ export class TrainSchedulingService {
|
||||
return { ...detail, warnings, deferredBookings };
|
||||
}
|
||||
|
||||
async unassignBooking(scheduleId: string, bookingId: string) {
|
||||
async unassignBooking(scheduleId: string, bookingId: string, userId?: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
@@ -481,6 +501,9 @@ export class TrainSchedulingService {
|
||||
throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
|
||||
}
|
||||
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
const bookingReference = booking?.reference ?? null;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const allocationIds = (schedule.trainSet?.wagons ?? [])
|
||||
.flatMap((w) => w.allocations ?? [])
|
||||
@@ -529,6 +552,18 @@ export class TrainSchedulingService {
|
||||
}
|
||||
});
|
||||
|
||||
await this.trainCompositionRemovalLogRepository.create({
|
||||
scheduleId,
|
||||
bookingId,
|
||||
bookingReference,
|
||||
removedByUserId: userId ?? null,
|
||||
removedAt: new Date(),
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`,
|
||||
);
|
||||
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
@@ -565,9 +600,9 @@ export class TrainSchedulingService {
|
||||
`Wagon ${physicalWagon.wagonNumber} is not available`,
|
||||
);
|
||||
}
|
||||
if (!wagonReadinessMatchesSchedule(physicalWagon.readiness, schedule.direction)) {
|
||||
if (physicalWagon.currentYardId !== schedule.originStationId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is ${physicalWagon.readiness} but schedule is ${schedule.direction ?? 'unknown'}`,
|
||||
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -827,8 +862,8 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a dispatched train arrived: close out the schedule, flip readiness on the
|
||||
* locomotive + wagons (they have repositioned), and free the assets for re-use.
|
||||
* Mark a dispatched train arrived: close out the schedule, move the locomotive
|
||||
* and wagons to the destination yard, and free the assets for re-use.
|
||||
*/
|
||||
async arriveSchedule(scheduleId: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
@@ -839,7 +874,6 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Only DISPATCHED trains can arrive');
|
||||
}
|
||||
|
||||
const isDomestic = schedule.direction === 'DOMESTIC';
|
||||
const now = new Date();
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -863,7 +897,7 @@ export class TrainSchedulingService {
|
||||
if (loco) {
|
||||
await manager.getRepository(Locomotive).update(loco.id, {
|
||||
status: 'AVAILABLE',
|
||||
readiness: isDomestic ? loco.readiness : flipReadiness(loco.readiness),
|
||||
currentYardId: schedule.destinationStationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -878,7 +912,7 @@ export class TrainSchedulingService {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
status: WagonStatus.Available,
|
||||
readiness: isDomestic ? wagon.readiness : flipReadiness(wagon.readiness),
|
||||
currentYardId: schedule.destinationStationId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1028,11 +1062,15 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
if (
|
||||
bookings.some(
|
||||
(b) =>
|
||||
bookings.some((b) => {
|
||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
b.originYardId !== dto.originStationId ||
|
||||
b.destinationYardId !== dto.destinationStationId,
|
||||
)
|
||||
b.destinationYardId !== dto.destinationStationId
|
||||
);
|
||||
})
|
||||
) {
|
||||
violations.push('Selected bookings must share the same origin and destination as the schedule');
|
||||
}
|
||||
@@ -1083,8 +1121,8 @@ export class TrainSchedulingService {
|
||||
: buildBulkWagonPlan(bookings, wagonType);
|
||||
}
|
||||
|
||||
const scheduleDirection = await this.resolveScheduleDirection(targetScheduleId, bookings);
|
||||
const fleetCounts = await this.countFleetAvailability(scheduleDirection, targetScheduleId);
|
||||
const originYardId = dto.originStationId;
|
||||
const fleetCounts = await this.countFleetAvailability(originYardId, targetScheduleId);
|
||||
const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available]));
|
||||
fleetAvailability = computeFleetAvailability(
|
||||
demandPlan,
|
||||
@@ -1113,7 +1151,7 @@ export class TrainSchedulingService {
|
||||
violations.push(
|
||||
...(await this.validatePhysicalFleetForPlan(
|
||||
wagonPlan,
|
||||
scheduleDirection,
|
||||
originYardId,
|
||||
targetScheduleId,
|
||||
)),
|
||||
);
|
||||
@@ -1170,26 +1208,43 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
const availableLocomotives = (
|
||||
await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE' },
|
||||
})
|
||||
).filter((l) => wagonReadinessMatchesSchedule(l.readiness, scheduleDirection));
|
||||
if (!availableLocomotives.length) {
|
||||
const readinessHint = requiredWagonReadiness(scheduleDirection);
|
||||
violations.push(
|
||||
readinessHint
|
||||
? `No available ${readinessHint} locomotive exists for this ${scheduleDirection} schedule`
|
||||
: 'No available locomotive exists for scheduling',
|
||||
);
|
||||
} else if (
|
||||
!availableLocomotives.some(
|
||||
(l) =>
|
||||
Number(l.maxPullWeightTons) >= totalWeightTons &&
|
||||
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
|
||||
)
|
||||
) {
|
||||
violations.push('No available locomotive can support the total train weight and length');
|
||||
let assignedLocomotive: Locomotive | null = null;
|
||||
if (targetScheduleId) {
|
||||
const targetSchedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
|
||||
assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null;
|
||||
}
|
||||
|
||||
if (assignedLocomotive) {
|
||||
if (assignedLocomotive.currentYardId !== originYardId) {
|
||||
violations.push(
|
||||
`Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`,
|
||||
);
|
||||
} else if (
|
||||
Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons ||
|
||||
Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters
|
||||
) {
|
||||
violations.push(
|
||||
'Assigned locomotive cannot support the total train weight and length',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const availableLocomotives = (
|
||||
await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE' },
|
||||
})
|
||||
).filter((l) => l.currentYardId === originYardId);
|
||||
if (!availableLocomotives.length) {
|
||||
violations.push('No available locomotive at the schedule origin yard');
|
||||
} else if (
|
||||
!availableLocomotives.some(
|
||||
(l) =>
|
||||
Number(l.maxPullWeightTons) >= totalWeightTons &&
|
||||
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
|
||||
)
|
||||
) {
|
||||
violations.push('No available locomotive can support the total train weight and length');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1334,26 +1389,8 @@ export class TrainSchedulingService {
|
||||
];
|
||||
}
|
||||
|
||||
private async resolveScheduleDirection(
|
||||
targetScheduleId: string | undefined,
|
||||
bookings: Booking[],
|
||||
): Promise<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(
|
||||
scheduleDirection: string | null,
|
||||
originYardId: string,
|
||||
targetScheduleId?: string,
|
||||
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
|
||||
const [wagons, wagonTypes] = await Promise.all([
|
||||
@@ -1368,7 +1405,7 @@ export class TrainSchedulingService {
|
||||
? wagon.currentTrainScheduleId === targetScheduleId
|
||||
: false;
|
||||
if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue;
|
||||
if (!wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection)) continue;
|
||||
if (wagon.currentYardId !== originYardId) continue;
|
||||
|
||||
const typeId = wagon.wagonTypeId;
|
||||
const code = typeCodeById.get(typeId) ?? typeId;
|
||||
@@ -1399,7 +1436,7 @@ export class TrainSchedulingService {
|
||||
private async autoPinWagonsForSchedule(
|
||||
manager: EntityManager,
|
||||
scheduleId: string,
|
||||
scheduleDirection: string | null,
|
||||
originYardId: string,
|
||||
slots: TrainSetWagon[],
|
||||
) {
|
||||
const wagons = await manager.getRepository(Wagon).find();
|
||||
@@ -1419,7 +1456,7 @@ export class TrainSchedulingService {
|
||||
planSlots,
|
||||
wagons,
|
||||
scheduleId,
|
||||
scheduleDirection,
|
||||
originYardId,
|
||||
);
|
||||
if (unpinnable.length) {
|
||||
throw new BadRequestException({
|
||||
@@ -1434,7 +1471,7 @@ export class TrainSchedulingService {
|
||||
slot,
|
||||
wagons,
|
||||
scheduleId,
|
||||
scheduleDirection,
|
||||
originYardId,
|
||||
assignedPhysicalIds,
|
||||
);
|
||||
if (!physical) continue;
|
||||
@@ -1455,7 +1492,7 @@ export class TrainSchedulingService {
|
||||
/** Pre-assign check: every planned slot must have a matching physical wagon. */
|
||||
private async validatePhysicalFleetForPlan(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
scheduleDirection: string | null,
|
||||
originYardId: string,
|
||||
targetScheduleId?: string,
|
||||
): Promise<string[]> {
|
||||
if (!wagonPlan.length) return [];
|
||||
@@ -1469,7 +1506,7 @@ export class TrainSchedulingService {
|
||||
})),
|
||||
wagons,
|
||||
targetScheduleId,
|
||||
scheduleDirection,
|
||||
originYardId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1477,24 +1514,22 @@ export class TrainSchedulingService {
|
||||
slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>,
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
scheduleDirection: string | null,
|
||||
originYardId: string,
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
const assignedPhysicalIds = new Set<string>();
|
||||
const required = requiredWagonReadiness(scheduleDirection);
|
||||
const readinessLabel = required ?? 'any readiness';
|
||||
|
||||
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
|
||||
const physical = this.pickPhysicalWagonForSlot(
|
||||
slot,
|
||||
wagons,
|
||||
scheduleId,
|
||||
scheduleDirection,
|
||||
originYardId,
|
||||
assignedPhysicalIds,
|
||||
);
|
||||
if (!physical) {
|
||||
violations.push(
|
||||
`No ${readinessLabel} ${slot.wagonTypeCode} wagon available for slot #${slot.sequenceNo}`,
|
||||
`No ${slot.wagonTypeCode} wagon available at yard for slot #${slot.sequenceNo}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -1508,7 +1543,7 @@ export class TrainSchedulingService {
|
||||
slot: { wagonTypeId: string },
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
scheduleDirection: string | null,
|
||||
originYardId: string,
|
||||
assignedPhysicalIds: Set<string>,
|
||||
): Wagon | undefined {
|
||||
return wagons.find((wagon) => {
|
||||
@@ -1518,7 +1553,7 @@ export class TrainSchedulingService {
|
||||
? wagon.currentTrainScheduleId === scheduleId
|
||||
: false;
|
||||
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
|
||||
return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection);
|
||||
return wagon.currentYardId === originYardId;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1834,7 +1869,7 @@ export class TrainSchedulingService {
|
||||
id: schedule.trainSet.locomotive.id,
|
||||
code: schedule.trainSet.locomotive.code,
|
||||
name: schedule.trainSet.locomotive.name ?? null,
|
||||
readiness: schedule.trainSet.locomotive.readiness ?? null,
|
||||
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
|
||||
}
|
||||
: null,
|
||||
wagonCount: schedule.trainSet?.wagonCount ?? 0,
|
||||
@@ -1852,47 +1887,80 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
/** AVAILABLE locomotives whose readiness matches the corridor implied by the route. */
|
||||
/** AVAILABLE locomotives at the route's origin yard. */
|
||||
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
|
||||
const route = await this.getActiveRoute(routeId);
|
||||
const direction = deriveScheduleDirection(
|
||||
route.originYard ?? { country: null },
|
||||
route.destinationYard ?? { country: null },
|
||||
);
|
||||
const requiredReadiness = requiredWagonReadiness(direction);
|
||||
|
||||
const locomotives = await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE' },
|
||||
where: { status: 'AVAILABLE', currentYardId: route.originYardId },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
|
||||
if (!requiredReadiness) {
|
||||
return locomotives;
|
||||
}
|
||||
|
||||
return locomotives.filter((l) => wagonReadinessMatchesSchedule(l.readiness, direction));
|
||||
return locomotives;
|
||||
}
|
||||
|
||||
/** OPEN, same-route schedules a new booking may target (with rough remaining capacity). */
|
||||
/** OPEN schedules a new booking may target (with rough remaining capacity).
|
||||
* Supports sub-route matching: if originYardId and/or destinationYardId are provided,
|
||||
* returns schedules whose route passes through both yards in the correct order.
|
||||
*/
|
||||
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: {
|
||||
bookingWindowStatus: 'OPEN',
|
||||
...(originYardId ? { originStationId: originYardId } : {}),
|
||||
...(destinationYardId ? { destinationStationId: destinationYardId } : {}),
|
||||
},
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
route: true,
|
||||
route: { milestones: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
scheduleBookings: { booking: true },
|
||||
},
|
||||
order: { scheduledDepartureDate: 'ASC' },
|
||||
});
|
||||
return schedules
|
||||
|
||||
const filteredSchedules = schedules
|
||||
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
|
||||
.filter((s) => {
|
||||
// Build the full stop list: origin -> milestones (ordered) -> destination
|
||||
const milestones = s.route?.milestones ?? [];
|
||||
const sortedMilestones = [...milestones].sort((a, b) => a.sequenceNo - b.sequenceNo);
|
||||
const stopYardIds = [s.originStationId, ...sortedMilestones.map((m) => m.yardId), s.destinationStationId];
|
||||
|
||||
// Remove duplicates while preserving order (in case origin/destination appears in milestones)
|
||||
const uniqueStopYardIds: string[] = [];
|
||||
for (const yardId of stopYardIds) {
|
||||
if (!uniqueStopYardIds.includes(yardId)) {
|
||||
uniqueStopYardIds.push(yardId);
|
||||
}
|
||||
}
|
||||
|
||||
// Check origin yard filter
|
||||
if (originYardId) {
|
||||
if (!uniqueStopYardIds.includes(originYardId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check destination yard filter
|
||||
if (destinationYardId) {
|
||||
if (!uniqueStopYardIds.includes(destinationYardId)) {
|
||||
return false;
|
||||
}
|
||||
// Ensure destination comes after origin (if both are specified)
|
||||
if (originYardId) {
|
||||
const originIndex = uniqueStopYardIds.indexOf(originYardId);
|
||||
const destIndex = uniqueStopYardIds.indexOf(destinationYardId);
|
||||
if (destIndex <= originIndex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((s) => this.mapScheduleListItem(s));
|
||||
|
||||
return filteredSchedules;
|
||||
}
|
||||
|
||||
private async mapScheduleDetail(
|
||||
@@ -1952,7 +2020,7 @@ export class TrainSchedulingService {
|
||||
code: schedule.trainSet.locomotive.code,
|
||||
name: schedule.trainSet.locomotive.name,
|
||||
status: schedule.trainSet.locomotive.status,
|
||||
readiness: schedule.trainSet.locomotive.readiness ?? null,
|
||||
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
|
||||
maxPullWeightTons: roundTons(
|
||||
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
||||
),
|
||||
@@ -2031,6 +2099,102 @@ export class TrainSchedulingService {
|
||||
return SchedulingStatus.Eligible;
|
||||
}
|
||||
|
||||
/** Assign one linked-but-unallocated booking onto wagons, preserving existing wagon assignments. */
|
||||
async assignUnassignedBookingToWagons(scheduleId: string, bookingId: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!schedule.trainSet?.locomotive) {
|
||||
throw new BadRequestException('Schedule has no locomotive — cannot assign booking');
|
||||
}
|
||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot assign bookings to schedule in status ${schedule.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
if (booking.trainScheduleId !== scheduleId) {
|
||||
throw new BadRequestException('Booking is not linked to this schedule');
|
||||
}
|
||||
if (!this.isReadyToLoadBooking(booking)) {
|
||||
throw new BadRequestException('Booking is not paid and ready to load');
|
||||
}
|
||||
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||
if (wagonAssignedIds.has(bookingId)) {
|
||||
throw new BadRequestException('Booking is already assigned to a wagon');
|
||||
}
|
||||
|
||||
const allBookingIds = [...wagonAssignedIds, bookingId];
|
||||
const previewDto = {
|
||||
bookingIds: allBookingIds,
|
||||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(undefined, schedule.trainSet.locomotive);
|
||||
|
||||
const validation = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
null,
|
||||
false,
|
||||
[],
|
||||
false,
|
||||
limits,
|
||||
scheduleId,
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new BadRequestException({
|
||||
message: 'Booking validation failed',
|
||||
violations: validation.violations,
|
||||
warnings: validation.warnings,
|
||||
});
|
||||
}
|
||||
|
||||
if (!validation.bookings.some((b) => b.id === bookingId)) {
|
||||
const deferred = validation.deferredBookings.find((d) => d.id === bookingId);
|
||||
throw new BadRequestException({
|
||||
message: deferred?.reason ?? 'Booking does not fit on available fleet wagons',
|
||||
violations: validation.violations,
|
||||
warnings: validation.warnings,
|
||||
deferredBookings: validation.deferredBookings,
|
||||
});
|
||||
}
|
||||
|
||||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||||
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
||||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||||
const placements = autoFillPlacements(units, slots);
|
||||
const missingForBooking = findMissingContainerNumberIssues(units, placements).find(
|
||||
(m) => m.bookingId === bookingId,
|
||||
);
|
||||
if (missingForBooking) {
|
||||
throw new BadRequestException({
|
||||
message: missingForBooking.issue,
|
||||
violations: [missingForBooking.issue],
|
||||
});
|
||||
}
|
||||
|
||||
const assignableSet = new Set(validation.bookings.map((b) => b.id));
|
||||
const assignPlacements = placementsForBookings(placements, assignableSet, units);
|
||||
const needsPlacements = containerBookings.length > 0;
|
||||
|
||||
return this.assignBookingsToSchedule(
|
||||
scheduleId,
|
||||
{
|
||||
bookingIds: validation.bookings.map((b) => b.id),
|
||||
containerPlacements: needsPlacements ? assignPlacements : undefined,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
|
||||
async previewAllocationForSchedule(
|
||||
scheduleId: string,
|
||||
@@ -2236,6 +2400,303 @@ export class TrainSchedulingService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async removeTrainSetWagonSlot(scheduleId: string, trainSetWagonId: string): Promise<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>> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WagonReadiness, type ScheduleTradeDirection } from '@edr/types';
|
||||
|
||||
/** @deprecated Replaced by yard-based fleet filtering via `currentYardId`. */
|
||||
export function requiredWagonReadiness(
|
||||
direction: ScheduleTradeDirection | string | null | undefined,
|
||||
): WagonReadiness | null {
|
||||
@@ -8,6 +9,7 @@ export function requiredWagonReadiness(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @deprecated Replaced by `wagon.currentYardId === originYardId` checks. */
|
||||
export function wagonReadinessMatchesSchedule(
|
||||
wagonReadiness: WagonReadiness | string,
|
||||
direction: ScheduleTradeDirection | string | null | undefined,
|
||||
@@ -18,9 +20,7 @@ export function wagonReadinessMatchesSchedule(
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a readiness value (IMPORT_READY ↔ EXPORT_READY). Used when a train
|
||||
* reaches its destination: the asset has repositioned, so it is now ready for
|
||||
* the opposite direction. Direction-agnostic so it handles round trips.
|
||||
* @deprecated Replaced by setting `currentYardId = schedule.destinationStationId` on arrival.
|
||||
*/
|
||||
export function flipReadiness(
|
||||
readiness: WagonReadiness | string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator';
|
||||
|
||||
export class CreateWagonDto {
|
||||
@@ -30,8 +30,8 @@ export class CreateWagonDto {
|
||||
status?: WagonStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(WagonReadiness)
|
||||
readiness?: WagonReadiness;
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
@@ -14,10 +14,10 @@ export class ListWagonsQueryDto {
|
||||
@IsEnum(WagonStatus)
|
||||
status?: WagonStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: WagonReadiness })
|
||||
@ApiPropertyOptional({ description: 'Filter by current yard' })
|
||||
@IsOptional()
|
||||
@IsEnum(WagonReadiness)
|
||||
readiness?: WagonReadiness;
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { Entity, Column, ManyToOne, OneToMany, JoinColumn, Index } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||
import { Container } from '../../container-management/entities/container.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
|
||||
export const WAGON_STATUSES = [
|
||||
WagonStatus.Available,
|
||||
@@ -14,16 +15,10 @@ export const WAGON_STATUSES = [
|
||||
WagonStatus.Retired,
|
||||
] as const;
|
||||
|
||||
export const WAGON_READINESS_VALUES = [
|
||||
WagonReadiness.ImportReady,
|
||||
WagonReadiness.ExportReady,
|
||||
] as const;
|
||||
|
||||
export type WagonStatusType = (typeof WAGON_STATUSES)[number];
|
||||
export type WagonReadinessType = (typeof WAGON_READINESS_VALUES)[number];
|
||||
|
||||
@Entity({ name: 'wagons', schema: 'freight' })
|
||||
@Index(['readiness'])
|
||||
@Index(['currentYardId'])
|
||||
export class Wagon extends BaseEntity {
|
||||
@Column({ unique: true, name: 'wagon_number' })
|
||||
wagonNumber!: string;
|
||||
@@ -46,8 +41,12 @@ export class Wagon extends BaseEntity {
|
||||
@Column({ type: 'varchar', length: 20, default: WagonStatus.Available })
|
||||
status!: WagonStatusType;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: WagonReadiness.ImportReady })
|
||||
readiness!: WagonReadinessType;
|
||||
@Column({ name: 'current_yard_id', type: 'uuid', nullable: true })
|
||||
currentYardId!: string | null;
|
||||
|
||||
@ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'current_yard_id' })
|
||||
currentYard?: Yard | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes!: string | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
|
||||
@@ -24,11 +24,11 @@ export class WagonsService {
|
||||
const wagon = this.wagonRepo.create({
|
||||
...dto,
|
||||
status: dto.status ?? WagonStatus.Available,
|
||||
readiness: dto.readiness ?? WagonReadiness.ImportReady,
|
||||
});
|
||||
// Convert undefined to null for nullable fields
|
||||
if (dto.trainId === undefined) wagon.trainId = null;
|
||||
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
|
||||
if (dto.currentYardId === undefined) wagon.currentYardId = null;
|
||||
return this.wagonRepo.save(wagon);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export class WagonsService {
|
||||
const wagonTypeId = query.wagonTypeId?.trim();
|
||||
const filters: FindOptionsWhere<Wagon> = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.readiness ? { readiness: query.readiness } : {}),
|
||||
...(query.currentYardId ? { currentYardId: query.currentYardId } : {}),
|
||||
...(trainId ? { trainId } : {}),
|
||||
...(wagonTypeId ? { wagonTypeId } : {}),
|
||||
};
|
||||
@@ -51,13 +51,14 @@ export class WagonsService {
|
||||
});
|
||||
}
|
||||
|
||||
const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'readiness', 'sequenceNumber'].includes(query.sortBy ?? '')
|
||||
const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'currentYardId', 'sequenceNumber'].includes(query.sortBy ?? '')
|
||||
? (query.sortBy as keyof Wagon)
|
||||
: 'wagonNumber';
|
||||
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
return this.wagonRepo.find({
|
||||
where: search ? where : filters,
|
||||
relations: { currentYard: true },
|
||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
|
||||
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
||||
take: query.limit ? Number(query.limit) : undefined,
|
||||
@@ -65,7 +66,10 @@ export class WagonsService {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<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`);
|
||||
return wagon;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Container } from "../modules/container-management/entities/container.en
|
||||
import { Route } from "../modules/routes/entities/route.entity";
|
||||
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
|
||||
import { Wagon } from "../modules/wagons/entities/wagon.entity";
|
||||
import { WagonReadiness, WagonStatus } from "@edr/types";
|
||||
import { WagonStatus } from "@edr/types";
|
||||
|
||||
const SEED_FLAG = "SEED_DEMO_BOOKINGS";
|
||||
|
||||
@@ -499,7 +499,7 @@ export class DemoBookingsSeeder {
|
||||
}
|
||||
|
||||
const nw5 = await manager.getRepository(WagonType).findOneBy({ code: "NW5" });
|
||||
if (nw5) {
|
||||
if (nw5 && djibouti && addis) {
|
||||
await manager.getRepository(Wagon).upsert(
|
||||
Array.from({ length: 20 }, (_, index) => ({
|
||||
wagonNumber: `WGN-DEMO-${String(index + 1).padStart(3, "0")}`,
|
||||
@@ -509,10 +509,7 @@ export class DemoBookingsSeeder {
|
||||
tareWeight: 20,
|
||||
maxPayloadWeight: 70,
|
||||
status: WagonStatus.Available,
|
||||
readiness:
|
||||
index % 2 === 0
|
||||
? WagonReadiness.ImportReady
|
||||
: WagonReadiness.ExportReady,
|
||||
currentYardId: index % 2 === 0 ? djibouti.id : addis.id,
|
||||
notes: "Demo wagon for train scheduling",
|
||||
trainSetWagonId: null,
|
||||
currentTrainScheduleId: null,
|
||||
@@ -521,6 +518,19 @@ export class DemoBookingsSeeder {
|
||||
);
|
||||
}
|
||||
|
||||
if (djibouti) {
|
||||
await manager.getRepository(Locomotive).update(
|
||||
{ code: "LOC-001" },
|
||||
{ currentYardId: djibouti.id },
|
||||
);
|
||||
}
|
||||
if (addis) {
|
||||
await manager.getRepository(Locomotive).update(
|
||||
{ code: "LOC-002" },
|
||||
{ currentYardId: addis.id },
|
||||
);
|
||||
}
|
||||
|
||||
const ft20 = containerTypeByCode.get("20FT");
|
||||
const ft40 = containerTypeByCode.get("40FT");
|
||||
if (ft20 && ft40) {
|
||||
|
||||
@@ -441,20 +441,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
rateValue: 1200,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "ETB",
|
||||
rateValue: 45000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "ETB",
|
||||
rateValue: 67000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
@@ -469,34 +455,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
rateValue: 900,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "ETB",
|
||||
rateValue: 34000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "ETB",
|
||||
rateValue: 50000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "ETB",
|
||||
rateValue: 20000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "ETB",
|
||||
rateValue: 30000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: null,
|
||||
@@ -504,13 +462,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
rateValue: 1000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 56000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: null,
|
||||
@@ -519,17 +470,24 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 42000,
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 350,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 550,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 25000,
|
||||
currency: "USD",
|
||||
rateValue: 400,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
@@ -539,13 +497,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
rateValue: 35,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_BULK",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 1900,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_IMPORT",
|
||||
containerTypeId: null,
|
||||
@@ -553,13 +504,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
rateValue: 50,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_IMPORT",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 2800,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_EXPORT",
|
||||
containerTypeId: null,
|
||||
@@ -567,13 +511,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
rateValue: 40,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_EXPORT",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 2200,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "OVERWEIGHT_PER_TON",
|
||||
containerTypeId: null,
|
||||
@@ -581,13 +518,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
rateValue: 25,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "OVERWEIGHT_PER_TON",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 1400,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "HAZARD_SURCHARGE",
|
||||
containerTypeId: null,
|
||||
@@ -595,13 +525,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
rateValue: 150,
|
||||
rateUnit: "FLAT",
|
||||
},
|
||||
{
|
||||
rateType: "HAZARD_SURCHARGE",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 8500,
|
||||
rateUnit: "FLAT",
|
||||
},
|
||||
{
|
||||
rateType: "REEFER_SURCHARGE",
|
||||
containerTypeId: null,
|
||||
@@ -609,13 +532,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
rateValue: 200,
|
||||
rateUnit: "FLAT",
|
||||
},
|
||||
{
|
||||
rateType: "REEFER_SURCHARGE",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 11000,
|
||||
rateUnit: "FLAT",
|
||||
},
|
||||
{
|
||||
rateType: "DOUBLE_HANDLING",
|
||||
containerTypeId: null,
|
||||
@@ -623,13 +539,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
rateValue: 100,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "DOUBLE_HANDLING",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 5500,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "LASHING",
|
||||
containerTypeId: null,
|
||||
@@ -637,13 +546,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
rateValue: 50,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "LASHING",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 2800,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
];
|
||||
|
||||
const entities = rateData.map((d) =>
|
||||
@@ -673,15 +575,10 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
};
|
||||
|
||||
const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD");
|
||||
const hazardRateEtb = findRate("HAZARD_SURCHARGE", "ETB");
|
||||
const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD");
|
||||
const reeferRateEtb = findRate("REEFER_SURCHARGE", "ETB");
|
||||
const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD");
|
||||
const overweightRateEtb = findRate("OVERWEIGHT_PER_TON", "ETB");
|
||||
const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD");
|
||||
const shipLineRateEtb = findRate("DOUBLE_HANDLING", "ETB");
|
||||
const consolidRateUsd = findRate("LASHING", "USD");
|
||||
const consolidRateEtb = findRate("LASHING", "ETB");
|
||||
|
||||
await surRepo.createQueryBuilder().delete().execute();
|
||||
await surRepo.save([
|
||||
@@ -689,35 +586,35 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
code: "HAZARDOUS_CARGO",
|
||||
label: "Hazardous Cargo",
|
||||
triggerCondition: "CARGO_FLAG_HAZARDOUS",
|
||||
rateId: hazardRateUsd?.id ?? hazardRateEtb?.id,
|
||||
rateId: hazardRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "REEFER_CARGO",
|
||||
label: "Reefer Cargo",
|
||||
triggerCondition: "CARGO_FLAG_REEFER",
|
||||
rateId: reeferRateUsd?.id ?? reeferRateEtb?.id,
|
||||
rateId: reeferRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "OVERWEIGHT_CARGO",
|
||||
label: "Overweight Cargo",
|
||||
triggerCondition: "VGM_EXCEEDS_LIMIT",
|
||||
rateId: overweightRateUsd?.id ?? overweightRateEtb?.id,
|
||||
rateId: overweightRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "SHIPPING_LINE_FEE",
|
||||
label: "Shipping Line Fee",
|
||||
triggerCondition: "SHIPPING_LINE_MAPPED",
|
||||
rateId: shipLineRateUsd?.id ?? shipLineRateEtb?.id,
|
||||
rateId: shipLineRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "CONSOLIDATION_FEE",
|
||||
label: "Consolidation Fee",
|
||||
triggerCondition: "CONSOLIDATION_ENABLED",
|
||||
rateId: consolidRateUsd?.id ?? consolidRateEtb?.id,
|
||||
rateId: consolidRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
]);
|
||||
@@ -784,10 +681,10 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
},
|
||||
{
|
||||
reference: "BKG-PRICE-003",
|
||||
description: "20FT container import + shipping line (ETB)",
|
||||
description: "20FT container import + shipping line (USD)",
|
||||
freightType: "CONTAINER" as const,
|
||||
tradeDirection: "IMPORT",
|
||||
paymentCurrency: "ETB",
|
||||
paymentCurrency: "USD",
|
||||
serviceTypeId: railContainer.id,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
@@ -799,7 +696,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
containers: [
|
||||
{ containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 },
|
||||
],
|
||||
expectedBaseRate: 45000,
|
||||
expectedBaseRate: 800,
|
||||
expectedSurcharges: ["SHIPPING_LINE_FEE"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -111,7 +111,10 @@ const FleetCardGrid = ({
|
||||
</Text>
|
||||
{subtitle != null && subtitle !== "" ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{String(subtitle)}
|
||||
{presentation.subtitleKey === "currentYard" ||
|
||||
presentation.subtitleKey === "currentYardId"
|
||||
? formatFleetCell(subtitle, "entityLabel", presentation.subtitleKey)
|
||||
: String(subtitle)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
@@ -6,7 +6,7 @@ export type FleetViewMode = "table" | "cards";
|
||||
|
||||
const STORAGE_PREFIX = "edr-freight-fleet-view:";
|
||||
|
||||
type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2";
|
||||
type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2" | "batch-board";
|
||||
|
||||
const readStored = (slug: ViewModeSlug): FleetViewMode => {
|
||||
try {
|
||||
|
||||
@@ -522,9 +522,7 @@ export function AllocateBookingWizard({
|
||||
placeholder={routeId ? "Select locomotive" : "Select a route first"}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code} · ${
|
||||
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
|
||||
}`,
|
||||
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Freight } from "@edr/types";
|
||||
|
||||
import type { PinWagonAssignment, TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import type { Wagon } from "@/services/wagon.service";
|
||||
import { wagonMatchesScheduleDirection } from "@/utils/wagonAvailability";
|
||||
import { wagonMatchesScheduleOrigin } from "@/utils/wagonAvailability";
|
||||
|
||||
import { autoFillWagonAssignments, countFilledSlots } from "./pinWagons.util";
|
||||
|
||||
@@ -35,6 +35,7 @@ export function PinWagonsForm({
|
||||
onSubmit: (assignments: PinWagonAssignment[]) => void;
|
||||
autoFillOnMount?: boolean;
|
||||
}) {
|
||||
const originYardId = schedule.originStation?.id;
|
||||
const slots = schedule.trainSet?.wagons ?? [];
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -43,7 +44,7 @@ export function PinWagonsForm({
|
||||
for (const wagon of availableWagons) {
|
||||
const isPinnedOnSlot = slots.some((s) => s.physicalWagonId === wagon.id);
|
||||
if (
|
||||
!wagonMatchesScheduleDirection(wagon, schedule.direction, {
|
||||
!wagonMatchesScheduleOrigin(wagon, originYardId, {
|
||||
allowPinned: isPinnedOnSlot,
|
||||
})
|
||||
) {
|
||||
@@ -58,7 +59,7 @@ export function PinWagonsForm({
|
||||
map.set(typeId, list);
|
||||
}
|
||||
return map;
|
||||
}, [availableWagons, schedule.direction, slots]);
|
||||
}, [availableWagons, originYardId, slots]);
|
||||
|
||||
const runAutoFill = useCallback(
|
||||
(preserveManual = false) => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 ?? []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button, Group, Modal, NumberInput, Select, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useRouteYards } from "@/hooks/useRoutes";
|
||||
import { useAssignWagonToTrain, useWagons } from "@/hooks/useWagons";
|
||||
|
||||
export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
||||
@@ -12,6 +13,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
||||
const [wagonId, setWagonId] = useState<string | null>(null);
|
||||
const [sequence, setSequence] = useState<number | "">("");
|
||||
const { data: wagons } = useWagons();
|
||||
const { data: yards = [] } = useRouteYards();
|
||||
const assign = useAssignWagonToTrain();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -19,9 +21,19 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
||||
(w) => w.status === Freight.WagonStatus.Available || !w.trainId,
|
||||
);
|
||||
|
||||
const yardLabelById = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
yards.map((y) => [y.id, y.label ?? y.code ?? y.id]),
|
||||
),
|
||||
[yards],
|
||||
);
|
||||
|
||||
const wagonOptions = available.map((w) => ({
|
||||
value: w.id,
|
||||
label: `${w.wagonNumber} (${w.readiness.replace("_", " ").toLowerCase()})`,
|
||||
label: w.currentYardId
|
||||
? `${w.wagonNumber} (${yardLabelById.get(w.currentYardId) ?? "yard"})`
|
||||
: `${w.wagonNumber} (no yard)`,
|
||||
}));
|
||||
|
||||
const handleAssign = async () => {
|
||||
|
||||
@@ -52,6 +52,10 @@ export const QUERY_KEYS = {
|
||||
batchBoard: () => ["train-scheduling", "batch-board"] as const,
|
||||
batchBoardDetail: (scheduleId: string) =>
|
||||
["train-scheduling", "batch-board", scheduleId] as const,
|
||||
unassignedBookings: (id: string) =>
|
||||
["train-scheduling", "unassigned", id] as const,
|
||||
compositionRemovals: (id: string) =>
|
||||
["train-scheduling", "removals", id] as const,
|
||||
},
|
||||
|
||||
FLEET: {
|
||||
|
||||
@@ -144,6 +144,8 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/batch-board/${scheduleId}`,
|
||||
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
||||
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
|
||||
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
|
||||
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
|
||||
MARK_BOOKING_PAID: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/mark-paid`,
|
||||
@@ -190,6 +192,14 @@ export const URL_CONSTANTS = {
|
||||
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
|
||||
CANCEL_SCHEDULE: (id: string) =>
|
||||
`/train-scheduling/container/schedules/${id}/cancel`,
|
||||
REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) =>
|
||||
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`,
|
||||
UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
|
||||
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`,
|
||||
UNASSIGNED_BOOKINGS: (scheduleId: string) =>
|
||||
`/train-scheduling/schedules/${scheduleId}/unassigned-bookings`,
|
||||
COMPOSITION_REMOVALS: (scheduleId: string) =>
|
||||
`/train-scheduling/schedules/${scheduleId}/composition-removals`,
|
||||
},
|
||||
|
||||
RULE_ENGINE: {
|
||||
|
||||
@@ -153,6 +153,12 @@ export const useScheduleMutations = (scheduleId?: string) => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId),
|
||||
});
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
|
||||
};
|
||||
@@ -191,6 +197,12 @@ export const useScheduleMutations = (scheduleId?: string) => {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const assignUnassigned = useMutation({
|
||||
mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) =>
|
||||
trainSchedulingService.assignUnassignedBooking(id, bookingId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const unassign = useMutation({
|
||||
mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) =>
|
||||
trainSchedulingService.unassignBooking(id, bookingId),
|
||||
@@ -234,6 +246,7 @@ export const useScheduleMutations = (scheduleId?: string) => {
|
||||
create,
|
||||
preview,
|
||||
assign,
|
||||
assignUnassigned,
|
||||
unassign,
|
||||
pin,
|
||||
finalize,
|
||||
@@ -244,3 +257,46 @@ export const useScheduleMutations = (scheduleId?: string) => {
|
||||
invalidate,
|
||||
};
|
||||
};
|
||||
|
||||
export const useUnassignedBookings = (scheduleId: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId ?? ""),
|
||||
queryFn: () => trainSchedulingService.getUnassignedBookings(scheduleId!),
|
||||
enabled: Boolean(scheduleId),
|
||||
});
|
||||
|
||||
export const useCompositionRemovals = (scheduleId: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId ?? ""),
|
||||
queryFn: () => trainSchedulingService.getCompositionRemovals(scheduleId!),
|
||||
enabled: Boolean(scheduleId),
|
||||
});
|
||||
|
||||
export const useRemoveWagonSlot = (scheduleId: string) => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (wagonId: string) =>
|
||||
trainSchedulingService.removeWagonSlot(scheduleId, wagonId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateContainerItem = (scheduleId: string) => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ itemId, containerNumber }: { itemId: string; containerNumber: string | null }) =>
|
||||
trainSchedulingService.updateContainerItem(scheduleId, itemId, { containerNumber }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useWagonTypes } from "@/hooks/use-wagon-types";
|
||||
import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
|
||||
import { useContainers } from "@/hooks/useContainers";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useRouteYards } from "@/hooks/useRoutes";
|
||||
import { useWagons } from "@/hooks/useWagons";
|
||||
import type { FleetListFilters } from "@/services/fleet/fleet.service";
|
||||
import {
|
||||
@@ -49,12 +50,12 @@ const FleetResourcePage = () => {
|
||||
if (slug !== "wagons" && slug !== "locomotives") return undefined;
|
||||
const filters: FleetListFilters = {};
|
||||
const status = listFilterValues.status;
|
||||
const readiness = listFilterValues.readiness;
|
||||
const currentYardId = listFilterValues.currentYardId;
|
||||
if (status && status !== "ALL") {
|
||||
filters.status = status as FleetListFilters["status"];
|
||||
(filters as { status?: string }).status = status;
|
||||
}
|
||||
if (readiness && readiness !== "ALL") {
|
||||
filters.readiness = readiness as FleetListFilters["readiness"];
|
||||
if (currentYardId && currentYardId !== "ALL") {
|
||||
filters.currentYardId = currentYardId;
|
||||
}
|
||||
if (slug === "wagons" && search.trim()) {
|
||||
filters.search = search.trim();
|
||||
@@ -70,6 +71,7 @@ const FleetResourcePage = () => {
|
||||
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useCargoTypes();
|
||||
const { data: wagons = [], isLoading: wagonsLoading } = useWagons();
|
||||
const { data: containers = [], isLoading: containersLoading } = useContainers();
|
||||
const { data: yards = [], isLoading: yardsLoading } = useRouteYards();
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
@@ -98,18 +100,6 @@ const FleetResourcePage = () => {
|
||||
];
|
||||
}, [allRows, hasStatusColumn, usesServerListFilters]);
|
||||
|
||||
const listFilterSelects = useMemo(() => {
|
||||
if (!config?.listFilters?.length) return null;
|
||||
return config.listFilters.map((filter) => ({
|
||||
...filter,
|
||||
value: listFilterValues[filter.key] ?? "ALL",
|
||||
data: [
|
||||
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
|
||||
...filter.options.map((opt) => ({ value: opt.value, label: opt.label })),
|
||||
],
|
||||
}));
|
||||
}, [config?.listFilters, listFilterValues]);
|
||||
|
||||
const dynamicOptions = useMemo(() => {
|
||||
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
|
||||
(t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }),
|
||||
@@ -128,14 +118,41 @@ const FleetResourcePage = () => {
|
||||
(c) => ({ value: c.id, label: c.containerNumber }),
|
||||
);
|
||||
|
||||
const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map(
|
||||
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
|
||||
);
|
||||
|
||||
registerFleetOptionLabels("currentYardId", yardOpts);
|
||||
|
||||
return {
|
||||
wagonTypes: wagonTypeOpts,
|
||||
containerTypes: containerTypeOpts,
|
||||
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
|
||||
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
|
||||
containers: containerOpts,
|
||||
yards: yardOpts,
|
||||
};
|
||||
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers]);
|
||||
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]);
|
||||
|
||||
const listFilterSelects = useMemo(() => {
|
||||
if (!config?.listFilters?.length) return null;
|
||||
return config.listFilters.map((filter) => {
|
||||
const dynamicOpts = filter.dynamicOptions
|
||||
? (dynamicOptions[filter.dynamicOptions] ?? [])
|
||||
: [];
|
||||
const staticOpts =
|
||||
filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? [];
|
||||
const opts = filter.dynamicOptions ? dynamicOpts : staticOpts;
|
||||
return {
|
||||
...filter,
|
||||
value: listFilterValues[filter.key] ?? "ALL",
|
||||
data: [
|
||||
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
|
||||
...opts,
|
||||
],
|
||||
};
|
||||
});
|
||||
}, [config?.listFilters, listFilterValues, dynamicOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
|
||||
@@ -146,6 +163,7 @@ const FleetResourcePage = () => {
|
||||
);
|
||||
registerFleetOptionLabels("wagonId", dynamicOptions.wagons);
|
||||
registerFleetOptionLabels("containerId", dynamicOptions.containers);
|
||||
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
|
||||
}, [dynamicOptions]);
|
||||
|
||||
const formFields = useMemo((): FleetFormFieldDef[] => {
|
||||
@@ -158,7 +176,12 @@ const FleetResourcePage = () => {
|
||||
}, [config, dynamicOptions]);
|
||||
|
||||
const selectOptionsLoading =
|
||||
wagonTypesLoading || containerTypesLoading || cargoTypesLoading || wagonsLoading || containersLoading;
|
||||
wagonTypesLoading ||
|
||||
containerTypesLoading ||
|
||||
cargoTypesLoading ||
|
||||
wagonsLoading ||
|
||||
containersLoading ||
|
||||
yardsLoading;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!config) return allRows;
|
||||
@@ -222,7 +245,7 @@ const FleetResourcePage = () => {
|
||||
});
|
||||
|
||||
return base;
|
||||
}, [config]);
|
||||
}, [config, dynamicOptions.yards]);
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
|
||||
|
||||
export type FleetResourceSlug =
|
||||
@@ -20,7 +19,8 @@ export type FleetDynamicOptions =
|
||||
| "containerTypes"
|
||||
| "cargoTypes"
|
||||
| "wagons"
|
||||
| "containers";
|
||||
| "containers"
|
||||
| "yards";
|
||||
|
||||
export interface FleetResourceColumn {
|
||||
id: string;
|
||||
@@ -35,10 +35,11 @@ export interface FleetFormFieldDef extends FormFieldDef {
|
||||
}
|
||||
|
||||
export interface FleetListFilterDef {
|
||||
key: "status" | "readiness" | "wagonTypeId" | "trainId";
|
||||
key: "status" | "currentYardId" | "wagonTypeId" | "trainId";
|
||||
label: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
allLabel?: string;
|
||||
dynamicOptions?: FleetDynamicOptions;
|
||||
}
|
||||
|
||||
export interface FleetResourceConfig {
|
||||
@@ -93,10 +94,6 @@ const WAGON_STATUS_OPTIONS = [
|
||||
{ label: "Retired", value: Freight.WagonStatus.Retired },
|
||||
];
|
||||
|
||||
const WAGON_READINESS_OPTIONS = [
|
||||
{ label: "Import ready", value: Freight.WagonReadiness.ImportReady },
|
||||
{ label: "Export ready", value: Freight.WagonReadiness.ExportReady },
|
||||
];
|
||||
|
||||
export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{
|
||||
@@ -122,19 +119,19 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
options: LOCOMOTIVE_STATUS_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "readiness",
|
||||
label: "Readiness",
|
||||
allLabel: "All readiness",
|
||||
options: WAGON_READINESS_OPTIONS,
|
||||
key: "currentYardId",
|
||||
label: "Current Yard",
|
||||
allLabel: "All yards",
|
||||
dynamicOptions: "yards",
|
||||
},
|
||||
],
|
||||
cardSubtitleKey: "readiness",
|
||||
searchKeys: ["code", "name", "locomotiveType", "status", "readiness"],
|
||||
cardSubtitleKey: "currentYard",
|
||||
searchKeys: ["code", "name", "locomotiveType", "status", "currentYardId"],
|
||||
columns: [
|
||||
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
|
||||
{ id: "name", header: "Name", accessorKey: "name" },
|
||||
{ id: "locomotiveType", header: "Type", accessorKey: "locomotiveType" },
|
||||
{ id: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" },
|
||||
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
||||
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
|
||||
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
|
||||
@@ -144,7 +141,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ name: "name", label: "Name", type: "text" },
|
||||
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
|
||||
{ name: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS },
|
||||
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
|
||||
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
|
||||
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
|
||||
{ name: "powerKw", label: "Power (kW)", type: "number" },
|
||||
@@ -156,7 +153,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
name: "",
|
||||
locomotiveType: "DIESEL",
|
||||
status: "AVAILABLE",
|
||||
readiness: Freight.WagonReadiness.ImportReady,
|
||||
currentYardId: "",
|
||||
maxPullWeightTons: 0,
|
||||
maxTrainLengthMeters: 760,
|
||||
powerKw: "",
|
||||
@@ -225,20 +222,20 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
options: WAGON_STATUS_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "readiness",
|
||||
label: "Readiness",
|
||||
allLabel: "All readiness",
|
||||
options: WAGON_READINESS_OPTIONS,
|
||||
key: "currentYardId",
|
||||
label: "Current Yard",
|
||||
allLabel: "All yards",
|
||||
dynamicOptions: "yards",
|
||||
},
|
||||
],
|
||||
cardTitleKey: "wagonNumber",
|
||||
cardSubtitleKey: "readiness",
|
||||
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "readiness"],
|
||||
cardSubtitleKey: "currentYard",
|
||||
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"],
|
||||
columns: [
|
||||
{ id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
|
||||
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
|
||||
{ id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" },
|
||||
{ id: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" },
|
||||
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
||||
],
|
||||
formFields: [
|
||||
@@ -246,7 +243,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
|
||||
{ name: "tareWeight", label: "Tare weight", type: "number", required: true },
|
||||
{ name: "maxPayloadWeight", label: "Max payload weight", type: "number", required: true },
|
||||
{ name: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS },
|
||||
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
|
||||
{ name: "notes", label: "Notes", type: "textarea" },
|
||||
],
|
||||
@@ -255,7 +252,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
wagonTypeId: "",
|
||||
tareWeight: 0,
|
||||
maxPayloadWeight: 0,
|
||||
readiness: Freight.WagonReadiness.ImportReady,
|
||||
currentYardId: "",
|
||||
status: Freight.WagonStatus.Available,
|
||||
notes: "",
|
||||
},
|
||||
|
||||
@@ -296,9 +296,12 @@ const RuleEngineResourcePage = () => {
|
||||
};
|
||||
|
||||
const handleFormSubmit = (values: Record<string, unknown>) => {
|
||||
const payload =
|
||||
config.slug === "rates" ? { ...values, currency: "USD" } : values;
|
||||
|
||||
if (editing?.id) {
|
||||
update.mutate(
|
||||
{ id: editing.id, payload: values },
|
||||
{ id: editing.id, payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
@@ -307,7 +310,7 @@ const RuleEngineResourcePage = () => {
|
||||
},
|
||||
);
|
||||
} else {
|
||||
create.mutate(values, {
|
||||
create.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
|
||||
@@ -110,10 +110,7 @@ const RATE_UNITS = ["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "FLAT"].m
|
||||
value: v,
|
||||
}));
|
||||
|
||||
const CURRENCIES = [
|
||||
{ label: "ETB", value: "ETB" },
|
||||
{ label: "USD", value: "USD" },
|
||||
];
|
||||
const CURRENCIES = [{ label: "USD", value: "USD" }];
|
||||
|
||||
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
|
||||
id: key,
|
||||
@@ -430,7 +427,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
type: "select",
|
||||
options: TRADE_DIRECTIONS,
|
||||
},
|
||||
{ name: "currency", label: "Currency", type: "select", required: true, options: CURRENCIES },
|
||||
{ name: "rateValue", label: "Rate value", type: "number", required: true },
|
||||
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
|
||||
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
RingProgress,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
@@ -17,17 +19,21 @@ import {
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
Inbox,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
Train,
|
||||
TrainFront,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import {
|
||||
BookingPipeline,
|
||||
HeroChip,
|
||||
@@ -35,6 +41,7 @@ import {
|
||||
WindowStatusPill,
|
||||
} from "@/components/trainScheduling/batchVisuals";
|
||||
import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
|
||||
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import type { BatchBoardSchedule } from "@/types/trainScheduling";
|
||||
@@ -58,6 +65,27 @@ const fmtScheduleDate = (iso: string | null) =>
|
||||
}).format(new Date(iso)) + " EAT"
|
||||
: "No date";
|
||||
|
||||
const splitDate = (iso: string | null) => {
|
||||
if (!iso) return { day: "—", time: "" };
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return { day: "—", time: "" };
|
||||
return {
|
||||
day: new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(date),
|
||||
time:
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(date) + " EAT",
|
||||
};
|
||||
};
|
||||
|
||||
/** Capacity ring color: gold normally, red once over capacity. */
|
||||
function ringColor(pct: number) {
|
||||
if (pct >= 100) return "#fa5252";
|
||||
@@ -109,18 +137,56 @@ function CapacityRing({
|
||||
);
|
||||
}
|
||||
|
||||
/** Small percent chip used in the table's capacity column. */
|
||||
function CapacityChip({
|
||||
icon: Icon,
|
||||
pct,
|
||||
text,
|
||||
}: {
|
||||
icon: typeof Weight;
|
||||
pct: number | null;
|
||||
text: string;
|
||||
}) {
|
||||
const over = pct != null && pct >= 100;
|
||||
return (
|
||||
<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 }) {
|
||||
const navigate = useNavigate();
|
||||
const { capacity, counts, locomotive } = schedule;
|
||||
|
||||
const lengthPct =
|
||||
capacity.maxLengthMeters && capacity.maxLengthMeters > 0
|
||||
? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100
|
||||
: null;
|
||||
const weightPct =
|
||||
capacity.maxWeightTons && capacity.maxWeightTons > 0
|
||||
? (capacity.usedWeightTons / capacity.maxWeightTons) * 100
|
||||
: null;
|
||||
const lengthPct = lengthPctOf(schedule);
|
||||
const weightPct = weightPctOf(schedule);
|
||||
|
||||
const totalBookings = totalBookingCount(counts);
|
||||
|
||||
@@ -298,7 +364,13 @@ function CardSkeleton() {
|
||||
}
|
||||
|
||||
export default function BatchBoardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isFetching, refetch } = useBatchBoard();
|
||||
const { viewMode, setViewMode } = useFleetViewMode("batch-board");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [windowFilter, setWindowFilter] = useState("ALL");
|
||||
|
||||
const schedules = data ?? [];
|
||||
|
||||
const summary = useMemo(() => {
|
||||
@@ -308,22 +380,199 @@ export default function BatchBoardPage() {
|
||||
return { openWindows, totalBookings, totalWagons };
|
||||
}, [schedules]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return schedules.filter((s) => {
|
||||
if (windowFilter !== "ALL" && s.bookingWindowStatus !== windowFilter) return false;
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
s.trainNumber,
|
||||
s.routeName,
|
||||
s.origin,
|
||||
s.destination,
|
||||
s.locomotive?.code,
|
||||
s.status,
|
||||
s.bookingWindowStatus,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [schedules, search, windowFilter]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
|
||||
const paged = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return filtered.slice(start, start + pagination.pageSize);
|
||||
}, [filtered, pagination]);
|
||||
|
||||
const columns = useMemo((): ColumnDef<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 (
|
||||
<Container fluid py="lg" px="xl">
|
||||
<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">
|
||||
<StatTile
|
||||
icon={Train}
|
||||
@@ -359,46 +608,121 @@ export default function BatchBoardPage() {
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{isLoading ? (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</SimpleGrid>
|
||||
) : schedules.length === 0 ? (
|
||||
<Paper radius="lg" withBorder p={48} mt="lg" 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)",
|
||||
<Card
|
||||
radius="lg"
|
||||
padding={0}
|
||||
withBorder
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<FleetToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search schedules…"
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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 as cards. Create or activate
|
||||
a schedule to get started.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg">
|
||||
{schedules.map((s) => (
|
||||
<ScheduleCard key={s.scheduleId} schedule={s} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={({ table, pagination: footerPagination }) => (
|
||||
<DataTableFooter
|
||||
table={table}
|
||||
pagination={footerPagination}
|
||||
options={{ labels: { items: "schedules" } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
@@ -25,6 +27,8 @@ import {
|
||||
Boxes,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FileSignature,
|
||||
Hourglass,
|
||||
@@ -41,6 +45,7 @@ import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { TrainConsistView, CompositionBookingTabs } from "@/components/trainScheduling/compositionEditor";
|
||||
import {
|
||||
BookingPipeline,
|
||||
HeroChip,
|
||||
@@ -318,6 +323,40 @@ function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** "05 Jun 2026 · 06:00 – 09:00 EAT" → "06:00 – 09:00 EAT" (date lives in the day header). */
|
||||
function timeLabelOf(label: string): string {
|
||||
const idx = label.indexOf("·");
|
||||
return idx >= 0 ? label.slice(idx + 1).trim() : label;
|
||||
}
|
||||
|
||||
const EAT_TZ = "Africa/Addis_Ababa";
|
||||
const dateKeyFmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: EAT_TZ,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const dateLabelFmt = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: EAT_TZ,
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */
|
||||
function windowDateKey(w: BatchWindowGroup): string {
|
||||
if (w.date) return w.date;
|
||||
if (w.start) return dateKeyFmt.format(new Date(w.start));
|
||||
return "undated";
|
||||
}
|
||||
|
||||
/** Human day label for a window — prefers the API field, falls back to `start`. */
|
||||
function windowDateLabel(w: BatchWindowGroup): string {
|
||||
if (w.dateLabel) return w.dateLabel;
|
||||
if (w.start) return dateLabelFmt.format(new Date(w.start));
|
||||
return "Undated";
|
||||
}
|
||||
|
||||
function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
const total = window.bookings.length;
|
||||
const hasIssues = window.bookings.some(
|
||||
@@ -347,7 +386,7 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" truncate>
|
||||
{window.label}
|
||||
{timeLabelOf(window.label)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"}
|
||||
@@ -454,17 +493,110 @@ export default function BatchScheduleDetailPage() {
|
||||
[data],
|
||||
);
|
||||
|
||||
const scheduleDetailQuery = useScheduleDetail(
|
||||
hasAssignedWagons ? scheduleId : undefined,
|
||||
"CONTAINER",
|
||||
const scheduleDetailQuery = useScheduleDetail(scheduleId, "CONTAINER");
|
||||
|
||||
// Batch bookings by state for the composition side panel (payment / expired lists).
|
||||
const batchBookings = useMemo(() => {
|
||||
if (!data) return { awaitingPayment: [], expired: [] };
|
||||
const all = [
|
||||
...data.windows.flatMap((w) => w.bookings),
|
||||
...data.pendingContract.bookings,
|
||||
];
|
||||
return {
|
||||
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
|
||||
expired: all.filter((b) => b.state === "EXPIRED"),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
// Group the flat window list into per-day sections (one per EAT calendar date).
|
||||
const dayGroups = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const byDate = new Map<
|
||||
string,
|
||||
{
|
||||
date: string;
|
||||
dateLabel: string;
|
||||
windows: BatchWindowGroup[];
|
||||
totalBookings: number;
|
||||
counts: BatchWindowGroup["counts"];
|
||||
hasIssues: boolean;
|
||||
}
|
||||
>();
|
||||
for (const w of data.windows) {
|
||||
const dateKey = windowDateKey(w);
|
||||
let group = byDate.get(dateKey);
|
||||
if (!group) {
|
||||
group = {
|
||||
date: dateKey,
|
||||
dateLabel: windowDateLabel(w),
|
||||
windows: [],
|
||||
totalBookings: 0,
|
||||
counts: {
|
||||
allocated: 0,
|
||||
selectedForBatch: 0,
|
||||
ready: 0,
|
||||
waiting: 0,
|
||||
expired: 0,
|
||||
pendingContract: 0,
|
||||
},
|
||||
hasIssues: false,
|
||||
};
|
||||
byDate.set(dateKey, group);
|
||||
}
|
||||
group.windows.push(w);
|
||||
group.totalBookings += w.bookings.length;
|
||||
group.counts.allocated += w.counts.allocated;
|
||||
group.counts.selectedForBatch += w.counts.selectedForBatch;
|
||||
group.counts.ready += w.counts.ready;
|
||||
group.counts.waiting += w.counts.waiting;
|
||||
group.counts.expired += w.counts.expired;
|
||||
group.counts.pendingContract += w.counts.pendingContract;
|
||||
group.hasIssues =
|
||||
group.hasIssues ||
|
||||
w.bookings.some(
|
||||
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
|
||||
);
|
||||
}
|
||||
return [...byDate.values()];
|
||||
}, [data]);
|
||||
|
||||
// Windows with bookings open by default (inside an expanded day).
|
||||
const openWindowKeys = useMemo(
|
||||
() => (data ? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const defaultOpen = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const withBookings = data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key);
|
||||
if (data.pendingContract.bookings.length) withBookings.push("pending-contract");
|
||||
return withBookings.length ? withBookings : [data.windows[0]?.key].filter(Boolean);
|
||||
}, [data]);
|
||||
const todayEat = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(new Date()),
|
||||
[],
|
||||
);
|
||||
|
||||
// Date-stepper: which day is currently shown. Default to today, else the first
|
||||
// day with bookings, else the first day. Keep the selection if still valid.
|
||||
const [selectedDate, setSelectedDate] = useState<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 = () => {
|
||||
runAllocation
|
||||
@@ -518,8 +650,17 @@ export default function BatchScheduleDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" mt="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Tabs value={activeTab} onChange={setActiveTab} mt="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 }}>
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<Button
|
||||
@@ -637,7 +778,6 @@ export default function BatchScheduleDetailPage() {
|
||||
variant="line"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
{/* Booking pipeline */}
|
||||
<Paper
|
||||
@@ -706,24 +846,126 @@ export default function BatchScheduleDetailPage() {
|
||||
<Box>
|
||||
<Title order={4}>Batch windows (EAT)</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Bookings grouped by contract signing time in 3-hour windows — expand one to
|
||||
see bookings and wagon allocation issues.
|
||||
3-hour windows for every day from when the booking window opened through the
|
||||
departure date. Bookings appear under the date their contract was signed — open a
|
||||
day to see its windows.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={defaultOpen}
|
||||
variant="separated"
|
||||
radius="md"
|
||||
mt="md"
|
||||
className="bb-window-accordion"
|
||||
>
|
||||
{data.windows.map((window) => (
|
||||
<WindowAccordionItem key={window.key} window={window} />
|
||||
))}
|
||||
{data.pendingContract.bookings.length ? (
|
||||
{dayGroups.length && selectedDay ? (
|
||||
<>
|
||||
{/* Date stepper — page back/forward through each day in the range */}
|
||||
<Group justify="center" align="center" wrap="nowrap" gap="md" mt="md">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
aria-label="Previous day"
|
||||
disabled={selectedIndex <= 0}
|
||||
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.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
|
||||
@@ -763,11 +1005,11 @@ export default function BatchScheduleDetailPage() {
|
||||
<BookingTable bookings={data.pendingContract.bookings} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
) : null}
|
||||
</Accordion>
|
||||
</Accordion>
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
{/* Train composition */}
|
||||
{/* Train composition diagram */}
|
||||
{hasAssignedWagons && scheduleDetailQuery.data ? (
|
||||
<Box mt="lg">
|
||||
<TrainCompositionDiagram
|
||||
@@ -779,6 +1021,48 @@ export default function BatchScheduleDetailPage() {
|
||||
/>
|
||||
</Box>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function TrainScheduleTrackPage() {
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: isFinal
|
||||
? "Train arrived — assets freed, readiness flipped"
|
||||
? "Train arrived — assets freed, moved to destination yard"
|
||||
: "Checkpoint logged",
|
||||
});
|
||||
},
|
||||
|
||||
@@ -785,11 +785,11 @@ export default function TrainScheduleV2DetailPage() {
|
||||
label="Locomotive"
|
||||
value={schedule.trainSet?.locomotive?.code ?? "—"}
|
||||
hint={
|
||||
schedule.trainSet?.locomotive?.readiness === "EXPORT_READY"
|
||||
? "Export-ready"
|
||||
: schedule.trainSet?.locomotive?.readiness === "IMPORT_READY"
|
||||
? "Import-ready"
|
||||
: undefined
|
||||
schedule.trainSet?.locomotive?.currentYardId
|
||||
? schedule.trainSet.locomotive.currentYardId === schedule.originStation?.id
|
||||
? `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? "origin yard"}`
|
||||
: "Not at schedule origin yard"
|
||||
: "No current yard set"
|
||||
}
|
||||
accent="#F2A516"
|
||||
graph="area"
|
||||
|
||||
@@ -89,15 +89,13 @@ export default function TrainScheduleV2ListPage() {
|
||||
|
||||
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
|
||||
|
||||
const locomotiveReadinessHint = useMemo(() => {
|
||||
const locomotiveYardHint = useMemo(() => {
|
||||
if (!selectedRoute) return "Select a route first";
|
||||
const origin = selectedRoute.originYard?.country?.trim();
|
||||
const dest = selectedRoute.destinationYard?.country?.trim();
|
||||
if (origin === "Djibouti") return "Import corridor — import-ready locomotives only";
|
||||
if (dest === "Djibouti" && origin !== "Djibouti") {
|
||||
return "Export corridor — export-ready locomotives only";
|
||||
}
|
||||
return "Domestic corridor — any readiness";
|
||||
const originLabel =
|
||||
selectedRoute.originYard?.label ??
|
||||
selectedRoute.originYard?.code ??
|
||||
"the route origin yard";
|
||||
return `Only locomotives currently at ${originLabel} are shown`;
|
||||
}, [selectedRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -525,7 +523,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
/>
|
||||
{routeId ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{locomotiveReadinessHint}
|
||||
{locomotiveYardHint}
|
||||
</Text>
|
||||
) : null}
|
||||
<TextInput
|
||||
@@ -542,9 +540,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
placeholder={routeId ? "Select locomotive" : "Select a route first"}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code}${l.name ? ` — ${l.name}` : ""} · ${
|
||||
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
|
||||
}`,
|
||||
label: `${l.code}${l.name ? ` — ${l.name}` : ""}`,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
@@ -13,7 +11,7 @@ export type LocomotiveStatus =
|
||||
|
||||
export interface LocomotiveListFilters {
|
||||
status?: LocomotiveStatus;
|
||||
readiness?: Freight.WagonReadiness;
|
||||
currentYardId?: string;
|
||||
}
|
||||
|
||||
export interface Locomotive {
|
||||
@@ -22,7 +20,8 @@ export interface Locomotive {
|
||||
name?: string | null;
|
||||
locomotiveType: LocomotiveType;
|
||||
status: LocomotiveStatus;
|
||||
readiness: Freight.WagonReadiness;
|
||||
currentYardId: string | null;
|
||||
currentYard?: { id: string; label?: string; code?: string } | null;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
powerKw?: number | null;
|
||||
@@ -41,7 +40,7 @@ export const locomotivesService = {
|
||||
getAll: (filters: LocomotiveListFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
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();
|
||||
return apiClient.get<Locomotive[]>(
|
||||
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,
|
||||
|
||||
@@ -6,6 +6,9 @@ import type {
|
||||
BatchBoardScheduleDetail,
|
||||
BookableSchedule,
|
||||
AssignBookingsPayload,
|
||||
CompositionRemovalEntry,
|
||||
CompositionUnassignedBooking,
|
||||
UnassignedBookingsResponse,
|
||||
CreateTrainSchedulePayload,
|
||||
EligibleContainerBookingsResponse,
|
||||
FreightType,
|
||||
@@ -160,6 +163,17 @@ export const trainSchedulingService = {
|
||||
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 (
|
||||
scheduleId: string,
|
||||
payload: AssignBookingsPayload,
|
||||
@@ -350,4 +364,41 @@ export const trainSchedulingService = {
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,14 +11,15 @@ export interface Wagon {
|
||||
tareWeight: number;
|
||||
maxPayloadWeight: number;
|
||||
status: Freight.WagonStatus;
|
||||
readiness: Freight.WagonReadiness;
|
||||
currentYardId: string | null;
|
||||
currentYard?: { id: string; label?: string; code?: string } | null;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface WagonListFilters {
|
||||
search?: string;
|
||||
status?: Freight.WagonStatus;
|
||||
readiness?: Freight.WagonReadiness;
|
||||
currentYardId?: string;
|
||||
wagonTypeId?: string;
|
||||
trainId?: string;
|
||||
}
|
||||
@@ -28,7 +29,7 @@ export const wagonService = {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search?.trim()) params.set('search', filters.search.trim());
|
||||
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.trainId) params.set('trainId', filters.trainId);
|
||||
const qs = params.toString();
|
||||
|
||||
@@ -127,8 +127,6 @@ export interface TrainSchedulePreviewResponse {
|
||||
containerSlotSequenceNos?: number[];
|
||||
}
|
||||
|
||||
export type Readiness = "IMPORT_READY" | "EXPORT_READY";
|
||||
|
||||
export interface LocomotiveRecord {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -136,7 +134,7 @@ export interface LocomotiveRecord {
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
status: "AVAILABLE" | "ASSIGNED" | "MAINTENANCE" | "OUT_OF_SERVICE";
|
||||
readiness?: Readiness | null;
|
||||
currentYardId?: string | null;
|
||||
locomotiveType?: "DIESEL" | "ELECTRIC";
|
||||
}
|
||||
|
||||
@@ -153,7 +151,7 @@ export interface TrainScheduleListItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
readiness?: Readiness | null;
|
||||
currentYardId?: string | null;
|
||||
}
|
||||
| null;
|
||||
wagonCount: number;
|
||||
@@ -247,6 +245,10 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
|
||||
date: string;
|
||||
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
|
||||
dateLabel: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
@@ -348,7 +350,7 @@ export interface TrainScheduleDetail {
|
||||
code: string;
|
||||
name?: string | null;
|
||||
status: string;
|
||||
readiness?: Readiness | null;
|
||||
currentYardId?: string | null;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters?: number;
|
||||
} | null;
|
||||
@@ -483,3 +485,33 @@ export interface PinWagonAssignment {
|
||||
export interface PinWagonsPayload {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2,31 +2,30 @@ import { Freight } from "@edr/types";
|
||||
|
||||
import type { Wagon } from "@/services/wagon.service";
|
||||
|
||||
export function wagonMatchesScheduleDirection(
|
||||
wagon: Pick<Wagon, "status" | "readiness">,
|
||||
scheduleDirection?: string | null,
|
||||
/** Check if a wagon is available for a schedule based on its current yard.
|
||||
* A wagon is eligible if:
|
||||
* 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 },
|
||||
): boolean {
|
||||
if (options?.allowPinned) return true;
|
||||
if (wagon.status !== Freight.WagonStatus.Available) return false;
|
||||
if (!scheduleDirection || scheduleDirection === "DOMESTIC") return true;
|
||||
if (scheduleDirection === "IMPORT") {
|
||||
return wagon.readiness === Freight.WagonReadiness.ImportReady;
|
||||
}
|
||||
if (scheduleDirection === "EXPORT") {
|
||||
return wagon.readiness === Freight.WagonReadiness.ExportReady;
|
||||
}
|
||||
return true;
|
||||
if (!originYardId) return true;
|
||||
return wagon.currentYardId === originYardId;
|
||||
}
|
||||
|
||||
export function filterWagonsForSchedule(
|
||||
wagons: Wagon[],
|
||||
scheduleDirection?: string | null,
|
||||
originYardId?: string | null,
|
||||
pinnedWagonIds?: Set<string>,
|
||||
): Wagon[] {
|
||||
return wagons.filter((wagon) => {
|
||||
const isPinned = pinnedWagonIds?.has(wagon.id) ?? false;
|
||||
return wagonMatchesScheduleDirection(wagon, scheduleDirection, {
|
||||
return wagonMatchesScheduleOrigin(wagon, originYardId, {
|
||||
allowPinned: isPinned,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user