diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 4c57af97a..b6898322b 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -47,6 +47,7 @@ import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from './modules/wagons/wagons.module'; import { ContainersModule } from './modules/container-management/containers.module'; import { CargoesModule } from './modules/cargoes/cargoes.module'; +import { RoutesModule } from './modules/routes/routes.module'; @Module({ imports: [ @@ -98,6 +99,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module'; WagonsModule, ContainersModule, CargoesModule, + RoutesModule, ], providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder], }) diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts index b35d79932..a29fb861e 100644 --- a/apps/edr-freight-api/src/data-source.ts +++ b/apps/edr-freight-api/src/data-source.ts @@ -1,14 +1,15 @@ // apps/edr-freight-api/src/data-source.ts +import 'dotenv/config'; import { DataSource } from 'typeorm'; //import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed export const AppDataSource = new DataSource({ type: 'postgres', - host: 'localhost', - port: 5432, - username: 'postgres', - password: '', // Laragon default: empty - database: 'edr_freight', + host: process.env.DB_HOST ?? 'localhost', + port: Number(process.env.DB_PORT ?? 5432), + username: process.env.DB_USER ?? 'postgres', + password: process.env.DB_PASSWORD ?? '', + database: process.env.DB_NAME ?? 'edr_freight', schema: 'freight', // default schema for entities without an explicit schema entities: [__dirname + '/**/*.entity{.ts,.js}'], migrations: [__dirname + '/migrations/*{.ts,.js}'], @@ -17,4 +18,4 @@ export const AppDataSource = new DataSource({ }); // Optional: call ensurePostgresSchemas before initializing -// But you can also run it separately. \ No newline at end of file +// But you can also run it separately. diff --git a/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts b/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts new file mode 100644 index 000000000..421f66ef9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRoutesAndExtendLocomotives1750100000000 implements MigrationInterface { + name = 'AddRoutesAndExtendLocomotives1750100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS locomotive_type VARCHAR(20) NOT NULL DEFAULT 'DIESEL', + ADD COLUMN IF NOT EXISTS max_train_length_meters NUMERIC(10,3) NOT NULL DEFAULT 760, + ADD COLUMN IF NOT EXISTS power_kw NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS traction_force_kn NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS max_speed_kmh NUMERIC(10,3) NULL; + `); + + await queryRunner.query(` + UPDATE freight.locomotives + SET status = 'OUT_OF_SERVICE' + WHERE status = 'INACTIVE'; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.routes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(120) NOT NULL UNIQUE, + origin_yard_id UUID NOT NULL REFERENCES freight.yards(id), + destination_yard_id UUID NOT NULL REFERENCES freight.yards(id), + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.route_milestones ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + route_id UUID NOT NULL REFERENCES freight.routes(id) ON DELETE CASCADE, + yard_id UUID NOT NULL REFERENCES freight.yards(id), + sequence_no INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_route_milestones_route_sequence UNIQUE (route_id, sequence_no) + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_routes_origin_yard_id + ON freight.routes(origin_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_routes_destination_yard_id + ON freight.routes(destination_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_routes_is_active + ON freight.routes(is_active); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_route_milestones_route_id + ON freight.route_milestones(route_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_route_milestones_yard_id + ON freight.route_milestones(yard_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.route_milestones;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.routes;`); + await queryRunner.query(` + ALTER TABLE freight.locomotives + DROP COLUMN IF EXISTS max_speed_kmh, + DROP COLUMN IF EXISTS traction_force_kn, + DROP COLUMN IF EXISTS power_kw, + DROP COLUMN IF EXISTS max_train_length_meters, + DROP COLUMN IF EXISTS locomotive_type; + `); + await queryRunner.query(` + UPDATE freight.locomotives + SET status = 'INACTIVE' + WHERE status = 'OUT_OF_SERVICE'; + `); + } +} diff --git a/apps/edr-freight-api/src/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-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts index 4697728e7..ecc410320 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts @@ -3,45 +3,57 @@ import { BookingsRepository } from './bookings.repository'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; +import { PaymentService } from '../payment/payment.service'; -export interface InAppPaymentReceipt extends InAppPaymentReceiptDto {} +export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { } @Injectable() export class BookingPaymentService { - constructor(private readonly bookingsRepository: BookingsRepository) {} + constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { } async pay( bookingId: string, - ): Promise<{ booking: Booking; receipt: InAppPaymentReceipt }> { + ): Promise<{ redirectUrl: string }> { const booking = await this.requireBooking(bookingId); assertBookingStatus(booking, ['FULLY_EXECUTED']); - const receipt = this.buildMockReceipt(booking); + // const receipt = this.buildMockReceipt(booking); - const updated = await this.bookingsRepository.update(bookingId, { - status: 'PAID', - paymentStatus: 'PAID', - } as never); - - return { booking: updated!, receipt }; - } - - private buildMockReceipt(booking: Booking): InAppPaymentReceipt { - const timestamp = Date.now(); - const isEtb = booking.paymentCurrency === 'ETB'; - const prefix = isEtb ? 'TB' : 'CARD'; - const provider = isEtb ? 'TELEBIRR' : 'CARD'; + // const updated = await this.bookingsRepository.update(bookingId, { + // status: 'PAID', + // paymentStatus: 'PAID', + // } as never); + const resp = await this.paymentService.pay(booking.totalAmount, "ETB", "telebirr", "payment for booking", 'booking', (_) => { + return new Promise((resp, _) => { + resp({ + id: booking.id, + type: "booking" + }) + }); + }) return { - success: true, - provider, - providerRef: `${prefix}-${booking.reference}-${timestamp}`, - amount: booking.totalAmount, - currency: booking.paymentCurrency, - paidAt: new Date().toISOString(), - }; + redirectUrl: resp.clientAction.type == "REDIRECT" ? `http://localhost:3001/api/payments/telebirr/${booking.id}` : "" + } + } + // private buildMockReceipt(booking: Booking): InAppPaymentReceipt { + // const timestamp = Date.now(); + // const isEtb = booking.paymentCurrency === 'ETB'; + // const prefix = isEtb ? 'TB' : 'CARD'; + // const provider = isEtb ? 'TELEBIRR' : 'CARD'; + + // return { + // success: true, + // provider, + // providerRef: `${prefix}-${booking.reference}-${timestamp}`, + // amount: booking.totalAmount, + // currency: booking.paymentCurrency, + // paidAt: new Date().toISOString(), + // }; + // } + private async requireBooking(id: string): Promise { const booking = await this.bookingsRepository.findById(id); if (!booking) throw new NotFoundException(`Booking ${id} not found`); 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/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 8fdaff7d1..c230a187f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -28,6 +28,7 @@ import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing import { ContractRendererService } from '../../contracts/contract-renderer.service'; import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; +import { PaymentModule } from '../payment/payment.module'; @Module({ imports: [ @@ -40,6 +41,7 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu BookingReviewNote, BookingContractSignature, ]), + PaymentModule, FilesModule, MinioModule, CompaniesModule, diff --git a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts index de66afac5..25f1927d3 100644 --- a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts @@ -2,10 +2,10 @@ import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BookingPaymentService } from './booking-payment.service'; -import { BookingTransitionService } from './booking-transition.service'; -import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; -import { Booking } from './entities/booking.entity'; -import { BookingNextStep } from './booking-next-step.util'; +// import { BookingTransitionService } from './booking-transition.service'; +// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; +// import { Booking } from './entities/booking.entity'; +// import { BookingNextStep } from './booking-next-step.util'; @ApiTags('payments') @ApiBearerAuth() @@ -13,22 +13,15 @@ import { BookingNextStep } from './booking-next-step.util'; export class PayController { constructor( private readonly paymentService: BookingPaymentService, - private readonly transitionService: BookingTransitionService, - ) {} + // private readonly transitionService: BookingTransitionService, + ) { } @Post(':id/payment/pay') @ApiOperation({ summary: 'Complete in-app payment (mock)' }) @ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' }) - async pay(@Param('id', ParseUUIDPipe) id: string): Promise< - Booking & { - latestChangeRequestNote?: string | null; - contractSummary?: string | null; - nextStep: BookingNextStep | null; - paymentReceipt: InAppPaymentReceiptDto; - } - > { - const { booking, receipt } = await this.paymentService.pay(id); - const abstract = await this.transitionService.enrichBookingResponse(booking); - return { ...abstract, paymentReceipt: receipt }; + async pay(@Param('id', ParseUUIDPipe) id: string) { + return await this.paymentService.pay(id); + // const abstract = await this.transitionService.enrichBookingResponse(booking); + // return { ...abstract, paymentReceipt: receipt }; } } 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/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index f4237fc16..296c60520 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -1,36 +1,42 @@ import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common"; import { PaymentService } from "./payment.service"; import { Public } from "@edr/api-common"; -import { randomUUID } from "crypto"; +// import { randomUUID } from "crypto"; import { Response } from "express" @Public() @Controller("payments") export class PaymentController { - constructor(private readonly paymentService: PaymentService) { } + constructor(private readonly paymentService: PaymentService,) { } - @Get("/receipts/:orderId/html") - async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) { - const filled = await this.paymentService.genReceiptHtml(orderId); - return res.send(filled) - } + // @Get("/receipts/:orderId/html") + // async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) { + // const filled = await this.paymentService.genReceiptHtml(orderId); + // return res.send(filled) + // } - @Post("/initiate/booking") - async initiatePayment() { + // @Post("/initiate/booking") + // async initiatePayment() { - //Only for testing.. - const description = "booking" - const data = await this.paymentService.pay(20, "ETB", "telebirr", description, "booking", (_) => { - return new Promise((resp, _) => { - resp({ - id: randomUUID(), - type: "booking" - }) - }); - }) + // //Only for testing.. + // const description = "Booking for contact" + // const price = 2000 + // const data = await this.paymentService.pay(price, "ETB", "telebirr", description, "booking", (_) => { + // return new Promise((resp, _) => { + // resp({ + // id: randomUUID(), + // type: "booking" + // }) + // }); + // }) - return data + // return data + // } + + @Post("/bookings/check-payment/:orderId") + checkPayment(@Param("orderId", ParseUUIDPipe) orderId: string) { + return this.paymentService.checkStatusAndUpdate(orderId) } diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 1c0605259..ff6a50943 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -7,11 +7,11 @@ import { ConfigModule } from "@nestjs/config"; import { PaymentRepository } from "./payment.repository"; import { WebhookController } from "./webhooks/webhook.controller"; import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service"; -import { BookingsModule } from "../bookings/bookings.module"; @Module({ - imports: [HttpModule, ConfigModule, BookingsModule], + imports: [HttpModule, ConfigModule], providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService], - controllers: [PaymentController, WebhookController] + controllers: [PaymentController, WebhookController], + exports: [PaymentService] }) export class PaymentModule { } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index e89fd5157..997a0e18f 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -10,6 +10,8 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as Handlebars from 'handlebars'; +import { ConfigService } from "@nestjs/config"; +import { Booking } from "../bookings/entities/booking.entity"; type PaymentMethod = PaymentEntity["method"] @@ -20,6 +22,7 @@ export class PaymentService { private strategies: Map; constructor( + private readonly configService: ConfigService, private readonly datasource: DataSource, private readonly paymentRepo: PaymentRepository, private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) { @@ -47,7 +50,8 @@ export class PaymentService { let redirectUrl: string; switch (type) { case "booking": - redirectUrl = `http://localhost:3001/api/payments/receipts/${orderId}/html` + const url = this.configService.get("TELEBIRR_SUCCESS_REDIRECT_BASE_URL") + redirectUrl = `${url}/check-status/${orderId}` break; } @@ -132,17 +136,27 @@ export class PaymentService { return html; } - // const templatePath = path.join( - // process.cwd(), - // 'src/modules/payment/templates/receipt.hbs', - // ); + async checkStatusAndUpdate(orderId: string) { + const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId }) + if (!resp) { + throw new NotFoundException("order id not found") + } + const result = await this.telebirrPaymentStategy.queryStatus(resp.merchantOrderId) + const bizContent = result.rawResponse.biz_content as { + order_status: string; + }; - - // console.log(templatePath) - - // const source = fs.readFileSync(templatePath, 'utf8'); - // const template = Handlebars.compile(source); - // return this.getReceiptTemplate(); + const ordersStatus = bizContent.order_status + if (ordersStatus == "PAY_SUCCESS") { + await this.datasource.transaction(async (mg) => { + await mg.update(Booking, { id: resp.refId }, { status: "PAID" }) + await mg.update(PaymentEntity, { id: resp.id }, { status: "success" }) + }) + } + return { + status: result.status + } + } } diff --git a/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts b/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts index a292863d8..de1768bc6 100644 --- a/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts +++ b/apps/edr-freight-api/src/modules/payment/strategies/payment.telebirr.strategy.ts @@ -301,5 +301,4 @@ export class PaymentTelebirrStrategy implements PaymentStrategy { return this.config.get('telebirr.publicKey') ?? ''; } - } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts b/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts index a57c79982..3e450c2a0 100644 --- a/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts +++ b/apps/edr-freight-api/src/modules/payment/webhooks/providers/telebirr.service.ts @@ -2,15 +2,16 @@ import { Injectable, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as crypto from "crypto" import { TelebirrDto } from '../dto/telebirr.dto'; -import { BookingsRepository } from 'src/modules/bookings/bookings.repository'; import { PaymentRepository } from '../../payment.repository'; +import { DataSource } from 'typeorm'; +import { Booking } from 'src/modules/bookings/entities/booking.entity'; @Injectable() export class TelebirrWebhookService { // private readonly logger = new Logger(TelebirrWebhookService.name); constructor( + private readonly datasource: DataSource, private readonly config: ConfigService, - private readonly bookingRepo: BookingsRepository, private readonly paymentRepo: PaymentRepository, ) { } @@ -57,7 +58,8 @@ export class TelebirrWebhookService { await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() }) switch (payment.type) { case "booking": - await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", }) + await this.datasource.manager.update(Booking, { id: payment.refId }, { paymentStatus: "PAID", }) + // await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", }) break; } break; 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/dto/get-eligible-container-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts index 24735808d..2ca15dc0c 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,7 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; - -import { BOOKING_STATUSES } from '../../bookings/entities/booking.entity'; +import { IsDateString, IsOptional, IsUUID } from 'class-validator'; export class GetEligibleContainerBookingsDto { @ApiPropertyOptional({ format: 'uuid' }) @@ -18,9 +16,4 @@ export class GetEligibleContainerBookingsDto { @IsOptional() @IsDateString() scheduleDate?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsIn(BOOKING_STATUSES) - status?: 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 659a4d6eb..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', }; @@ -37,7 +38,7 @@ const makeBooking = ( scheduledDate: new Date(scheduledDate), originYardId, destinationYardId, - status: 'APPROVED', + status: 'PAID', customer: { companyName: 'Demo Customer' }, originYard: { label: 'Djibouti', code: 'DJIBOUTI' }, destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' }, @@ -163,48 +164,51 @@ 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, + it('rejects bookings that are not in schedulable status', async () => { + const bookings = [ + { + ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'), + status: 'APPROVED', }, - 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, - }, - ], - }, - ], + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Booking') { + return { find: jest.fn().mockResolvedValue(bookings) }; + } + if (entity?.name === 'TrainScheduleBooking') { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity?.name === 'Locomotive') { + return { + count: jest.fn().mockResolvedValue(1), + find: jest.fn().mockResolvedValue([locomotive]), + }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['b7'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(false); + expect(result.violations).toContain( + 'Only PAID bookings can be scheduled; received: APPROVED', + ); + }); + + it('creates a schedule transactionally when validation passes', async () => { + const route = { + id: 'route-1', + name: 'Djibouti to Addis', + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + isActive: true, }; const lockedLocomotiveRepo = { @@ -215,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' }), @@ -243,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: @@ -257,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 34c4a7843..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"; @@ -27,6 +27,7 @@ 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; type EligibleBookingItem = { id: string; @@ -83,7 +84,7 @@ export class TrainSchedulingService { const bookingRepository = this.dataSource.getRepository(Booking); const queryBuilder = bookingRepository .createQueryBuilder("booking") - .leftJoinAndSelect("booking.customer", "customer") + .leftJoinAndSelect("booking.company", "company") .leftJoinAndSelect("booking.originYard", "originYard") .leftJoinAndSelect("booking.destinationYard", "destinationYard") .leftJoinAndSelect("booking.bookingContainers", "bookingContainer") @@ -96,6 +97,10 @@ export class TrainSchedulingService { .where("booking.freightType = :freightType", { freightType: "CONTAINER" }) .andWhere("scheduleBooking.id IS NULL"); + queryBuilder.andWhere("booking.status IN (:...schedulableStatuses)", { + schedulableStatuses: SCHEDULABLE_BOOKING_STATUSES, + }); + if (query.originStationId) { queryBuilder.andWhere("booking.originYardId = :originStationId", { originStationId: query.originStationId, @@ -118,12 +123,6 @@ export class TrainSchedulingService { ); } - if (query.status) { - queryBuilder.andWhere("booking.status = :status", { - status: query.status, - }); - } - const bookings = await queryBuilder .orderBy("booking.scheduled_date", "ASC") .addOrderBy("booking.created_at", "ASC") @@ -180,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( @@ -212,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", }); @@ -357,6 +284,16 @@ export class TrainSchedulingService { ); } + const invalidStatusBookings = bookings.filter( + (booking) => !SCHEDULABLE_BOOKING_STATUSES.includes(booking.status as "PAID"), + ); + 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(", ")}`, + ); + } + const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate); const routeMismatch = bookings.some( (booking) => @@ -452,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', + ); } } @@ -504,6 +445,7 @@ export class TrainSchedulingService { async selectOrValidateLocomotive( locomotiveId: string, totalWeightTons: number, + totalLengthMeters: number, ) { const locomotive = await this.locomotivesRepository.findById(locomotiveId); @@ -523,6 +465,12 @@ export class TrainSchedulingService { ); } + if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, + ); + } + return locomotive; } @@ -559,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[], @@ -618,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, @@ -628,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: @@ -659,6 +624,7 @@ export class TrainSchedulingService { .findOne({ where: { id }, relations: { + route: true, trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } }, @@ -678,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, @@ -702,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 ?? [])] @@ -797,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 new file mode 100644 index 000000000..d06953769 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; + +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 }) + @IsString() + @MaxLength(100) + name!: string; + + @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)) + supportedLoadTypes?: string[]; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts new file mode 100644 index 000000000..846987556 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateWagonTypeDto } from './create-wagon-type.dto'; + +export class UpdateWagonTypeDto extends PartialType(CreateWagonTypeDto) {} 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 0c0d65bd7..7717dd703 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 @@ -1,16 +1,67 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; -import { WagonTypesService } from './wagon-types.service'; -import { WagonType } from './entities/wagon-type.entity'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -@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) {} @Get() - @ApiOperation({ summary: 'Get all active wagon types' }) - async findAll(): Promise { - return this.wagonTypesService.findAll(); + @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, + }); } -} \ No newline at end of file + + @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) { + return this.wagonTypesService.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('wagon-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a wagon type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + 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 879245783..a3bbfa005 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,5 +1,13 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { generateCode } from '../../common/utils/generate-code.util'; + +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'; @@ -7,20 +15,86 @@ import { WagonTypesRepository } from './wagon-types.repository'; export class WagonTypesService { constructor(private readonly wagonTypesRepository: WagonTypesRepository) {} - async findAll(): Promise { - return this.wagonTypesRepository.findAll({ - where: { 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, }); + + 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 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; } + + 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`); + } + return updated; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.wagonTypesRepository.softDelete(id); + } } 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 c009d21f8..edf19adbb 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -51,6 +51,8 @@ const DEMO_BOOKINGS = [ originCode: "DJIBOUTI", destinationCode: "ADDIS_ABABA", scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", }, { reference: "BKG-CONT-002", @@ -60,6 +62,8 @@ const DEMO_BOOKINGS = [ originCode: "DJIBOUTI", destinationCode: "ADDIS_ABABA", scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", }, { reference: "BKG-CONT-003", @@ -69,6 +73,8 @@ const DEMO_BOOKINGS = [ originCode: "DJIBOUTI", destinationCode: "ADDIS_ABABA", scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", }, { reference: "BKG-CONT-007", @@ -78,6 +84,30 @@ const DEMO_BOOKINGS = [ originCode: "DJIBOUTI", destinationCode: "ADDIS_ABABA", scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-CONT-008", + containerCode: "40FT", + quantity: 4, + totalWeightTons: 120, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-CONT-009", + containerCode: "20FT", + quantity: 5, + totalWeightTons: 110, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", }, { reference: "BKG-CONT-004", @@ -87,6 +117,8 @@ const DEMO_BOOKINGS = [ originCode: "ADDIS_ABABA", destinationCode: "DIRE_DAWA", scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", }, { reference: "BKG-CONT-005", @@ -96,6 +128,8 @@ const DEMO_BOOKINGS = [ originCode: "DJIBOUTI", destinationCode: "ADDIS_ABABA", scheduledDate: "2026-06-21T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", }, { reference: "BKG-CONT-006", @@ -105,6 +139,8 @@ const DEMO_BOOKINGS = [ originCode: "DJIBOUTI", destinationCode: "ADDIS_ABABA", scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", }, ]; @@ -142,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", }, ], @@ -249,10 +289,10 @@ export class DemoBookingsSeeder { { reference: demoBooking.reference, companyId: company.id, - status: "APPROVED", + status: demoBooking.status, scheduledDate: new Date(demoBooking.scheduledDate), totalAmount: 0, - paymentStatus: "PENDING", + paymentStatus: demoBooking.paymentStatus, contractType: "NEW", serviceTypeId: serviceType.id, equipmentReturn: "WITHOUT_RETURN", 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-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/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 2ee3241d8..ea724b31e 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -38,11 +38,13 @@ import TrainsPage from "./pages/trains/TrainsPage"; import { CargoesCrudPage, ContainersCrudPage, + LocomotivesCrudPage, TrainMasterDataPage, WagonsCrudPage, } from "./pages/fleet/FleetCrudPages"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; +import RoutesPage from "./pages/fleet/RoutesPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -59,17 +61,32 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/booking-requests", icon: , }, + ...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", @@ -173,26 +190,7 @@ const DashboardShell = () => { const location = useLocation(); const { user, logout } = useAuth(); - const demoItems: SidebarItem[] = [ - ...(hasPermission(user, "can:demo:user1") - ? [ - { - label: "User1", - href: "/dashboard/user1", - icon: , - }, - ] - : []), - ...(hasPermission(user, "can:demo:user2") - ? [ - { - label: "User2", - href: "/dashboard/user2", - icon: , - }, - ] - : []), - ]; + const demoItems: SidebarItem[] = []; const sidebarSections = buildSidebarSections(demoItems); const displayName = user?.name?.en || user?.username || user?.email || "User"; @@ -244,6 +242,10 @@ const App = () => { /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> 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 bbb770fa3..25b3816b7 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx @@ -162,7 +162,7 @@ function StepRow({ borderRadius: 8, border: "1px solid var(--mantine-color-gray-2)", borderLeft: isNext - ? "3px solid var(--mantine-color-green-6)" + ? "3px solid var(--freight-brand)" : "1px solid var(--mantine-color-gray-2)", background: isNext ? "var(--mantine-color-gray-0)" : "white", }} 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 96f2b3829..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,4 +1,4 @@ -import { Download, Zap, FileText } from "lucide-react"; +import { Download, Zap, FileText, Clock } from "lucide-react"; import { Stack, Text, Button } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; @@ -69,6 +69,28 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool ); } + 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 ( 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/BookingStatGrid.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatGrid.tsx index d584e09c1..d98373ceb 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatGrid.tsx @@ -12,7 +12,7 @@ export interface StatItem { const accentColors = { default: { bg: "var(--mantine-color-gray-1)", color: "var(--mantine-color-gray-6)" }, amber: { bg: "var(--mantine-color-yellow-1)", color: "var(--mantine-color-yellow-6)" }, - emerald: { bg: "var(--mantine-color-green-1)", color: "var(--mantine-color-green-6)" }, + emerald: { bg: "var(--freight-brand-muted)", color: "var(--freight-brand)" }, rose: { bg: "var(--mantine-color-red-1)", color: "var(--mantine-color-red-6)" }, }; @@ -61,7 +61,7 @@ export function BookingStatGrid({ items }: { items: StatItem[] }) { }} onMouseEnter={(e) => { e.currentTarget.style.boxShadow = "0 4px 12px rgba(34, 197, 94, 0.12)"; - e.currentTarget.style.borderColor = "var(--mantine-color-green-3)"; + e.currentTarget.style.borderColor = "var(--freight-brand-border)"; }} onMouseLeave={(e) => { e.currentTarget.style.boxShadow = "none"; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx index d8b93dbc0..2743d0c31 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx @@ -63,12 +63,12 @@ export function BookingStatusTabs({ style={{ flexShrink: 0, background: isActive ? "white" : "transparent", - border: isActive ? "1px solid var(--mantine-color-green-3)" : "1px solid var(--mantine-color-gray-2)", + border: isActive ? "1px solid var(--freight-brand-border)" : "1px solid var(--mantine-color-gray-2)", borderRadius: "10px", padding: "10px 16px", transition: "all 0.2s ease", cursor: "pointer", - boxShadow: isActive ? "0 2px 8px rgba(34, 197, 94, 0.1)" : "none", + boxShadow: isActive ? "0 2px 8px rgb(21 128 61 / 0.12)" : "none", }} > @@ -81,8 +81,8 @@ export function BookingStatusTabs({ width: "32px", height: "32px", borderRadius: "8px", - background: isActive ? "var(--mantine-color-green-1)" : "var(--mantine-color-gray-1)", - color: isActive ? "var(--mantine-color-green-7)" : "var(--mantine-color-gray-6)", + background: isActive ? "var(--freight-brand-muted)" : "var(--mantine-color-gray-1)", + color: isActive ? "var(--freight-brand-dark)" : "var(--mantine-color-gray-6)", }} > {TAB_ICONS[tab.key]} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingWorkflowStepper.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingWorkflowStepper.tsx index 1b2c279a5..62765b06e 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingWorkflowStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingWorkflowStepper.tsx @@ -73,7 +73,7 @@ export function BookingWorkflowStepper({ color: isComplete ? "white" : isActive - ? "var(--mantine-color-green-7)" + ? "var(--freight-brand-dark)" : "var(--mantine-color-gray-5)", transition: "all 0.2s ease", }} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts b/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts index 6a955237c..de357fa05 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts @@ -1,4 +1,4 @@ -/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */ +/** Shared surfaces for booking list & detail — frosted glass, brand accents. */ export const bookingGlass = { card: "border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60", @@ -10,9 +10,9 @@ export const bookingGlass = { iconWellHero: "border border-border/50 bg-background/60 text-foreground shadow-sm ring-1 ring-border/30 backdrop-blur-md supports-[backdrop-filter]:bg-background/45", activeTab: - "border border-emerald-500/20 bg-emerald-500/10 shadow-sm backdrop-blur-md ring-1 ring-emerald-500/10 supports-[backdrop-filter]:bg-emerald-500/[0.08]", + "border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] shadow-sm backdrop-blur-md ring-1 ring-[color:var(--freight-brand-ring)]", iconWellGreen: - "border border-emerald-500/20 bg-emerald-500/15 text-emerald-700 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-emerald-500/10 dark:text-emerald-400", + "border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] shadow-sm backdrop-blur-sm", tabRail: "rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5", tableHeader: @@ -38,7 +38,7 @@ export const bookingSurface = { sectionIcon: `flex size-9 shrink-0 items-center justify-center rounded-lg ${bookingGlass.iconWellGreen}`, sectionIconLg: `flex size-11 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`, valueCard: - "rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-emerald-500/[0.08]", + "rounded-xl border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] p-4 shadow-sm backdrop-blur-md", stickySidebar: "lg:sticky lg:top-6 lg:self-start", metricTile: "rounded-lg border border-border/50 bg-background/70 px-4 py-3 shadow-xs backdrop-blur-sm", @@ -48,7 +48,7 @@ export const bookingSurface = { export const bookingInput = { search: - "h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/20 sm:max-w-xs", + "h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-[color:var(--freight-brand)] focus-visible:ring-[3px] focus-visible:ring-[color:var(--freight-brand-ring)] sm:max-w-xs", } as const; export const bookingTable = { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDocumentsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDocumentsCard.tsx index add08fc75..8f0e3746c 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDocumentsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDocumentsCard.tsx @@ -36,7 +36,7 @@ export function BookingDocumentsCard({ files, onDownload }: BookingDocumentsCard style={detailStyles.fileRow} onMouseEnter={(e) => { e.currentTarget.style.background = "var(--mantine-color-gray-0)"; - e.currentTarget.style.borderColor = "var(--mantine-color-green-3)"; + e.currentTarget.style.borderColor = "var(--freight-brand-border)"; }} onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingLifecycleStepper.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingLifecycleStepper.tsx index 8a9baaae7..eb00b8b71 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingLifecycleStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingLifecycleStepper.tsx @@ -52,7 +52,7 @@ export function BookingLifecycleStepper({ status }: BookingLifecycleStepperProps color: isComplete ? "white" : isActive - ? "var(--mantine-color-green-7)" + ? "var(--freight-brand-dark)" : "var(--mantine-color-gray-5)", transition: "all 0.2s ease", }} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx index 72b37116d..964d83803 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteCard.tsx @@ -31,7 +31,7 @@ export function BookingRouteCard({ booking }: BookingRouteCardProps) { width: 8, height: 8, borderRadius: "50%", - background: "var(--mantine-color-green-6)", + background: "var(--freight-brand)", flexShrink: 0, }} /> diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx index 2978bea38..ea2aa491e 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx @@ -27,7 +27,7 @@ function Endpoint({ {label} - + {station} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts index 9b252adba..4a6f79661 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts @@ -1,7 +1,9 @@ import type { CSSProperties } from "react"; +import { FREIGHT_BRAND } from "@/theme/freight-brand"; + /** Single brand accent. Minimal design uses solid green sparingly, no gradients. */ -export const BRAND_GREEN = "var(--mantine-color-green-6)"; +export const BRAND_GREEN = FREIGHT_BRAND; /** Centralised style tokens for the booking detail page + cards. */ export const detailStyles = { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts index de734ca34..0b7238caf 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts @@ -86,14 +86,8 @@ export function useBookingActionDialog( ); break; } - case "generateContract": - mutations.generateContract.mutate(undefined, { onSuccess }); - break; case "viewContract": break; - case "payBooking": - mutations.payBooking.mutate(undefined, { onSuccess }); - break; case "startTransit": mutations.startTransit.mutate(undefined, { onSuccess }); break; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index b512fc864..ba69363d4 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -9,9 +9,10 @@ import { Sun, User, } from "lucide-react"; -import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge } from "@mantine/core"; +import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge, Box } from "@mantine/core"; import type { PageMeta } from "./types"; +import { freightBrand } from "@/theme/freight-brand"; export interface FreightDashboardHeaderProps { pageMeta: PageMeta; @@ -81,10 +82,11 @@ const FreightDashboardHeader = ({ justifyContent: "space-between", gap: "16px", padding: "0 24px", + // borderBottom: `3px solid ${freightBrand.primary}`, }} > - + {pageMeta.title} @@ -173,7 +175,7 @@ const FreightDashboardHeader = ({

setIsUserMenuOpen(true)} onClose={() => setIsUserMenuOpen(false)}> - + diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx index cece126bd..8417065cf 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx @@ -5,6 +5,7 @@ import FreightDashboardHeader from "./FreightDashboardHeader"; import FreightSidebar from "./FreightSidebar"; import { getPageMeta } from "./route-meta"; import type { SidebarSection } from "./types"; +import { freightMantineTheme } from "@/theme/freight-brand"; type Theme = "light" | "dark"; const THEME_STORAGE_KEY = "edr-theme"; @@ -71,7 +72,7 @@ const FreightDashboardLayout = ({ rel="stylesheet" /> - + toggleExpanded(key)} style={{ - background: groupActive ? "var(--mantine-color-green-1)" : "transparent", + background: groupActive ? freightBrand.mutedBg : "transparent", padding: "8px 12px", borderRadius: "8px", width: "100%", @@ -126,7 +127,7 @@ const FreightSidebar = ({ }} > - + {child.label} {isOpen && ( - + {renderNavBranch(child.children!, depth + 1, key)} )} @@ -184,11 +185,9 @@ const FreightSidebar = ({ height: "30px", borderRadius: "8px", flexShrink: 0, - background: active - ? "linear-gradient(135deg, #10b981 0%, #059669 100%)" - : "var(--mantine-color-gray-1)", + background: active ? freightBrand.gradient : "var(--mantine-color-gray-1)", color: active ? "white" : "var(--mantine-color-gray-6)", - boxShadow: active ? "0 2px 6px rgba(16, 185, 129, 0.25)" : "none", + boxShadow: active ? freightBrand.shadowSm : "none", transition: "all 0.2s ease", }} > @@ -251,7 +250,7 @@ const FreightSidebar = ({ /> {hasChildren && isOpen && ( - + {renderNavBranch(item.children!, 0, item.href)} )} @@ -295,8 +294,8 @@ const FreightSidebar = ({ width: "44px", height: "44px", borderRadius: "12px", - background: "linear-gradient(135deg, #10b981 0%, #059669 100%)", - boxShadow: "0 4px 12px rgba(16, 185, 129, 0.3)", + background: freightBrand.gradient, + boxShadow: freightBrand.shadow, flexShrink: 0, }} > diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index 517ca6468..5c46b1390 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -35,6 +35,20 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "Dashboard summary and key metrics", }, }, + { + prefix: "/dashboard/routes", + meta: { + title: "Routes", + subtitle: "Manage route definitions built from freight yards", + }, + }, + { + prefix: "/dashboard/locomotives", + meta: { + title: "Locomotives", + subtitle: "Manage locomotive master data and service status", + }, + }, { prefix: "/dashboard/user-management/employees", meta: { @@ -81,7 +95,7 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration, meta: { title: "Configuration", - subtitle: "Master data: cargo, containers, services, surcharges, yards, and shipping lines", + subtitle: "Master data: cargo, containers, wagon types, services, surcharges, yards, and shipping lines", }, }, ...configurationRouteMeta, diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx index a953f47e6..ae0ac7821 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx @@ -121,8 +121,8 @@ const RuleEngineCardGrid = ({ ); } - const avatarColors = ["blue", "cyan", "grape", "green", "lime", "orange", "pink", "red", "teal", "violet", "yellow"]; - const getAvatarColor = (title: string) => avatarColors[title.charCodeAt(0) % avatarColors.length]; + const avatarBg = "#f1f5f9"; + const avatarText = "#475569"; return ( @@ -160,8 +160,8 @@ const RuleEngineCardGrid = ({ cursor: "pointer", }} onMouseEnter={(e) => { - e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)"; - e.currentTarget.style.borderColor = "var(--mantine-color-blue-3)"; + e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.08)"; + e.currentTarget.style.borderColor = "var(--mantine-color-gray-3)"; }} onMouseLeave={(e) => { e.currentTarget.style.boxShadow = "none"; @@ -178,10 +178,10 @@ const RuleEngineCardGrid = ({ width: "44px", height: "44px", borderRadius: "8px", - background: `var(--mantine-color-${getAvatarColor(title)}-1)`, + background: avatarBg, fontSize: "16px", fontWeight: 700, - color: `var(--mantine-color-${getAvatarColor(title)}-7)`, + color: avatarText, flexShrink: 0, }} > diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 2cb57d8bc..ae5914370 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Loader2 } from "lucide-react"; import { Modal, @@ -9,9 +9,10 @@ import { Switch, Stack, Group, - Divider, Text, Box, + SimpleGrid, + Divider, } from "@mantine/core"; import { @@ -32,6 +33,43 @@ export interface RuleEngineFormDialogProps { onSubmit: (values: Record) => void; } +type FormRow = + | { kind: "pair"; fields: [FormFieldDef, FormFieldDef] } + | { kind: "single"; field: FormFieldDef }; + +const isShortField = (field: FormFieldDef) => + field.type === "text" || + field.type === "number" || + field.type === "select" || + field.type === "date"; + +const buildFormRows = (fields: FormFieldDef[]): FormRow[] => { + const rows: FormRow[] = []; + let index = 0; + + while (index < fields.length) { + const field = fields[index]; + + if (field.type === "textarea" || field.type === "boolean") { + rows.push({ kind: "single", field }); + index += 1; + continue; + } + + const next = fields[index + 1]; + if (next && isShortField(next)) { + rows.push({ kind: "pair", fields: [field, next] }); + index += 2; + continue; + } + + rows.push({ kind: "single", field }); + index += 1; + } + + return rows; +}; + const buildInitialValues = ( fields: FormFieldDef[], record?: RuleEngineRecord | null, @@ -42,6 +80,8 @@ const buildInitialValues = ( if (raw !== undefined && raw !== null) { if (field.type === "date" && typeof raw === "string") { values[field.name] = raw.slice(0, 10); + } else if (Array.isArray(raw)) { + values[field.name] = raw.join(", "); } else { values[field.name] = raw; } @@ -74,11 +114,34 @@ const resolveSelectValue = ( return String(raw); }; +const inputStyles = { + label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" }, + input: { + borderColor: "#e2e8f0", + background: "white", + transition: "border-color 0.15s ease, box-shadow 0.15s ease", + "&:focus": { + borderColor: "var(--freight-brand)", + boxShadow: "0 0 0 3px var(--freight-brand-ring)", + }, + }, +} as const; + +const FieldLabel = ({ label, required }: { label: string; required?: boolean }) => ( + + {label} + {required ? ( + + * + + ) : null} + +); + const RuleEngineFormDialog = ({ open, onOpenChange, title, - description, fields, initialRecord, isSubmitting, @@ -95,6 +158,8 @@ const RuleEngineFormDialog = ({ } }, [open, fields, initialRecord]); + const formRows = useMemo(() => buildFormRows(fields), [fields]); + const setField = (name: string, value: unknown) => { setValues((current) => ({ ...current, [name]: value })); }; @@ -130,157 +195,147 @@ const RuleEngineFormDialog = ({ onSubmit(payload); }; + const renderField = (field: FormFieldDef) => { + if (field.type === "boolean") { + return ( + + + {field.label} + + setField(field.name, e.currentTarget.checked)} + size="md" + color="green" + /> + + ); + } + + const label = ; + + if (field.type === "select") { + return ( +