Merge pull request #109 from Tria-plc/freight/feature/add_train_routing

Freight/feature/add train routing
This commit is contained in:
Michael Abebe
2026-06-06 16:31:45 +03:00
committed by GitHub
36 changed files with 1825 additions and 794 deletions

View File

@@ -47,6 +47,7 @@ import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module';
import { ContainersModule } from './modules/container-management/containers.module';
import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
@Module({
imports: [
@@ -98,6 +99,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
WagonsModule,
ContainersModule,
CargoesModule,
RoutesModule,
],
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
})

View File

@@ -1,14 +1,15 @@
// apps/edr-freight-api/src/data-source.ts
import 'dotenv/config';
import { DataSource } from 'typeorm';
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
export const AppDataSource = new DataSource({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'postgres',
password: '', // Laragon default: empty
database: 'edr_freight',
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 5432),
username: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'edr_freight',
schema: 'freight', // default schema for entities without an explicit schema
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],

View File

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

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRouteToTrainSchedules1750300000000 implements MigrationInterface {
name = 'AddRouteToTrainSchedules1750300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS route_id UUID NULL;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_train_schedules_route'
) THEN
ALTER TABLE freight.train_schedules
ADD CONSTRAINT fk_train_schedules_route
FOREIGN KEY (route_id) REFERENCES freight.routes(id);
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_schedules_route_id
ON freight.train_schedules(route_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_route_id;`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP CONSTRAINT IF EXISTS fk_train_schedules_route;
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS route_id;
`);
}
}

View File

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

View File

@@ -1,11 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
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 {
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
@IsOptional()
@IsIn([...LOCOMOTIVE_STATUSES])
status?: string;
@ApiPropertyOptional({ enum: LOCOMOTIVE_TYPES })
@IsOptional()
@IsIn([...LOCOMOTIVE_TYPES])
locomotiveType?: string;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger';
import { CreateLocomotiveDto } from './create-locomotive.dto';
export class UpdateLocomotiveDto extends PartialType(CreateLocomotiveDto) {}

View File

@@ -7,10 +7,13 @@ export const LOCOMOTIVE_STATUSES = [
'AVAILABLE',
'ASSIGNED',
'MAINTENANCE',
'INACTIVE',
'OUT_OF_SERVICE',
] as const;
export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const;
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
@Entity({ schema: 'freight', name: 'locomotives' })
@Index(['code'])
@@ -22,14 +25,26 @@ export class Locomotive extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
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 })
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' })
status!: LocomotiveStatus;
@Column({ name: 'available_from', type: 'timestamptz', nullable: true })
availableFrom?: Date | null;
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
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)
trainSets?: TrainSet[];

View File

@@ -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 { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
import { LocomotivesService } from './locomotives.service';
@ApiTags('locomotives')
@@ -15,4 +17,28 @@ export class LocomotivesController {
findAll(@Query() filter: FilterLocomotivesDto) {
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);
}
}

View File

@@ -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 { 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';
@Injectable()
@@ -10,13 +12,36 @@ export class LocomotivesService {
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
return this.locomotivesRepository.findAll({
where: filter.status
? { status: filter.status as LocomotiveStatus }
: undefined,
where: {
...(filter.status ? { status: filter.status as LocomotiveStatus } : {}),
...(filter.locomotiveType
? { locomotiveType: filter.locomotiveType as LocomotiveType }
: {}),
},
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> {
const locomotive = await this.locomotivesRepository.findById(id);
@@ -26,4 +51,48 @@ export class LocomotivesService {
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;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger';
import { CreateRouteDto } from './create-route.dto';
export class UpdateRouteDto extends PartialType(CreateRouteDto) {}

View File

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

View File

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

View 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 { RouteMilestone } from './entities/route-milestone.entity';
@Injectable()
export class RouteMilestonesRepository extends BaseRepository<RouteMilestone> {
constructor(@InjectRepository(RouteMilestone) repository: Repository<RouteMilestone>) {
super(repository);
}
}

View 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);
}
}

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

View 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);
}
}

View 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,
};
}
}

View File

@@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Route } from '../../routes/entities/route.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from './train-schedule-booking.entity';
@@ -26,6 +27,13 @@ export class TrainSchedule extends BaseEntity {
@JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet;
@Column({ name: 'route_id', type: 'uuid', nullable: true })
routeId?: string | null;
@ManyToOne(() => Route)
@JoinColumn({ name: 'route_id' })
route?: Route | null;
@Column({ name: 'origin_station_id', type: 'uuid' })
originStationId!: string;

View File

@@ -1,9 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
import { IsDateString, IsUUID } from 'class-validator';
import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto';
export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
routeId!: string;
@ApiProperty({ example: '2026-06-20T08:00:00.000Z' })
@IsDateString()
scheduleDate!: string;
export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
locomotiveId!: string;

View File

@@ -17,6 +17,7 @@ const locomotive = {
id: 'loc-1',
code: 'LOC-001',
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
status: 'AVAILABLE',
};
@@ -202,47 +203,12 @@ describe('TrainSchedulingService', () => {
});
it('creates a schedule transactionally when validation passes', async () => {
const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')];
const validation = {
valid: true,
violations: [],
bookings,
wagonType: nw5,
summary: {
totalBookings: 1,
totalWeightTons: 140,
wagonType: 'NW5',
wagonsNeeded: 2,
totalLengthMeters: 28,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
{
sequenceNo: 2,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
],
const route = {
id: 'route-1',
name: 'Djibouti to Addis',
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
isActive: true,
};
const lockedLocomotiveRepo = {
@@ -253,23 +219,6 @@ describe('TrainSchedulingService', () => {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
};
const trainScheduleBookingRepo = {
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetWagonRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
find: jest.fn().mockResolvedValue([
{ id: 'wagon-1', sequenceNo: 1 },
{ id: 'wagon-2', sequenceNo: 2 },
]),
};
const wagonAllocRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
@@ -281,12 +230,6 @@ describe('TrainSchedulingService', () => {
return lockedLocomotiveRepo;
case 'TrainSchedule':
return trainScheduleRepo;
case 'TrainScheduleBooking':
return trainScheduleBookingRepo;
case 'TrainSetWagon':
return trainSetWagonRepo;
case 'WagonBookingAllocation':
return wagonAllocRepo;
case 'TrainSet':
return trainSetRepo;
default:
@@ -295,70 +238,60 @@ describe('TrainSchedulingService', () => {
}),
};
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
return { findOne: jest.fn().mockResolvedValue(route) };
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
const result = await service.createContainerTrainSchedule({
bookingIds: ['b1'],
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1',
});
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(trainSetWagonRepo.save).toHaveBeenCalled();
expect(wagonAllocRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(result).toEqual({ id: 'schedule-1' });
});
it('rejects create when the locked locomotive is no longer available', async () => {
const validation = {
valid: true,
violations: [],
bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')],
wagonType: nw5,
summary: {
totalBookings: 1,
totalWeightTons: 70,
wagonType: 'NW5',
wagonsNeeded: 1,
totalLengthMeters: 14,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [],
},
],
};
const manager = {
getRepository: jest.fn(() => ({
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
})),
};
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
return {
findOne: jest.fn().mockResolvedValue({
id: 'route-1',
name: 'Djibouti to Addis',
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
isActive: true,
}),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
await expect(
service.createContainerTrainSchedule({
bookingIds: ['b1'],
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1',
}),
).rejects.toBeInstanceOf(ConflictException);

View File

@@ -15,9 +15,9 @@ import {
import { LocomotivesRepository } from "../locomotives/locomotives.repository";
import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity";
import { TrainSet } from "../train-sets/entities/train-set.entity";
import { Route } from "../routes/entities/route.entity";
import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
import { WagonTypesRepository } from "../wagon-types/wagon-types.repository";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
@@ -179,18 +179,12 @@ export class TrainSchedulingService {
}
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const validation = await this.validateContainerBookingsForScheduling(dto);
if (!validation.valid) {
throw new BadRequestException({
message: "train_schedule_invalid",
violations: validation.violations,
});
}
const route = await this.getActiveRoute(dto.routeId);
const locomotive = await this.selectOrValidateLocomotive(
dto.locomotiveId,
validation.summary.totalWeightTons,
0,
0,
);
const createdSchedule = await this.dataSource.transaction(
@@ -211,90 +205,24 @@ export class TrainSchedulingService {
);
}
if (
Number(lockedLocomotive.maxPullWeightTons) <
validation.summary.totalWeightTons
) {
throw new BadRequestException(
`Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
);
}
const existingScheduleCount = await manager
.getRepository(TrainScheduleBooking)
.count({
where: {
bookingId: In(validation.bookings.map((booking) => booking.id)),
},
});
if (existingScheduleCount > 0) {
throw new BadRequestException(
"One or more bookings are already scheduled",
);
}
const trainSet = await this.buildTrainSet(
const trainSet = await this.buildEmptyTrainSet(
manager,
lockedLocomotive,
validation.wagonType,
validation.summary.totalWeightTons,
validation.summary.totalLengthMeters,
validation.wagonPlan,
);
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
routeId: route.id,
originStationId: route.originYardId,
destinationStationId: route.destinationYardId,
scheduledDepartureDate: new Date(dto.scheduleDate),
status: "SCHEDULED",
status: "DRAFT",
});
const savedSchedule = await manager
.getRepository(TrainSchedule)
.save(schedule);
const scheduleBookings = validation.bookings.map((booking) =>
manager.getRepository(TrainScheduleBooking).create({
trainScheduleId: savedSchedule.id,
bookingId: booking.id,
}),
);
await manager
.getRepository(TrainScheduleBooking)
.save(scheduleBookings);
const savedWagons = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: trainSet.id },
order: { sequenceNo: "ASC" },
});
const wagonBySequence = new Map(
savedWagons.map((wagon) => [wagon.sequenceNo, wagon]),
);
const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => {
const wagon = wagonBySequence.get(wagonPlan.sequenceNo);
if (!wagon) {
throw new BadRequestException(
`Missing wagon sequence ${wagonPlan.sequenceNo}`,
);
}
return wagonPlan.allocations.map((allocation) =>
manager.getRepository(WagonBookingAllocation).create({
trainSetWagonId: wagon.id,
bookingId: allocation.bookingId,
allocatedWeightTons: allocation.allocatedWeightTons,
}),
);
});
await manager
.getRepository(WagonBookingAllocation)
.save(allocationRows);
await locomotiveRepository.update(lockedLocomotive.id, {
status: "ASSIGNED",
});
@@ -461,10 +389,14 @@ export class TrainSchedulingService {
where: { status: "AVAILABLE" },
});
const canPull = capableLocomotives.some(
(locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons,
(locomotive) =>
Number(locomotive.maxPullWeightTons) >= totalWeightTons &&
Number(locomotive.maxTrainLengthMeters) >= totalLengthMeters,
);
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 +445,7 @@ export class TrainSchedulingService {
async selectOrValidateLocomotive(
locomotiveId: string,
totalWeightTons: number,
totalLengthMeters: number,
) {
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
@@ -532,6 +465,12 @@ export class TrainSchedulingService {
);
}
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
);
}
return locomotive;
}
@@ -568,6 +507,21 @@ export class TrainSchedulingService {
return savedTrainSet;
}
async buildEmptyTrainSet(
manager: EntityManager,
locomotive: Locomotive,
) {
const trainSet = manager.getRepository(TrainSet).create({
locomotiveId: locomotive.id,
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
return manager.getRepository(TrainSet).save(trainSet);
}
allocateBookingsToWagons(
bookings: Booking[],
baseWagonPlan: WagonPlanRecord[],
@@ -627,6 +581,7 @@ export class TrainSchedulingService {
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
relations: {
trainSet: { locomotive: true },
route: true,
originStation: true,
destinationStation: true,
scheduleBookings: true,
@@ -637,6 +592,7 @@ export class TrainSchedulingService {
return schedules.map((schedule) => ({
id: schedule.id,
scheduleDate: schedule.scheduledDepartureDate,
routeName: schedule.route?.name ?? null,
origin:
schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination:
@@ -668,6 +624,7 @@ export class TrainSchedulingService {
.findOne({
where: { id },
relations: {
route: true,
trainSet: {
locomotive: true,
wagons: { wagonType: true, allocations: { booking: true } },
@@ -687,6 +644,12 @@ export class TrainSchedulingService {
return {
id: schedule.id,
status: schedule.status,
route: schedule.route
? {
id: schedule.route.id,
name: schedule.route.name,
}
: null,
scheduledDepartureDate: schedule.scheduledDepartureDate,
scheduledArrivalDate: schedule.scheduledArrivalDate,
originStation: schedule.originStation,
@@ -711,6 +674,9 @@ export class TrainSchedulingService {
maxPullWeightTons: this.roundTons(
Number(schedule.trainSet.locomotive.maxPullWeightTons),
),
maxTrainLengthMeters: this.roundTons(
Number(schedule.trainSet.locomotive.maxTrainLengthMeters),
),
}
: null,
wagons: [...(schedule.trainSet.wagons ?? [])]
@@ -806,6 +772,22 @@ export class TrainSchedulingService {
});
}
private async getActiveRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId },
});
if (!route) {
throw new NotFoundException(`Route ${routeId} not found`);
}
if (!route.isActive) {
throw new BadRequestException(`Route ${route.name} is inactive`);
}
return route;
}
private toUtcDateKey(value: Date | string) {
const date = value instanceof Date ? value : new Date(value);
return date.toISOString().slice(0, 10);

View File

@@ -178,13 +178,17 @@ export class DemoBookingsSeeder {
{
code: "LOC-001",
name: "Demo Locomotive 1",
locomotiveType: 'ELECTRIC',
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
status: "AVAILABLE",
},
{
code: "LOC-002",
name: "Demo Locomotive 2",
locomotiveType: 'DIESEL',
maxPullWeightTons: 2500,
maxTrainLengthMeters: 760,
status: "AVAILABLE",
},
],

View File

@@ -38,11 +38,13 @@ import TrainsPage from "./pages/trains/TrainsPage";
import {
CargoesCrudPage,
ContainersCrudPage,
LocomotivesCrudPage,
TrainMasterDataPage,
WagonsCrudPage,
} from "./pages/fleet/FleetCrudPages";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import RoutesPage from "./pages/fleet/RoutesPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -59,17 +61,32 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/booking-requests",
icon: <FileText />,
},
...demoItems,
],
},
{
label: "Train scheduling",
title: "Operations",
items: [
{
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling",
icon: <Train />,
},
...demoItems,
],
},
{
title: "Fleet Management",
items: [
{
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
},
{
label: "Trains",
href: "/dashboard/trains",
@@ -224,6 +241,8 @@ const App = () => {
element={<BookingContractPage />}
/>
<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/:id" element={<TrainDetailPage />} />
<Route path="wagons" element={<WagonsCrudPage />} />

View File

@@ -35,6 +35,20 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
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",
meta: {

View File

@@ -111,6 +111,13 @@ export const URL_CONSTANTS = {
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: {

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

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

View File

@@ -27,8 +27,15 @@ import {
} from '@/hooks/useContainers';
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
import {
useCreateLocomotive,
useDecommissionLocomotive,
useLocomotives,
useUpdateLocomotive,
} from '@/hooks/useLocomotives';
import type { Cargo } from '@/services/cargoService';
import type { Container } from '@/services/containerService';
import type { Locomotive } from '@/services/locomotives.service';
import type { Train } from '@/services/trains.service';
import type { Wagon } from '@/services/wagon.service';
@@ -57,6 +64,7 @@ type FleetCrudPageProps<T extends { id: string }> = {
title: string;
description: string;
addLabel: string;
entityLabel?: string;
data?: T[];
isLoading: boolean;
columns: Column<T>[];
@@ -66,6 +74,10 @@ type FleetCrudPageProps<T extends { id: string }> = {
create: { mutateAsync: (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 };
removeActionLabel?: string;
removeConfirmMessage?: string;
removeSuccessMessage?: string;
hideViewAction?: boolean;
};
const normalizePayload = (values: Record<string, FormValue>) =>
@@ -121,6 +133,7 @@ function FleetCrudPage<T extends { id: string }>({
title,
description,
addLabel,
entityLabel,
data,
isLoading,
columns,
@@ -130,6 +143,10 @@ function FleetCrudPage<T extends { id: string }>({
create,
update,
remove,
removeActionLabel = 'Delete',
removeConfirmMessage,
removeSuccessMessage,
hideViewAction = false,
}: FleetCrudPageProps<T>) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
@@ -228,12 +245,13 @@ function FleetCrudPage<T extends { id: string }>({
};
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 {
await remove.mutateAsync(item.id);
toast({ title: `${title.slice(0, -1)} deleted` });
toast({ title: removeSuccessMessage ?? `${normalizedEntityLabel} ${removeActionLabel.toLowerCase()}ed` });
} 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>
<div className="flex justify-end gap-1">
{!hideViewAction ? (
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
<Eye className="size-4" />
</Button>
) : null}
<Button variant="ghost" size="icon" onClick={() => openEdit(item)} title="Edit">
<Edit className="size-4" />
</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" />
</Button>
</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: '',
}}
/>
);
}

View 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>
);
}

View File

@@ -19,13 +19,8 @@ import {
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { useRoutes } from '@/hooks/useRoutes';
import { trainSchedulingService } from '@/services/trainScheduling.service';
import type {
EligibleContainerBooking,
TrainScheduleFilters,
TrainSchedulePreviewResponse,
YardOption,
} from '@/types/trainScheduling';
const inputClassName =
'w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950';
@@ -43,11 +38,6 @@ const formatDate = (value?: string | null) => {
}).format(date);
};
const formatDayInput = (value?: string | null) => {
if (!value) return '';
return value.slice(0, 10);
};
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
@@ -59,62 +49,39 @@ const parseError = (error: unknown, fallback: string) => {
return fallback;
};
const deriveFromBooking = (
booking: EligibleContainerBooking | undefined,
stations: YardOption[],
) => {
if (!booking) {
return { originStationId: '', destinationStationId: '', scheduleDate: '' };
}
const originStationId = stations.find((station) => station.name === booking.origin)?.id ?? '';
const destinationStationId =
stations.find((station) => station.name === booking.destination)?.id ?? '';
return {
originStationId,
destinationStationId,
scheduleDate: formatDayInput(booking.preferredDepartureDate),
};
};
const TrainsPage = () => {
const qc = useQueryClient();
const [filters, setFilters] = useState<TrainScheduleFilters>({});
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
const [preview, setPreview] = useState<TrainSchedulePreviewResponse | null>(null);
const [routeId, setRouteId] = useState('');
const [scheduleDate, setScheduleDate] = useState('');
const [selectedLocomotiveId, setSelectedLocomotiveId] = useState('');
const [detailId, setDetailId] = useState<string | null>(null);
const [scheduleSearch, setScheduleSearch] = useState('');
const [scheduleStatusFilter, setScheduleStatusFilter] = useState('ALL');
const stationsQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.stations(),
queryFn: () => trainSchedulingService.getStations(),
});
const eligibleQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.eligible(filters),
queryFn: () => trainSchedulingService.getEligibleBookings(filters),
});
const routesQuery = useRoutes();
const locomotivesQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
});
const schedulesQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
queryFn: () => trainSchedulingService.listSchedules(),
});
const detailQuery = useQuery({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ''),
queryFn: () => trainSchedulingService.getScheduleById(detailId!),
enabled: Boolean(detailId),
});
const eligibleItems = eligibleQuery.data?.items ?? [];
const activeRoutes = useMemo(
() => (routesQuery.data ?? []).filter((route) => route.isActive),
[routesQuery.data],
);
const selectedRoute = activeRoutes.find((route) => route.id === routeId) ?? null;
const selectedLocomotive = (locomotivesQuery.data ?? []).find(
(locomotive) => locomotive.id === selectedLocomotiveId,
);
const filteredSchedules = useMemo(() => {
const query = scheduleSearch.trim().toLowerCase();
@@ -132,6 +99,7 @@ const TrainsPage = () => {
const haystack = [
schedule.id,
schedule.routeName ?? '',
schedule.origin ?? '',
schedule.destination ?? '',
schedule.locomotive?.code ?? '',
@@ -143,70 +111,24 @@ const TrainsPage = () => {
return haystack.includes(query);
});
}, [scheduleSearch, scheduleStatusFilter, schedulesQuery.data]);
const selectedBookings = useMemo(
() => eligibleItems.filter((booking) => selectedBookingIds.includes(booking.id)),
[eligibleItems, selectedBookingIds],
);
const summary = useMemo(() => {
const totalWeightTons = selectedBookings.reduce((sum, booking) => sum + booking.weightTons, 0);
const wagonsNeeded = Math.ceil(totalWeightTons / 70);
const totalLengthMeters = wagonsNeeded * 14;
const routeSet = new Set(selectedBookings.map((booking) => `${booking.origin} -> ${booking.destination}`));
const dateSet = new Set(selectedBookings.map((booking) => formatDayInput(booking.preferredDepartureDate)));
return {
count: selectedBookings.length,
totalWeightTons,
wagonsNeeded: Number.isFinite(wagonsNeeded) ? wagonsNeeded : 0,
totalLengthMeters: Number.isFinite(totalLengthMeters) ? totalLengthMeters : 0,
route: routeSet.size === 1 ? [...routeSet][0] : selectedBookings.length ? 'Mixed route' : '-',
scheduleDate: dateSet.size === 1 ? [...dateSet][0] : selectedBookings.length ? 'Mixed date' : '-',
};
}, [selectedBookings]);
const previewMutation = useMutation({
mutationFn: () => {
if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) {
throw new Error('Please select origin, destination, and schedule date');
}
return trainSchedulingService.preview({
bookingIds: selectedBookingIds,
scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(),
originStationId: filters.originStationId,
destinationStationId: filters.destinationStationId,
});
},
onSuccess: (data) => {
setPreview(data);
toast.success(data.valid ? 'Preview generated' : 'Preview has validation issues');
},
onError: (error) => {
toast.error(parseError(error, 'Failed to preview train schedule'));
},
});
const createMutation = useMutation({
mutationFn: () => {
if (!selectedLocomotiveId) {
throw new Error('Please select a locomotive');
}
if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) {
throw new Error('Please select origin, destination, and schedule date');
if (!routeId || !scheduleDate || !selectedLocomotiveId) {
throw new Error('Please select route, departure date, and locomotive');
}
return trainSchedulingService.createSchedule({
bookingIds: selectedBookingIds,
scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(),
originStationId: filters.originStationId,
destinationStationId: filters.destinationStationId,
routeId,
scheduleDate: new Date(`${scheduleDate}T08:00:00.000Z`).toISOString(),
locomotiveId: selectedLocomotiveId,
});
},
onSuccess: (data) => {
toast.success('Train schedule created');
setSelectedBookingIds([]);
setRouteId('');
setScheduleDate('');
setSelectedLocomotiveId('');
setPreview(null);
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
@@ -232,32 +154,12 @@ const TrainsPage = () => {
},
});
const toggleBooking = (booking: EligibleContainerBooking, checked: boolean) => {
setSelectedBookingIds((current) => {
if (checked) {
const next = [...new Set([...current, booking.id])];
if (next.length === 1) {
const defaults = deriveFromBooking(booking, stationsQuery.data ?? []);
setFilters((prev) => ({
...prev,
originStationId: prev.originStationId || defaults.originStationId,
destinationStationId: prev.destinationStationId || defaults.destinationStationId,
scheduleDate: prev.scheduleDate || defaults.scheduleDate,
}));
}
return next;
}
return current.filter((id) => id !== booking.id);
});
setPreview(null);
};
const detail = detailQuery.data;
const isBusy = previewMutation.isPending || createMutation.isPending;
const isBusy = createMutation.isPending;
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[{ label: 'Operations' }, { label: 'Train scheduling' }]} />
<Breadcrumbs items={[{ label: 'Operations' }, { label: 'Train schedules' }]} />
<section className="overflow-hidden rounded-3xl border border-border bg-card shadow-sm">
<div className="flex flex-col gap-5 border-b border-border px-6 py-6 lg:flex-row lg:items-center lg:justify-between">
@@ -266,9 +168,9 @@ const TrainsPage = () => {
<TrainTrack className="size-7" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Train Scheduling</h1>
<h1 className="text-2xl font-bold tracking-tight">Train Schedules</h1>
<p className="mt-1 text-sm text-muted-foreground">
Build container train schedules from compatible bookings, preview wagon plans, and assign locomotives.
Create the train schedule first, reserve the locomotive, and assign bookings and wagons later.
</p>
</div>
</div>
@@ -276,7 +178,7 @@ const TrainsPage = () => {
variant="outline"
className="gap-2"
onClick={() => {
void eligibleQuery.refetch();
void routesQuery.refetch();
void schedulesQuery.refetch();
void locomotivesQuery.refetch();
}}
@@ -286,184 +188,39 @@ const TrainsPage = () => {
</Button>
</div>
<div className="grid gap-6 p-6 xl:grid-cols-[1.8fr,1fr]">
<div className="space-y-6">
<div className="grid gap-6 p-6 xl:grid-cols-[1.1fr,1.4fr]">
<section className="rounded-2xl border border-border bg-background/60 p-5">
<div className="mb-4 flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Filters
</h2>
<h2 className="text-lg font-semibold">Schedule builder</h2>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Origin station</label>
<Select
value={filters.originStationId ?? ''}
onValueChange={(value) =>
setFilters((current) => ({
...current,
originStationId: value === '__all__' ? undefined : value,
}))
}
>
<label className="text-sm font-medium">Route</label>
<Select value={routeId} onValueChange={setRouteId}>
<SelectTrigger>
<SelectValue placeholder="All origins" />
<SelectValue placeholder="Select active route" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All origins</SelectItem>
{(stationsQuery.data ?? []).map((station) => (
<SelectItem key={station.id} value={station.id}>
{station.name}
{activeRoutes.map((route) => (
<SelectItem key={route.id} value={route.id}>
{route.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Destination station</label>
<Select
value={filters.destinationStationId ?? ''}
onValueChange={(value) =>
setFilters((current) => ({
...current,
destinationStationId: value === '__all__' ? undefined : value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="All destinations" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All destinations</SelectItem>
{(stationsQuery.data ?? []).map((station) => (
<SelectItem key={station.id} value={station.id}>
{station.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Schedule date</label>
<label className="text-sm font-medium">Departure date</label>
<input
className={inputClassName}
type="date"
value={filters.scheduleDate ?? ''}
onChange={(event) =>
setFilters((current) => ({
...current,
scheduleDate: event.target.value || undefined,
}))
}
value={scheduleDate}
onChange={(event) => setScheduleDate(event.target.value)}
/>
</div>
</div>
</section>
<section className="rounded-2xl border border-border bg-background/60 p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Eligible container bookings</h2>
<p className="text-sm text-muted-foreground">
Only paid container bookings not already assigned to a schedule appear here.
</p>
</div>
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{eligibleQuery.data?.count ?? 0} bookings
</span>
</div>
<div className="overflow-x-auto rounded-2xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-3">Select</th>
<th className="px-3 py-3">Booking</th>
<th className="px-3 py-3">Customer</th>
<th className="px-3 py-3">Container</th>
<th className="px-3 py-3">Qty</th>
<th className="px-3 py-3">Weight</th>
<th className="px-3 py-3">Origin</th>
<th className="px-3 py-3">Destination</th>
<th className="px-3 py-3">Departure</th>
<th className="px-3 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{eligibleItems.map((booking) => (
<tr key={booking.id} className="hover:bg-muted/20">
<td className="px-3 py-3">
<input
type="checkbox"
checked={selectedBookingIds.includes(booking.id)}
onChange={(event) => toggleBooking(booking, event.target.checked)}
/>
</td>
<td className="px-3 py-3 font-medium">{booking.reference}</td>
<td className="px-3 py-3">{booking.customer}</td>
<td className="px-3 py-3">{booking.containerType}</td>
<td className="px-3 py-3">{booking.quantity}</td>
<td className="px-3 py-3">{booking.weightTons.toLocaleString()} T</td>
<td className="px-3 py-3">{booking.origin}</td>
<td className="px-3 py-3">{booking.destination}</td>
<td className="px-3 py-3">{formatDate(booking.preferredDepartureDate)}</td>
<td className="px-3 py-3">{booking.status}</td>
</tr>
))}
{!eligibleQuery.isLoading && eligibleItems.length === 0 ? (
<tr>
<td className="px-3 py-8 text-center text-sm text-muted-foreground" colSpan={10}>
No eligible container bookings matched the current filters.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
</div>
<div className="space-y-6">
<section className="rounded-2xl border border-border bg-background/60 p-5">
<h2 className="text-lg font-semibold">Schedule builder</h2>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Selected bookings</p>
<p className="mt-2 text-2xl font-semibold">{summary.count}</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Total weight</p>
<p className="mt-2 text-2xl font-semibold">{summary.totalWeightTons.toLocaleString()} T</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Route</p>
<p className="mt-2 text-sm font-medium">{summary.route}</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Schedule date</p>
<p className="mt-2 text-sm font-medium">{summary.scheduleDate}</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Estimated wagon type</p>
<p className="mt-2 text-sm font-medium">NW5</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Estimated wagons / length</p>
<p className="mt-2 text-sm font-medium">
{summary.wagonsNeeded} wagons / {summary.totalLengthMeters} m
</p>
</div>
</div>
<div className="mt-5 flex flex-col gap-3">
<Button
className="w-full"
disabled={!selectedBookingIds.length || isBusy}
onClick={() => previewMutation.mutate()}
>
Preview schedule
</Button>
<div className="space-y-2">
<label className="text-sm font-medium">Locomotive</label>
@@ -474,70 +231,55 @@ const TrainsPage = () => {
<SelectContent>
{(locomotivesQuery.data ?? []).map((locomotive) => (
<SelectItem key={locomotive.id} value={locomotive.id}>
{locomotive.code} - {locomotive.maxPullWeightTons}T
{locomotive.code} - {locomotive.maxPullWeightTons}T / {locomotive.maxTrainLengthMeters}m
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
className="w-full"
disabled={!preview?.valid || !selectedLocomotiveId || isBusy}
onClick={() => createMutation.mutate()}
>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Origin</p>
<p className="mt-2 text-sm font-medium">
{selectedRoute?.originYard?.label ?? selectedRoute?.originYard?.code ?? '-'}
</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Destination</p>
<p className="mt-2 text-sm font-medium">
{selectedRoute?.destinationYard?.label ?? selectedRoute?.destinationYard?.code ?? '-'}
</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Locomotive capacity</p>
<p className="mt-2 text-sm font-medium">
{selectedLocomotive
? `${selectedLocomotive.maxPullWeightTons}T / ${selectedLocomotive.maxTrainLengthMeters}m`
: '-'}
</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Next step</p>
<p className="mt-2 text-sm font-medium">Assign bookings, then allocate wagons</p>
</div>
</div>
<div className="mt-5">
<Button className="w-full" disabled={isBusy} onClick={() => createMutation.mutate()}>
Create schedule
</Button>
</div>
{preview ? (
<div className="mt-5 space-y-4 rounded-2xl border border-border bg-card p-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold">Preview result</h3>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
preview.valid
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
: 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300'
}`}
>
{preview.valid ? 'Valid' : 'Invalid'}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-border p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Wagons</p>
<p className="mt-1 font-semibold">{preview.summary.wagonsNeeded}</p>
</div>
<div className="rounded-xl border border-border p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Weight</p>
<p className="mt-1 font-semibold">{preview.summary.totalWeightTons} T</p>
</div>
<div className="rounded-xl border border-border p-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Length</p>
<p className="mt-1 font-semibold">{preview.summary.totalLengthMeters} m</p>
</div>
</div>
{preview.violations.length > 0 ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700 dark:border-rose-950 dark:bg-rose-950/30 dark:text-rose-300">
<ul className="list-disc space-y-1 pl-5">
{preview.violations.map((violation) => (
<li key={violation}>{violation}</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
</section>
<section className="rounded-2xl border border-border bg-background/60 p-5">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Created schedules</h2>
<p className="text-sm text-muted-foreground">Open a schedule to inspect wagons and allocations.</p>
<p className="text-sm text-muted-foreground">
Open a schedule to inspect the reserved locomotive and prepare for later booking and wagon work.
</p>
</div>
<span className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{filteredSchedules.length} schedules
@@ -588,7 +330,7 @@ const TrainsPage = () => {
<td className="px-3 py-3 font-mono text-xs">{schedule.id}</td>
<td className="px-3 py-3">{formatDate(schedule.scheduleDate)}</td>
<td className="px-3 py-3">
{schedule.origin} to {schedule.destination}
{schedule.routeName ?? `${schedule.origin ?? '-'} to ${schedule.destination ?? '-'}`}
</td>
<td className="px-3 py-3">{schedule.locomotive?.code ?? '-'}</td>
<td className="px-3 py-3">{schedule.bookingsCount}</td>
@@ -626,7 +368,6 @@ const TrainsPage = () => {
</div>
</section>
</div>
</div>
</section>
<Dialog open={Boolean(detailId)} onOpenChange={(open) => (!open ? setDetailId(null) : null)}>
@@ -634,13 +375,13 @@ const TrainsPage = () => {
<DialogHeader>
<DialogTitle>Train schedule detail</DialogTitle>
<DialogDescription>
Inspect the selected schedule, locomotive, wagons, and booking allocations.
Inspect the selected schedule. Booking assignment and wagon allocation happen after schedule creation.
</DialogDescription>
</DialogHeader>
{detail ? (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Schedule</p>
<p className="mt-2 break-all font-mono text-xs">{detail.id}</p>
@@ -651,6 +392,10 @@ const TrainsPage = () => {
</div>
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Route</p>
<p className="mt-2 text-sm font-medium">{detail.route?.name ?? '-'}</p>
</div>
<div className="rounded-xl border border-border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Origin / destination</p>
<p className="mt-2 text-sm font-medium">
{detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '}
{detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'}
@@ -666,13 +411,16 @@ const TrainsPage = () => {
<h3 className="text-lg font-semibold">Locomotive</h3>
<p className="mt-2 text-sm text-muted-foreground">
{detail.trainSet?.locomotive
? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity)`
? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity / ${detail.trainSet.locomotive.maxTrainLengthMeters ?? 0}m)`
: 'No locomotive attached'}
</p>
</div>
<div className="rounded-2xl border border-border p-4">
<h3 className="text-lg font-semibold">Wagons and allocations</h3>
{(detail.trainSet?.wagons?.length ?? 0) === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">No wagons allocated yet.</p>
) : (
<div className="mt-4 space-y-4">
{(detail.trainSet?.wagons ?? []).map((wagon) => (
<div key={wagon.id} className="rounded-xl border border-border p-4">
@@ -686,31 +434,17 @@ const TrainsPage = () => {
</p>
</div>
</div>
<div className="mt-3 overflow-x-auto rounded-xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2">Booking</th>
<th className="px-3 py-2">Allocated weight</th>
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{wagon.allocations.map((allocation) => (
<tr key={allocation.id}>
<td className="px-3 py-2">{allocation.bookingReference ?? allocation.bookingId}</td>
<td className="px-3 py-2">{allocation.allocatedWeightTons} T</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-2xl border border-border p-4">
<h3 className="text-lg font-semibold">Bookings in schedule</h3>
{detail.bookings.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">No bookings assigned yet.</p>
) : (
<div className="mt-4 overflow-x-auto rounded-xl border border-border">
<table className="min-w-full divide-y divide-border text-sm">
<thead className="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
@@ -733,6 +467,7 @@ const TrainsPage = () => {
</tbody>
</table>
</div>
)}
</div>
</div>
) : (
@@ -743,43 +478,5 @@ const TrainsPage = () => {
</div>
);
};
export default TrainsPage;
// export default function TrainsPage() {
// const { data: trains, isLoading } = useTrains();
// const deleteTrain = useDeleteTrain();
// const [open, setOpen] = useState(false);
// if (isLoading) return <div className="p-8">Loading trains...</div>;
// return (
// <Card>
// <CardHeader className="flex flex-row items-center justify-between">
// <CardTitle>Trains</CardTitle>
// <Dialog open={open} onOpenChange={setOpen}>
// <DialogTrigger asChild><Button size="sm"><Plus className="mr-2 h-4 w-4" />New Train</Button></DialogTrigger>
// <DialogContent><DialogHeader><DialogTitle>Create Train</DialogTitle></DialogHeader><CreateTrainForm onSuccess={() => setOpen(false)} /></DialogContent>
// </Dialog>
// </CardHeader>
// <CardContent>
// <Table>
// <TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Name</TableHead><TableHead>Status</TableHead><TableHead>Capacity</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
// <TableBody>
// {trains?.map(train => (
// <TableRow key={train.id}>
// <TableCell>{train.trainNumber || train.code}</TableCell>
// <TableCell>{train.trainName || '-'}</TableCell>
// <TableCell><Badge variant="outline">{train.status}</Badge></TableCell>
// <TableCell>{train.capacityTons} t</TableCell>
// <TableCell className="flex space-x-2">
// <Link to={`/trains/${train.id}`}><Button variant="ghost" size="icon"><Eye className="h-4 w-4" /></Button></Link>
// <Button variant="ghost" size="icon" onClick={() => deleteTrain.mutate(train.id)}><Trash2 className="h-4 w-4" /></Button>
// </TableCell>
// </TableRow>
// ))}
// </TableBody>
// </Table>
// </CardContent>
// </Card>
// );
// }

View File

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

View File

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

View File

@@ -56,13 +56,15 @@ export interface LocomotiveRecord {
code: string;
name?: string | null;
maxPullWeightTons: number;
status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'INACTIVE';
availableFrom?: string | null;
maxTrainLengthMeters: number;
status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'OUT_OF_SERVICE';
locomotiveType?: 'DIESEL' | 'ELECTRIC';
}
export interface TrainScheduleListItem {
id: string;
scheduleDate: string;
routeName?: string | null;
origin: string | null;
destination: string | null;
locomotive:
@@ -82,6 +84,10 @@ export interface TrainScheduleListItem {
export interface TrainScheduleDetail {
id: string;
status: string;
route?: {
id: string;
name: string;
} | null;
scheduledDepartureDate: string;
scheduledArrivalDate?: string | null;
originStation?: {
@@ -106,6 +112,7 @@ export interface TrainScheduleDetail {
name?: string | null;
status: string;
maxPullWeightTons: number;
maxTrainLengthMeters?: number;
} | null;
wagons: Array<{
id: string;
@@ -148,6 +155,8 @@ export interface TrainSchedulePreviewPayload {
destinationStationId: string;
}
export interface CreateTrainSchedulePayload extends TrainSchedulePreviewPayload {
export interface CreateTrainSchedulePayload {
routeId: string;
scheduleDate: string;
locomotiveId: string;
}