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/main.ts b/apps/edr-freight-api/src/main.ts index f6f32be91..a76378b0c 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -18,6 +18,7 @@ async function bootstrap() { // freight portal (5173), passenger portal (5174), backoffices (5183/5184) // and any other dev port can call the API with cookies + Authorization. // For production, restrict `origin` to known FQDNs. + app.enableCors({ origin: true, // reflect request origin credentials: true, @@ -52,9 +53,12 @@ async function bootstrap() { SwaggerModule.setup("api/docs", app, document); const port = parseInt(process.env.PORT ?? "3001", 10); - await app.listen(port); + // await app.listen(port, "0.0.0.0"); + await app.listen( + + port) // eslint-disable-next-line no-console - console.log(`[freight-api] listening on http://localhost:${port}`); + console.log(`[freight-api] listening on port ${port}`); } bootstrap(); 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/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/bookings/booking-list-tabs.config.ts b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts index ce15125c2..332916727 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts @@ -25,10 +25,10 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{ key: 'approved_contract', statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], }, - { key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] }, + { key: 'payment', statuses: ['FULLY_EXECUTED'] }, { key: 'operations', - statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'], + statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'], }, { key: 'completed', statuses: ['COMPLETED'] }, { key: 'closed', statuses: ['REJECTED', 'CANCELLED'] }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts index c0a865949..c591a1db4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -34,8 +34,8 @@ export function computeNextStep( }; case 'APPROVED': return { - action: 'GENERATE_CONTRACT', - description: 'Generate the contract document', + action: 'CUSTOMER_SIGN', + description: 'Contract generated; customer must sign', }; case 'CONTRACT_READY': return { @@ -49,8 +49,8 @@ export function computeNextStep( }; case 'FULLY_EXECUTED': return { - action: 'PAY', - description: 'Complete in-app payment', + action: 'AWAIT_PAYMENT', + description: 'Awaiting customer payment', }; case 'PAID': return { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 56ab6394b..0fdfc5084 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -185,6 +185,11 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, updates as never); } + if (allDone) { + const generated = await this.contractService.generateContract(bookingId); + return this.bookingsService.findById(generated.id); + } + return this.bookingsService.findById(bookingId); } 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-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 227b329d0..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 @@ -17,6 +17,7 @@ const locomotive = { id: 'loc-1', code: 'LOC-001', maxPullWeightTons: 3500, + maxTrainLengthMeters: 760, status: 'AVAILABLE', }; @@ -202,47 +203,12 @@ describe('TrainSchedulingService', () => { }); it('creates a schedule transactionally when validation passes', async () => { - const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')]; - const validation = { - valid: true, - violations: [], - bookings, - wagonType: nw5, - summary: { - totalBookings: 1, - totalWeightTons: 140, - wagonType: 'NW5', - wagonsNeeded: 2, - totalLengthMeters: 28, - }, - wagonPlan: [ - { - sequenceNo: 1, - capacityTons: 70, - lengthMeters: 14, - assignedWeightTons: 70, - allocations: [ - { - bookingId: 'b1', - bookingReference: 'BKG-CONT-001', - allocatedWeightTons: 70, - }, - ], - }, - { - sequenceNo: 2, - capacityTons: 70, - lengthMeters: 14, - assignedWeightTons: 70, - allocations: [ - { - bookingId: 'b1', - bookingReference: 'BKG-CONT-001', - allocatedWeightTons: 70, - }, - ], - }, - ], + const route = { + id: 'route-1', + name: 'Djibouti to Addis', + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + isActive: true, }; const lockedLocomotiveRepo = { @@ -253,23 +219,6 @@ describe('TrainSchedulingService', () => { create: jest.fn().mockImplementation((value) => value), save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), }; - const trainScheduleBookingRepo = { - count: jest.fn().mockResolvedValue(0), - create: jest.fn().mockImplementation((value) => value), - save: jest.fn().mockResolvedValue(undefined), - }; - const trainSetWagonRepo = { - create: jest.fn().mockImplementation((value) => value), - save: jest.fn().mockResolvedValue(undefined), - find: jest.fn().mockResolvedValue([ - { id: 'wagon-1', sequenceNo: 1 }, - { id: 'wagon-2', sequenceNo: 2 }, - ]), - }; - const wagonAllocRepo = { - create: jest.fn().mockImplementation((value) => value), - save: jest.fn().mockResolvedValue(undefined), - }; const trainSetRepo = { create: jest.fn().mockImplementation((value) => value), save: jest.fn().mockResolvedValue({ id: 'train-set-1' }), @@ -281,12 +230,6 @@ describe('TrainSchedulingService', () => { return lockedLocomotiveRepo; case 'TrainSchedule': return trainScheduleRepo; - case 'TrainScheduleBooking': - return trainScheduleBookingRepo; - case 'TrainSetWagon': - return trainSetWagonRepo; - case 'WagonBookingAllocation': - return wagonAllocRepo; case 'TrainSet': return trainSetRepo; default: @@ -295,70 +238,60 @@ describe('TrainSchedulingService', () => { }), }; - jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never); jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Route') { + return { findOne: jest.fn().mockResolvedValue(route) }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never); dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => 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 e6b921656..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,18 +179,12 @@ export class TrainSchedulingService { } async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { - const validation = await this.validateContainerBookingsForScheduling(dto); - - if (!validation.valid) { - throw new BadRequestException({ - message: "train_schedule_invalid", - violations: validation.violations, - }); - } + const route = await this.getActiveRoute(dto.routeId); const locomotive = await this.selectOrValidateLocomotive( dto.locomotiveId, - validation.summary.totalWeightTons, + 0, + 0, ); const createdSchedule = await this.dataSource.transaction( @@ -211,90 +205,24 @@ export class TrainSchedulingService { ); } - if ( - Number(lockedLocomotive.maxPullWeightTons) < - validation.summary.totalWeightTons - ) { - throw new BadRequestException( - `Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`, - ); - } - - const existingScheduleCount = await manager - .getRepository(TrainScheduleBooking) - .count({ - where: { - bookingId: In(validation.bookings.map((booking) => booking.id)), - }, - }); - - if (existingScheduleCount > 0) { - throw new BadRequestException( - "One or more bookings are already scheduled", - ); - } - - const trainSet = await this.buildTrainSet( + const trainSet = await this.buildEmptyTrainSet( manager, lockedLocomotive, - validation.wagonType, - validation.summary.totalWeightTons, - validation.summary.totalLengthMeters, - validation.wagonPlan, ); const schedule = manager.getRepository(TrainSchedule).create({ trainSetId: trainSet.id, - originStationId: dto.originStationId, - destinationStationId: dto.destinationStationId, + routeId: route.id, + originStationId: route.originYardId, + destinationStationId: route.destinationYardId, scheduledDepartureDate: new Date(dto.scheduleDate), - status: "SCHEDULED", + status: "DRAFT", }); const savedSchedule = await manager .getRepository(TrainSchedule) .save(schedule); - const scheduleBookings = validation.bookings.map((booking) => - manager.getRepository(TrainScheduleBooking).create({ - trainScheduleId: savedSchedule.id, - bookingId: booking.id, - }), - ); - await manager - .getRepository(TrainScheduleBooking) - .save(scheduleBookings); - - const savedWagons = await manager.getRepository(TrainSetWagon).find({ - where: { trainSetId: trainSet.id }, - order: { sequenceNo: "ASC" }, - }); - - const wagonBySequence = new Map( - savedWagons.map((wagon) => [wagon.sequenceNo, wagon]), - ); - const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => { - const wagon = wagonBySequence.get(wagonPlan.sequenceNo); - - if (!wagon) { - throw new BadRequestException( - `Missing wagon sequence ${wagonPlan.sequenceNo}`, - ); - } - - return wagonPlan.allocations.map((allocation) => - manager.getRepository(WagonBookingAllocation).create({ - trainSetWagonId: wagon.id, - bookingId: allocation.bookingId, - allocatedWeightTons: allocation.allocatedWeightTons, - }), - ); - }); - - await manager - .getRepository(WagonBookingAllocation) - .save(allocationRows); - await locomotiveRepository.update(lockedLocomotive.id, { status: "ASSIGNED", }); @@ -461,10 +389,14 @@ export class TrainSchedulingService { where: { status: "AVAILABLE" }, }); const canPull = capableLocomotives.some( - (locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons, + (locomotive) => + Number(locomotive.maxPullWeightTons) >= totalWeightTons && + Number(locomotive.maxTrainLengthMeters) >= totalLengthMeters, ); if (!canPull) { - violations.push("No available locomotive can pull the total weight"); + violations.push( + 'No available locomotive can support the total train weight and length', + ); } } @@ -513,6 +445,7 @@ export class TrainSchedulingService { async selectOrValidateLocomotive( locomotiveId: string, totalWeightTons: number, + totalLengthMeters: number, ) { const locomotive = await this.locomotivesRepository.findById(locomotiveId); @@ -532,6 +465,12 @@ export class TrainSchedulingService { ); } + if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, + ); + } + return locomotive; } @@ -568,6 +507,21 @@ export class TrainSchedulingService { return savedTrainSet; } + async buildEmptyTrainSet( + manager: EntityManager, + locomotive: Locomotive, + ) { + const trainSet = manager.getRepository(TrainSet).create({ + locomotiveId: locomotive.id, + totalWeightTons: 0, + totalLengthMeters: 0, + wagonCount: 0, + status: 'DRAFT', + }); + + return manager.getRepository(TrainSet).save(trainSet); + } + allocateBookingsToWagons( bookings: Booking[], baseWagonPlan: WagonPlanRecord[], @@ -627,6 +581,7 @@ export class TrainSchedulingService { const schedules = await this.dataSource.getRepository(TrainSchedule).find({ relations: { trainSet: { locomotive: true }, + route: true, originStation: true, destinationStation: true, scheduleBookings: true, @@ -637,6 +592,7 @@ export class TrainSchedulingService { return schedules.map((schedule) => ({ id: schedule.id, scheduleDate: schedule.scheduledDepartureDate, + routeName: schedule.route?.name ?? null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: @@ -668,6 +624,7 @@ export class TrainSchedulingService { .findOne({ where: { id }, relations: { + route: true, trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } }, @@ -687,6 +644,12 @@ export class TrainSchedulingService { return { id: schedule.id, status: schedule.status, + route: schedule.route + ? { + id: schedule.route.id, + name: schedule.route.name, + } + : null, scheduledDepartureDate: schedule.scheduledDepartureDate, scheduledArrivalDate: schedule.scheduledArrivalDate, originStation: schedule.originStation, @@ -711,6 +674,9 @@ export class TrainSchedulingService { maxPullWeightTons: this.roundTons( Number(schedule.trainSet.locomotive.maxPullWeightTons), ), + maxTrainLengthMeters: this.roundTons( + Number(schedule.trainSet.locomotive.maxTrainLengthMeters), + ), } : null, wagons: [...(schedule.trainSet.wagons ?? [])] @@ -806,6 +772,22 @@ export class TrainSchedulingService { }); } + private async getActiveRoute(routeId: string) { + const route = await this.dataSource.getRepository(Route).findOne({ + where: { id: routeId }, + }); + + if (!route) { + throw new NotFoundException(`Route ${routeId} not found`); + } + + if (!route.isActive) { + throw new BadRequestException(`Route ${route.name} is inactive`); + } + + return route; + } + private toUtcDateKey(value: Date | string) { const date = value instanceof Date ? value : new Date(value); return date.toISOString().slice(0, 10); diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts index 686336b6d..46134d097 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts @@ -11,6 +11,7 @@ import { Min, } from 'class-validator'; +<<<<<<< HEAD const toNumber = ({ value }: { value: unknown }) => value === '' || value == null ? value : Number(value); @@ -37,10 +38,28 @@ export class CreateWagonTypeDto { code!: string; @ApiProperty({ maxLength: 100, example: 'Flat wagon' }) +======= +const parseLoadTypes = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.map((item) => String(item).trim()).filter(Boolean); + } + if (typeof value === 'string') { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + } + return []; +}; + +export class CreateWagonTypeDto { + @ApiProperty({ description: 'Display name, e.g. "Flat Wagon"', maxLength: 100 }) +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 @IsString() @MaxLength(100) name!: string; +<<<<<<< HEAD @ApiProperty({ example: 60 }) @Transform(toNumber) @IsNumber() @@ -65,11 +84,44 @@ export class CreateWagonTypeDto { @Transform(toStringArray) @IsArray() @IsString({ each: true }) +======= + @ApiProperty({ description: 'Maximum payload capacity in metric tons' }) + @IsNumber() + @Min(0.001) + @Transform(({ value }) => Number(value)) + capacityTons!: number; + + @ApiProperty({ description: 'Wagon length in meters' }) + @IsNumber() + @Min(0.001) + @Transform(({ value }) => Number(value)) + lengthMeters!: number; + + @ApiPropertyOptional({ description: 'Maximum wagons of this type per train' }) + @IsOptional() + @IsInt() + @Min(1) + @Transform(({ value }) => (value === '' || value === null || value === undefined ? undefined : Number(value))) + maxWagonsPerTrain?: number; + + @ApiPropertyOptional({ + description: 'Supported load types, e.g. CONTAINER,BULK', + type: [String], + default: [], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Transform(({ value }) => parseLoadTypes(value)) +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 supportedLoadTypes?: string[]; @ApiPropertyOptional({ default: true }) @IsOptional() +<<<<<<< HEAD @Transform(toBoolean) +======= +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 @IsBoolean() isActive?: boolean; } diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts index 76b52a0aa..5f46300ca 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts @@ -11,17 +11,29 @@ import { Post, Query, } from '@nestjs/common'; +<<<<<<< HEAD import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; import { WagonTypesService } from './wagon-types.service'; import { WagonType } from './entities/wagon-type.entity'; +======= +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 -@ApiTags('Wagon Types') +import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards'; + +import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; +import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; +import { WagonTypesService } from './wagon-types.service'; + +@ApiTags('wagon-types') @Controller('wagon-types') +@ApiBearerAuth() export class WagonTypesController { constructor(private readonly wagonTypesService: WagonTypesService) {} +<<<<<<< HEAD @Post() @ApiOperation({ summary: 'Create a wagon type' }) async create(@Body() dto: CreateWagonTypeDto): Promise { @@ -46,13 +58,51 @@ export class WagonTypesController { @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto, ): Promise { +======= + @Get() + @RuleEngineView('wagon-types') + @ApiOperation({ summary: 'List wagon types' }) + findAll(@Query() query: Record) { + return this.wagonTypesService.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get(':id') + @RuleEngineView('wagon-types') + @ApiOperation({ summary: 'Get a wagon type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonTypesService.findById(id); + } + + @Post() + @RuleEngineManage('wagon-types') + @ApiOperation({ summary: 'Create a wagon type' }) + create(@Body() dto: CreateWagonTypeDto) { + return this.wagonTypesService.create(dto); + } + + @Patch(':id') + @RuleEngineManage('wagon-types') + @ApiOperation({ summary: 'Update a wagon type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) { +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 return this.wagonTypesService.update(id, dto); } @Delete(':id') +<<<<<<< HEAD @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Deactivate a wagon type' }) async remove(@Param('id', ParseUUIDPipe) id: string): Promise { +======= + @RuleEngineManage('wagon-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a wagon type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 return this.wagonTypesService.remove(id); } } diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts index 001ff0212..ce7166e67 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts @@ -13,4 +13,8 @@ export class WagonTypesRepository extends BaseRepository { ) { super(repository); } + + findByCode(code: string): Promise { + return this.repository.findOne({ where: { code } }); + } } diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts index 6f9e5830e..2c2d49747 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -1,6 +1,17 @@ +<<<<<<< HEAD import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { FindOptionsOrder } from 'typeorm'; +======= +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; + +import { generateCode } from '../../common/utils/generate-code.util'; + +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; import { WagonType } from './entities/wagon-type.entity'; @@ -10,6 +21,7 @@ import { WagonTypesRepository } from './wagon-types.repository'; export class WagonTypesService { constructor(private readonly wagonTypesRepository: WagonTypesRepository) {} +<<<<<<< HEAD async create(dto: CreateWagonTypeDto): Promise { const code = dto.code.trim().toUpperCase(); const existing = await this.wagonTypesRepository.findAll({ where: { code } }); @@ -23,7 +35,47 @@ export class WagonTypesService { name: dto.name.trim(), supportedLoadTypes: dto.supportedLoadTypes ?? [], isActive: dto.isActive ?? true, +======= + async findAll(filter: { + isActive?: boolean; + page?: number; + pageSize?: number; + } = {}): Promise<{ + data: WagonType[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) { + where.isActive = filter.isActive; + } + + const [data, total] = await this.wagonTypesRepository.findAndCount({ + where, + order: { code: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 }); + + return { + data, + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + async findById(id: string): Promise { + const wagonType = await this.wagonTypesRepository.findById(id); + if (!wagonType) { + throw new NotFoundException(`Wagon type ${id} not found`); + } + return wagonType; } async findAll(query: Record = {}): Promise { @@ -57,15 +109,14 @@ export class WagonTypesService { } async findByCode(code: string): Promise { - const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } }); - + const wagonType = await this.wagonTypesRepository.findByCode(code); if (!wagonType) { throw new NotFoundException(`Wagon type ${code} not found`); } - return wagonType; } +<<<<<<< HEAD async update(id: string, dto: UpdateWagonTypeDto): Promise { const wagonType = await this.findById(id); const nextCode = dto.code?.trim().toUpperCase(); @@ -87,11 +138,43 @@ export class WagonTypesService { throw new NotFoundException(`Wagon type ${id} not found`); } +======= + async create(dto: CreateWagonTypeDto): Promise { + const code = generateCode(dto.name); + const existing = await this.wagonTypesRepository.findByCode(code); + if (existing) { + throw new ConflictException( + `Wagon type with name "${dto.name}" conflicts with existing code "${code}"`, + ); + } + + return this.wagonTypesRepository.create({ + code, + name: dto.name, + capacityTons: dto.capacityTons, + lengthMeters: dto.lengthMeters, + maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null, + supportedLoadTypes: dto.supportedLoadTypes ?? [], + isActive: dto.isActive ?? true, + }); + } + + async update(id: string, dto: UpdateWagonTypeDto): Promise { + await this.findById(id); + const updated = await this.wagonTypesRepository.update(id, dto); + if (!updated) { + throw new NotFoundException(`Wagon type ${id} not found`); + } +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 return updated; } async remove(id: string): Promise { await this.findById(id); +<<<<<<< HEAD await this.wagonTypesRepository.update(id, { isActive: false }); +======= + await this.wagonTypesRepository.softDelete(id); +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 } } 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-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index dba80d96b..e82032cff 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -10,6 +10,7 @@ export type FreightPermissionSeed = { export const RULE_ENGINE_RESOURCE_SLUGS = [ 'cargo-types', 'container-types', + 'wagon-types', 'service-types', 'yards', 'shipping-lines', @@ -56,6 +57,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ const RULE_ENGINE_PERMISSION_IDS: Record = { 'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' }, 'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' }, + 'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' }, 'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' }, yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' }, 'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' }, diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 671436a73..fb8fbf2e6 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -353,6 +353,8 @@ export class PricingDataSeeder { ): Promise { const effectiveFrom = new Date("2026-01-01"); const now = new Date(); + // await rRepo.createQueryBuilder().delete().execute(); + const rateData = [ { rateType: "CONTAINER_IMPORT", diff --git a/apps/edr-freight-web/backoffice/index.css b/apps/edr-freight-web/backoffice/index.css index d232351ed..8d40838ea 100644 --- a/apps/edr-freight-web/backoffice/index.css +++ b/apps/edr-freight-web/backoffice/index.css @@ -1,6 +1,15 @@ @import "tailwindcss"; @import "@edr/ui-common/theme.css" layer(theme); +:root { + --freight-brand: #15803d; + --freight-brand-dark: #166534; + --freight-brand-light: #22c55e; + --freight-brand-muted: #f0fdf4; + --freight-brand-border: #bbf7d0; + --freight-brand-ring: rgb(21 128 61 / 0.2); +} + html, body, #root { diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 6bf5df01c..13e522996 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -14,6 +14,9 @@ "dependencies": { "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", + "@mantine/core": "^9.3.0", + "@mantine/hooks": "^9.3.0", + "@tabler/icons-react": "^3.44.0", "@hello-pangea/dnd": "^18.0.1", "@tanstack/react-query": "^5.100.11", "@tria-plc/iamui-common": "1.1.2", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index d02e4ff0d..66fee9212 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -36,14 +36,24 @@ import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirec import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import TrainsPage from "./pages/trains/TrainsPage"; import { +<<<<<<< HEAD CargoesCrudPage, ContainersCrudPage, TrainMasterDataPage, WagonTypesCrudPage, WagonsCrudPage, } from "./pages/fleet/FleetCrudPages"; +======= + CargoesCrudPage, + ContainersCrudPage, + LocomotivesCrudPage, + TrainMasterDataPage, + WagonsCrudPage, +} from "./pages/fleet/FleetCrudPages"; +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; +import RoutesPage from "./pages/fleet/RoutesPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -60,17 +70,32 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/booking-requests", icon: , }, + ...demoItems, + ], + }, + { + title: "Operations", + items: [ { - label: "Train scheduling", + label: "Train Schedules", href: "/dashboard/operations/train-scheduling", icon: , }, - ...demoItems, ], }, { title: "Fleet Management", items: [ + { + label: "Routes", + href: "/dashboard/routes", + icon: , + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + }, { label: "Trains", href: "/dashboard/trains", @@ -230,10 +255,20 @@ const App = () => { element={} /> } /> +<<<<<<< HEAD } /> } /> } /> } /> +======= + } /> + } /> + } /> + } /> + } /> + } /> + } /> +>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx index 293c17e2a..25b3816b7 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { Check, ShieldCheck } from "lucide-react"; +import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core"; import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { useAuth } from "@/auth/useAuth"; @@ -11,9 +12,7 @@ import { } from "@/features/bookings/booking-actions.config"; import type { useBookingMutations } from "@/hooks/bookings/useBookings"; import type { BookingApprovalStep, BookingDetail } from "@/types/booking"; -import { bookingGlass, bookingSurface } from "./booking-ui.styles"; -import { Badge, Button } from "@edr/ui-common"; -import { cn } from "@/lib/utils"; +import { SectionCard } from "./detail/SectionCard"; type Mutations = ReturnType; @@ -26,23 +25,16 @@ interface ApprovalStepsCardProps { export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) { const { user } = useAuth(); const [confirmOpen, setConfirmOpen] = useState(false); - const [pendingStep, setPendingStep] = useState( - null, - ); + const [pendingStep, setPendingStep] = useState(null); const steps = useMemo( - () => - [...(booking.approvalSteps ?? [])].sort( - (a, b) => a.stepOrder - b.stepOrder, - ), + () => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder), [booking.approvalSteps], ); const nextPending = getNextPendingApprovalStep(steps); const summary = formatApprovalProgress(booking.status, steps); - const pendingAction = pendingStep - ? buildApproveActionForStep(pendingStep) - : null; + const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null; const openApprove = (step: BookingApprovalStep) => { setPendingStep(step); @@ -62,54 +54,60 @@ export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps ); }; + const subtitle = + summary.detail || + (nextPending + ? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}` + : steps.length + ? "All steps complete" + : "Accept submission to begin"); + return ( <> -
-
-
- -
-
-

- Approval chain -

-

- {summary.detail || - (nextPending - ? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}` - : steps.length - ? "All steps complete" - : "Accept submission to begin")} -

-
-
+ + {steps.filter((s) => s.status === "APPROVED").length}/{steps.length} + + } + > + + {subtitle} + -
- {steps.length === 0 ? ( -

- Use{" "} - - Accept for approval - {" "} - in staff actions to instantiate steps. -

- ) : ( -
    - {steps.map((step) => ( - - ))} -
- )} -
-
+ {steps.length === 0 ? ( + + Use Accept for approval in staff actions to instantiate steps. + + ) : ( + + {steps.map((step) => ( + + ))} + + )} + void; }) { const canApprove = canActOnApprovalStep(user, step, steps); - const statusStyles = + const statusColor = step.status === "APPROVED" - ? "border-emerald-500/25 bg-emerald-500/10 text-black" + ? "green" : step.status === "REJECTED" - ? "bg-red-500/10 text-red-800 dark:text-red-300" + ? "red" : isNext - ? "border-emerald-500/25 bg-emerald-500/10 text-black" - : "bg-muted/40 text-muted-foreground"; + ? "green" + : "gray"; return ( -
  • -
    - + {step.stepOrder} - -
    -

    + + + {step.requiredRole} -

    + {step.remarks && ( -

    + {step.remarks} -

    + )} -
    -
    -
    + + + {canApprove && ( )} - + {step.status} -
    -
  • + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index e4e1d8db9..49950d97b 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -1,10 +1,6 @@ import { useNavigate } from "react-router-dom"; -import { - ChevronRight, - ExternalLink, - Loader2, - MoreHorizontal, -} from "lucide-react"; +import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react"; +import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core"; import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { useBookingActionDialog } from "./useBookingActionDialog"; @@ -16,16 +12,6 @@ import { type BookingActionContext, } from "@/features/bookings/booking-actions.config"; import type { BookingListRow } from "@/types/booking"; -import { cn } from "@/lib/utils"; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@edr/ui-common"; interface BookingActionsMenuProps { row: BookingListRow; @@ -39,7 +25,6 @@ interface BookingActionsMenuProps { export function BookingActionsMenu({ row, variant = "table", - className, onSuppressRowClick, }: BookingActionsMenuProps) { const navigate = useNavigate(); @@ -57,184 +42,179 @@ export function BookingActionsMenu({ const goToContract = () => navigate(`/dashboard/booking-requests/${row.id}/contract`); - const hasMenu = listRowHasActions(row, user); + const handleAction = (action: (typeof actions)[number]) => { + onSuppressRowClick?.(); + if (isContractNavAction(action.id)) { + goToContract(); + } else { + flow.openAction(action); + } + }; + const hasMenu = listRowHasActions(row, user); const primary = actions.find((a) => a.primary) ?? actions[0]; if (!hasMenu && variant === "table") { return ( - + + + ); + } + + // Toolbar: lay every action out as a button row. + if (variant === "toolbar" && actions.length > 0) { + return ( + <> + + {actions.map((action) => { + const Icon = action.icon; + const destructive = action.variant === "destructive"; + return ( + + ); + })} + + + ); } return ( - <> -
    e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - {variant === "table" && primary && ( - + )} + + + + - - {primary.shortLabel} - - )} - - {variant === "toolbar" && actions.length > 0 ? ( -
    - {actions.map((action) => { - const Icon = action.icon; - return ( - - ); - })} -
    - ) : ( - - - - - - + +
    +
    + + + {row.reference} - - - {actions.map((action) => { - const Icon = action.icon; - return ( - { - event.preventDefault(); - onSuppressRowClick?.(); - if (isContractNavAction(action.id)) { - goToContract(); - } else { - flow.openAction(action); - } - }} - > - - {action.label} - - ); - })} - {actions.length > 0 && } - { - event.preventDefault(); - onSuppressRowClick?.(); - navigate(`/dashboard/booking-requests/${row.id}`); - }} - > - - Open full details - - - - )} -
    + + + {actions.map((action) => { + const Icon = action.icon; + return ( + } + onClick={() => handleAction(action)} + > + {action.label} + + ); + })} + {actions.length > 0 && } + } + onClick={() => { + onSuppressRowClick?.(); + navigate(`/dashboard/booking-requests/${row.id}`); + }} + > + Open full details + + + - { - if (!open) onSuppressRowClick?.(); - flow.setDialogOpen(open); - }} - action={pendingAction} - reference={flow.mergedContext.reference} - inputValue={flow.inputValue} - onInputChange={flow.setInputValue} - selectedFile={flow.selectedFile} - onFileChange={flow.setSelectedFile} - onConfirm={() => { - onSuppressRowClick?.(); - flow.runAction(); - }} - isPending={mutations.isPending || flow.detailLoading} - confirmDisabled={flow.confirmDisabled} - extra={ - flow.detailLoading ? ( -

    - - Loading approval steps… -

    - ) : pendingAction?.id === "approve" && - !getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? ( -

    - No pending approval step. Refresh the page after staff accept, or - reject the booking. -

    - ) : null - } - /> - + + + ); +} + +function ActionDialog({ + flow, + pendingAction, + onSuppressRowClick, +}: { + flow: ReturnType; + pendingAction: ReturnType["pendingAction"]; + onSuppressRowClick?: () => void; +}) { + return ( + { + if (!open) onSuppressRowClick?.(); + flow.setDialogOpen(open); + }} + action={pendingAction} + reference={flow.mergedContext.reference} + inputValue={flow.inputValue} + onInputChange={flow.setInputValue} + selectedFile={flow.selectedFile} + onFileChange={flow.setSelectedFile} + onConfirm={() => { + onSuppressRowClick?.(); + flow.runAction(); + }} + isPending={flow.mutations.isPending || flow.detailLoading} + confirmDisabled={flow.confirmDisabled} + extra={ + flow.detailLoading ? ( + + Loading approval steps… + + ) : pendingAction?.id === "approve" && + !getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? ( + + No pending approval step. Refresh the page after staff accept, or reject the + booking. + + ) : null + } + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx index 006c57e11..e451f2d2c 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -1,12 +1,11 @@ -import { Download, Zap } from "lucide-react"; +import { Download, Zap, FileText, Clock } from "lucide-react"; +import { Stack, Text, Button } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; import { BookingActionsMenu } from "./BookingActionsMenu"; -import { bookingSurface } from "./booking-ui.styles"; +import { SectionCard } from "./detail/SectionCard"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import type { useBookingMutations } from "@/hooks/bookings/useBookings"; -import { Button } from "@edr/ui-common"; -import { cn } from "@/lib/utils"; type Mutations = ReturnType; @@ -16,10 +15,7 @@ interface BookingActionsToolbarProps { } /** Detail-page actions: primary toolbar + downloads. */ -export function BookingActionsToolbar({ - booking, - mutations, -}: BookingActionsToolbarProps) { +export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) { const row = toBookingListRow(booking); const { status } = booking; @@ -33,50 +29,84 @@ export function BookingActionsToolbar({ URL.revokeObjectURL(url); }; - if ( - status === "REJECTED" || - status === "CANCELLED" || - status === "COMPLETED" - ) { + if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") { return null; } if (status === "CHANGES_REQUESTED") { return ( - - {booking.latestChangeRequestNote && ( -

    - {booking.latestChangeRequestNote} -

    - )} -
    + + + + No staff actions until resubmit. + + {booking.latestChangeRequestNote && ( + + {booking.latestChangeRequestNote} + + )} + + ); } if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) { return ( - + + + Monitor until the customer or system advances status. + + + ); + } + + if ( + ["FULLY_EXECUTED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS"].includes( + status, + ) + ) { + return ( + + + + + Payment is completed by the customer. The booking status updates + automatically once payment is confirmed, then moves to Operations. + + {status === "FULLY_EXECUTED" && ( + + )} + + + ); } return ( -
    - - - + + + + + Confirm each step before it is applied. + + + + {status === "CONTRACT_READY" && ( - + - + )} -
    - ); -} - -function PanelShell({ - title, - description, - children, - muted, -}: { - title: string; - description: string; - children: React.ReactNode; - muted?: boolean; -}) { - return ( -
    -
    -
    - -
    -
    -

    {title}

    -

    {description}

    -
    -
    -
    {children}
    -
    + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx index 69d01692b..be051371c 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx @@ -14,7 +14,7 @@ export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCell

    {summary.label} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx index 190fe4475..3eae09b49 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx @@ -1,17 +1,16 @@ -import { Loader2 } from "lucide-react"; +import type { ReactNode } from "react"; +import { + Modal, + Group, + Stack, + Text, + Box, + Button, + Textarea, + FileInput, +} from "@mantine/core"; import type { BookingActionDef } from "@/features/bookings/booking-actions.config"; -import { cn } from "@/lib/utils"; -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Textarea, -} from "@edr/ui-common"; interface BookingConfirmDialogProps { open: boolean; @@ -25,7 +24,7 @@ interface BookingConfirmDialogProps { onConfirm: () => void; isPending: boolean; confirmDisabled?: boolean; - extra?: React.ReactNode; + extra?: ReactNode; } export function BookingConfirmDialog({ @@ -45,129 +44,119 @@ export function BookingConfirmDialog({ if (!action || !action.confirmTitle) return null; const Icon = action.icon; - const needsTextInput = - action.input === "note" || action.input === "reason"; + const needsTextInput = action.input === "note" || action.input === "reason"; const needsFileInput = action.input === "file"; const inputMissing = - (needsTextInput && !inputValue.trim()) || - (needsFileInput && !selectedFile); + (needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile); const isDestructive = action.variant === "destructive"; - - const preventClickThrough = (event: React.MouseEvent) => { - event.preventDefault(); - }; + const accent = isDestructive ? "red" : "green"; return ( -

    - event.preventDefault()} + onOpenChange(false)} + withCloseButton={false} + centered + radius="md" + size="md" + padding={0} + title={null} + > + {/* Header */} + -
    - -
    -
    - -
    -
    - - {action.confirmTitle} - - {reference && ( -

    - {reference} -

    - )} -
    -
    - - {action.confirmDescription} - -
    -
    - -
    - {needsTextInput && ( -
    - -