From a9c2f2eb97b743ba903ac010aa4efca146591308 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Sat, 6 Jun 2026 16:23:48 +0300 Subject: [PATCH] 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;