diff --git a/.gitignore b/.gitignore index 13865633d..f4b98ebb1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules/ coverage/ *.tsbuildinfo **/*.tsbuildinfo +**/vite.config.ts.timestamp-*.mjs # env .env diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index eec917c7e..4eb237a39 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -13,6 +13,7 @@ "lint": "eslint src", "test": "jest", "test:e2e": "jest --config ./test/jest-e2e.json", + "seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts", "type-check": "tsc --noEmit" }, "dependencies": { diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 65272e253..0e7375b19 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -17,10 +17,6 @@ import { PositionType, Position, Project, -<<<<<<< HEAD - UnitConfiguration, -======= ->>>>>>> 95fb544ec20f01ec4a2d92f546954a5b4e464a4f GlobalUnitConfiguration, Unit, EmployeeSignature, @@ -67,10 +63,6 @@ const iamEntities = [ PositionType, Position, Project, -<<<<<<< HEAD - UnitConfiguration, -======= ->>>>>>> 95fb544ec20f01ec4a2d92f546954a5b4e464a4f GlobalUnitConfiguration, Unit, EmployeeSignature, diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts index a29fb861e..2ae202ebd 100644 --- a/apps/edr-freight-api/src/data-source.ts +++ b/apps/edr-freight-api/src/data-source.ts @@ -6,7 +6,7 @@ import { DataSource } from 'typeorm'; export const AppDataSource = new DataSource({ type: 'postgres', host: process.env.DB_HOST ?? 'localhost', - port: Number(process.env.DB_PORT ?? 5432), + port: Number(process.env.DB_PORT ?? 5433), username: process.env.DB_USER ?? 'postgres', password: process.env.DB_PASSWORD ?? '', database: process.env.DB_NAME ?? 'edr_freight', @@ -14,7 +14,7 @@ export const AppDataSource = new DataSource({ entities: [__dirname + '/**/*.entity{.ts,.js}'], migrations: [__dirname + '/migrations/*{.ts,.js}'], synchronize: false, - logging: true, + logging: process.env.TYPEORM_LOGGING === 'true', }); // Optional: call ensurePostgresSchemas before initializing diff --git a/apps/edr-freight-api/src/migrations/1750200000000-AddPhysicalWagonToTrainSetWagons.ts b/apps/edr-freight-api/src/migrations/1750200000000-AddPhysicalWagonToTrainSetWagons.ts new file mode 100644 index 000000000..9ce1db6c1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750200000000-AddPhysicalWagonToTrainSetWagons.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPhysicalWagonToTrainSetWagons1750200000000 implements MigrationInterface { + name = 'AddPhysicalWagonToTrainSetWagons1750200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL; + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_schema = 'freight' + AND table_name = 'train_set_wagons' + AND constraint_name = 'fk_train_set_wagons_physical_wagon' + ) THEN + ALTER TABLE freight.train_set_wagons + ADD CONSTRAINT fk_train_set_wagons_physical_wagon + FOREIGN KEY (physical_wagon_id) + REFERENCES freight.wagons(id) + ON DELETE SET NULL; + END IF; + END $$; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon + ON freight.train_set_wagons(physical_wagon_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_set_wagons_physical_wagon;`); + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + DROP CONSTRAINT IF EXISTS fk_train_set_wagons_physical_wagon; + `); + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + DROP COLUMN IF EXISTS physical_wagon_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750300000000-AddCurrentLocationToWagons.ts b/apps/edr-freight-api/src/migrations/1750300000000-AddCurrentLocationToWagons.ts new file mode 100644 index 000000000..454218fd7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750300000000-AddCurrentLocationToWagons.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCurrentLocationToWagons1750300000000 implements MigrationInterface { + name = 'AddCurrentLocationToWagons1750300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS current_location_yard_id UUID NULL; + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_schema = 'freight' + AND table_name = 'wagons' + AND constraint_name = 'FK_wagons_current_location_yard_id' + ) THEN + ALTER TABLE freight.wagons + ADD CONSTRAINT "FK_wagons_current_location_yard_id" + FOREIGN KEY (current_location_yard_id) + REFERENCES freight.yards(id) + ON DELETE SET NULL; + END IF; + END $$; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_wagons_current_location_yard_id" + ON freight.wagons(current_location_yard_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_wagons_current_location_yard_id";`); + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP CONSTRAINT IF EXISTS "FK_wagons_current_location_yard_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS current_location_yard_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750400000000-SeedEdRWagonFleet.ts b/apps/edr-freight-api/src/migrations/1750400000000-SeedEdRWagonFleet.ts new file mode 100644 index 000000000..0a4e96276 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750400000000-SeedEdRWagonFleet.ts @@ -0,0 +1,214 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +type FleetRow = { + code: string; + name: string; + count: number; + start: number; + end: number; + capacityTons: number; + tareWeight: number; + lengthMeters: number; + supportedLoadTypes: string[]; +}; + +const FLEET: FleetRow[] = [ + { + code: 'PW2', + name: 'Box wagon', + count: 220, + start: 1, + end: 220, + capacityTons: 70, + tareWeight: 25.2, + lengthMeters: 17.066, + supportedLoadTypes: ['BULK', 'GENERAL_CARGO', 'BAGGED_CARGO', 'BOXED_CARGO'], + }, + { + code: 'CW4', + name: 'Gondola wagon covered', + count: 110, + start: 221, + end: 330, + capacityTons: 70, + tareWeight: 24.8, + lengthMeters: 13.976, + supportedLoadTypes: ['CONTAINER'], + }, + { + code: 'CW3', + name: 'Gondola wagon', + count: 20, + start: 331, + end: 350, + capacityTons: 70, + tareWeight: 23.4, + lengthMeters: 13.976, + supportedLoadTypes: ['BULK', 'COAL', 'ORE'], + }, + { + code: 'KW2', + name: 'Hopper wagon covered', + count: 20, + start: 351, + end: 370, + capacityTons: 69, + tareWeight: 25.2, + lengthMeters: 16.466, + supportedLoadTypes: ['BULK', 'GRAIN'], + }, + { + code: 'KW3', + name: 'Hopper wagon', + count: 20, + start: 371, + end: 390, + capacityTons: 70, + tareWeight: 24, + lengthMeters: 14.4, + supportedLoadTypes: ['BULK', 'COAL'], + }, + { + code: 'NW5', + name: 'Flat wagon container', + count: 550, + start: 391, + end: 940, + capacityTons: 70, + tareWeight: 0, + lengthMeters: 14, + supportedLoadTypes: ['CONTAINER'], + }, +]; + +const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`; + +export class SeedEdRWagonFleet1750400000000 implements MigrationInterface { + name = 'SeedEdRWagonFleet1750400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagon_types + SET name = 'Flat wagon container', + capacity_tons = 70, + length_meters = 14.000, + supported_load_types = ARRAY['CONTAINER'], + max_wagons_per_train = 53, + is_active = true, + deleted_at = NULL, + updated_at = now() + WHERE code = 'NW5'; + `); + + const [defaultLocation] = await queryRunner.query(` + SELECT id + FROM freight.yards + WHERE code IN ('DJIBOUTI', 'DJIB_PORT', 'NAGAD') + OR lower(label) LIKE '%djibouti%' + ORDER BY + CASE code + WHEN 'DJIBOUTI' THEN 1 + WHEN 'DJIB_PORT' THEN 2 + WHEN 'NAGAD' THEN 3 + ELSE 4 + END, + display_order ASC + LIMIT 1; + `); + const defaultLocationYardId = defaultLocation?.id ?? null; + + for (const row of FLEET) { + await queryRunner.query( + ` + INSERT INTO freight.wagon_types ( + code, + name, + capacity_tons, + length_meters, + max_wagons_per_train, + supported_load_types, + is_active + ) + VALUES ($1, $2, $3, $4, $5, $6::text[], true) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + capacity_tons = EXCLUDED.capacity_tons, + length_meters = EXCLUDED.length_meters, + max_wagons_per_train = EXCLUDED.max_wagons_per_train, + supported_load_types = EXCLUDED.supported_load_types, + is_active = true, + deleted_at = NULL, + updated_at = now(); + `, + [ + row.code, + row.name, + row.capacityTons, + row.lengthMeters, + row.supportedLoadTypes.includes('CONTAINER') ? 53 : 37, + row.supportedLoadTypes, + ], + ); + + const [typeRecord] = await queryRunner.query( + `SELECT id FROM freight.wagon_types WHERE code = $1 LIMIT 1;`, + [row.code], + ); + + if (!typeRecord?.id) { + throw new Error(`wagon_type_seed_failed:${row.code}`); + } + + if (row.end - row.start + 1 !== row.count) { + throw new Error(`wagon_range_mismatch:${row.code}`); + } + + for (let sequence = row.start; sequence <= row.end; sequence += 1) { + await queryRunner.query( + ` + INSERT INTO freight.wagons ( + wagon_number, + wagon_type_id, + tare_weight, + max_payload_weight, + current_location_yard_id, + status, + notes + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (wagon_number) DO UPDATE SET + wagon_type_id = EXCLUDED.wagon_type_id, + tare_weight = EXCLUDED.tare_weight, + max_payload_weight = EXCLUDED.max_payload_weight, + current_location_yard_id = CASE + WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.current_location_yard_id + ELSE freight.wagons.current_location_yard_id + END, + status = CASE + WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.status + ELSE freight.wagons.status + END, + notes = EXCLUDED.notes, + updated_at = now(); + `, + [ + wagonNumber(sequence), + typeRecord.id, + row.tareWeight, + row.capacityTons, + defaultLocationYardId, + defaultLocationYardId ? 'IMPORT_READY' : 'AVAILABLE', + `Seeded Ethio-Djibouti Railway ${row.code} fleet record.`, + ], + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM freight.wagons + WHERE wagon_number BETWEEN 'ER0001' AND 'ER0940'; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index fb3679a60..f0c4ad623 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -21,6 +21,9 @@ export const BOOKING_STATUSES = [ 'PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE', 'APPROVED', + 'READY_FOR_ASSIGNMENT', + 'WAGON_ASSIGNED', + 'INVOICED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED', 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 2c5aa463a..a03183d02 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 @@ -5,6 +5,9 @@ import { TrainSet } from '../../train-sets/entities/train-set.entity'; export const LOCOMOTIVE_STATUSES = [ 'AVAILABLE', + 'UNAVAILABLE', + 'IMPORT_READY', + 'EXPORT_READY', 'ASSIGNED', 'MAINTENANCE', 'OUT_OF_SERVICE', 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 4edd09f09..746b73248 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 @@ -8,9 +8,12 @@ import { TrainScheduleBooking } from './train-schedule-booking.entity'; export const TRAIN_SCHEDULE_STATUSES = [ 'DRAFT', - 'SCHEDULED', - 'DISPATCHED', + 'READY', + 'PUBLISHED', + 'DEPARTED', + 'IN_TRANSIT', 'ARRIVED', + 'COMPLETED', 'CANCELLED', ] as const; 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 1b4fa29d8..fa40e7131 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,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDateString, IsUUID } from 'class-validator'; +import { ArrayMinSize, IsArray, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; export class CreateContainerTrainScheduleDto { @ApiProperty({ format: 'uuid' }) @@ -10,7 +10,31 @@ export class CreateContainerTrainScheduleDto { @IsDateString() scheduleDate!: string; + @ApiProperty({ example: '2026-06-22T08:00:00.000Z', required: false }) + @IsOptional() + @IsDateString() + arrivalDate?: string; + @ApiProperty({ format: 'uuid' }) @IsUUID() locomotiveId!: string; + + @ApiProperty({ enum: ['CONTAINER', 'BULK'], default: 'CONTAINER' }) + @IsOptional() + @IsIn(['CONTAINER', 'BULK']) + assignmentType?: 'CONTAINER' | 'BULK'; + + @ApiProperty({ type: [String] }) + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds?: string[]; + + @ApiProperty({ type: [String], required: false }) + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + wagonIds?: string[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts index 2ca15dc0c..06eb2886e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsOptional, IsUUID } from 'class-validator'; +import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; export class GetEligibleContainerBookingsDto { @ApiPropertyOptional({ format: 'uuid' }) @@ -16,4 +16,14 @@ export class GetEligibleContainerBookingsDto { @IsOptional() @IsDateString() scheduleDate?: string; + + @ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] }) + @IsOptional() + @IsIn(['CONTAINER', 'BULK']) + assignmentType?: 'CONTAINER' | 'BULK'; + + @ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT', 'DOMESTIC'] }) + @IsOptional() + @IsIn(['IMPORT', 'EXPORT', 'DOMESTIC']) + tradeDirection?: 'IMPORT' | 'EXPORT' | 'DOMESTIC'; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts index e3e142a61..6c5ca70b2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ArrayMinSize, IsArray, IsDateString, IsUUID } from 'class-validator'; +import { ArrayMinSize, IsArray, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; export class PreviewContainerTrainScheduleDto { @ApiProperty({ type: [String] }) @@ -19,4 +19,9 @@ export class PreviewContainerTrainScheduleDto { @ApiProperty({ format: 'uuid' }) @IsUUID() destinationStationId!: string; + + @ApiProperty({ enum: ['CONTAINER', 'BULK'], default: 'CONTAINER' }) + @IsOptional() + @IsIn(['CONTAINER', 'BULK']) + assignmentType?: 'CONTAINER' | 'BULK'; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 1dd01ffba..fda32bf09 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -55,4 +55,10 @@ export class TrainSchedulingController { cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) { return this.trainSchedulingService.cancelTrainSchedule(id); } + + @Post('container/schedules/:id/publish') + @ApiOperation({ summary: 'Publish container train schedule' }) + publishTrainSchedule(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.publishTrainSchedule(id); + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index dbf79e403..1af1cb31f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -8,6 +8,7 @@ import { LocomotivesModule } from '../locomotives/locomotives.module'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonTypesModule } from '../wagon-types/wagon-types.module'; +import { Wagon } from '../wagons/entities/wagon.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSetsModule } from '../train-sets/train-sets.module'; @@ -26,6 +27,7 @@ import { TrainSchedulingService } from './train-scheduling.service'; BookingContainer, Locomotive, WagonType, + Wagon, TrainSet, TrainSetWagon, TrainSchedule, 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 8f2b77bb4..73b2e0662 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 @@ -38,7 +38,7 @@ const makeBooking = ( scheduledDate: new Date(scheduledDate), originYardId, destinationYardId, - status: 'PAID', + status: 'APPROVED', customer: { companyName: 'Demo Customer' }, originYard: { label: 'Djibouti', code: 'DJIBOUTI' }, destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' }, @@ -164,11 +164,11 @@ describe('TrainSchedulingService', () => { ); }); - it('rejects bookings that are not in schedulable status', async () => { + it('rejects bookings that are not in assignable status', async () => { const bookings = [ { ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'), - status: 'APPROVED', + status: 'PAID', }, ]; @@ -198,7 +198,7 @@ describe('TrainSchedulingService', () => { expect(result.valid).toBe(false); expect(result.violations).toContain( - 'Only PAID bookings can be scheduled; received: APPROVED', + 'Only APPROVED, READY_FOR_ASSIGNMENT bookings can be assigned; received: PAID', ); }); 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 64147940d..7c27123fe 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 @@ -18,6 +18,8 @@ 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 { Wagon } from "../wagons/entities/wagon.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"; @@ -27,7 +29,10 @@ import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train- const DEFAULT_WAGON_TYPE_CODE = "NW5"; const MAX_TRAIN_WEIGHT_TONS = 3500; const MAX_TRAIN_LENGTH_METERS = 760; -const SCHEDULABLE_BOOKING_STATUSES = ["PAID"] as const; +const ASSIGNABLE_BOOKING_STATUSES = ["APPROVED", "READY_FOR_ASSIGNMENT"] as const; +const EXCLUDED_BOOKING_STATUSES = ["CANCELLED", "COMPLETED", "IN_TRANSIT", "ARRIVED"] as const; +const MAX_CONTAINER_WAGONS = 53; +const MAX_BULK_WAGONS = 37; type EligibleBookingItem = { id: string; @@ -94,11 +99,13 @@ export class TrainSchedulingService { "scheduleBooking", "scheduleBooking.booking_id = booking.id", ) - .where("booking.freightType = :freightType", { freightType: "CONTAINER" }) + .where("booking.freightType = :freightType", { + freightType: query.assignmentType ?? "CONTAINER", + }) .andWhere("scheduleBooking.id IS NULL"); - queryBuilder.andWhere("booking.status IN (:...schedulableStatuses)", { - schedulableStatuses: SCHEDULABLE_BOOKING_STATUSES, + queryBuilder.andWhere("booking.status IN (:...assignableStatuses)", { + assignableStatuses: ASSIGNABLE_BOOKING_STATUSES, }); if (query.originStationId) { @@ -116,6 +123,26 @@ export class TrainSchedulingService { ); } + if (query.tradeDirection === "IMPORT") { + queryBuilder.andWhere( + `( + lower(originYard.country) IN ('djibouti', 'djoubti', 'dj') + OR lower(originYard.code) LIKE '%djib%' + OR lower(originYard.label) LIKE '%djib%' + )`, + ); + } + + if (query.tradeDirection === "EXPORT") { + queryBuilder.andWhere( + `( + lower(destinationYard.country) IN ('djibouti', 'djoubti', 'dj') + OR lower(destinationYard.code) LIKE '%djib%' + OR lower(destinationYard.label) LIKE '%djib%' + )`, + ); + } + if (query.scheduleDate) { queryBuilder.andWhere( `DATE(booking.scheduled_date AT TIME ZONE 'UTC') = :scheduleDate`, @@ -141,7 +168,7 @@ export class TrainSchedulingService { container.containerType?.code ?? "Container", ) - .join(", ") ?? "Container", + .join(", ") ?? (booking.freightType === "BULK" ? "Bulk cargo" : "Container"), quantity: booking.bookingContainers?.reduce( (sum, container) => sum + Number(container.quantity ?? 0), @@ -180,11 +207,27 @@ export class TrainSchedulingService { async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { const route = await this.getActiveRoute(dto.routeId); + const validation = dto.bookingIds?.length + ? await this.validateContainerBookingsForScheduling({ + bookingIds: dto.bookingIds, + scheduleDate: dto.scheduleDate, + originStationId: route.originYardId, + destinationStationId: route.destinationYardId, + assignmentType: dto.assignmentType ?? "CONTAINER", + }) + : null; + + if (validation && !validation.valid) { + throw new BadRequestException({ + message: "Train schedule assignment is invalid", + violations: validation.violations, + }); + } const locomotive = await this.selectOrValidateLocomotive( dto.locomotiveId, - 0, - 0, + validation?.summary.totalWeightTons ?? 0, + validation?.summary.totalLengthMeters ?? 0, ); const createdSchedule = await this.dataSource.transaction( @@ -205,10 +248,28 @@ export class TrainSchedulingService { ); } - const trainSet = await this.buildEmptyTrainSet( - manager, - lockedLocomotive, - ); + const selectedPhysicalWagons = validation + ? await this.lockSelectedWagonsForSchedule( + manager, + dto.wagonIds ?? [], + validation.wagonPlan.length, + route, + dto.assignmentType ?? "CONTAINER", + ) + : []; + + const trainSetResult = validation + ? await this.buildTrainSet( + manager, + lockedLocomotive, + validation.wagonType, + validation.summary.totalWeightTons, + validation.summary.totalLengthMeters, + validation.wagonPlan, + selectedPhysicalWagons, + ) + : { trainSet: await this.buildEmptyTrainSet(manager, lockedLocomotive), wagons: [] }; + const { trainSet, wagons } = trainSetResult; const schedule = manager.getRepository(TrainSchedule).create({ trainSetId: trainSet.id, @@ -216,13 +277,51 @@ export class TrainSchedulingService { originStationId: route.originYardId, destinationStationId: route.destinationYardId, scheduledDepartureDate: new Date(dto.scheduleDate), - status: "DRAFT", + scheduledArrivalDate: dto.arrivalDate ? new Date(dto.arrivalDate) : null, + status: validation ? "READY" : "DRAFT", }); const savedSchedule = await manager .getRepository(TrainSchedule) .save(schedule); + if (validation) { + await manager.getRepository(TrainScheduleBooking).save( + validation.bookings.map((booking) => + manager.getRepository(TrainScheduleBooking).create({ + trainScheduleId: savedSchedule.id, + bookingId: booking.id, + }), + ), + ); + + const wagonBySequence = new Map(wagons.map((wagon) => [wagon.sequenceNo, wagon])); + const allocations = validation.wagonPlan.flatMap((wagonPlan) => { + const savedWagon = wagonBySequence.get(wagonPlan.sequenceNo); + if (!savedWagon) return []; + + return wagonPlan.allocations.map((allocation) => + manager.getRepository(WagonBookingAllocation).create({ + trainSetWagonId: savedWagon.id, + bookingId: allocation.bookingId, + allocatedWeightTons: allocation.allocatedWeightTons, + }), + ); + }); + + if (allocations.length > 0) { + await manager.getRepository(WagonBookingAllocation).save(allocations); + } + + await manager.getRepository(Booking).update( + { id: In(validation.bookings.map((booking) => booking.id)) }, + { + status: "INVOICED", + paymentStatus: "PENDING", + }, + ); + } + await locomotiveRepository.update(lockedLocomotive.id, { status: "ASSIGNED", }); @@ -276,24 +375,32 @@ export class TrainSchedulingService { } const nonContainerBookings = bookings.filter( - (booking) => booking.freightType !== "CONTAINER", + (booking) => booking.freightType !== (dto.assignmentType ?? "CONTAINER"), ); if (nonContainerBookings.length > 0) { violations.push( - "Only CONTAINER bookings are supported for train scheduling", + `Only ${dto.assignmentType ?? "CONTAINER"} bookings are supported for this assignment`, ); } const invalidStatusBookings = bookings.filter( - (booking) => !SCHEDULABLE_BOOKING_STATUSES.includes(booking.status as "PAID"), + (booking) => + !ASSIGNABLE_BOOKING_STATUSES.includes(booking.status as (typeof ASSIGNABLE_BOOKING_STATUSES)[number]), ); if (invalidStatusBookings.length > 0) { const invalidStatuses = [...new Set(invalidStatusBookings.map((booking) => booking.status))]; violations.push( - `Only ${SCHEDULABLE_BOOKING_STATUSES.join(", ")} bookings can be scheduled; received: ${invalidStatuses.join(", ")}`, + `Only ${ASSIGNABLE_BOOKING_STATUSES.join(", ")} bookings can be assigned; received: ${invalidStatuses.join(", ")}`, ); } + const excludedStatusBookings = bookings.filter((booking) => + EXCLUDED_BOOKING_STATUSES.includes(booking.status as (typeof EXCLUDED_BOOKING_STATUSES)[number]), + ); + if (excludedStatusBookings.length > 0) { + violations.push(`Cancelled, completed, in-transit, or arrived bookings cannot be assigned`); + } + const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate); const routeMismatch = bookings.some( (booking) => @@ -366,11 +473,11 @@ export class TrainSchedulingService { } if ( - wagonType.maxWagonsPerTrain != null && - wagonPlan.length > Number(wagonType.maxWagonsPerTrain) + wagonPlan.length > + (dto.assignmentType === "BULK" ? MAX_BULK_WAGONS : MAX_CONTAINER_WAGONS) ) { violations.push( - `Wagon count ${wagonPlan.length} exceeds wagon marshalling limit ${wagonType.maxWagonsPerTrain}`, + `Wagon count ${wagonPlan.length} exceeds ${dto.assignmentType === "BULK" ? "bulk" : "container"} limit ${dto.assignmentType === "BULK" ? MAX_BULK_WAGONS : MAX_CONTAINER_WAGONS}`, ); } @@ -474,6 +581,82 @@ export class TrainSchedulingService { return locomotive; } + async lockSelectedWagonsForSchedule( + manager: EntityManager, + wagonIds: string[], + requiredCount: number, + route: Route, + assignmentType: "CONTAINER" | "BULK", + ) { + const uniqueWagonIds = [...new Set(wagonIds)]; + + if (uniqueWagonIds.length < requiredCount) { + throw new BadRequestException( + `Select at least ${requiredCount} available wagons for this schedule`, + ); + } + + const wagons = await manager + .getRepository(Wagon) + .createQueryBuilder("wagon") + .leftJoinAndSelect("wagon.wagonType", "wagonType") + .where("wagon.id IN (:...wagonIds)", { wagonIds: uniqueWagonIds }) + .setLock("pessimistic_write") + .getMany(); + + if (wagons.length !== uniqueWagonIds.length) { + throw new BadRequestException("One or more selected wagons were not found"); + } + + const expectedStatus = this.expectedWagonStatusForRoute(route); + const allowedStatuses = new Set([ + expectedStatus, + "AVAILABLE", + ...(expectedStatus === "EXPORT_READY" ? ["IMPORT_READY"] : []), + ]); + const invalidWagon = wagons.find( + (wagon) => + wagon.trainId || + wagon.status === "ASSIGNED" || + wagon.currentLocationYardId !== route.originYardId || + !allowedStatuses.has(wagon.status) || + !this.wagonTypeSupportsAssignment(wagon, assignmentType), + ); + + if (invalidWagon) { + throw new BadRequestException( + `Wagon ${invalidWagon.wagonNumber} is not at the route origin or is not ready for this ${this.routeDirection(route).toLowerCase()} route`, + ); + } + + const wagonById = new Map(wagons.map((wagon) => [wagon.id, wagon])); + return uniqueWagonIds.slice(0, requiredCount).map((wagonId) => wagonById.get(wagonId)!); + } + + private wagonTypeSupportsAssignment(wagon: Wagon, assignmentType: "CONTAINER" | "BULK") { + const supportedLoadTypes = wagon.wagonType?.supportedLoadTypes ?? []; + const normalized = supportedLoadTypes.map((loadType) => loadType.trim().toUpperCase()); + return normalized.includes(assignmentType); + } + + private routeDirection(route: Route) { + const originCountry = route.originYard?.country?.trim().toLowerCase(); + const destinationCountry = route.destinationYard?.country?.trim().toLowerCase(); + const isOriginEthiopia = originCountry === "ethiopia" || originCountry === "et"; + const isDestinationEthiopia = destinationCountry === "ethiopia" || destinationCountry === "et"; + + if (!isOriginEthiopia && isDestinationEthiopia) return "IMPORT"; + if (isOriginEthiopia && !isDestinationEthiopia) return "EXPORT"; + return "DOMESTIC"; + } + + private expectedWagonStatusForRoute(route: Route) { + const direction = this.routeDirection(route); + if (direction === "IMPORT") return "IMPORT_READY"; + if (direction === "EXPORT" || direction === "DOMESTIC") return "EXPORT_READY"; + return "AVAILABLE"; + } + async buildTrainSet( manager: EntityManager, locomotive: Locomotive, @@ -481,6 +664,7 @@ export class TrainSchedulingService { totalWeightTons: number, totalLengthMeters: number, wagonPlan: WagonPlanRecord[], + physicalWagons: Wagon[] = [], ) { const trainSet = manager.getRepository(TrainSet).create({ locomotiveId: locomotive.id, @@ -491,20 +675,35 @@ export class TrainSchedulingService { }); const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet); - const wagons = wagonPlan.map((wagon) => - manager.getRepository(TrainSetWagon).create({ + const wagons = wagonPlan.map((wagon, index) => { + const physicalWagon = physicalWagons[index]; + const selectedWagonType = physicalWagon?.wagonType ?? wagonType; + + return manager.getRepository(TrainSetWagon).create({ trainSetId: savedTrainSet.id, - wagonTypeId: wagonType.id, + wagonTypeId: selectedWagonType.id, + physicalWagonId: physicalWagon?.id ?? null, sequenceNo: wagon.sequenceNo, - capacityTons: wagon.capacityTons, - lengthMeters: wagon.lengthMeters, + capacityTons: Number(selectedWagonType.capacityTons), + lengthMeters: Number(selectedWagonType.lengthMeters), assignedWeightTons: wagon.assignedWeightTons, - }), - ); + }); + }); - await manager.getRepository(TrainSetWagon).save(wagons); + const savedWagons = await manager.getRepository(TrainSetWagon).save(wagons); - return savedTrainSet; + if (physicalWagons.length > 0) { + await Promise.all( + physicalWagons.map((wagon, index) => + manager.getRepository(Wagon).update(wagon.id, { + status: "ASSIGNED", + sequenceNumber: index + 1, + }), + ), + ); + } + + return { trainSet: savedTrainSet, wagons: savedWagons }; } async buildEmptyTrainSet( @@ -627,7 +826,7 @@ export class TrainSchedulingService { route: true, trainSet: { locomotive: true, - wagons: { wagonType: true, allocations: { booking: true } }, + wagons: { wagonType: true, physicalWagon: true, allocations: { booking: true } }, }, originStation: true, destinationStation: true, @@ -696,6 +895,13 @@ export class TrainSchedulingService { name: wagon.wagonType.name, } : null, + physicalWagon: wagon.physicalWagon + ? { + id: wagon.physicalWagon.id, + wagonNumber: wagon.physicalWagon.wagonNumber, + status: wagon.physicalWagon.status, + } + : null, allocations: wagon.allocations?.map((allocation) => ({ id: allocation.id, @@ -729,7 +935,7 @@ export class TrainSchedulingService { .getRepository(TrainSchedule) .findOne({ where: { id }, - relations: { trainSet: { locomotive: true } }, + relations: { trainSet: { locomotive: true, wagons: true } }, }); if (!schedule) { @@ -754,11 +960,58 @@ export class TrainSchedulingService { status: "AVAILABLE", }); } + + const physicalWagonIds = + schedule.trainSet?.wagons + ?.map((wagon) => wagon.physicalWagonId) + .filter((wagonId): wagonId is string => Boolean(wagonId)) ?? []; + + if (physicalWagonIds.length > 0) { + const physicalWagons = await manager.getRepository(Wagon).find({ + where: { id: In(physicalWagonIds) }, + relations: { currentLocationYard: true }, + }); + + await Promise.all( + physicalWagons.map((wagon) => + manager.getRepository(Wagon).update(wagon.id, { + status: this.expectedWagonStatusForYard(wagon.currentLocationYard), + sequenceNumber: null, + }), + ), + ); + } }); return this.getContainerTrainScheduleById(id); } + async publishTrainSchedule(id: string) { + const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ + where: { id }, + relations: { trainSet: true, scheduleBookings: true }, + }); + + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + + if (schedule.status === "CANCELLED") { + throw new BadRequestException("Cancelled schedules cannot be published"); + } + + if (!schedule.trainSet || schedule.trainSet.wagonCount <= 0) { + throw new BadRequestException("Allocate wagons before publishing the schedule"); + } + + if ((schedule.scheduleBookings?.length ?? 0) === 0) { + throw new BadRequestException("Assign bookings before publishing the schedule"); + } + + await this.dataSource.getRepository(TrainSchedule).update(id, { status: "PUBLISHED" }); + return this.getContainerTrainScheduleById(id); + } + private async loadBookingsForScheduling(bookingIds: string[]) { return this.dataSource.getRepository(Booking).find({ where: { id: In(bookingIds) }, @@ -775,6 +1028,7 @@ export class TrainSchedulingService { private async getActiveRoute(routeId: string) { const route = await this.dataSource.getRepository(Route).findOne({ where: { id: routeId }, + relations: { originYard: true, destinationYard: true }, }); if (!route) { @@ -788,6 +1042,13 @@ export class TrainSchedulingService { return route; } + private expectedWagonStatusForYard(yard?: { country?: string } | null) { + const country = yard?.country?.trim().toLowerCase(); + if (country === "ethiopia" || country === "et") return "EXPORT_READY"; + if (country === "djibouti" || country === "djoubti" || country === "dj") return "IMPORT_READY"; + return "AVAILABLE"; + } + 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/train-sets/entities/train-set-wagon.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts index 780bc1977..4a2ab16cb 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts @@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; +import { Wagon } from '../../wagons/entities/wagon.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { TrainSet } from './train-set.entity'; @@ -18,6 +19,13 @@ export class TrainSetWagon extends BaseEntity { @Column({ name: 'wagon_type_id', type: 'uuid' }) wagonTypeId!: string; + @Column({ name: 'physical_wagon_id', type: 'uuid', nullable: true }) + physicalWagonId!: string | null; + + @ManyToOne(() => Wagon, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'physical_wagon_id' }) + physicalWagon?: Wagon | null; + @ManyToOne(() => WagonType, (wagonType) => wagonType.trainSetWagons) @JoinColumn({ name: 'wagon_type_id' }) wagonType?: WagonType; 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 46134d097..0888f4fbf 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,10 +11,12 @@ import { Min, } from 'class-validator'; -<<<<<<< HEAD const toNumber = ({ value }: { value: unknown }) => value === '' || value == null ? value : Number(value); +const toOptionalNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? undefined : Number(value); + const toBoolean = ({ value }: { value: unknown }) => { if (typeof value === 'boolean') return value; if (value === 'true') return true; @@ -23,7 +25,9 @@ const toBoolean = ({ value }: { value: unknown }) => { }; const toStringArray = ({ value }: { value: unknown }) => { - if (Array.isArray(value)) return value; + if (Array.isArray(value)) { + return value.map((entry) => String(entry).trim()).filter(Boolean); + } if (typeof value !== 'string') return []; return value .split(',') @@ -37,71 +41,28 @@ export class CreateWagonTypeDto { @MaxLength(32) 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 }) + @ApiProperty({ description: 'Maximum payload capacity in metric tons', example: 60 }) @Transform(toNumber) @IsNumber() - @Min(0) - capacityTons!: number; - - @ApiProperty({ example: 14.2 }) - @Transform(toNumber) - @IsNumber() - @Min(0) - lengthMeters!: number; - - @ApiPropertyOptional({ example: 45 }) - @IsOptional() - @Transform(toNumber) - @IsInt() - @Min(1) - maxWagonsPerTrain?: number; - - @ApiPropertyOptional({ type: [String], example: ['container', 'break-bulk'] }) - @IsOptional() - @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' }) + @ApiProperty({ description: 'Wagon length in meters', example: 14.2 }) + @Transform(toNumber) @IsNumber() @Min(0.001) - @Transform(({ value }) => Number(value)) lengthMeters!: number; - @ApiPropertyOptional({ description: 'Maximum wagons of this type per train' }) + @ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 45 }) @IsOptional() + @Transform(toOptionalNumber) @IsInt() @Min(1) - @Transform(({ value }) => (value === '' || value === null || value === undefined ? undefined : Number(value))) maxWagonsPerTrain?: number; @ApiPropertyOptional({ @@ -110,18 +71,14 @@ export class CreateWagonTypeDto { default: [], }) @IsOptional() + @Transform(toStringArray) @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 5f46300ca..d7dceaeb0 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,18 +11,9 @@ 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 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'; @@ -33,41 +24,11 @@ import { WagonTypesService } from './wagon-types.service'; export class WagonTypesController { constructor(private readonly wagonTypesService: WagonTypesService) {} -<<<<<<< HEAD - @Post() - @ApiOperation({ summary: 'Create a wagon type' }) - async create(@Body() dto: CreateWagonTypeDto): Promise { - return this.wagonTypesService.create(dto); - } - - @Get() - @ApiOperation({ summary: 'Get wagon types' }) - async findAll(@Query() query: Record): Promise { - return this.wagonTypesService.findAll(query); - } - - @Get(':id') - @ApiOperation({ summary: 'Get a wagon type by ID' }) - async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { - return this.wagonTypesService.findById(id); - } - - @Patch(':id') - @ApiOperation({ summary: 'Update a wagon type' }) - async update( - @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, - }); + findAll(@Query() query: Record) { + return this.wagonTypesService.findAll(query); } @Get(':id') @@ -88,21 +49,14 @@ export class WagonTypesController { @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.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts index 2c2d49747..ec039cfd5 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,84 +1,23 @@ -<<<<<<< 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'; import { WagonTypesRepository } from './wagon-types.repository'; +type WagonTypeListResponse = { + data: WagonType[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; +}; + @Injectable() 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 } }); - if (existing.length > 0) { - throw new ConflictException(`Wagon type code "${code}" already exists`); - } - - return this.wagonTypesRepository.create({ - ...dto, - code, - 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 { + async findAll(query: Record = {}): Promise { + const page = Math.max(1, Number(query.page) || 1); + const pageSize = Math.max(1, Number(query.pageSize) || 20); const isActive = query.isActive === 'all' ? undefined @@ -92,19 +31,29 @@ export class WagonTypesService { : 'code'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; - return this.wagonTypesRepository.findAll({ + const [data, total] = await this.wagonTypesRepository.findAndCount({ where: isActive === undefined ? {} : { isActive }, order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: (page - 1) * pageSize, + take: pageSize, }); + + return { + data, + meta: { + total, + page, + pageSize, + totalPages: 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; } @@ -116,14 +65,30 @@ export class WagonTypesService { return wagonType; } -<<<<<<< HEAD + async create(dto: CreateWagonTypeDto): Promise { + const code = dto.code.trim().toUpperCase(); + const existing = await this.wagonTypesRepository.findByCode(code); + if (existing) { + throw new ConflictException(`Wagon type code "${code}" already exists`); + } + + return this.wagonTypesRepository.create({ + ...dto, + code, + name: dto.name.trim(), + maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null, + supportedLoadTypes: dto.supportedLoadTypes ?? [], + isActive: dto.isActive ?? true, + }); + } + async update(id: string, dto: UpdateWagonTypeDto): Promise { const wagonType = await this.findById(id); const nextCode = dto.code?.trim().toUpperCase(); if (nextCode && nextCode !== wagonType.code) { - const existing = await this.wagonTypesRepository.findAll({ where: { code: nextCode } }); - if (existing.length > 0) { + const existing = await this.wagonTypesRepository.findByCode(nextCode); + if (existing) { throw new ConflictException(`Wagon type code "${nextCode}" already exists`); } } @@ -138,43 +103,11 @@ 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/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts index c3108d68b..5e5ba9035 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -16,6 +16,10 @@ export class CreateWagonDto { @Min(1) sequenceNumber?: number; + @IsOptional() + @IsUUID() + currentLocationYardId?: string; + @IsNumber() @Min(0) tareWeight!: number; @@ -25,10 +29,10 @@ export class CreateWagonDto { maxPayloadWeight!: number; @IsOptional() - @IsIn(['AVAILABLE', 'ASSIGNED', 'MAINTENANCE', 'RETIRED']) + @IsIn(['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED', 'MAINTENANCE', 'RETIRED']) status?: string; @IsOptional() @IsString() notes?: string; -} \ No newline at end of file +} diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index cdff14330..5d6894e88 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -3,6 +3,8 @@ import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; import { BaseEntity } from '@edr/api-common'; import { Train } from '../../trains/entities/train.entity'; import { Container } from '../../container-management/entities/container.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; @Entity({ name: 'wagons', schema: 'freight' }) export class Wagon extends BaseEntity { @@ -12,12 +14,23 @@ export class Wagon extends BaseEntity { @Column({ name: 'wagon_type_id', type: 'uuid' }) wagonTypeId!: string; + @ManyToOne(() => WagonType, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType; + @Column({ name: 'train_id', type: 'uuid', nullable: true }) trainId!: string | null; @Column({ name: 'sequence_number', type: 'int', nullable: true }) sequenceNumber!: number | null; + @Column({ name: 'current_location_yard_id', type: 'uuid', nullable: true }) + currentLocationYardId!: string | null; + + @ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'current_location_yard_id' }) + currentLocationYard?: Yard | null; + @Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 }) tareWeight!: number; @@ -25,7 +38,7 @@ export class Wagon extends BaseEntity { maxPayloadWeight!: number; @Column({ type: 'varchar', default: 'AVAILABLE' }) - status!: string; // AVAILABLE, ASSIGNED, MAINTENANCE, RETIRED + status!: string; // AVAILABLE, IMPORT_READY, EXPORT_READY, ASSIGNED, MAINTENANCE, RETIRED @Column({ type: 'text', nullable: true }) notes!: string | null; @@ -38,4 +51,4 @@ export class Wagon extends BaseEntity { // Relationship to Container @OneToMany(() => Container, (container) => container.wagon) containers!: Container[]; -} \ No newline at end of file +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts index 914de4cbd..bffe28860 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Wagon } from './entities/wagon.entity'; import { Train } from '../trains/entities/train.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; import { WagonsController, TrainWagonsReorderController } from './wagons.controller'; import { WagonsService } from './wagons.service'; @Module({ - imports: [TypeOrmModule.forFeature([Wagon, Train])], + imports: [TypeOrmModule.forFeature([Wagon, Train, Yard])], controllers: [WagonsController, TrainWagonsReorderController], providers: [WagonsService], exports: [WagonsService], }) -export class WagonsModule {} \ No newline at end of file +export class WagonsModule {} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index bc0b52a69..95b27f8b5 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -7,6 +7,7 @@ import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; import { Wagon } from './entities/wagon.entity'; import { Train } from '../trains/entities/train.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; @Injectable() export class WagonsService { @@ -15,6 +16,8 @@ export class WagonsService { private readonly wagonRepo: Repository, @InjectRepository(Train) private readonly trainRepo: Repository, + @InjectRepository(Yard) + private readonly yardRepo: Repository, private readonly dataSource: DataSource, ) {} @@ -23,6 +26,7 @@ export class WagonsService { // Convert undefined to null for nullable fields if (dto.trainId === undefined) wagon.trainId = null; if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; + wagon.status = await this.statusForLocation(dto.currentLocationYardId, dto.status); return this.wagonRepo.save(wagon); } @@ -31,12 +35,14 @@ export class WagonsService { const search = query.search?.trim(); const status = query.status?.trim(); const trainId = query.trainId?.trim(); + const currentLocationYardId = query.currentLocationYardId?.trim(); if (search) { where.push({ wagonNumber: ILike(`%${search}%`), ...(status ? { status } : {}), ...(trainId ? { trainId } : {}), + ...(currentLocationYardId ? { currentLocationYardId } : {}), }); } @@ -46,7 +52,8 @@ export class WagonsService { const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; return this.wagonRepo.find({ - where: search ? where : { ...(status ? { status } : {}), ...(trainId ? { trainId } : {}) }, + where: search ? where : { ...(status ? { status } : {}), ...(trainId ? { trainId } : {}), ...(currentLocationYardId ? { currentLocationYardId } : {}) }, + relations: { currentLocationYard: true, wagonType: true }, order: { [sortBy]: sortOrder } as FindOptionsOrder, skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, take: query.limit ? Number(query.limit) : undefined, @@ -54,7 +61,7 @@ export class WagonsService { } async findById(id: string): Promise { - const wagon = await this.wagonRepo.findOne({ where: { id } }); + const wagon = await this.wagonRepo.findOne({ where: { id }, relations: { currentLocationYard: true, wagonType: true } }); if (!wagon) throw new NotFoundException(`Wagon ${id} not found`); return wagon; } @@ -62,6 +69,9 @@ export class WagonsService { async update(id: string, dto: UpdateWagonDto): Promise { const wagon = await this.findById(id); Object.assign(wagon, dto); + if (dto.currentLocationYardId !== undefined) { + wagon.status = await this.statusForLocation(dto.currentLocationYardId, dto.status); + } return this.wagonRepo.save(wagon); } @@ -99,10 +109,21 @@ export class WagonsService { const wagon = await this.findById(wagonId); wagon.trainId = null; wagon.sequenceNumber = null; - wagon.status = 'AVAILABLE'; + wagon.status = await this.statusForLocation(wagon.currentLocationYardId, 'AVAILABLE'); return this.wagonRepo.save(wagon); } + private async statusForLocation(yardId?: string | null, fallback = 'AVAILABLE') { + if (!yardId) return fallback; + + const yard = await this.yardRepo.findOne({ where: { id: yardId } }); + const country = yard?.country?.trim().toLowerCase(); + + if (country === 'ethiopia' || country === 'et') return 'EXPORT_READY'; + if (country === 'djibouti' || country === 'djoubti' || country === 'dj') return 'IMPORT_READY'; + return fallback; + } + async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); diff --git a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts new file mode 100644 index 000000000..b6a6484f8 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts @@ -0,0 +1,47 @@ +import { AppDataSource } from '../data-source'; +import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet'; + +async function seedEdRWagons() { + await AppDataSource.initialize(); + + const queryRunner = AppDataSource.createQueryRunner(); + + try { + await queryRunner.connect(); + await queryRunner.startTransaction(); + + await new SeedEdRWagonFleet1750400000000().up(queryRunner); + + const [summary] = await queryRunner.query(` + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2, + COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4, + COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3, + COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2, + COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3, + COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5, + COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['CONTAINER'])::int AS container_ready, + COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['BULK'])::int AS bulk_ready, + COUNT(*) FILTER (WHERE w.status = 'IMPORT_READY')::int AS import_ready + FROM freight.wagons w + JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id + WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER0940'; + `); + + await queryRunner.commitTransaction(); + + console.log('Seeded EDR wagon fleet:', summary); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + await AppDataSource.destroy(); + } +} + +seedEdRWagons().catch((error) => { + console.error('Failed to seed EDR wagon fleet:', error); + process.exit(1); +}); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 66fee9212..d4b7d458a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -36,21 +36,12 @@ 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"; @@ -101,15 +92,15 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/trains", icon: , }, - { - label: "Wagon types", - href: "/dashboard/wagon-types", - icon: , - }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , + { + label: "Wagon types", + href: "/dashboard/wagon-types", + icon: , + }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , }, { label: "Containers", @@ -255,12 +246,6 @@ const App = () => { element={} /> } /> -<<<<<<< HEAD - } /> - } /> - } /> - } /> -======= } /> } /> } /> @@ -268,7 +253,6 @@ const App = () => { } /> } /> } /> ->>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467 } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 5787f7e85..21b64bb9d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -127,6 +127,8 @@ export const URL_CONSTANTS = { SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`, CANCEL_SCHEDULE: (id: string) => `/train-scheduling/container/schedules/${id}/cancel`, + PUBLISH_SCHEDULE: (id: string) => + `/train-scheduling/container/schedules/${id}/publish`, }, RULE_ENGINE: { 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 225653ef5..bdbf113b8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -50,6 +50,7 @@ import { useUpdateContainer, } from '@/hooks/useContainers'; import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains'; +import { useRouteYards } from '@/hooks/useRoutes'; import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons'; import { useCreateLocomotive, @@ -892,10 +893,15 @@ export function WagonTypesCrudPage() { export function WagonsCrudPage() { const query = useWagons(); const { data: wagonTypes = [] } = useWagonTypes(); + const { data: yards = [] } = useRouteYards(); const wagonTypeOptions = wagonTypes.map((type: any) => ({ value: type.id, label: `${type.code} - ${type.name}`, })); + const yardOptions = yards.map((yard: any) => ({ + value: yard.id, + label: `${yard.label ?? yard.code} (${yard.country ?? '-'})`, + })); return ( title="Wagons" @@ -906,10 +912,26 @@ export function WagonsCrudPage() { create={useCreateWagon()} update={useUpdateWagon()} remove={useDeleteWagon()} - searchText={(wagon) => [wagon.wagonNumber, wagon.wagonTypeId, wagon.trainId, wagon.status].join(' ')} + searchText={(wagon) => [ + wagon.wagonNumber, + wagon.wagonTypeId, + wagon.trainId, + wagon.status, + wagon.currentLocationYard?.label, + wagon.currentLocationYard?.code, + wagon.currentLocationYard?.country, + ].join(' ')} columns={[ { key: 'wagonNumber', label: 'Number' }, { key: 'wagonTypeId', label: 'Type', render: (wagon) => optionLabel(wagonTypeOptions, wagon.wagonTypeId) }, + { + key: 'currentLocationYardId', + label: 'Location', + render: (wagon) => + wagon.currentLocationYard + ? `${wagon.currentLocationYard.label ?? wagon.currentLocationYard.code} (${wagon.currentLocationYard.country ?? '-'})` + : '-', + }, { key: 'maxPayloadWeight', label: 'Max payload' }, { key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) }, ]} @@ -927,12 +949,31 @@ export function WagonsCrudPage() { return { maxPayloadWeight: Number(selectedType.capacityTons) }; }, }, + { + key: 'currentLocationYardId', + label: 'Wagon location', + type: 'select', + required: true, + options: yardOptions, + }, { key: 'tareWeight', label: 'Tare weight', type: 'number', required: true }, { key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true }, - { key: 'status', label: 'Status' }, + { + key: 'status', + label: 'Status', + type: 'select', + options: [ + { value: 'AVAILABLE', label: 'Available' }, + { value: 'IMPORT_READY', label: 'Import ready' }, + { value: 'EXPORT_READY', label: 'Export ready' }, + { value: 'ASSIGNED', label: 'Assigned' }, + { value: 'MAINTENANCE', label: 'Maintenance' }, + { value: 'RETIRED', label: 'Retired' }, + ], + }, { key: 'notes', label: 'Notes' }, ]} - emptyValues={{ wagonNumber: '', wagonTypeId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }} + emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }} /> ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx index f9b367fe6..0ee79b211 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx @@ -1,112 +1,265 @@ -import { useMemo, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { isAxiosError } from 'axios'; -import toast from 'react-hot-toast'; -import { Calendar, RefreshCw, TrainTrack } from 'lucide-react'; +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; import { + Accordion, Badge, Box, Breadcrumbs, Button, + Card, + Checkbox, Divider, + Grid, Group, + Loader, Modal, - Paper, + MultiSelect, + Notification, + Progress, ScrollArea, + SegmentedControl, Select, SimpleGrid, Stack, Table, + Tabs, Text, TextInput, ThemeIcon, Title, -} from '@mantine/core'; +} from "@mantine/core"; +import { Calendar, CheckCircle2, MapPin, RefreshCw, Send, Train, TrainTrack } from "lucide-react"; -import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; -import { useRoutes } from '@/hooks/useRoutes'; -import { trainSchedulingService } from '@/services/trainScheduling.service'; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { useRoutes } from "@/hooks/useRoutes"; +import { useWagons } from "@/hooks/useWagons"; +import { bookingsService } from "@/services/bookings.service"; +import { trainSchedulingService } from "@/services/trainScheduling.service"; +import type { Wagon } from "@/services/wagon.service"; +import type { BookingDetail } from "@/types/booking"; +import type { + AssignmentType, + EligibleContainerBooking, + LocomotiveRecord, + TradeDirection, + TrainSchedulePreviewResponse, +} from "@/types/trainScheduling"; const formatDate = (value?: string | null) => { - if (!value) return '-'; + if (!value) return "-"; const date = new Date(value); - if (Number.isNaN(date.getTime())) return '-'; - return new Intl.DateTimeFormat('en', { - year: 'numeric', - month: 'short', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', + if (Number.isNaN(date.getTime())) return "-"; + return new Intl.DateTimeFormat("en", { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", }).format(date); }; +const toIso = (value: string) => new Date(value).toISOString(); + +const TRAIN_LIMITS = { + maxWeightTons: 3500, + maxLengthMeters: 760, + maxContainerWagons: 53, + maxBulkWagons: 37, +} as const; + +const assignmentLabels: Record = { + CONTAINER: "Wagon for Container", + BULK: "Wagon for Bulk", +}; + +const ETHIOPIA_NAMES = new Set(["ethiopia", "et"]); + const parseError = (error: unknown, fallback: string) => { if (isAxiosError(error)) { const message = error.response?.data?.message; - if (Array.isArray(message)) return message.join(', '); - if (typeof message === 'string') return message; - const violations = error.response?.data?.violations; - if (Array.isArray(violations)) return violations.join(', '); + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + const violations = error.response?.data?.violations ?? error.response?.data?.message?.violations; + if (Array.isArray(violations)) return violations.join(", "); } + if (error instanceof Error) return error.message; return fallback; }; const statusColor = (status?: string | null) => { switch (status) { - case 'SCHEDULED': - return 'green'; - case 'DISPATCHED': - return 'blue'; - case 'ARRIVED': - return 'teal'; - case 'CANCELLED': - return 'red'; - case 'DRAFT': - return 'yellow'; + case "AVAILABLE": + case "READY": + case "PUBLISHED": + case "PAID": + return "green"; + case "IMPORT_READY": + case "EXPORT_READY": + case "IN_TRANSIT": + return "blue"; + case "ARRIVED": + case "COMPLETED": + return "teal"; + case "CANCELLED": + case "UNAVAILABLE": + return "red"; + case "DRAFT": + case "WAGON_ASSIGNED": + case "INVOICED": + return "yellow"; default: - return 'gray'; + return "gray"; } }; -function MetricTile({ - label, - value, -}: { - label: string; - value: string | number; -}) { +const routeDirection = (originCountry?: string, destinationCountry?: string): TradeDirection => { + const origin = (originCountry ?? "").trim().toLowerCase(); + const destination = (destinationCountry ?? "").trim().toLowerCase(); + if (!ETHIOPIA_NAMES.has(origin) && ETHIOPIA_NAMES.has(destination)) return "IMPORT"; + if (ETHIOPIA_NAMES.has(origin) && !ETHIOPIA_NAMES.has(destination)) return "EXPORT"; + return "DOMESTIC"; +}; + +const expectedWagonStatus = (direction: string) => { + if (direction === "IMPORT") return "IMPORT_READY"; + if (direction === "EXPORT" || direction === "DOMESTIC") return "EXPORT_READY"; + return "AVAILABLE"; +}; + +const wagonLabel = (wagon: Wagon) => + `${wagon.wagonNumber} - ${wagon.maxPayloadWeight ?? 0}T / ${wagon.status}`; + +const wagonSupportsAssignment = (wagon: Wagon, assignmentType: AssignmentType) => + (wagon.wagonType?.supportedLoadTypes ?? []) + .map((loadType) => loadType.trim().toUpperCase()) + .includes(assignmentType); + +const bookingYardLabel = (yard?: { label?: string; name?: string; code?: string } | null) => + yard?.label ?? yard?.name ?? yard?.code ?? "-"; + +const isDjiboutiYard = (yard?: { label?: string; name?: string; code?: string; country?: string } | null) => { + const value = [yard?.country, yard?.code, yard?.label, yard?.name] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return value.includes("djibouti") || value.includes("djoubti") || value.includes("djib"); +}; + +const bookingMatchesDirection = (booking: BookingDetail, direction: TradeDirection) => { + if (direction === "IMPORT") return isDjiboutiYard(booking.originYard); + if (direction === "EXPORT") return isDjiboutiYard(booking.destinationYard); + return true; +}; + +const bookingQuantity = (booking: BookingDetail) => + booking.bookingContainers?.reduce((sum, container) => sum + Number(container.quantity ?? 0), 0) ?? 0; + +const toEligibleBooking = (booking: BookingDetail): EligibleContainerBooking => ({ + id: booking.id, + reference: booking.reference, + customer: booking.company?.name ?? booking.company?.companyName ?? "Unknown customer", + containerType: + booking.freightType === "BULK" + ? booking.cargoType?.label ?? booking.cargoType?.name ?? "Bulk cargo" + : booking.bookingContainers + ?.map((container) => container.containerType?.label ?? container.containerType?.code ?? "Container") + .join(", ") || "Container", + quantity: bookingQuantity(booking), + weightTons: Number(booking.cargoTotalWeightVgm ?? 0), + origin: bookingYardLabel(booking.originYard), + destination: bookingYardLabel(booking.destinationYard), + preferredDepartureDate: booking.scheduledDate, + status: booking.status, +}); + +function MetricTile({ label, value }: { label: string; value: string | number }) { return ( - + {label} {value} - + + ); +} + +function LocomotiveCard({ + locomotive, + selected, + onSelect, +}: { + locomotive: LocomotiveRecord; + selected: boolean; + onSelect: () => void; +}) { + return ( + + + + + {locomotive.code} + + {locomotive.name ?? locomotive.locomotiveType ?? "Locomotive"} + + + + {locomotive.status} + + + + + + + + + ); } const TrainsPage = () => { const qc = useQueryClient(); - const [routeId, setRouteId] = useState(''); - const [scheduleDate, setScheduleDate] = useState(''); - const [selectedLocomotiveId, setSelectedLocomotiveId] = useState(''); + const [routeId, setRouteId] = useState(""); + const [departureDate, setDepartureDate] = useState(""); + const [arrivalDate, setArrivalDate] = useState(""); + const [assignmentType, setAssignmentType] = useState("CONTAINER"); + const [selectedLocomotiveId, setSelectedLocomotiveId] = useState(""); + const [selectedWagonIds, setSelectedWagonIds] = useState([]); + const [wagonSearch, setWagonSearch] = useState(""); + const [selectedBookingIds, setSelectedBookingIds] = useState([]); + const [locomotiveSearch, setLocomotiveSearch] = useState(""); + const [locomotiveStatusFilter, setLocomotiveStatusFilter] = useState("ALL"); + const [scheduleSearch, setScheduleSearch] = useState(""); + const [scheduleStatusFilter, setScheduleStatusFilter] = useState("ALL"); const [detailId, setDetailId] = useState(null); - const [scheduleSearch, setScheduleSearch] = useState(''); - const [scheduleStatusFilter, setScheduleStatusFilter] = useState('ALL'); + const [preview, setPreview] = useState(null); + const [formMessage, setFormMessage] = useState<{ color: string; title: string; message: string } | null>(null); + const [invoiceModalOpen, setInvoiceModalOpen] = useState(false); const routesQuery = useRoutes(); + const wagonsQuery = useWagons(); const locomotivesQuery = useQuery({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(), queryFn: () => trainSchedulingService.getAvailableLocomotives(), }); + const stationsQuery = useQuery({ + queryKey: [ + ...QUERY_KEYS.TRAIN_SCHEDULING.stations(), + selectedLocomotiveId, + ], + queryFn: () => trainSchedulingService.getStations(), + enabled: Boolean(selectedLocomotiveId), + }); const schedulesQuery = useQuery({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(), queryFn: () => trainSchedulingService.listSchedules(), }); const detailQuery = useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ''), + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ""), queryFn: () => trainSchedulingService.getScheduleById(detailId!), enabled: Boolean(detailId), }); @@ -119,98 +272,291 @@ const TrainsPage = () => { const selectedLocomotive = (locomotivesQuery.data ?? []).find( (locomotive) => locomotive.id === selectedLocomotiveId, ); + const maxWagons = + assignmentType === "CONTAINER" ? TRAIN_LIMITS.maxContainerWagons : TRAIN_LIMITS.maxBulkWagons; + const selectedRouteOrigin = selectedRoute?.originYard; + const selectedRouteDestination = selectedRoute?.destinationYard; + const selectedRouteDirection = routeDirection( + selectedRouteOrigin?.country, + selectedRouteDestination?.country, + ); + const requiredWagonStatus = expectedWagonStatus(selectedRouteDirection); + const stationNameById = useMemo(() => { + return new Map((stationsQuery.data ?? []).map((station) => [station.id, station])); + }, [stationsQuery.data]); + const stationLabel = (yardId?: string, fallback?: { label?: string; code?: string } | null) => { + const station = yardId ? stationNameById.get(yardId) : undefined; + return station + ? `${station.name}${station.code ? ` (${station.code})` : ""}` + : fallback?.label ?? fallback?.code ?? "-"; + }; - const routeOptions = activeRoutes.map((route) => ({ - value: route.id, - label: route.name, - })); - const locomotiveOptions = (locomotivesQuery.data ?? []).map((locomotive) => ({ - value: locomotive.id, - label: `${locomotive.code} - ${locomotive.maxPullWeightTons}T / ${locomotive.maxTrainLengthMeters}m`, - })); + const bookingsApiFilter = useMemo( + () => ({ + page: 1, + pageSize: 1000, + sortBy: "scheduledDate", + sortOrder: "ASC" as const, + }), + [], + ); + + const bookingsApiQuery = useQuery({ + queryKey: [ + ...QUERY_KEYS.BOOKINGS.list(bookingsApiFilter), + "train-assignment", + routeId, + ], + queryFn: () => bookingsService.list(bookingsApiFilter), + enabled: Boolean(selectedLocomotive && selectedRoute), + }); + + const eligibleBookings = useMemo(() => { + return (bookingsApiQuery.data?.items ?? []) + .filter((booking) => { + const matchesFreightType = booking.freightType === assignmentType; + const matchesCorridorDirection = bookingMatchesDirection(booking, selectedRouteDirection); + return matchesFreightType && matchesCorridorDirection; + }) + .map(toEligibleBooking); + }, [assignmentType, bookingsApiQuery.data?.items, selectedRouteDirection]); + + const filteredLocomotives = useMemo(() => { + const query = locomotiveSearch.trim().toLowerCase(); + return (locomotivesQuery.data ?? []).filter((locomotive) => { + const matchesStatus = locomotiveStatusFilter === "ALL" || locomotive.status === locomotiveStatusFilter; + if (!matchesStatus) return false; + if (!query) return true; + return [locomotive.code, locomotive.name ?? "", locomotive.status, locomotive.locomotiveType ?? ""] + .join(" ") + .toLowerCase() + .includes(query); + }); + }, [locomotiveSearch, locomotiveStatusFilter, locomotivesQuery.data]); + + const availableWagons = useMemo(() => { + const query = wagonSearch.trim().toLowerCase(); + return (wagonsQuery.data ?? []).filter((wagon) => { + const isUnassigned = !wagon.trainId; + const isAtRouteOrigin = Boolean(selectedRoute?.originYardId) && wagon.currentLocationYardId === selectedRoute?.originYardId; + const isReadyForRoute = wagon.status === requiredWagonStatus; + const supportsAssignment = wagonSupportsAssignment(wagon, assignmentType); + const matchesQuery = query + ? [ + wagon.wagonNumber, + wagon.status, + wagon.wagonTypeId, + wagon.wagonType?.code ?? "", + wagon.wagonType?.name ?? "", + wagon.currentLocationYard?.label ?? "", + wagon.currentLocationYard?.code ?? "", + wagon.currentLocationYard?.country ?? "", + wagon.notes ?? "", + ] + .join(" ") + .toLowerCase() + .includes(query) + : true; + return isUnassigned && isAtRouteOrigin && isReadyForRoute && supportsAssignment && matchesQuery; + }); + }, [assignmentType, requiredWagonStatus, selectedRoute?.originYardId, wagonSearch, wagonsQuery.data]); + + const routeReadyWagonCounts = useMemo(() => { + return (wagonsQuery.data ?? []).reduce( + (counts, wagon) => { + const isRouteReady = + !wagon.trainId && + Boolean(selectedRoute?.originYardId) && + wagon.currentLocationYardId === selectedRoute?.originYardId && + wagon.status === requiredWagonStatus; + + if (!isRouteReady) return counts; + if (wagonSupportsAssignment(wagon, "CONTAINER")) counts.container += 1; + if (wagonSupportsAssignment(wagon, "BULK")) counts.bulk += 1; + return counts; + }, + { container: 0, bulk: 0 }, + ); + }, [requiredWagonStatus, selectedRoute?.originYardId, wagonsQuery.data]); const filteredSchedules = useMemo(() => { const query = scheduleSearch.trim().toLowerCase(); - return (schedulesQuery.data ?? []).filter((schedule) => { - const matchesStatus = - scheduleStatusFilter === 'ALL' || schedule.status === scheduleStatusFilter; - + const matchesStatus = scheduleStatusFilter === "ALL" || schedule.status === scheduleStatusFilter; if (!matchesStatus) return false; if (!query) return true; - - const haystack = [ + return [ schedule.id, - schedule.routeName ?? '', - schedule.origin ?? '', - schedule.destination ?? '', - schedule.locomotive?.code ?? '', + schedule.routeName ?? "", + schedule.origin ?? "", + schedule.destination ?? "", + schedule.locomotive?.code ?? "", schedule.status, ] - .join(' ') - .toLowerCase(); - - return haystack.includes(query); + .join(" ") + .toLowerCase() + .includes(query); }); }, [scheduleSearch, scheduleStatusFilter, schedulesQuery.data]); - const createMutation = useMutation({ - mutationFn: () => { - if (!routeId || !scheduleDate || !selectedLocomotiveId) { - throw new Error('Please select route, departure date, and locomotive'); - } + const bookingOptions = eligibleBookings.map((booking) => ({ + value: booking.id, + label: `${booking.reference} - ${booking.customer} (${booking.weightTons} T / ${booking.status})`, + })); + const selectedBookings = eligibleBookings.filter((booking) => + selectedBookingIds.includes(booking.id), + ); + const selectedWeightTons = selectedBookings.reduce( + (total, booking) => total + Number(booking.weightTons || 0), + 0, + ); + const previewWagonLimitExceeded = preview ? preview.summary.wagonsNeeded > maxWagons : false; + const selectedWagonLimitExceeded = selectedWagonIds.length > maxWagons; + const selectedWagonsShort = + preview ? selectedWagonIds.length < preview.summary.wagonsNeeded : selectedWagonIds.length === 0; + const previewWeightExceeded = preview + ? preview.summary.totalWeightTons > TRAIN_LIMITS.maxWeightTons + : false; + const previewLengthExceeded = preview + ? preview.summary.totalLengthMeters > TRAIN_LIMITS.maxLengthMeters + : false; + const canGenerateSchedule = + Boolean(routeId) && + Boolean(departureDate) && + Boolean(arrivalDate) && + Boolean(selectedLocomotiveId) && + selectedBookingIds.length > 0 && + selectedWagonIds.length > 0 && + !selectedWagonLimitExceeded && + (!preview || !selectedWagonsShort); - return trainSchedulingService.createSchedule({ - routeId, - scheduleDate: new Date(`${scheduleDate}T08:00:00.000Z`).toISOString(), - locomotiveId: selectedLocomotiveId, + const resetPreview = () => setPreview(null); + + const previewMutation = useMutation({ + mutationFn: () => { + if (!selectedRoute || !departureDate || selectedBookingIds.length === 0) { + throw new Error("Select route, departure date, and at least one eligible booking"); + } + return trainSchedulingService.preview({ + bookingIds: selectedBookingIds, + scheduleDate: toIso(departureDate), + originStationId: selectedRoute.originYardId, + destinationStationId: selectedRoute.destinationYardId, + assignmentType, }); }, onSuccess: (data) => { - toast.success('Train schedule created'); - setRouteId(''); - setScheduleDate(''); - setSelectedLocomotiveId(''); + setPreview(data); + setFormMessage({ + color: data.valid ? "green" : "red", + title: data.valid ? "Assignment preview ready" : "Assignment needs attention", + message: data.valid + ? "Wagon count, weight, and length validations passed." + : data.violations.join(", "), + }); + }, + onError: (error) => { + setFormMessage({ color: "red", title: "Preview failed", message: parseError(error, "Failed to preview assignment") }); + }, + }); + + const createMutation = useMutation({ + mutationFn: () => { + if (!routeId || !departureDate || !arrivalDate || !selectedLocomotiveId || selectedBookingIds.length === 0 || selectedWagonIds.length === 0) { + throw new Error("Select locomotive, route, departure, arrival, wagons, and bookings"); + } + if (selectedWagonLimitExceeded) { + throw new Error(`Select no more than ${maxWagons} wagons for this locomotive`); + } + if (preview && selectedWagonIds.length < preview.summary.wagonsNeeded) { + throw new Error(`Select at least ${preview.summary.wagonsNeeded} wagons for this assignment`); + } + return trainSchedulingService.createSchedule({ + routeId, + scheduleDate: toIso(departureDate), + arrivalDate: toIso(arrivalDate), + locomotiveId: selectedLocomotiveId, + assignmentType, + bookingIds: selectedBookingIds, + wagonIds: selectedWagonIds, + }); + }, + onSuccess: (data) => { + setFormMessage({ + color: "green", + title: "Schedule generated", + message: "The schedule was created and added to Created schedules.", + }); + setInvoiceModalOpen(true); + setRouteId(""); + setDepartureDate(""); + setArrivalDate(""); + setSelectedLocomotiveId(""); + setSelectedWagonIds([]); + setSelectedBookingIds([]); + setPreview(null); void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() }); + void schedulesQuery.refetch(); + void bookingsApiQuery.refetch(); + void wagonsQuery.refetch(); + void locomotivesQuery.refetch(); setDetailId(data.id); }, onError: (error) => { - toast.error(parseError(error, 'Failed to create train schedule')); + setFormMessage({ color: "red", title: "Schedule failed", message: parseError(error, "Failed to generate schedule") }); }, }); const cancelMutation = useMutation({ mutationFn: (id: string) => trainSchedulingService.cancelSchedule(id), onSuccess: (data) => { - toast.success('Train schedule cancelled'); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(data.id) }); setDetailId(data.id); }, onError: (error) => { - toast.error(parseError(error, 'Failed to cancel train schedule')); + setFormMessage({ color: "red", title: "Cancel failed", message: parseError(error, "Failed to cancel schedule") }); + }, + }); + + const publishMutation = useMutation({ + mutationFn: (id: string) => trainSchedulingService.publishSchedule(id), + onSuccess: (data) => { + setFormMessage({ + color: "green", + title: "Schedule published", + message: "The schedule is published and customers can be notified.", + }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT }); + setDetailId(data.id); + }, + onError: (error) => { + setFormMessage({ color: "red", title: "Publish failed", message: parseError(error, "Failed to publish schedule") }); }, }); const detail = detailQuery.data; + const weightProgress = preview + ? Math.min(100, (preview.summary.totalWeightTons / TRAIN_LIMITS.maxWeightTons) * 100) + : 0; + const lengthProgress = preview + ? Math.min(100, (preview.summary.totalLengthMeters / TRAIN_LIMITS.maxLengthMeters) * 100) + : 0; return ( - - Operations - - - Train schedules - + Operations + Locomotive scheduling - + {formMessage ? ( + setFormMessage(null)}> + {formMessage.message} + + ) : null} + + @@ -218,9 +564,9 @@ const TrainsPage = () => { - Train Schedules + Locomotive Scheduling - Create the train schedule first, reserve the locomotive, and assign bookings and wagons later. + Select a locomotive, route, wagon assignment type, and available bookings before generating a scheduled train. @@ -231,6 +577,8 @@ const TrainsPage = () => { void routesQuery.refetch(); void schedulesQuery.refetch(); void locomotivesQuery.refetch(); + void wagonsQuery.refetch(); + void bookingsApiQuery.refetch(); }} > Refresh @@ -239,290 +587,646 @@ const TrainsPage = () => { - - + + - - - Schedule builder - + + + + + Select locomotive + Use the table to choose the locomotive that will pull this scheduled train. + + + {selectedLocomotive ? ( + + {selectedLocomotive.code} selected + + ) : null} + {filteredLocomotives.length} locomotives + + - setLocomotiveStatusFilter(value ?? "ALL")} + /> + - setScheduleDate(event.currentTarget.value)} - /> + + + Table view + Card view + + + + + + + Select + Locomotive + Type + Pull + Length + Status + + + + {filteredLocomotives.map((locomotive) => { + const isSelected = locomotive.id === selectedLocomotiveId; + return ( + { + setSelectedLocomotiveId(locomotive.id); + setRouteId(""); + setSelectedWagonIds([]); + setSelectedBookingIds([]); + resetPreview(); + }} + > + + + + + {locomotive.code} + {locomotive.name ?? "Locomotive"} + + {locomotive.locomotiveType ?? "-"} + {locomotive.maxPullWeightTons} T + {locomotive.maxTrainLengthMeters} m + + {locomotive.status} + + + ); + })} + +
+
+
+ + {locomotivesQuery.isLoading ? : null} + + {filteredLocomotives.map((locomotive) => ( + { + setSelectedLocomotiveId(locomotive.id); + setRouteId(""); + setSelectedWagonIds([]); + setSelectedBookingIds([]); + resetPreview(); + }} + /> + ))} + + +
+
+
- setScheduleStatusFilter(value ?? 'ALL')} - /> - - - - - - - Schedule - Departure - Route - Locomotive - Bookings - Wagons - Weight - Length - Status - Actions - - - - {filteredSchedules.map((schedule) => ( - - - - {schedule.id} - - - {formatDate(schedule.scheduleDate)} - - {schedule.routeName ?? `${schedule.origin ?? '-'} to ${schedule.destination ?? '-'}`} - - {schedule.locomotive?.code ?? '-'} - {schedule.bookingsCount} - {schedule.wagonCount} - {schedule.totalWeightTons} T - {schedule.totalLengthMeters} m - - - {schedule.status} - - - - - - {schedule.status !== 'CANCELLED' ? ( - - ) : null} - - - - ))} - {!schedulesQuery.isLoading && filteredSchedules.length === 0 ? ( - - - - No train schedules matched the current filters. - - - - ) : null} - {schedulesQuery.isLoading ? ( - - - - Loading schedules... - - - - ) : null} - -
-
+ + setScheduleSearch(event.currentTarget.value)} + /> + ({ + value: locomotive.id, + label: `${locomotive.code} - ${locomotive.maxPullWeightTons}T / ${locomotive.maxTrainLengthMeters}m`, + }))} + value={selectedLocomotiveId || null} + searchable + onChange={(value) => { + setSelectedLocomotiveId(value ?? ""); + setRouteId(""); + setSelectedWagonIds([]); + setSelectedBookingIds([]); + resetPreview(); + }} + /> + +