mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 07:08:18 +00:00
feat(freight): added routes and locomotives
This commit is contained in:
@@ -47,6 +47,7 @@ import { TrainsModule } from "./modules/trains/trains.module";
|
|||||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||||
import { ContainersModule } from './modules/container-management/containers.module';
|
import { ContainersModule } from './modules/container-management/containers.module';
|
||||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||||
|
import { RoutesModule } from './modules/routes/routes.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -98,6 +99,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
|
|||||||
WagonsModule,
|
WagonsModule,
|
||||||
ContainersModule,
|
ContainersModule,
|
||||||
CargoesModule,
|
CargoesModule,
|
||||||
|
RoutesModule,
|
||||||
],
|
],
|
||||||
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
// apps/edr-freight-api/src/data-source.ts
|
// apps/edr-freight-api/src/data-source.ts
|
||||||
|
import 'dotenv/config';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
|
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
|
||||||
|
|
||||||
export const AppDataSource = new DataSource({
|
export const AppDataSource = new DataSource({
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: 'localhost',
|
host: process.env.DB_HOST ?? 'localhost',
|
||||||
port: 5432,
|
port: Number(process.env.DB_PORT ?? 5432),
|
||||||
username: 'postgres',
|
username: process.env.DB_USER ?? 'postgres',
|
||||||
password: '', // Laragon default: empty
|
password: process.env.DB_PASSWORD ?? '',
|
||||||
database: 'edr_freight',
|
database: process.env.DB_NAME ?? 'edr_freight',
|
||||||
schema: 'freight', // default schema for entities without an explicit schema
|
schema: 'freight', // default schema for entities without an explicit schema
|
||||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||||
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddRoutesAndExtendLocomotives1750100000000 implements MigrationInterface {
|
||||||
|
name = 'AddRoutesAndExtendLocomotives1750100000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.locomotives
|
||||||
|
ADD COLUMN IF NOT EXISTS locomotive_type VARCHAR(20) NOT NULL DEFAULT 'DIESEL',
|
||||||
|
ADD COLUMN IF NOT EXISTS max_train_length_meters NUMERIC(10,3) NOT NULL DEFAULT 760,
|
||||||
|
ADD COLUMN IF NOT EXISTS power_kw NUMERIC(10,3) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS traction_force_kn NUMERIC(10,3) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS max_speed_kmh NUMERIC(10,3) NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.locomotives
|
||||||
|
SET status = 'OUT_OF_SERVICE'
|
||||||
|
WHERE status = 'INACTIVE';
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.routes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(120) NOT NULL UNIQUE,
|
||||||
|
origin_yard_id UUID NOT NULL REFERENCES freight.yards(id),
|
||||||
|
destination_yard_id UUID NOT NULL REFERENCES freight.yards(id),
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.route_milestones (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
route_id UUID NOT NULL REFERENCES freight.routes(id) ON DELETE CASCADE,
|
||||||
|
yard_id UUID NOT NULL REFERENCES freight.yards(id),
|
||||||
|
sequence_no INT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL,
|
||||||
|
CONSTRAINT uq_route_milestones_route_sequence UNIQUE (route_id, sequence_no)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_routes_origin_yard_id
|
||||||
|
ON freight.routes(origin_yard_id);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_routes_destination_yard_id
|
||||||
|
ON freight.routes(destination_yard_id);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_routes_is_active
|
||||||
|
ON freight.routes(is_active);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_route_milestones_route_id
|
||||||
|
ON freight.route_milestones(route_id);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_route_milestones_yard_id
|
||||||
|
ON freight.route_milestones(yard_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.route_milestones;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.routes;`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.locomotives
|
||||||
|
DROP COLUMN IF EXISTS max_speed_kmh,
|
||||||
|
DROP COLUMN IF EXISTS traction_force_kn,
|
||||||
|
DROP COLUMN IF EXISTS power_kw,
|
||||||
|
DROP COLUMN IF EXISTS max_train_length_meters,
|
||||||
|
DROP COLUMN IF EXISTS locomotive_type;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.locomotives
|
||||||
|
SET status = 'INACTIVE'
|
||||||
|
WHERE status = 'OUT_OF_SERVICE';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
|
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
|
||||||
|
|
||||||
|
export class CreateLocomotiveDto {
|
||||||
|
@ApiProperty({ example: 'LOCO-001' })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: LOCOMOTIVE_TYPES })
|
||||||
|
@IsIn([...LOCOMOTIVE_TYPES])
|
||||||
|
locomotiveType!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: LOCOMOTIVE_STATUSES })
|
||||||
|
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||||
|
status!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 3500 })
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxPullWeightTons!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 760 })
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxTrainLengthMeters!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 4200 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
powerKw?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 300 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
tractionForceKn?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 120 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxSpeedKmh?: number;
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsIn, IsOptional } from 'class-validator';
|
import { IsIn, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
import { LOCOMOTIVE_STATUSES } from '../entities/locomotive.entity';
|
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
|
||||||
|
|
||||||
export class FilterLocomotivesDto {
|
export class FilterLocomotivesDto {
|
||||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
|
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...LOCOMOTIVE_STATUSES])
|
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: LOCOMOTIVE_TYPES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...LOCOMOTIVE_TYPES])
|
||||||
|
locomotiveType?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { CreateLocomotiveDto } from './create-locomotive.dto';
|
||||||
|
|
||||||
|
export class UpdateLocomotiveDto extends PartialType(CreateLocomotiveDto) {}
|
||||||
@@ -7,10 +7,13 @@ export const LOCOMOTIVE_STATUSES = [
|
|||||||
'AVAILABLE',
|
'AVAILABLE',
|
||||||
'ASSIGNED',
|
'ASSIGNED',
|
||||||
'MAINTENANCE',
|
'MAINTENANCE',
|
||||||
'INACTIVE',
|
'OUT_OF_SERVICE',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const;
|
||||||
|
|
||||||
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
|
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
|
||||||
|
export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'locomotives' })
|
@Entity({ schema: 'freight', name: 'locomotives' })
|
||||||
@Index(['code'])
|
@Index(['code'])
|
||||||
@@ -22,14 +25,26 @@ export class Locomotive extends BaseEntity {
|
|||||||
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
|
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'locomotive_type', type: 'varchar', length: 20, default: 'DIESEL' })
|
||||||
|
locomotiveType!: LocomotiveType;
|
||||||
|
|
||||||
@Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
@Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||||
maxPullWeightTons!: number;
|
maxPullWeightTons!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 })
|
||||||
|
maxTrainLengthMeters!: number;
|
||||||
|
|
||||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
|
||||||
status!: LocomotiveStatus;
|
status!: LocomotiveStatus;
|
||||||
|
|
||||||
@Column({ name: 'available_from', type: 'timestamptz', nullable: true })
|
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||||
availableFrom?: Date | null;
|
powerKw?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'traction_force_kn', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||||
|
tractionForceKn?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'max_speed_kmh', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||||
|
maxSpeedKmh?: number | null;
|
||||||
|
|
||||||
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
|
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
|
||||||
trainSets?: TrainSet[];
|
trainSets?: TrainSet[];
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||||
|
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||||
import { LocomotivesService } from './locomotives.service';
|
import { LocomotivesService } from './locomotives.service';
|
||||||
|
|
||||||
@ApiTags('locomotives')
|
@ApiTags('locomotives')
|
||||||
@@ -15,4 +17,28 @@ export class LocomotivesController {
|
|||||||
findAll(@Query() filter: FilterLocomotivesDto) {
|
findAll(@Query() filter: FilterLocomotivesDto) {
|
||||||
return this.locomotivesService.findAll(filter);
|
return this.locomotivesService.findAll(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get a locomotive by ID' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.locomotivesService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a locomotive' })
|
||||||
|
create(@Body() dto: CreateLocomotiveDto) {
|
||||||
|
return this.locomotivesService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update a locomotive' })
|
||||||
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
|
||||||
|
return this.locomotivesService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/decommission')
|
||||||
|
@ApiOperation({ summary: 'Decommission a locomotive' })
|
||||||
|
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.locomotivesService.decommission(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||||
import { Locomotive, type LocomotiveStatus } from './entities/locomotive.entity';
|
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||||
|
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
|
||||||
import { LocomotivesRepository } from './locomotives.repository';
|
import { LocomotivesRepository } from './locomotives.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -10,13 +12,36 @@ export class LocomotivesService {
|
|||||||
|
|
||||||
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
|
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
|
||||||
return this.locomotivesRepository.findAll({
|
return this.locomotivesRepository.findAll({
|
||||||
where: filter.status
|
where: {
|
||||||
? { status: filter.status as LocomotiveStatus }
|
...(filter.status ? { status: filter.status as LocomotiveStatus } : {}),
|
||||||
: undefined,
|
...(filter.locomotiveType
|
||||||
|
? { locomotiveType: filter.locomotiveType as LocomotiveType }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
order: { code: 'ASC' },
|
order: { code: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateLocomotiveDto): Promise<Locomotive> {
|
||||||
|
const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(`Locomotive code ${dto.code} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.locomotivesRepository.create({
|
||||||
|
code: dto.code,
|
||||||
|
name: dto.name?.trim() || null,
|
||||||
|
locomotiveType: dto.locomotiveType as LocomotiveType,
|
||||||
|
status: dto.status as LocomotiveStatus,
|
||||||
|
maxPullWeightTons: dto.maxPullWeightTons,
|
||||||
|
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||||
|
powerKw: dto.powerKw ?? null,
|
||||||
|
tractionForceKn: dto.tractionForceKn ?? null,
|
||||||
|
maxSpeedKmh: dto.maxSpeedKmh ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<Locomotive> {
|
async findById(id: string): Promise<Locomotive> {
|
||||||
const locomotive = await this.locomotivesRepository.findById(id);
|
const locomotive = await this.locomotivesRepository.findById(id);
|
||||||
|
|
||||||
@@ -26,4 +51,48 @@ export class LocomotivesService {
|
|||||||
|
|
||||||
return locomotive;
|
return locomotive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateLocomotiveDto): Promise<Locomotive> {
|
||||||
|
const locomotive = await this.findById(id);
|
||||||
|
|
||||||
|
if (dto.code && dto.code !== locomotive.code) {
|
||||||
|
const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } });
|
||||||
|
if (existing && existing.id !== id) {
|
||||||
|
throw new ConflictException(`Locomotive code ${dto.code} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.locomotivesRepository.update(id, {
|
||||||
|
...dto,
|
||||||
|
locomotiveType:
|
||||||
|
dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
|
||||||
|
status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
|
||||||
|
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
|
||||||
|
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
|
||||||
|
tractionForceKn:
|
||||||
|
dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null,
|
||||||
|
maxSpeedKmh:
|
||||||
|
dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async decommission(id: string): Promise<Locomotive> {
|
||||||
|
await this.findById(id);
|
||||||
|
|
||||||
|
const updated = await this.locomotivesRepository.update(id, {
|
||||||
|
status: 'OUT_OF_SERVICE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateRouteMilestoneDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
yardId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateRouteDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [CreateRouteMilestoneDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(2)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateRouteMilestoneDto)
|
||||||
|
milestones!: CreateRouteMilestoneDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class FilterRoutesDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { CreateRouteDto } from './create-route.dto';
|
||||||
|
|
||||||
|
export class UpdateRouteDto extends PartialType(CreateRouteDto) {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
|
import { Route } from './route.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'route_milestones' })
|
||||||
|
@Index(['routeId', 'sequenceNo'], { unique: true })
|
||||||
|
export class RouteMilestone extends BaseEntity {
|
||||||
|
@Column({ name: 'route_id', type: 'uuid' })
|
||||||
|
routeId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Route, (route) => route.milestones, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'route_id' })
|
||||||
|
route?: Route;
|
||||||
|
|
||||||
|
@Column({ name: 'yard_id', type: 'uuid' })
|
||||||
|
yardId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Yard)
|
||||||
|
@JoinColumn({ name: 'yard_id' })
|
||||||
|
yard?: Yard;
|
||||||
|
|
||||||
|
@Column({ name: 'sequence_no', type: 'int' })
|
||||||
|
sequenceNo!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
|
|
||||||
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
|
import { RouteMilestone } from './route-milestone.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'routes' })
|
||||||
|
@Index(['name'])
|
||||||
|
@Index(['isActive'])
|
||||||
|
export class Route extends BaseEntity {
|
||||||
|
@Column({ name: 'name', type: 'varchar', length: 120, unique: true })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||||
|
originYardId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Yard)
|
||||||
|
@JoinColumn({ name: 'origin_yard_id' })
|
||||||
|
originYard?: Yard;
|
||||||
|
|
||||||
|
@Column({ name: 'destination_yard_id', type: 'uuid' })
|
||||||
|
destinationYardId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Yard)
|
||||||
|
@JoinColumn({ name: 'destination_yard_id' })
|
||||||
|
destinationYard?: Yard;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
|
||||||
|
milestones?: RouteMilestone[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { RouteMilestone } from './entities/route-milestone.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RouteMilestonesRepository extends BaseRepository<RouteMilestone> {
|
||||||
|
constructor(@InjectRepository(RouteMilestone) repository: Repository<RouteMilestone>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/edr-freight-api/src/modules/routes/routes.controller.ts
Normal file
44
apps/edr-freight-api/src/modules/routes/routes.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { CreateRouteDto } from './dto/create-route.dto';
|
||||||
|
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||||
|
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||||
|
import { RoutesService } from './routes.service';
|
||||||
|
|
||||||
|
@ApiTags('routes')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('routes')
|
||||||
|
export class RoutesController {
|
||||||
|
constructor(private readonly routesService: RoutesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List routes' })
|
||||||
|
findAll(@Query() filter: FilterRoutesDto) {
|
||||||
|
return this.routesService.findAll(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get route by ID' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.routesService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create route' })
|
||||||
|
create(@Body() dto: CreateRouteDto) {
|
||||||
|
return this.routesService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update route' })
|
||||||
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
|
||||||
|
return this.routesService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Deactivate route' })
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.routesService.deactivate(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/edr-freight-api/src/modules/routes/routes.module.ts
Normal file
18
apps/edr-freight-api/src/modules/routes/routes.module.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
|
import { RouteMilestone } from './entities/route-milestone.entity';
|
||||||
|
import { Route } from './entities/route.entity';
|
||||||
|
import { RouteMilestonesRepository } from './route-milestones.repository';
|
||||||
|
import { RoutesController } from './routes.controller';
|
||||||
|
import { RoutesRepository } from './routes.repository';
|
||||||
|
import { RoutesService } from './routes.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Route, RouteMilestone, Yard])],
|
||||||
|
controllers: [RoutesController],
|
||||||
|
providers: [RoutesRepository, RouteMilestonesRepository, RoutesService],
|
||||||
|
exports: [RoutesRepository, RouteMilestonesRepository, RoutesService],
|
||||||
|
})
|
||||||
|
export class RoutesModule {}
|
||||||
13
apps/edr-freight-api/src/modules/routes/routes.repository.ts
Normal file
13
apps/edr-freight-api/src/modules/routes/routes.repository.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Route } from './entities/route.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RoutesRepository extends BaseRepository<Route> {
|
||||||
|
constructor(@InjectRepository(Route) repository: Repository<Route>) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
apps/edr-freight-api/src/modules/routes/routes.service.ts
Normal file
171
apps/edr-freight-api/src/modules/routes/routes.service.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource, ILike } from 'typeorm';
|
||||||
|
|
||||||
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
|
import { CreateRouteDto } from './dto/create-route.dto';
|
||||||
|
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||||
|
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||||
|
import { RouteMilestone } from './entities/route-milestone.entity';
|
||||||
|
import { Route } from './entities/route.entity';
|
||||||
|
import { RoutesRepository } from './routes.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RoutesService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly routesRepository: RoutesRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
findAll(filter: FilterRoutesDto): Promise<Route[]> {
|
||||||
|
return this.routesRepository.findAll({
|
||||||
|
where: {
|
||||||
|
...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}),
|
||||||
|
...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}),
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
originYard: true,
|
||||||
|
destinationYard: true,
|
||||||
|
milestones: { yard: true },
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
name: 'ASC',
|
||||||
|
milestones: { sequenceNo: 'ASC' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Route> {
|
||||||
|
const route = await this.dataSource.getRepository(Route).findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: {
|
||||||
|
originYard: true,
|
||||||
|
destinationYard: true,
|
||||||
|
milestones: { yard: true },
|
||||||
|
},
|
||||||
|
order: { milestones: { sequenceNo: 'ASC' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!route) {
|
||||||
|
throw new NotFoundException(`Route ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateRouteDto): Promise<Route> {
|
||||||
|
await this.validateRouteName(dto.name);
|
||||||
|
const validated = await this.validateMilestones(dto.milestones);
|
||||||
|
|
||||||
|
const route = await this.dataSource.transaction(async (manager) => {
|
||||||
|
const savedRoute = await manager.getRepository(Route).save(
|
||||||
|
manager.getRepository(Route).create({
|
||||||
|
name: dto.name.trim(),
|
||||||
|
originYardId: validated.originYardId,
|
||||||
|
destinationYardId: validated.destinationYardId,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.getRepository(RouteMilestone).save(
|
||||||
|
validated.milestones.map((milestone) =>
|
||||||
|
manager.getRepository(RouteMilestone).create({
|
||||||
|
routeId: savedRoute.id,
|
||||||
|
yardId: milestone.yardId,
|
||||||
|
sequenceNo: milestone.sequenceNo,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return savedRoute;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(route.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateRouteDto): Promise<Route> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
|
if (dto.name && dto.name.trim() !== existing.name) {
|
||||||
|
await this.validateRouteName(dto.name, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const milestoneInput = dto.milestones
|
||||||
|
? await this.validateMilestones(dto.milestones)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(Route).update(id, {
|
||||||
|
name: dto.name?.trim() ?? existing.name,
|
||||||
|
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
|
||||||
|
destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
||||||
|
isActive: dto.isActive ?? existing.isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (milestoneInput) {
|
||||||
|
await manager.getRepository(RouteMilestone).delete({ routeId: id });
|
||||||
|
await manager.getRepository(RouteMilestone).save(
|
||||||
|
milestoneInput.milestones.map((milestone) =>
|
||||||
|
manager.getRepository(RouteMilestone).create({
|
||||||
|
routeId: id,
|
||||||
|
yardId: milestone.yardId,
|
||||||
|
sequenceNo: milestone.sequenceNo,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivate(id: string): Promise<Route> {
|
||||||
|
await this.findById(id);
|
||||||
|
const updated = await this.routesRepository.update(id, { isActive: false });
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException(`Route ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateRouteName(name: string, routeId?: string) {
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } });
|
||||||
|
|
||||||
|
if (existing && existing.id !== routeId) {
|
||||||
|
throw new ConflictException(`Route name ${trimmedName} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateMilestones(milestones: Array<{ yardId: string }>) {
|
||||||
|
if (milestones.length < 2) {
|
||||||
|
throw new BadRequestException('A route requires at least two yards');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = milestones.map((milestone, index) => ({
|
||||||
|
yardId: milestone.yardId,
|
||||||
|
sequenceNo: index + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
|
||||||
|
const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) });
|
||||||
|
const yardIds = new Set(yards.map((yard) => yard.id));
|
||||||
|
|
||||||
|
for (const milestone of normalized) {
|
||||||
|
if (!yardIds.has(milestone.yardId)) {
|
||||||
|
throw new BadRequestException(`Yard ${milestone.yardId} does not exist`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized[0].yardId === normalized[normalized.length - 1].yardId) {
|
||||||
|
throw new BadRequestException('Origin and destination yards must be different');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
originYardId: normalized[0].yardId,
|
||||||
|
destinationYardId: normalized[normalized.length - 1].yardId,
|
||||||
|
milestones: normalized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ const locomotive = {
|
|||||||
id: 'loc-1',
|
id: 'loc-1',
|
||||||
code: 'LOC-001',
|
code: 'LOC-001',
|
||||||
maxPullWeightTons: 3500,
|
maxPullWeightTons: 3500,
|
||||||
|
maxTrainLengthMeters: 760,
|
||||||
status: 'AVAILABLE',
|
status: 'AVAILABLE',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ export class TrainSchedulingService {
|
|||||||
const locomotive = await this.selectOrValidateLocomotive(
|
const locomotive = await this.selectOrValidateLocomotive(
|
||||||
dto.locomotiveId,
|
dto.locomotiveId,
|
||||||
validation.summary.totalWeightTons,
|
validation.summary.totalWeightTons,
|
||||||
|
validation.summary.totalLengthMeters,
|
||||||
);
|
);
|
||||||
|
|
||||||
const createdSchedule = await this.dataSource.transaction(
|
const createdSchedule = await this.dataSource.transaction(
|
||||||
@@ -220,6 +221,15 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Number(lockedLocomotive.maxTrainLengthMeters) <
|
||||||
|
validation.summary.totalLengthMeters
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Locomotive ${lockedLocomotive.code} cannot support ${validation.summary.totalLengthMeters}m`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const existingScheduleCount = await manager
|
const existingScheduleCount = await manager
|
||||||
.getRepository(TrainScheduleBooking)
|
.getRepository(TrainScheduleBooking)
|
||||||
.count({
|
.count({
|
||||||
@@ -461,10 +471,14 @@ export class TrainSchedulingService {
|
|||||||
where: { status: "AVAILABLE" },
|
where: { status: "AVAILABLE" },
|
||||||
});
|
});
|
||||||
const canPull = capableLocomotives.some(
|
const canPull = capableLocomotives.some(
|
||||||
(locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons,
|
(locomotive) =>
|
||||||
|
Number(locomotive.maxPullWeightTons) >= totalWeightTons &&
|
||||||
|
Number(locomotive.maxTrainLengthMeters) >= totalLengthMeters,
|
||||||
);
|
);
|
||||||
if (!canPull) {
|
if (!canPull) {
|
||||||
violations.push("No available locomotive can pull the total weight");
|
violations.push(
|
||||||
|
'No available locomotive can support the total train weight and length',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,6 +527,7 @@ export class TrainSchedulingService {
|
|||||||
async selectOrValidateLocomotive(
|
async selectOrValidateLocomotive(
|
||||||
locomotiveId: string,
|
locomotiveId: string,
|
||||||
totalWeightTons: number,
|
totalWeightTons: number,
|
||||||
|
totalLengthMeters: number,
|
||||||
) {
|
) {
|
||||||
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
|
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
|
||||||
|
|
||||||
@@ -532,6 +547,12 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return locomotive;
|
return locomotive;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,6 +732,9 @@ export class TrainSchedulingService {
|
|||||||
maxPullWeightTons: this.roundTons(
|
maxPullWeightTons: this.roundTons(
|
||||||
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
||||||
),
|
),
|
||||||
|
maxTrainLengthMeters: this.roundTons(
|
||||||
|
Number(schedule.trainSet.locomotive.maxTrainLengthMeters),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
wagons: [...(schedule.trainSet.wagons ?? [])]
|
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||||||
|
|||||||
@@ -178,13 +178,17 @@ export class DemoBookingsSeeder {
|
|||||||
{
|
{
|
||||||
code: "LOC-001",
|
code: "LOC-001",
|
||||||
name: "Demo Locomotive 1",
|
name: "Demo Locomotive 1",
|
||||||
|
locomotiveType: 'ELECTRIC',
|
||||||
maxPullWeightTons: 3500,
|
maxPullWeightTons: 3500,
|
||||||
|
maxTrainLengthMeters: 760,
|
||||||
status: "AVAILABLE",
|
status: "AVAILABLE",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "LOC-002",
|
code: "LOC-002",
|
||||||
name: "Demo Locomotive 2",
|
name: "Demo Locomotive 2",
|
||||||
|
locomotiveType: 'DIESEL',
|
||||||
maxPullWeightTons: 2500,
|
maxPullWeightTons: 2500,
|
||||||
|
maxTrainLengthMeters: 760,
|
||||||
status: "AVAILABLE",
|
status: "AVAILABLE",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -38,11 +38,13 @@ import TrainsPage from "./pages/trains/TrainsPage";
|
|||||||
import {
|
import {
|
||||||
CargoesCrudPage,
|
CargoesCrudPage,
|
||||||
ContainersCrudPage,
|
ContainersCrudPage,
|
||||||
|
LocomotivesCrudPage,
|
||||||
TrainMasterDataPage,
|
TrainMasterDataPage,
|
||||||
WagonsCrudPage,
|
WagonsCrudPage,
|
||||||
} from "./pages/fleet/FleetCrudPages";
|
} from "./pages/fleet/FleetCrudPages";
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||||
|
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -59,17 +61,32 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
href: "/dashboard/booking-requests",
|
href: "/dashboard/booking-requests",
|
||||||
icon: <FileText />,
|
icon: <FileText />,
|
||||||
},
|
},
|
||||||
|
...demoItems,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Operations",
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
label: "Train scheduling",
|
label: "Train Schedules",
|
||||||
href: "/dashboard/operations/train-scheduling",
|
href: "/dashboard/operations/train-scheduling",
|
||||||
icon: <Train />,
|
icon: <Train />,
|
||||||
},
|
},
|
||||||
...demoItems,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Fleet Management",
|
title: "Fleet Management",
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
label: "Routes",
|
||||||
|
href: "/dashboard/routes",
|
||||||
|
icon: <Network />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Locomotives",
|
||||||
|
href: "/dashboard/locomotives",
|
||||||
|
icon: <Train />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Trains",
|
label: "Trains",
|
||||||
href: "/dashboard/trains",
|
href: "/dashboard/trains",
|
||||||
@@ -224,6 +241,8 @@ const App = () => {
|
|||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||||
|
<Route path="routes" element={<RoutesPage />} />
|
||||||
|
<Route path="locomotives" element={<LocomotivesCrudPage />} />
|
||||||
<Route path="trains" element={<TrainMasterDataPage />} />
|
<Route path="trains" element={<TrainMasterDataPage />} />
|
||||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||||
<Route path="wagons" element={<WagonsCrudPage />} />
|
<Route path="wagons" element={<WagonsCrudPage />} />
|
||||||
|
|||||||
@@ -35,6 +35,20 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
|||||||
subtitle: "Dashboard summary and key metrics",
|
subtitle: "Dashboard summary and key metrics",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/routes",
|
||||||
|
meta: {
|
||||||
|
title: "Routes",
|
||||||
|
subtitle: "Manage route definitions built from freight yards",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/locomotives",
|
||||||
|
meta: {
|
||||||
|
title: "Locomotives",
|
||||||
|
subtitle: "Manage locomotive master data and service status",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
prefix: "/dashboard/user-management/employees",
|
prefix: "/dashboard/user-management/employees",
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ export const URL_CONSTANTS = {
|
|||||||
|
|
||||||
LOCOMOTIVES: {
|
LOCOMOTIVES: {
|
||||||
BASE: "/locomotives",
|
BASE: "/locomotives",
|
||||||
|
BY_ID: (id: string) => `/locomotives/${id}`,
|
||||||
|
DECOMMISSION: (id: string) => `/locomotives/${id}/decommission`,
|
||||||
|
},
|
||||||
|
|
||||||
|
ROUTES: {
|
||||||
|
BASE: '/routes',
|
||||||
|
BY_ID: (id: string) => `/routes/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
|
|||||||
47
apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts
Normal file
47
apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { locomotivesService } from '@/services/locomotives.service';
|
||||||
|
|
||||||
|
export const locomotiveKeys = {
|
||||||
|
all: ['locomotives'] as const,
|
||||||
|
details: () => [...locomotiveKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...locomotiveKeys.details(), id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useLocomotives() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: locomotiveKeys.all,
|
||||||
|
queryFn: () => locomotivesService.getAll().then((response) => response.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateLocomotive() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: locomotivesService.create,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: locomotiveKeys.all }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateLocomotive() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
|
locomotivesService.update(id, data),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: locomotiveKeys.all });
|
||||||
|
qc.invalidateQueries({ queryKey: locomotiveKeys.detail(id) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDecommissionLocomotive() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: locomotivesService.decommission,
|
||||||
|
onSuccess: (_, id) => {
|
||||||
|
qc.invalidateQueries({ queryKey: locomotiveKeys.all });
|
||||||
|
qc.invalidateQueries({ queryKey: locomotiveKeys.detail(id) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
55
apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts
Normal file
55
apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { routesService } from '@/services/routes.service';
|
||||||
|
|
||||||
|
export const routeKeys = {
|
||||||
|
all: ['routes'] as const,
|
||||||
|
yards: ['routes', 'yards'] as const,
|
||||||
|
details: () => [...routeKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...routeKeys.details(), id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useRoutes() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: routeKeys.all,
|
||||||
|
queryFn: () => routesService.getAll().then((response) => response.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRouteYards() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: routeKeys.yards,
|
||||||
|
queryFn: () => routesService.getYards().then((response) => response.data.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRoute() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: routesService.create,
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: routeKeys.all }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRoute() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
|
routesService.update(id, data),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: routeKeys.all });
|
||||||
|
qc.invalidateQueries({ queryKey: routeKeys.detail(id) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeactivateRoute() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: routesService.deactivate,
|
||||||
|
onSuccess: (_, id) => {
|
||||||
|
qc.invalidateQueries({ queryKey: routeKeys.all });
|
||||||
|
qc.invalidateQueries({ queryKey: routeKeys.detail(id) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -27,8 +27,15 @@ import {
|
|||||||
} from '@/hooks/useContainers';
|
} from '@/hooks/useContainers';
|
||||||
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
|
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
|
||||||
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
|
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
|
||||||
|
import {
|
||||||
|
useCreateLocomotive,
|
||||||
|
useDecommissionLocomotive,
|
||||||
|
useLocomotives,
|
||||||
|
useUpdateLocomotive,
|
||||||
|
} from '@/hooks/useLocomotives';
|
||||||
import type { Cargo } from '@/services/cargoService';
|
import type { Cargo } from '@/services/cargoService';
|
||||||
import type { Container } from '@/services/containerService';
|
import type { Container } from '@/services/containerService';
|
||||||
|
import type { Locomotive } from '@/services/locomotives.service';
|
||||||
import type { Train } from '@/services/trains.service';
|
import type { Train } from '@/services/trains.service';
|
||||||
import type { Wagon } from '@/services/wagon.service';
|
import type { Wagon } from '@/services/wagon.service';
|
||||||
|
|
||||||
@@ -57,6 +64,7 @@ type FleetCrudPageProps<T extends { id: string }> = {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
addLabel: string;
|
addLabel: string;
|
||||||
|
entityLabel?: string;
|
||||||
data?: T[];
|
data?: T[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
columns: Column<T>[];
|
columns: Column<T>[];
|
||||||
@@ -66,6 +74,10 @@ type FleetCrudPageProps<T extends { id: string }> = {
|
|||||||
create: { mutateAsync: (data: Record<string, unknown>) => Promise<unknown>; isPending: boolean };
|
create: { mutateAsync: (data: Record<string, unknown>) => Promise<unknown>; isPending: boolean };
|
||||||
update: { mutateAsync: (data: { id: string; data: Record<string, unknown> }) => Promise<unknown>; isPending: boolean };
|
update: { mutateAsync: (data: { id: string; data: Record<string, unknown> }) => Promise<unknown>; isPending: boolean };
|
||||||
remove: { mutateAsync: (id: string) => Promise<unknown>; isPending: boolean };
|
remove: { mutateAsync: (id: string) => Promise<unknown>; isPending: boolean };
|
||||||
|
removeActionLabel?: string;
|
||||||
|
removeConfirmMessage?: string;
|
||||||
|
removeSuccessMessage?: string;
|
||||||
|
hideViewAction?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizePayload = (values: Record<string, FormValue>) =>
|
const normalizePayload = (values: Record<string, FormValue>) =>
|
||||||
@@ -121,6 +133,7 @@ function FleetCrudPage<T extends { id: string }>({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
addLabel,
|
addLabel,
|
||||||
|
entityLabel,
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
columns,
|
columns,
|
||||||
@@ -130,6 +143,10 @@ function FleetCrudPage<T extends { id: string }>({
|
|||||||
create,
|
create,
|
||||||
update,
|
update,
|
||||||
remove,
|
remove,
|
||||||
|
removeActionLabel = 'Delete',
|
||||||
|
removeConfirmMessage,
|
||||||
|
removeSuccessMessage,
|
||||||
|
hideViewAction = false,
|
||||||
}: FleetCrudPageProps<T>) {
|
}: FleetCrudPageProps<T>) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -228,12 +245,13 @@ function FleetCrudPage<T extends { id: string }>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (item: T) => {
|
const handleDelete = async (item: T) => {
|
||||||
if (!window.confirm(`Delete this ${title.slice(0, -1).toLowerCase()}?`)) return;
|
const normalizedEntityLabel = entityLabel ?? title.slice(0, -1);
|
||||||
|
if (!window.confirm(removeConfirmMessage ?? `${removeActionLabel} this ${normalizedEntityLabel.toLowerCase()}?`)) return;
|
||||||
try {
|
try {
|
||||||
await remove.mutateAsync(item.id);
|
await remove.mutateAsync(item.id);
|
||||||
toast({ title: `${title.slice(0, -1)} deleted` });
|
toast({ title: removeSuccessMessage ?? `${normalizedEntityLabel} ${removeActionLabel.toLowerCase()}ed` });
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Delete failed', description: 'This record may still be referenced.', variant: 'destructive' });
|
toast({ title: `${removeActionLabel} failed`, description: 'This record may still be referenced.', variant: 'destructive' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -294,13 +312,15 @@ function FleetCrudPage<T extends { id: string }>({
|
|||||||
))}
|
))}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
|
{!hideViewAction ? (
|
||||||
<Eye className="size-4" />
|
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
|
||||||
</Button>
|
<Eye className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button variant="ghost" size="icon" onClick={() => openEdit(item)} title="Edit">
|
<Button variant="ghost" size="icon" onClick={() => openEdit(item)} title="Edit">
|
||||||
<Edit className="size-4" />
|
<Edit className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(item)} title="Delete">
|
<Button variant="ghost" size="icon" onClick={() => handleDelete(item)} title={removeActionLabel}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -631,3 +651,82 @@ export function CargoesCrudPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LocomotivesCrudPage() {
|
||||||
|
const query = useLocomotives();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FleetCrudPage<Locomotive>
|
||||||
|
title="Locomotives"
|
||||||
|
entityLabel="Locomotive"
|
||||||
|
description="Manage locomotive master data used by train scheduling and fleet operations."
|
||||||
|
addLabel="Add Locomotive"
|
||||||
|
data={query.data}
|
||||||
|
isLoading={query.isLoading}
|
||||||
|
create={useCreateLocomotive()}
|
||||||
|
update={useUpdateLocomotive()}
|
||||||
|
remove={useDecommissionLocomotive()}
|
||||||
|
removeActionLabel="Decommission"
|
||||||
|
removeConfirmMessage="Decommission this locomotive?"
|
||||||
|
removeSuccessMessage="Locomotive decommissioned"
|
||||||
|
searchText={(locomotive) =>
|
||||||
|
[
|
||||||
|
locomotive.code,
|
||||||
|
locomotive.name,
|
||||||
|
locomotive.locomotiveType,
|
||||||
|
locomotive.status,
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
columns={[
|
||||||
|
{ key: 'code', label: 'Code' },
|
||||||
|
{ key: 'name', label: 'Name', render: (locomotive) => locomotive.name || '-' },
|
||||||
|
{ key: 'locomotiveType', label: 'Type' },
|
||||||
|
{ key: 'status', label: 'Status', render: (locomotive) => statusBadge(locomotive.status) },
|
||||||
|
{ key: 'maxPullWeightTons', label: 'Max pull (tons)' },
|
||||||
|
{ key: 'maxTrainLengthMeters', label: 'Max length (m)' },
|
||||||
|
]}
|
||||||
|
fields={[
|
||||||
|
{ key: 'code', label: 'Code', required: true },
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{
|
||||||
|
key: 'locomotiveType',
|
||||||
|
label: 'Locomotive type',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: 'DIESEL', label: 'Diesel' },
|
||||||
|
{ value: 'ELECTRIC', label: 'Electric' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: 'AVAILABLE', label: 'Available' },
|
||||||
|
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
||||||
|
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||||
|
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
|
||||||
|
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },
|
||||||
|
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
|
||||||
|
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
|
||||||
|
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
|
||||||
|
]}
|
||||||
|
emptyValues={{
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
locomotiveType: 'DIESEL',
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
maxPullWeightTons: 0,
|
||||||
|
maxTrainLengthMeters: 760,
|
||||||
|
powerKw: '',
|
||||||
|
tractionForceKn: '',
|
||||||
|
maxSpeedKmh: '',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
379
apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx
Normal file
379
apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { FormEvent, useMemo, useState } from 'react';
|
||||||
|
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { useCreateRoute, useDeactivateRoute, useRouteYards, useRoutes, useUpdateRoute } from '@/hooks/useRoutes';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import type { RouteRecord, YardRef } from '@/services/routes.service';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
|
||||||
|
|
||||||
|
type RouteFormState = {
|
||||||
|
name: string;
|
||||||
|
milestones: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm = (): RouteFormState => ({ name: '', milestones: ['', ''] });
|
||||||
|
|
||||||
|
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : '-');
|
||||||
|
|
||||||
|
const routeStops = (route: RouteRecord) =>
|
||||||
|
(route.milestones ?? [])
|
||||||
|
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
||||||
|
.map((milestone) => milestone.yard?.label ?? milestone.yard?.code ?? milestone.yardId);
|
||||||
|
|
||||||
|
const normalizeRouteError = (error: unknown) => {
|
||||||
|
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||||
|
const data = responseData && typeof responseData === 'object' ? (responseData as Record<string, unknown>) : undefined;
|
||||||
|
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
||||||
|
|
||||||
|
return Array.isArray(rawMessage)
|
||||||
|
? rawMessage.join(', ')
|
||||||
|
: rawMessage
|
||||||
|
? String(rawMessage)
|
||||||
|
: 'Save failed';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RoutesPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [viewing, setViewing] = useState<RouteRecord | null>(null);
|
||||||
|
const [editing, setEditing] = useState<RouteRecord | null>(null);
|
||||||
|
const [form, setForm] = useState<RouteFormState>(emptyForm());
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const routesQuery = useRoutes();
|
||||||
|
const yardsQuery = useRouteYards();
|
||||||
|
const createMutation = useCreateRoute();
|
||||||
|
const updateMutation = useUpdateRoute();
|
||||||
|
const deactivateMutation = useDeactivateRoute();
|
||||||
|
|
||||||
|
const filteredRoutes = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
if (!query) return routesQuery.data ?? [];
|
||||||
|
|
||||||
|
return (routesQuery.data ?? []).filter((route) => {
|
||||||
|
const searchable = [
|
||||||
|
route.name,
|
||||||
|
route.originYard?.label,
|
||||||
|
route.originYard?.code,
|
||||||
|
route.destinationYard?.label,
|
||||||
|
route.destinationYard?.code,
|
||||||
|
...routeStops(route),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return searchable.includes(query);
|
||||||
|
});
|
||||||
|
}, [routesQuery.data, search]);
|
||||||
|
|
||||||
|
const yardOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(yardsQuery.data ?? []).map((yard) => ({
|
||||||
|
value: yard.id,
|
||||||
|
label: `${yard.label} (${yard.code})`,
|
||||||
|
})),
|
||||||
|
[yardsQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (route: RouteRecord) => {
|
||||||
|
setEditing(route);
|
||||||
|
setForm({
|
||||||
|
name: route.name,
|
||||||
|
milestones: (route.milestones ?? [])
|
||||||
|
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
||||||
|
.map((milestone) => milestone.yardId),
|
||||||
|
});
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMilestone = (index: number, yardId: string) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
milestones: current.milestones.map((value, currentIndex) =>
|
||||||
|
currentIndex === index ? yardId : value,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMilestone = () => {
|
||||||
|
setForm((current) => ({ ...current, milestones: [...current.milestones, ''] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMilestone = (index: number) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
milestones: current.milestones.filter((_, currentIndex) => currentIndex !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
toast({ title: 'Save failed', description: 'Route name is required', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.milestones.length < 2 || form.milestones.some((yardId) => !yardId)) {
|
||||||
|
toast({
|
||||||
|
title: 'Save failed',
|
||||||
|
description: 'Select at least an origin and destination yard',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
milestones: form.milestones.map((yardId) => ({ yardId })),
|
||||||
|
isActive: editing?.isActive ?? true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
await updateMutation.mutateAsync({ id: editing.id, data: payload });
|
||||||
|
toast({ title: 'Route updated' });
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(payload);
|
||||||
|
toast({ title: 'Route created' });
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm();
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: 'Save failed', description: normalizeRouteError(error), variant: 'destructive' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivate = async (route: RouteRecord) => {
|
||||||
|
if (!window.confirm('Deactivate this route?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deactivateMutation.mutateAsync(route.id);
|
||||||
|
toast({ title: 'Route deactivated' });
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Deactivate failed', description: 'Could not deactivate route', variant: 'destructive' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
const availableOptionsForIndex = (index: number) => {
|
||||||
|
const selectedByOthers = new Set(
|
||||||
|
form.milestones.filter((value, currentIndex) => currentIndex !== index && value),
|
||||||
|
);
|
||||||
|
|
||||||
|
return yardOptions.filter(
|
||||||
|
(option) => option.value === form.milestones[index] || !selectedByOthers.has(option.value),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 p-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Routes</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Build train routes from an ordered yard list where the first stop is the origin and the last stop is the destination.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add Route
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex max-w-md items-center gap-2 rounded-md border bg-background px-3">
|
||||||
|
<Search className="size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
className="border-0 px-0 shadow-none focus-visible:ring-0"
|
||||||
|
placeholder="Search routes"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Origin</TableHead>
|
||||||
|
<TableHead>Destination</TableHead>
|
||||||
|
<TableHead>Milestones</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="w-[150px] text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredRoutes.map((route) => (
|
||||||
|
<TableRow key={route.id}>
|
||||||
|
<TableCell>{route.name}</TableCell>
|
||||||
|
<TableCell>{yardLabel(route.originYard)}</TableCell>
|
||||||
|
<TableCell>{yardLabel(route.destinationYard)}</TableCell>
|
||||||
|
<TableCell>{Math.max((route.milestones?.length ?? 0) - 2, 0)}</TableCell>
|
||||||
|
<TableCell>{route.isActive ? 'Active' : 'Inactive'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setViewing(route)} title="View">
|
||||||
|
<Eye className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => openEdit(route)} title="Edit">
|
||||||
|
<Edit className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeactivate(route)}
|
||||||
|
title="Deactivate"
|
||||||
|
disabled={!route.isActive || deactivateMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!routesQuery.isLoading && filteredRoutes.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-28 text-center text-muted-foreground">
|
||||||
|
No routes found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
{routesQuery.isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-28 text-center text-muted-foreground">
|
||||||
|
Loading...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={formOpen} onOpenChange={(open) => (!open ? resetForm() : setFormOpen(true))}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editing ? 'Edit Route' : 'Add Route'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="route-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="route-name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Stops</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addMilestone}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add next milestone
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{form.milestones.map((yardId, index) => {
|
||||||
|
const role = index === 0 ? 'Origin' : index === form.milestones.length - 1 ? 'Destination' : 'Milestone';
|
||||||
|
const availableOptions = availableOptionsForIndex(index);
|
||||||
|
return (
|
||||||
|
<div key={`${role}-${index}`} className="grid gap-2 rounded-lg border p-3 sm:grid-cols-[120px,1fr,auto] sm:items-center">
|
||||||
|
<p className="text-sm font-medium">{role}</p>
|
||||||
|
<Select value={yardId} onValueChange={(value) => setMilestone(index, value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select yard" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeMilestone(index)}
|
||||||
|
disabled={form.milestones.length <= 2}
|
||||||
|
title="Remove stop"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={resetForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSaving}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(viewing)} onOpenChange={(open) => (!open ? setViewing(null) : null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Route details</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{viewing ? (
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Name</p>
|
||||||
|
<p className="text-muted-foreground">{viewing.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Status</p>
|
||||||
|
<p className="text-muted-foreground">{viewing.isActive ? 'Active' : 'Inactive'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Stops</p>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{routeStops(viewing).map((stop, index, stops) => (
|
||||||
|
<div key={`${stop}-${index}`} className="rounded-md border px-3 py-2 text-muted-foreground">
|
||||||
|
{index === 0 ? 'Origin' : index === stops.length - 1 ? 'Destination' : `Milestone ${index}`}:
|
||||||
|
{' '}
|
||||||
|
{stop}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { api as apiClient } from '../auth/http';
|
||||||
|
|
||||||
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
|
|
||||||
|
export type LocomotiveType = 'DIESEL' | 'ELECTRIC';
|
||||||
|
export type LocomotiveStatus =
|
||||||
|
| 'AVAILABLE'
|
||||||
|
| 'MAINTENANCE'
|
||||||
|
| 'ASSIGNED'
|
||||||
|
| 'OUT_OF_SERVICE';
|
||||||
|
|
||||||
|
export interface Locomotive {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name?: string | null;
|
||||||
|
locomotiveType: LocomotiveType;
|
||||||
|
status: LocomotiveStatus;
|
||||||
|
maxPullWeightTons: number;
|
||||||
|
maxTrainLengthMeters: number;
|
||||||
|
powerKw?: number | null;
|
||||||
|
tractionForceKn?: number | null;
|
||||||
|
maxSpeedKmh?: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SaveLocomotivePayload = Omit<
|
||||||
|
Locomotive,
|
||||||
|
'id' | 'createdAt' | 'updatedAt'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const locomotivesService = {
|
||||||
|
getAll: () => apiClient.get<Locomotive[]>(URL_CONSTANTS.LOCOMOTIVES.BASE),
|
||||||
|
getById: (id: string) => apiClient.get<Locomotive>(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)),
|
||||||
|
create: (data: Partial<SaveLocomotivePayload>) =>
|
||||||
|
apiClient.post(URL_CONSTANTS.LOCOMOTIVES.BASE, data),
|
||||||
|
update: (id: string, data: Partial<SaveLocomotivePayload>) =>
|
||||||
|
apiClient.patch(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id), data),
|
||||||
|
decommission: (id: string) => apiClient.post(URL_CONSTANTS.LOCOMOTIVES.DECOMMISSION(id), {}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { api as apiClient } from '../auth/http';
|
||||||
|
|
||||||
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
|
|
||||||
|
export interface YardRef {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteMilestone {
|
||||||
|
id: string;
|
||||||
|
routeId: string;
|
||||||
|
yardId: string;
|
||||||
|
sequenceNo: number;
|
||||||
|
yard?: YardRef | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
originYardId: string;
|
||||||
|
destinationYardId: string;
|
||||||
|
isActive: boolean;
|
||||||
|
originYard?: YardRef | null;
|
||||||
|
destinationYard?: YardRef | null;
|
||||||
|
milestones?: RouteMilestone[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveRoutePayload {
|
||||||
|
name: string;
|
||||||
|
milestones: Array<{ yardId: string }>;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YardListResponse {
|
||||||
|
data: YardRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routesService = {
|
||||||
|
getAll: () => apiClient.get<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE),
|
||||||
|
getById: (id: string) => apiClient.get<RouteRecord>(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
||||||
|
create: (data: SaveRoutePayload) => apiClient.post(URL_CONSTANTS.ROUTES.BASE, data),
|
||||||
|
update: (id: string, data: Partial<SaveRoutePayload>) =>
|
||||||
|
apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data),
|
||||||
|
deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
||||||
|
getYards: () =>
|
||||||
|
apiClient.get<YardListResponse>(URL_CONSTANTS.RULE_ENGINE.YARDS, {
|
||||||
|
params: { isActive: true, pageSize: 200 },
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -56,8 +56,9 @@ export interface LocomotiveRecord {
|
|||||||
code: string;
|
code: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
maxPullWeightTons: number;
|
maxPullWeightTons: number;
|
||||||
status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'INACTIVE';
|
maxTrainLengthMeters: number;
|
||||||
availableFrom?: string | null;
|
status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'OUT_OF_SERVICE';
|
||||||
|
locomotiveType?: 'DIESEL' | 'ELECTRIC';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrainScheduleListItem {
|
export interface TrainScheduleListItem {
|
||||||
@@ -100,13 +101,14 @@ export interface TrainScheduleDetail {
|
|||||||
wagonCount: number;
|
wagonCount: number;
|
||||||
totalWeightTons: number;
|
totalWeightTons: number;
|
||||||
totalLengthMeters: number;
|
totalLengthMeters: number;
|
||||||
locomotive?: {
|
locomotive?: {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
maxPullWeightTons: number;
|
maxPullWeightTons: number;
|
||||||
} | null;
|
maxTrainLengthMeters?: number;
|
||||||
|
} | null;
|
||||||
wagons: Array<{
|
wagons: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
sequenceNo: number;
|
sequenceNo: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user