From a9c2f2eb97b743ba903ac010aa4efca146591308 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Sat, 6 Jun 2026 16:23:48 +0300 Subject: [PATCH 1/2] feat(freight): added routes and locomotives --- apps/edr-freight-api/src/app.module.ts | 2 + apps/edr-freight-api/src/data-source.ts | 13 +- ...100000000-AddRoutesAndExtendLocomotives.ts | 87 ++++ .../locomotives/dto/create-locomotive.dto.ts | 59 +++ .../locomotives/dto/filter-locomotives.dto.ts | 7 +- .../locomotives/dto/update-locomotive.dto.ts | 5 + .../locomotives/entities/locomotive.entity.ts | 21 +- .../locomotives/locomotives.controller.ts | 28 +- .../locomotives/locomotives.service.ts | 79 +++- .../modules/routes/dto/create-route.dto.ts | 28 ++ .../modules/routes/dto/filter-routes.dto.ts | 16 + .../modules/routes/dto/update-route.dto.ts | 5 + .../routes/entities/route-milestone.entity.ts | 26 ++ .../modules/routes/entities/route.entity.ts | 33 ++ .../routes/route-milestones.repository.ts | 13 + .../src/modules/routes/routes.controller.ts | 44 ++ .../src/modules/routes/routes.module.ts | 18 + .../src/modules/routes/routes.repository.ts | 13 + .../src/modules/routes/routes.service.ts | 171 ++++++++ .../train-scheduling.service.spec.ts | 1 + .../train-scheduling.service.ts | 28 +- .../src/seed/demo-bookings.seeder.ts | 4 + apps/edr-freight-web/backoffice/src/App.tsx | 79 ++-- .../src/components/layout/route-meta.ts | 14 + .../backoffice/src/constants/URLS.ts | 7 + .../backoffice/src/hooks/useLocomotives.ts | 47 +++ .../backoffice/src/hooks/useRoutes.ts | 55 +++ .../src/pages/fleet/FleetCrudPages.tsx | 113 +++++- .../backoffice/src/pages/fleet/RoutesPage.tsx | 379 ++++++++++++++++++ .../src/services/locomotives.service.ts | 40 ++ .../backoffice/src/services/routes.service.ts | 52 +++ .../backoffice/src/types/trainScheduling.ts | 20 +- 32 files changed, 1443 insertions(+), 64 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/dto/update-locomotive.dto.ts create mode 100644 apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts create mode 100644 apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts create mode 100644 apps/edr-freight-api/src/modules/routes/dto/update-route.dto.ts create mode 100644 apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts create mode 100644 apps/edr-freight-api/src/modules/routes/entities/route.entity.ts create mode 100644 apps/edr-freight-api/src/modules/routes/route-milestones.repository.ts create mode 100644 apps/edr-freight-api/src/modules/routes/routes.controller.ts create mode 100644 apps/edr-freight-api/src/modules/routes/routes.module.ts create mode 100644 apps/edr-freight-api/src/modules/routes/routes.repository.ts create mode 100644 apps/edr-freight-api/src/modules/routes/routes.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/locomotives.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/routes.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 4c57af97a..b6898322b 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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], }) diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts index b35d79932..a29fb861e 100644 --- a/apps/edr-freight-api/src/data-source.ts +++ b/apps/edr-freight-api/src/data-source.ts @@ -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}'], @@ -17,4 +18,4 @@ export const AppDataSource = new DataSource({ }); // Optional: call ensurePostgresSchemas before initializing -// But you can also run it separately. \ No newline at end of file +// But you can also run it separately. diff --git a/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts b/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts new file mode 100644 index 000000000..421f66ef9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRoutesAndExtendLocomotives1750100000000 implements MigrationInterface { + name = 'AddRoutesAndExtendLocomotives1750100000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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'; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts new file mode 100644 index 000000000..1469630ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts index 3ee26beb9..1ea5ef29d 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -1,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; } diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/update-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/update-locomotive.dto.ts new file mode 100644 index 000000000..0f5cd2761 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/dto/update-locomotive.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateLocomotiveDto } from './create-locomotive.dto'; + +export class UpdateLocomotiveDto extends PartialType(CreateLocomotiveDto) {} diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index 1676674b1..2c5aa463a 100644 --- a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -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[]; diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts index 9e64c1e2b..f7ccdde1d 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -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); + } } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index 946a77d48..ac030d5d8 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -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 { 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 { + 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 { const locomotive = await this.locomotivesRepository.findById(id); @@ -26,4 +51,48 @@ export class LocomotivesService { return locomotive; } + + async update(id: string, dto: UpdateLocomotiveDto): Promise { + 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 { + 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; + } } diff --git a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts new file mode 100644 index 000000000..45e737607 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts new file mode 100644 index 000000000..020a34cdf --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/routes/dto/update-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/update-route.dto.ts new file mode 100644 index 000000000..ccda6bd61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/dto/update-route.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateRouteDto } from './create-route.dto'; + +export class UpdateRouteDto extends PartialType(CreateRouteDto) {} diff --git a/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts new file mode 100644 index 000000000..63e37b8ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts new file mode 100644 index 000000000..8c6e4785e --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/routes/route-milestones.repository.ts b/apps/edr-freight-api/src/modules/routes/route-milestones.repository.ts new file mode 100644 index 000000000..a0e97cd23 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/route-milestones.repository.ts @@ -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 { + constructor(@InjectRepository(RouteMilestone) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts new file mode 100644 index 000000000..4af088727 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.module.ts b/apps/edr-freight-api/src/modules/routes/routes.module.ts new file mode 100644 index 000000000..c7033f25b --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/routes/routes.repository.ts b/apps/edr-freight-api/src/modules/routes/routes.repository.ts new file mode 100644 index 000000000..df6df41d0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.repository.ts @@ -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 { + constructor(@InjectRepository(Route) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts new file mode 100644 index 000000000..4c8e62498 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 227b329d0..33ae0b599 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -17,6 +17,7 @@ const locomotive = { id: 'loc-1', code: 'LOC-001', maxPullWeightTons: 3500, + maxTrainLengthMeters: 760, status: 'AVAILABLE', }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index e6b921656..4686d6986 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -191,6 +191,7 @@ export class TrainSchedulingService { const locomotive = await this.selectOrValidateLocomotive( dto.locomotiveId, validation.summary.totalWeightTons, + validation.summary.totalLengthMeters, ); 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 .getRepository(TrainScheduleBooking) .count({ @@ -461,10 +471,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 +527,7 @@ export class TrainSchedulingService { async selectOrValidateLocomotive( locomotiveId: string, totalWeightTons: number, + totalLengthMeters: number, ) { 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; } @@ -711,6 +732,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 ?? [])] diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index c5dfad32c..edf19adbb 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -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", }, ], diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 554411c8d..f0d47b2e3 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -35,14 +35,16 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import TrainsPage from "./pages/trains/TrainsPage"; -import { - CargoesCrudPage, - ContainersCrudPage, - TrainMasterDataPage, - WagonsCrudPage, -} from "./pages/fleet/FleetCrudPages"; -import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; -import TrainDetailPage from "./pages/trains/TrainDetailPage"; +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[] => [ { @@ -54,26 +56,41 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/overview", icon: , }, - { - label: "Booking requests", - href: "/dashboard/booking-requests", - icon: , - }, - { - label: "Train scheduling", - href: "/dashboard/operations/train-scheduling", - icon: , - }, - ...demoItems, - ], - }, - { - title: "Fleet Management", - items: [ - { - label: "Trains", - href: "/dashboard/trains", - icon: , + { + label: "Booking requests", + href: "/dashboard/booking-requests", + icon: , + }, + ...demoItems, + ], + }, + { + title: "Operations", + items: [ + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling", + icon: , + }, + ], + }, + { + title: "Fleet Management", + items: [ + { + label: "Routes", + href: "/dashboard/routes", + icon: , + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + }, + { + label: "Trains", + href: "/dashboard/trains", + icon: , }, { label: "Wagons", @@ -223,8 +240,10 @@ const App = () => { path="booking-requests/:id/contract" element={} /> - } /> - } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index 517ca6468..901b9e94e 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -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: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d0c2a73f7..19056335e 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts b/apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts new file mode 100644 index 000000000..ee50bd59e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts @@ -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 }) => + 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) }); + }, + }); +} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts b/apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts new file mode 100644 index 000000000..3ae4ba924 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts @@ -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 }) => + 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) }); + }, + }); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index 539151fec..ede0da4cc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -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 = { title: string; description: string; addLabel: string; + entityLabel?: string; data?: T[]; isLoading: boolean; columns: Column[]; @@ -66,6 +74,10 @@ type FleetCrudPageProps = { create: { mutateAsync: (data: Record) => Promise; isPending: boolean }; update: { mutateAsync: (data: { id: string; data: Record }) => Promise; isPending: boolean }; remove: { mutateAsync: (id: string) => Promise; isPending: boolean }; + removeActionLabel?: string; + removeConfirmMessage?: string; + removeSuccessMessage?: string; + hideViewAction?: boolean; }; const normalizePayload = (values: Record) => @@ -121,6 +133,7 @@ function FleetCrudPage({ title, description, addLabel, + entityLabel, data, isLoading, columns, @@ -130,6 +143,10 @@ function FleetCrudPage({ create, update, remove, + removeActionLabel = 'Delete', + removeConfirmMessage, + removeSuccessMessage, + hideViewAction = false, }: FleetCrudPageProps) { const [search, setSearch] = useState(''); const [page, setPage] = useState(1); @@ -228,12 +245,13 @@ function FleetCrudPage({ }; 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({ ))}
- + {!hideViewAction ? ( + + ) : null} -
@@ -631,3 +651,82 @@ export function CargoesCrudPage() { /> ); } + +export function LocomotivesCrudPage() { + const query = useLocomotives(); + + return ( + + 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: '', + }} + /> + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx new file mode 100644 index 000000000..7eca14891 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx @@ -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) : 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(null); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(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 ( +
+
+
+

Routes

+

+ Build train routes from an ordered yard list where the first stop is the origin and the last stop is the destination. +

+
+ +
+ +
+ + setSearch(event.target.value)} + /> +
+ +
+ + + + Name + Origin + Destination + Milestones + Status + Actions + + + + {filteredRoutes.map((route) => ( + + {route.name} + {yardLabel(route.originYard)} + {yardLabel(route.destinationYard)} + {Math.max((route.milestones?.length ?? 0) - 2, 0)} + {route.isActive ? 'Active' : 'Inactive'} + +
+ + + +
+
+
+ ))} + {!routesQuery.isLoading && filteredRoutes.length === 0 ? ( + + + No routes found. + + + ) : null} + {routesQuery.isLoading ? ( + + + Loading... + + + ) : null} +
+
+
+ + (!open ? resetForm() : setFormOpen(true))}> + + + {editing ? 'Edit Route' : 'Add Route'} + +
+
+ + setForm((current) => ({ ...current, name: event.target.value }))} + /> +
+ +
+
+ + +
+ {form.milestones.map((yardId, index) => { + const role = index === 0 ? 'Origin' : index === form.milestones.length - 1 ? 'Destination' : 'Milestone'; + const availableOptions = availableOptionsForIndex(index); + return ( +
+

{role}

+ + +
+ ); + })} +
+ + + + + +
+
+
+ + (!open ? setViewing(null) : null)}> + + + Route details + + {viewing ? ( +
+
+

Name

+

{viewing.name}

+
+
+

Status

+

{viewing.isActive ? 'Active' : 'Inactive'}

+
+
+

Stops

+
+ {routeStops(viewing).map((stop, index, stops) => ( +
+ {index === 0 ? 'Origin' : index === stops.length - 1 ? 'Destination' : `Milestone ${index}`}: + {' '} + {stop} +
+ ))} +
+
+
+ ) : null} +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts new file mode 100644 index 000000000..03cffbf5f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts @@ -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(URL_CONSTANTS.LOCOMOTIVES.BASE), + getById: (id: string) => apiClient.get(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)), + create: (data: Partial) => + apiClient.post(URL_CONSTANTS.LOCOMOTIVES.BASE, data), + update: (id: string, data: Partial) => + apiClient.patch(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id), data), + decommission: (id: string) => apiClient.post(URL_CONSTANTS.LOCOMOTIVES.DECOMMISSION(id), {}), +}; diff --git a/apps/edr-freight-web/backoffice/src/services/routes.service.ts b/apps/edr-freight-web/backoffice/src/services/routes.service.ts new file mode 100644 index 000000000..52832913d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/routes.service.ts @@ -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(URL_CONSTANTS.ROUTES.BASE), + getById: (id: string) => apiClient.get(URL_CONSTANTS.ROUTES.BY_ID(id)), + create: (data: SaveRoutePayload) => apiClient.post(URL_CONSTANTS.ROUTES.BASE, data), + update: (id: string, data: Partial) => + apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data), + deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)), + getYards: () => + apiClient.get(URL_CONSTANTS.RULE_ENGINE.YARDS, { + params: { isActive: true, pageSize: 200 }, + }), +}; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 9d14bc7dd..44dae4876 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -56,8 +56,9 @@ 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 { @@ -100,13 +101,14 @@ export interface TrainScheduleDetail { wagonCount: number; totalWeightTons: number; totalLengthMeters: number; - locomotive?: { - id: string; - code: string; - name?: string | null; - status: string; - maxPullWeightTons: number; - } | null; + locomotive?: { + id: string; + code: string; + name?: string | null; + status: string; + maxPullWeightTons: number; + maxTrainLengthMeters?: number; + } | null; wagons: Array<{ id: string; sequenceNo: number; From b45421d2a20f5eebef2200ca8ce0cafdfc96c987 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Sat, 6 Jun 2026 16:31:19 +0300 Subject: [PATCH 2/2] feat(freight): changed the train schedule --- .../1750300000000-AddRouteToTrainSchedules.ts | 44 + .../entities/train-schedule.entity.ts | 8 + .../create-container-train-schedule.dto.ts | 12 +- .../train-scheduling.service.spec.ts | 124 +-- .../train-scheduling.service.ts | 140 ++- .../src/pages/trains/TrainsPage.tsx | 795 ++++++------------ .../backoffice/src/types/trainScheduling.ts | 9 +- 7 files changed, 392 insertions(+), 740 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts diff --git a/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts b/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts new file mode 100644 index 000000000..027ebfe98 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRouteToTrainSchedules1750300000000 implements MigrationInterface { + name = 'AddRouteToTrainSchedules1750300000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 965723f6c..4edd09f09 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 173b0a6b3..1b4fa29d8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 33ae0b599..8f2b77bb4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -203,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 = { @@ -254,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' }), @@ -282,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: @@ -296,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) => 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) => 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); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 4686d6986..64147940d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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,19 +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, - validation.summary.totalLengthMeters, + 0, + 0, ); const createdSchedule = await this.dataSource.transaction( @@ -212,99 +205,24 @@ export class TrainSchedulingService { ); } - if ( - Number(lockedLocomotive.maxPullWeightTons) < - validation.summary.totalWeightTons - ) { - throw new BadRequestException( - `Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`, - ); - } - - if ( - Number(lockedLocomotive.maxTrainLengthMeters) < - validation.summary.totalLengthMeters - ) { - throw new BadRequestException( - `Locomotive ${lockedLocomotive.code} cannot support ${validation.summary.totalLengthMeters}m`, - ); - } - - 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", }); @@ -589,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[], @@ -648,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, @@ -658,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: @@ -689,6 +624,7 @@ export class TrainSchedulingService { .findOne({ where: { id }, relations: { + route: true, trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } }, @@ -708,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, @@ -830,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); diff --git a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx index 581515421..53ee2fa6f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx @@ -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({}); - const [selectedBookingIds, setSelectedBookingIds] = useState([]); - const [preview, setPreview] = useState(null); + const [routeId, setRouteId] = useState(''); + const [scheduleDate, setScheduleDate] = useState(''); const [selectedLocomotiveId, setSelectedLocomotiveId] = useState(''); const [detailId, setDetailId] = useState(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 (
- +
@@ -266,9 +168,9 @@ const TrainsPage = () => {
-

Train Scheduling

+

Train Schedules

- 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.

@@ -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,346 +188,185 @@ const TrainsPage = () => { -
-
-
-
- -

- Filters -

-
-
-
- - -
-
- - -
-
- - - setFilters((current) => ({ - ...current, - scheduleDate: event.target.value || undefined, - })) - } - /> -
-
-
- -
-
-
-

Eligible container bookings

-

- Only paid container bookings not already assigned to a schedule appear here. -

-
- - {eligibleQuery.data?.count ?? 0} bookings - -
- -
- - - - - - - - - - - - - - - - - {eligibleItems.map((booking) => ( - - - - - - - - - - - - - ))} - {!eligibleQuery.isLoading && eligibleItems.length === 0 ? ( - - - - ) : null} - -
SelectBookingCustomerContainerQtyWeightOriginDestinationDepartureStatus
- toggleBooking(booking, event.target.checked)} - /> - {booking.reference}{booking.customer}{booking.containerType}{booking.quantity}{booking.weightTons.toLocaleString()} T{booking.origin}{booking.destination}{formatDate(booking.preferredDepartureDate)}{booking.status}
- No eligible container bookings matched the current filters. -
-
-
-
- -
-
+
+
+
+

Schedule builder

-
-
-

Selected bookings

-

{summary.count}

-
-
-

Total weight

-

{summary.totalWeightTons.toLocaleString()} T

-
-
-

Route

-

{summary.route}

-
-
-

Schedule date

-

{summary.scheduleDate}

-
-
-

Estimated wagon type

-

NW5

-
-
-

Estimated wagons / length

-

- {summary.wagonsNeeded} wagons / {summary.totalLengthMeters} m -

-
-
+
-
- - -
- - -
- - -
- - {preview ? ( -
-
-

Preview result

- - {preview.valid ? 'Valid' : 'Invalid'} - -
- -
-
-

Wagons

-

{preview.summary.wagonsNeeded}

-
-
-

Weight

-

{preview.summary.totalWeightTons} T

-
-
-

Length

-

{preview.summary.totalLengthMeters} m

-
-
- - {preview.violations.length > 0 ? ( -
-
    - {preview.violations.map((violation) => ( -
  • {violation}
  • - ))} -
-
- ) : null} -
- ) : null} -
- -
-
-
-

Created schedules

-

Open a schedule to inspect wagons and allocations.

-
- - {filteredSchedules.length} schedules - -
- -
- setScheduleSearch(event.target.value)} - /> - - + - All statuses - DRAFT - SCHEDULED - DISPATCHED - ARRIVED - CANCELLED + {activeRoutes.map((route) => ( + + {route.name} + + ))}
-
- - - - - - - - - - - - - - - - - {filteredSchedules.map((schedule) => ( - - - - - - - - - - - - - ))} - {!schedulesQuery.isLoading && filteredSchedules.length === 0 ? ( - - - - ) : null} - -
ScheduleDepartureRouteLocomotiveBookingsWagonsWeightLengthStatusActions
{schedule.id}{formatDate(schedule.scheduleDate)} - {schedule.origin} to {schedule.destination} - {schedule.locomotive?.code ?? '-'}{schedule.bookingsCount}{schedule.wagonCount}{schedule.totalWeightTons} T{schedule.totalLengthMeters} m{schedule.status} -
- - {schedule.status !== 'CANCELLED' ? ( - - ) : null} -
-
- No train schedules matched the current filters. -
+
+ + setScheduleDate(event.target.value)} + />
-
-
+ +
+ + +
+
+ +
+
+

Origin

+

+ {selectedRoute?.originYard?.label ?? selectedRoute?.originYard?.code ?? '-'} +

+
+
+

Destination

+

+ {selectedRoute?.destinationYard?.label ?? selectedRoute?.destinationYard?.code ?? '-'} +

+
+
+

Locomotive capacity

+

+ {selectedLocomotive + ? `${selectedLocomotive.maxPullWeightTons}T / ${selectedLocomotive.maxTrainLengthMeters}m` + : '-'} +

+
+
+

Next step

+

Assign bookings, then allocate wagons

+
+
+ +
+ +
+ + +
+
+
+

Created schedules

+

+ Open a schedule to inspect the reserved locomotive and prepare for later booking and wagon work. +

+
+ + {filteredSchedules.length} schedules + +
+ +
+ setScheduleSearch(event.target.value)} + /> + +
+ +
+ + + + + + + + + + + + + + + + + {filteredSchedules.map((schedule) => ( + + + + + + + + + + + + + ))} + {!schedulesQuery.isLoading && filteredSchedules.length === 0 ? ( + + + + ) : null} + +
ScheduleDepartureRouteLocomotiveBookingsWagonsWeightLengthStatusActions
{schedule.id}{formatDate(schedule.scheduleDate)} + {schedule.routeName ?? `${schedule.origin ?? '-'} to ${schedule.destination ?? '-'}`} + {schedule.locomotive?.code ?? '-'}{schedule.bookingsCount}{schedule.wagonCount}{schedule.totalWeightTons} T{schedule.totalLengthMeters} m{schedule.status} +
+ + {schedule.status !== 'CANCELLED' ? ( + + ) : null} +
+
+ No train schedules matched the current filters. +
+
+
@@ -634,13 +375,13 @@ const TrainsPage = () => { Train schedule detail - Inspect the selected schedule, locomotive, wagons, and booking allocations. + Inspect the selected schedule. Booking assignment and wagon allocation happen after schedule creation. {detail ? (
-
+

Schedule

{detail.id}

@@ -651,6 +392,10 @@ const TrainsPage = () => {

Route

+

{detail.route?.name ?? '-'}

+
+
+

Origin / destination

{detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '} {detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'} @@ -666,73 +411,63 @@ const TrainsPage = () => {

Locomotive

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

Wagons and allocations

-
- {(detail.trainSet?.wagons ?? []).map((wagon) => ( -
-
-
-

- Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'} -

-

- {wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m -

+ {(detail.trainSet?.wagons?.length ?? 0) === 0 ? ( +

No wagons allocated yet.

+ ) : ( +
+ {(detail.trainSet?.wagons ?? []).map((wagon) => ( +
+
+
+

+ Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'} +

+

+ {wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m +

+
-
- - - - - - - - - {wagon.allocations.map((allocation) => ( - - - - - ))} - -
BookingAllocated weight
{allocation.bookingReference ?? allocation.bookingId}{allocation.allocatedWeightTons} T
-
-
- ))} -
+ ))} +
+ )}

Bookings in schedule

-
- - - - - - - - - - - {detail.bookings.map((booking) => ( - - - - - + {detail.bookings.length === 0 ? ( +

No bookings assigned yet.

+ ) : ( +
+
ReferenceCustomerWeightStatus
{booking.reference ?? booking.id}{booking.customer ?? '-'}{booking.weightTons} T{booking.status ?? '-'}
+ + + + + + - ))} - -
ReferenceCustomerWeightStatus
-
+ + + {detail.bookings.map((booking) => ( + + {booking.reference ?? booking.id} + {booking.customer ?? '-'} + {booking.weightTons} T + {booking.status ?? '-'} + + ))} + + +
+ )}
) : ( @@ -743,43 +478,5 @@ const TrainsPage = () => {
); }; + export default TrainsPage; - -// export default function TrainsPage() { -// const { data: trains, isLoading } = useTrains(); -// const deleteTrain = useDeleteTrain(); -// const [open, setOpen] = useState(false); - -// if (isLoading) return
Loading trains...
; - -// return ( -// -// -// Trains -// -// -// Create Train setOpen(false)} /> -// -// -// -// -// NumberNameStatusCapacityActions -// -// {trains?.map(train => ( -// -// {train.trainNumber || train.code} -// {train.trainName || '-'} -// {train.status} -// {train.capacityTons} t -// -// -// -// -// -// ))} -// -//
-//
-//
-// ); -// } diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 44dae4876..6595cef1e 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -64,6 +64,7 @@ export interface LocomotiveRecord { export interface TrainScheduleListItem { id: string; scheduleDate: string; + routeName?: string | null; origin: string | null; destination: string | null; locomotive: @@ -83,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?: { @@ -150,6 +155,8 @@ export interface TrainSchedulePreviewPayload { destinationStationId: string; } -export interface CreateTrainSchedulePayload extends TrainSchedulePreviewPayload { +export interface CreateTrainSchedulePayload { + routeId: string; + scheduleDate: string; locomotiveId: string; }