From 088295d81f379ccf8625680bb7cc52d7e8c26e64 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 10 Jun 2026 09:22:57 +0000 Subject: [PATCH] schedule logic --- .../1781000000000-AddLocomotiveReadiness.ts | 25 + ...81000000001-CreateTrainCheckpointEvents.ts | 35 + .../locomotives/entities/locomotive.entity.ts | 12 + .../dto/record-checkpoint.dto.ts | 34 + .../entities/train-checkpoint-event.entity.ts | 45 + .../train-checkpoint-events.repository.ts | 24 + .../train-scheduling.controller.ts | 25 + .../train-scheduling.module.ts | 5 +- .../train-scheduling.service.spec.ts | 8 + .../train-scheduling.service.ts | 249 ++++- .../train-scheduling/wagon-readiness.util.ts | 13 + apps/edr-freight-web/backoffice/src/App.tsx | 5 + .../trainScheduling/AllocateBookingWizard.tsx | 902 ++++++++++++------ .../trainScheduling/RouteCorridorTrack.tsx | 192 ++++ .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../backoffice/src/constants/URLS.ts | 2 + .../trainScheduling/useTrainScheduling.ts | 36 +- .../TrainScheduleTrackPage.tsx | 263 +++++ .../TrainScheduleV2DetailPage.tsx | 45 +- .../TrainScheduleV2ListPage.tsx | 74 +- .../src/services/trainScheduling.service.ts | 28 + .../backoffice/src/types/trainScheduling.ts | 46 + packages/types/src/freight/index.ts | 16 + 23 files changed, 1766 insertions(+), 319 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1781000000000-AddLocomotiveReadiness.ts create mode 100644 apps/edr-freight-api/src/migrations/1781000000001-CreateTrainCheckpointEvents.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/RouteCorridorTrack.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx diff --git a/apps/edr-freight-api/src/migrations/1781000000000-AddLocomotiveReadiness.ts b/apps/edr-freight-api/src/migrations/1781000000000-AddLocomotiveReadiness.ts new file mode 100644 index 000000000..f6d87f41d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000000-AddLocomotiveReadiness.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLocomotiveReadiness1781000000000 implements MigrationInterface { + name = 'AddLocomotiveReadiness1781000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY' + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_locomotives_readiness + ON freight.locomotives (readiness) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`); + await queryRunner.query(` + ALTER TABLE freight.locomotives + DROP COLUMN IF EXISTS readiness + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1781000000001-CreateTrainCheckpointEvents.ts b/apps/edr-freight-api/src/migrations/1781000000001-CreateTrainCheckpointEvents.ts new file mode 100644 index 000000000..7d9ce745a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000001-CreateTrainCheckpointEvents.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateTrainCheckpointEvents1781000000001 implements MigrationInterface { + name = 'CreateTrainCheckpointEvents1781000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_checkpoint_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_schedule_id UUID NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + yard_id UUID NOT NULL, + sequence_no INT NOT NULL, + kind VARCHAR(20) NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + note TEXT NULL, + recorded_by_user_id UUID NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_checkpoint_events_schedule + ON freight.train_checkpoint_events (train_schedule_id, sequence_no) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_train_checkpoint_events_schedule`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_checkpoint_events`); + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index 2c5aa463a..40e00aa68 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 @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import { WagonReadiness } from '@edr/types'; import { Column, Entity, Index, OneToMany } from 'typeorm'; import { TrainSet } from '../../train-sets/entities/train-set.entity'; @@ -12,12 +13,20 @@ export const LOCOMOTIVE_STATUSES = [ export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const; +/** Locomotives reuse the wagon readiness values (IMPORT_READY / EXPORT_READY). */ +export const LOCOMOTIVE_READINESS_VALUES = [ + WagonReadiness.ImportReady, + WagonReadiness.ExportReady, +] as const; + export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number]; export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number]; +export type LocomotiveReadiness = (typeof LOCOMOTIVE_READINESS_VALUES)[number]; @Entity({ schema: 'freight', name: 'locomotives' }) @Index(['code']) @Index(['status']) +@Index(['readiness']) export class Locomotive extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) code!: string; @@ -37,6 +46,9 @@ export class Locomotive extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' }) status!: LocomotiveStatus; + @Column({ name: 'readiness', type: 'varchar', length: 20, default: WagonReadiness.ImportReady }) + readiness!: LocomotiveReadiness; + @Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true }) powerKw?: number | null; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts new file mode 100644 index 000000000..7f778760d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { TrainCheckpointKind } from '@edr/types'; +import { + IsEnum, + IsInt, + IsISO8601, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; + +export class RecordCheckpointDto { + @ApiProperty({ description: 'Station position along the route (0 = origin).' }) + @IsInt() + @Min(0) + sequenceNo!: number; + + @ApiProperty({ enum: TrainCheckpointKind, required: false }) + @IsOptional() + @IsEnum(TrainCheckpointKind) + kind?: TrainCheckpointKind; + + @ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' }) + @IsOptional() + @IsISO8601() + occurredAt?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts new file mode 100644 index 000000000..5fa90a2e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts @@ -0,0 +1,45 @@ +import { BaseEntity } from '@edr/api-common'; +import { TrainCheckpointKind } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; + +/** + * One staff-logged tracking checkpoint for a dispatched train as it passes a + * station along its route (origin → milestones → destination). + */ +@Entity({ schema: 'freight', name: 'train_checkpoint_events' }) +@Index(['trainScheduleId']) +@Index(['trainScheduleId', 'sequenceNo']) +export class TrainCheckpointEvent extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + /** Position along the corridor: 0 = origin, N+1 = destination. */ + @Column({ name: 'sequence_no', type: 'int' }) + sequenceNo!: number; + + @Column({ name: 'kind', type: 'varchar', length: 20 }) + kind!: TrainCheckpointKind; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; + + @Column({ name: 'recorded_by_user_id', type: 'uuid', nullable: true }) + recordedByUserId?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts new file mode 100644 index 000000000..210de382e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts @@ -0,0 +1,24 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; + +@Injectable() +export class TrainCheckpointEventsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainCheckpointEvent) + repository: Repository, + ) { + super(repository); + } + + findBySchedule(trainScheduleId: string): Promise { + return this.findAll({ + where: { trainScheduleId }, + relations: { yard: true }, + order: { sequenceNo: 'ASC', occurredAt: 'ASC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 320efb859..80afca1d5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -21,6 +21,7 @@ import { PinWagonsDto } from './dto/pin-wagons.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; +import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; import { TrainSchedulingService } from './train-scheduling.service'; @@ -161,6 +162,30 @@ export class TrainSchedulingController { return this.trainSchedulingService.dispatchSchedule(id); } + @Get('schedules/:id/checkpoints') + @TrainSchedulingView() + @ApiOperation({ summary: 'Get the tracking corridor + logged checkpoints for a train' }) + getScheduleCheckpoints(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getScheduleCheckpoints(id); + } + + @Post('schedules/:id/checkpoints') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Log the train passing a station (final station triggers arrival)' }) + recordCheckpoint( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RecordCheckpointDto, + ) { + return this.trainSchedulingService.recordCheckpoint(id, dto); + } + + @Post('schedules/:id/arrive') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Mark a dispatched train arrived (flip readiness, free assets)' }) + arriveSchedule(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.arriveSchedule(id); + } + @Get('container/schedules') @TrainSchedulingView() @ApiOperation({ summary: 'List container train schedules' }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index d133cbc74..dd8f1837d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -14,7 +14,9 @@ import { TrainSchedulesModule } from '../train-schedules/train-schedules.module' import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { Wagon } from '../wagons/entities/wagon.entity'; +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { TrainSchedulingController } from './train-scheduling.controller'; import { TrainSchedulingService } from './train-scheduling.service'; @@ -29,6 +31,7 @@ import { TrainSchedulingService } from './train-scheduling.service'; Wagon, Container, TrainSchedulingGlobalRules, + TrainCheckpointEvent, ]), BookingsModule, LocomotivesModule, @@ -38,7 +41,7 @@ import { TrainSchedulingService } from './train-scheduling.service'; RuleEngineModule, ], controllers: [TrainSchedulingController], - providers: [TrainSchedulingService], + providers: [TrainSchedulingService, TrainCheckpointEventsRepository], exports: [TrainSchedulingService], }) export class TrainSchedulingModule {} 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 1b7e25ec6..6f63755c3 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 @@ -125,6 +125,13 @@ describe('TrainSchedulingService', () => { findAll: jest.fn().mockResolvedValue([]), }; + const trainCheckpointEventsRepository = { + findBySchedule: jest.fn().mockResolvedValue([]), + findAll: jest.fn().mockResolvedValue([]), + create: jest.fn(), + update: jest.fn(), + }; + service = new TrainSchedulingService( dataSource as never, bookingsRepository as never, @@ -135,6 +142,7 @@ describe('TrainSchedulingService', () => { wagonBookingAllocationsRepository as never, wagonAllocationContainerItemsRepository as never, wagonAllocationBulkLoadsRepository as never, + trainCheckpointEventsRepository as never, ); const defaultFleetWagons = [ 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 bbf2f4f95..4735c8386 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 @@ -1,6 +1,7 @@ import { AllocationLoadType, SchedulingStatus, + TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, WagonStatus, } from '@edr/types'; @@ -74,7 +75,11 @@ import { pickBulkWagonType, } from './wagon-type-resolver.util'; import { deriveScheduleDirection } from './derive-schedule-direction.util'; -import { wagonReadinessMatchesSchedule } from './wagon-readiness.util'; +import { flipReadiness, wagonReadinessMatchesSchedule } from './wagon-readiness.util'; +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; +import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; +import { RouteMilestone } from '../routes/entities/route-milestone.entity'; const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; const DEFAULT_TRAIN_LIMITS: Required = { @@ -98,6 +103,7 @@ export class TrainSchedulingService { private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository, private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository, private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository, + private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository, private readonly configService?: ConfigService, ) {} @@ -219,11 +225,17 @@ export class TrainSchedulingService { throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`); } - const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive); const direction = deriveScheduleDirection( route.originYard ?? { country: null }, route.destinationYard ?? { country: null }, ); + if (!wagonReadinessMatchesSchedule(lockedLocomotive.readiness, direction)) { + throw new ConflictException( + `Locomotive ${lockedLocomotive.code} is ${lockedLocomotive.readiness} and cannot run a ${direction} schedule`, + ); + } + + const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive); const schedule = manager.getRepository(TrainSchedule).create({ trainSetId: trainSet.id, routeId: route.id, @@ -581,6 +593,237 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } + /** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */ + private async buildScheduleStations(schedule: TrainSchedule) { + type Station = { sequenceNo: number; yardId: string; label: string; code: string }; + const stations: Station[] = []; + + const route = schedule.routeId + ? await this.dataSource.getRepository(Route).findOne({ + where: { id: schedule.routeId }, + relations: { originYard: true, destinationYard: true, milestones: { yard: true } }, + }) + : null; + + if (route) { + const origin = route.originYard; + const destination = route.destinationYard; + const milestones = [...(route.milestones ?? [])].sort( + (a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo, + ); + stations.push({ + sequenceNo: 0, + yardId: route.originYardId, + label: origin?.label ?? origin?.code ?? 'Origin', + code: origin?.code ?? '', + }); + milestones.forEach((m, i) => + stations.push({ + sequenceNo: i + 1, + yardId: m.yardId, + label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`, + code: m.yard?.code ?? '', + }), + ); + stations.push({ + sequenceNo: milestones.length + 1, + yardId: route.destinationYardId, + label: destination?.label ?? destination?.code ?? 'Destination', + code: destination?.code ?? '', + }); + return stations; + } + + // Fallback: no route milestones — just origin → destination from the schedule stations. + stations.push({ + sequenceNo: 0, + yardId: schedule.originStationId, + label: schedule.originStation?.label ?? schedule.originStation?.code ?? 'Origin', + code: schedule.originStation?.code ?? '', + }); + stations.push({ + sequenceNo: 1, + yardId: schedule.destinationStationId, + label: + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? 'Destination', + code: schedule.destinationStation?.code ?? '', + }); + return stations; + } + + /** Track payload for a schedule: ordered stations, logged checkpoints, current position. */ + async getScheduleCheckpoints(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const stations = await this.buildScheduleStations(schedule); + const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); + const currentSequenceNo = events.length + ? Math.max(...events.map((e) => e.sequenceNo)) + : -1; + + return { + scheduleId, + status: schedule.status, + direction: schedule.direction ?? null, + trainNumber: schedule.trainNumber ?? null, + actualDepartureAt: schedule.actualDepartureAt + ? schedule.actualDepartureAt.toISOString() + : null, + actualArrivalAt: schedule.actualArrivalAt + ? schedule.actualArrivalAt.toISOString() + : null, + origin: stations[0]?.label ?? null, + destination: stations[stations.length - 1]?.label ?? null, + stations, + currentSequenceNo, + checkpoints: events.map((e) => ({ + id: e.id, + sequenceNo: e.sequenceNo, + yardId: e.yardId, + label: e.yard?.label ?? e.yard?.code ?? null, + kind: e.kind, + occurredAt: e.occurredAt.toISOString(), + note: e.note ?? null, + })), + }; + } + + /** Log the train passing a station. Logging the destination station triggers arrival. */ + async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== TrainScheduleStatusEnum.Dispatched) { + throw new BadRequestException('Only DISPATCHED trains can be tracked'); + } + + const stations = await this.buildScheduleStations(schedule); + const finalSeq = stations[stations.length - 1].sequenceNo; + const station = stations.find((s) => s.sequenceNo === dto.sequenceNo); + if (!station) { + throw new BadRequestException(`Station ${dto.sequenceNo} is not on this route`); + } + + const kind = + dto.kind ?? + (dto.sequenceNo === 0 + ? TrainCheckpointKind.Departed + : dto.sequenceNo === finalSeq + ? TrainCheckpointKind.Arrived + : TrainCheckpointKind.Passed); + const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date(); + + // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates. + const [existing] = await this.trainCheckpointEventsRepository.findAll({ + where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo }, + }); + if (existing) { + await this.trainCheckpointEventsRepository.update(existing.id, { + kind, + occurredAt, + note: dto.note ?? null, + yardId: station.yardId, + }); + } else { + await this.trainCheckpointEventsRepository.create({ + trainScheduleId: scheduleId, + yardId: station.yardId, + sequenceNo: dto.sequenceNo, + kind, + occurredAt, + note: dto.note ?? null, + }); + } + + if (dto.sequenceNo === finalSeq) { + await this.arriveSchedule(scheduleId); + } + + return this.getScheduleCheckpoints(scheduleId); + } + + /** + * Mark a dispatched train arrived: close out the schedule, flip readiness on the + * locomotive + wagons (they have repositioned), and free the assets for re-use. + */ + async arriveSchedule(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== TrainScheduleStatusEnum.Dispatched) { + throw new BadRequestException('Only DISPATCHED trains can arrive'); + } + + const isDomestic = schedule.direction === 'DOMESTIC'; + const now = new Date(); + + await this.dataSource.transaction(async (manager) => { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + TrainScheduleStatusEnum.Arrived, + { actualArrivalAt: now }, + manager, + ); + + if (schedule.trainSetId) { + await manager.getRepository(TrainSet).update(schedule.trainSetId, { + status: 'COMPLETED', + }); + } + + if (schedule.trainSet?.locomotiveId) { + const loco = await manager + .getRepository(Locomotive) + .findOne({ where: { id: schedule.trainSet.locomotiveId } }); + if (loco) { + await manager.getRepository(Locomotive).update(loco.id, { + status: 'AVAILABLE', + readiness: isDomestic ? loco.readiness : flipReadiness(loco.readiness), + }); + } + } + + for (const slot of schedule.trainSet?.wagons ?? []) { + if (!slot.physicalWagonId) continue; + const wagon = await manager + .getRepository(Wagon) + .findOne({ where: { id: slot.physicalWagonId } }); + if (!wagon) continue; + await manager.getRepository(Wagon).update(wagon.id, { + currentTrainScheduleId: null, + trainSetWagonId: null, + status: WagonStatus.Available, + readiness: isDomestic ? wagon.readiness : flipReadiness(wagon.readiness), + }); + } + + // Ensure a destination checkpoint exists so the timeline shows ARRIVED. + const stations = await this.buildScheduleStations(schedule); + const finalStation = stations[stations.length - 1]; + const [existingFinal] = await this.trainCheckpointEventsRepository.findAll({ + where: { trainScheduleId: scheduleId, sequenceNo: finalStation.sequenceNo }, + }); + if (!existingFinal) { + await manager.getRepository(TrainCheckpointEvent).save( + manager.getRepository(TrainCheckpointEvent).create({ + trainScheduleId: scheduleId, + yardId: finalStation.yardId, + sequenceNo: finalStation.sequenceNo, + kind: TrainCheckpointKind.Arrived, + occurredAt: now, + }), + ); + } + }); + + return this.getTrainScheduleById(scheduleId); + } + async getContainerTrainSchedules() { const schedules = await this.trainSchedulesRepository.findAll({ relations: { @@ -1339,6 +1582,7 @@ export class TrainSchedulingService { id: schedule.trainSet.locomotive.id, code: schedule.trainSet.locomotive.code, name: schedule.trainSet.locomotive.name ?? null, + readiness: schedule.trainSet.locomotive.readiness ?? null, } : null, wagonCount: schedule.trainSet?.wagonCount ?? 0, @@ -1407,6 +1651,7 @@ export class TrainSchedulingService { code: schedule.trainSet.locomotive.code, name: schedule.trainSet.locomotive.name, status: schedule.trainSet.locomotive.status, + readiness: schedule.trainSet.locomotive.readiness ?? null, maxPullWeightTons: roundTons( Number(schedule.trainSet.locomotive.maxPullWeightTons), ), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts index 3cdd717d8..bda854d58 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts @@ -16,3 +16,16 @@ export function wagonReadinessMatchesSchedule( if (!required) return true; return wagonReadiness === required; } + +/** + * Toggle a readiness value (IMPORT_READY ↔ EXPORT_READY). Used when a train + * reaches its destination: the asset has repositioned, so it is now ready for + * the opposite direction. Direction-agnostic so it handles round trips. + */ +export function flipReadiness( + readiness: WagonReadiness | string, +): WagonReadiness { + return readiness === WagonReadiness.ImportReady + ? WagonReadiness.ExportReady + : WagonReadiness.ImportReady; +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 4c4c876a9..66510c06c 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -38,6 +38,7 @@ import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import TrainsPage from "./pages/trains/TrainsPage"; import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; +import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; @@ -259,6 +260,10 @@ const App = () => { path="operations/train-scheduling-v2/:scheduleId" element={} /> + } + /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx index 0958a4824..b4ae069b2 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx @@ -2,19 +2,34 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { isAxiosError } from "axios"; import { + Badge, + Box, Button, - Card, Checkbox, Group, Modal, Paper, Radio, + RingProgress, Select, + SimpleGrid, Stack, - Stepper, Text, + ThemeIcon, + Title, } from "@mantine/core"; -import { CheckCircle2 } from "lucide-react"; +import { + CheckCircle2, + Container as ContainerIcon, + Eye, + Flame, + LayoutGrid, + Package, + Route as RouteIcon, + Train, + Wallet, + Weight, +} from "lucide-react"; import { useAvailableLocomotives, @@ -46,10 +61,16 @@ import { shouldShowContainerPlacementStep } from "./schedulingContainerStep.util import { FleetAvailabilitySummary } from "./FleetAvailabilitySummary"; import { ScheduleBookingsStep } from "./ScheduleBookingsStep"; import { PreviewSummary, ScheduleWarningsAlert } from "./ScheduleWarningsAlert"; -import { SchedulingWorkflowHeader } from "./SchedulingWorkflowHeader"; -import { schedulingWorkflow } from "./schedulingWorkflow.styles"; -import { SchedulingStatusBadge } from "./ScheduleStatusBadge"; +import { FreightTypeBadge, SchedulingStatusBadge } from "./ScheduleStatusBadge"; +import { + RouteCorridor, + StatTile, + StatusPill, + scheduleBrand, +} from "./scheduleVisuals"; +import { TrainCompositionDiagram } from "./TrainCompositionDiagram"; import { WagonPlanGrid } from "./WagonPlanGrid"; +import { WorkflowRail, WorkflowStep } from "./WorkflowStep"; const parseError = (error: unknown, fallback: string) => { if (isAxiosError(error)) { @@ -157,13 +178,6 @@ export function AllocateBookingWizard({ const previewFreightType = previewResult?.summary?.freightMode as FreightType | undefined; const finalizeStep = hasContainerStep ? 3 : 2; - const stepLabels = [ - "Bookings", - "Wagon plan", - ...(hasContainerStep ? ["Containers"] : []), - "Finalize", - ]; - useEffect(() => { if (!opened) { setActiveStep(0); @@ -216,6 +230,21 @@ export function AllocateBookingWizard({ [routesQuery.data], ); + const displayWagonPlan = useMemo(() => { + const savedWagons = assignedSchedule?.trainSet?.wagons ?? []; + const physicalBySeq = new Map( + savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]), + ); + if (previewResult?.wagonPlan?.length) { + return previewResult.wagonPlan.map((slot) => ({ + ...slot, + physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null, + })); + } + if (savedWagons.length) return savedWagons; + return []; + }, [previewResult?.wagonPlan, assignedSchedule?.trainSet?.wagons]); + const ensureSchedule = async (): Promise => { if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId; if (!routeId || !scheduleDate || !locomotiveId) { @@ -357,305 +386,602 @@ export function AllocateBookingWizard({ } }; + const amount = Number(booking.totalAmount); + const containers = booking.bookingContainers ?? []; + const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); + const weight = Number(booking.cargoTotalWeightVgm ?? 0); const holdCountdown = formatCountdown(booking.holdExpiresAt); - const stepDescription = - activeStep === 0 - ? "Select & preview" - : activeStep === 1 - ? "Allocations" - : hasContainerStep && activeStep === 2 - ? "Map units" - : "Depart"; + const containerComplete = + hasContainerStep && + containerUnits.length > 0 && + validateLocalPlacements(containerUnits, containerPlacements).length === 0; - const stepIcon = - activeStep === 0 - ? "package" - : activeStep === 1 - ? "layout" - : hasContainerStep && activeStep === 2 - ? "container" - : "check"; + const stepsMeta = [ + { + key: "bookings", + icon: Package, + title: "Bookings", + subtitle: "Select cargo & preview the plan", + complete: Boolean(previewResult) || Boolean(assignedSchedule), + }, + { + key: "wagon", + icon: LayoutGrid, + title: "Wagon plan", + subtitle: "Review generated allocations", + complete: displayWagonPlan.length > 0, + }, + ...(hasContainerStep + ? [ + { + key: "container", + icon: ContainerIcon, + title: "Containers", + subtitle: "Map units to wagon slots", + complete: containerComplete, + }, + ] + : []), + { + key: "finalize", + icon: CheckCircle2, + title: "Finalize", + subtitle: "Lock the plan & dispatch", + complete: allocationComplete, + }, + ]; + const completedCount = stepsMeta.filter((s) => s.complete).length; + const progressPct = Math.round((completedCount / stepsMeta.length) * 100); + const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i)); + + const renderStepRightSlot = (key: string) => { + if (key === "bookings") { + if (previewResult) { + return ( + + {previewResult.valid ? "Plan valid" : "Has issues"} + + ); + } + return allBookingIds.length ? ( + + {allBookingIds.length} selected + + ) : null; + } + if (key === "wagon" && displayWagonPlan.length) { + return ( + + {displayWagonPlan.length} wagons + + ); + } + if (key === "container" && containerUnits.length) { + return ( + + {containerUnits.length} units + + ); + } + if (key === "finalize" && allocationComplete) { + return ; + } + return null; + }; + + const renderStepBody = (key: string) => { + if (key === "bookings") { + return ( + + + + + Train schedule + + setScheduleMode(v as "existing" | "new")} + > + + + + + + + {scheduleMode === "existing" ? ( + ({ value: r.id, label: r.name }))} + value={routeId || null} + onChange={(v) => setRouteId(v ?? "")} + searchable + /> + ({ - value: s.id, - label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`, - }))} - value={selectedScheduleId} - onChange={setSelectedScheduleId} - searchable + + - ) : ( - - ({ - value: l.id, - label: l.code, - }))} - value={locomotiveId || null} - onChange={(v) => setLocomotiveId(v ?? "")} - searchable - /> - - )} + + + + {booking.schedulingStatus ? ( + + ) : null} + - - - ({ - id: b.id, - reference: b.reference ?? b.id.slice(0, 8), - weightTons: b.weightTons, - }))} - eligibleItems={eligibleQuery.data?.items ?? []} - eligibleLoading={eligibleQuery.isLoading} - selectedIds={allBookingIds} - onSelectionChange={(ids) => { - setExtraBookingIds(ids.filter((id) => id !== booking.id)); - }} - freightType={bookingFreightType} - /> - - - - setForceAssign(e.currentTarget.checked)} - /> - {previewResult ? ( - - - - - - ) : null} - - - - - - - {reschedulePlan?.displaced.length ? ( - - - - Government preempt — bookings to displace - - {reschedulePlan.displaced.map((b) => ( - - {b.reference} (priority {b.priorityScore}) - - ))} - setConfirmPreempt(e.currentTarget.checked)} + - - + } + > + Preview {previewResult.valid ? "valid" : "has issues"} + ) : null} - - + + + - + - - {!hasContainerStep ? ( - - ) : ( - - )} - + + + + + + {/* Workflow */} + + + + + + + + + + Allocation workflow + + + {completedCount} of {stepsMeta.length} steps complete · expand any step + to edit + + - - + + {progressPct}% + + } + /> + - {hasContainerStep ? ( - - - {!containerUnits.length ? ( - - - Run preview from the Bookings step to load container units for numbering. - - - ) : ( - - )} - - - - - - - ) : null} - - - - {allocationComplete ? ( - - - - - Allocation complete - - - Booking {booking.reference} is scheduled on train{" "} - {assignedSchedule?.trainSet?.locomotive?.code ?? "—"}. - - - - - - - - ) : ( - <> - - - Finalize moves the schedule to SCHEDULED and completes the booking - allocation. - - - - - - - )} - - - + + {stepsMeta.map((step, index) => ( + toggleStep(index)} + rightSlot={renderStepRightSlot(step.key)} + > + {renderStepBody(step.key)} + + ))} + + + ); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/RouteCorridorTrack.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/RouteCorridorTrack.tsx new file mode 100644 index 000000000..9f30611d0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/RouteCorridorTrack.tsx @@ -0,0 +1,192 @@ +import { Fragment } from "react"; +import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core"; +import { Check, Flag, MapPin, Train } from "lucide-react"; + +import { freightBrand } from "@/theme/freight-brand"; +import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling"; + +export interface RouteCorridorTrackProps { + stations: TrackStation[]; + /** Highest sequenceNo reached so far (−1 = not yet departed). */ + currentSequenceNo: number; + checkpoints: TrainCheckpoint[]; + /** True when the train is DISPATCHED and staff may log progress. */ + canLog: boolean; + loggingSeq?: number | null; + onLogCheckpoint?: (sequenceNo: number) => void; +} + +const COLUMN_WIDTH = 150; +const PASSED = freightBrand.primary; +const UPCOMING = "var(--mantine-color-gray-3)"; + +function railColor(active: boolean) { + return active ? PASSED : UPCOMING; +} + +export function RouteCorridorTrack({ + stations, + currentSequenceNo, + checkpoints, + canLog, + loggingSeq, + onLogCheckpoint, +}: RouteCorridorTrackProps) { + const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c])); + const lastIndex = stations.length - 1; + + return ( + + + {stations.map((station, index) => { + const passed = station.sequenceNo <= currentSequenceNo; + const isCurrent = station.sequenceNo === currentSequenceNo; + const isFinal = index === lastIndex; + const isNext = canLog && station.sequenceNo === currentSequenceNo + 1; + const checkpoint = bySeq.get(station.sequenceNo); + // left rail solid once this node is reached; right rail solid once the next node is reached + const leftActive = station.sequenceNo <= currentSequenceNo; + const rightActive = station.sequenceNo + 1 <= currentSequenceNo; + + return ( + + + {/* rail + node */} + + {index > 0 && ( + + )} + {index < lastIndex && ( + + )} + + {/* train marker hovering over the current node */} + {isCurrent && ( + + + + )} + + {/* node */} + + {passed ? ( + + ) : isFinal ? ( + + ) : ( + + )} + + + + {/* label */} + + + {station.label} + + {index === 0 ? ( + + Origin + + ) : isFinal ? ( + + Destination + + ) : null} + + + {/* checkpoint time or action */} + {checkpoint ? ( + + {new Date(checkpoint.occurredAt).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + ) : isNext ? ( + + ) : ( + + )} + + + ); + })} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 5c6a45d54..d892fc68d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -47,6 +47,7 @@ export const QUERY_KEYS = { stations: () => ["train-scheduling", "stations"] as const, schedules: () => ["train-scheduling", "schedules"] as const, scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const, + track: (id: string) => ["train-scheduling", "track", id] as const, }, FLEET: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index b285e91ce..ff1891b60 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -160,6 +160,8 @@ export const URL_CONSTANTS = { PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, + CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`, + ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`, RESCHEDULE_PREVIEW: (id: string) => `/train-scheduling/schedules/${id}/reschedule/preview`, RESCHEDULE_EXECUTE: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts index 644e6e19f..a180e0ae0 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts @@ -7,6 +7,7 @@ import type { CreateTrainSchedulePayload, FreightType, PinWagonsPayload, + RecordCheckpointPayload, TrainScheduleFilters, TrainSchedulePreviewPayload, } from "@/types/trainScheduling"; @@ -41,6 +42,13 @@ export const useAvailableLocomotives = () => queryFn: () => trainSchedulingService.getAvailableLocomotives(), }); +export const useTrainTrack = (id: string | undefined) => + useQuery({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""), + queryFn: () => trainSchedulingService.getTrack(id!), + enabled: Boolean(id), + }); + export const useScheduleMutations = (scheduleId?: string) => { const qc = useQueryClient(); @@ -52,6 +60,9 @@ export const useScheduleMutations = (scheduleId?: string) => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId), }); + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(scheduleId), + }); } void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT }); }; @@ -118,5 +129,28 @@ export const useScheduleMutations = (scheduleId?: string) => { onSuccess: invalidate, }); - return { create, preview, assign, unassign, pin, finalize, dispatch, cancel, invalidate }; + const recordCheckpoint = useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: RecordCheckpointPayload }) => + trainSchedulingService.recordCheckpoint(id, payload), + onSuccess: invalidate, + }); + + const arrive = useMutation({ + mutationFn: (id: string) => trainSchedulingService.arriveSchedule(id), + onSuccess: invalidate, + }); + + return { + create, + preview, + assign, + unassign, + pin, + finalize, + dispatch, + cancel, + recordCheckpoint, + arrive, + invalidate, + }; }; diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx new file mode 100644 index 000000000..068370932 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx @@ -0,0 +1,263 @@ +import { Link, useParams } from "react-router-dom"; +import { isAxiosError } from "axios"; +import { + ArrowLeft, + CalendarClock, + CheckCircle2, + Flag, + MapPin, + Navigation, + Train, +} from "lucide-react"; +import { + Badge, + Box, + Button, + Group, + Loader, + Paper, + SimpleGrid, + Stack, + Text, + ThemeIcon, + Timeline, + Title, +} from "@mantine/core"; + +import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack"; +import { + RouteCorridor, + StatTile, + StatusPill, + scheduleBrand, +} from "@/components/trainScheduling/scheduleVisuals"; +import { useTrainTrack, useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useToast } from "@/hooks/use-toast"; + +const parseError = (error: unknown, fallback: string) => { + if (isAxiosError(error)) { + const data = error.response?.data as Record | undefined; + const message = data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +}; + +function formatDateTime(iso?: string | null) { + if (!iso) return "—"; + return new Date(iso).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export default function TrainScheduleTrackPage() { + const { scheduleId } = useParams<{ scheduleId: string }>(); + const { toast } = useToast(); + const trackQuery = useTrainTrack(scheduleId); + const { recordCheckpoint } = useScheduleMutations(scheduleId); + + if (trackQuery.isLoading) { + return ( + + + + ); + } + + const track = trackQuery.data; + if (!track || !scheduleId) { + return ( + + Tracking data not found. + + ); + } + + const canLog = track.status === "DISPATCHED"; + const totalStations = track.stations.length; + const reached = Math.min(track.currentSequenceNo + 1, totalStations); + const progressLabel = `${reached} / ${totalStations}`; + + const handleLog = (sequenceNo: number) => { + const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo; + recordCheckpoint.mutate( + { id: scheduleId, payload: { sequenceNo } }, + { + onSuccess: () => { + toast({ + title: isFinal + ? "Train arrived — assets freed, readiness flipped" + : "Checkpoint logged", + }); + }, + onError: (err) => + toast({ + title: "Could not log checkpoint", + description: parseError(err, "Please try again"), + variant: "destructive", + }), + }, + ); + }; + + return ( + + + + {/* Hero */} + + + + + + + + + + + + Track train + + {track.trainNumber ? ( + + {track.trainNumber} + + ) : null} + {track.direction ? ( + + {track.direction} + + ) : null} + + + + + + + + + + + + + + + + + + + {/* Corridor */} + + + + + + + + + + Route corridor + + + {canLog + ? "Log the train passing each station; the final station marks arrival." + : track.status === "ARRIVED" + ? "This train has arrived at its destination." + : "Tracking becomes available once the train is dispatched."} + + + + + + + + + + {/* Timeline */} + + + + Checkpoint log + + {track.checkpoints.length === 0 ? ( + + No checkpoints logged yet. + + ) : ( + + {track.checkpoints.map((cp) => ( + : } + title={ + + + {cp.label ?? `Station ${cp.sequenceNo}`} + + + {cp.kind} + + + } + > + + {formatDateTime(cp.occurredAt)} + + {cp.note ? {cp.note} : null} + + ))} + + )} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 818582e0a..98d671f94 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -8,6 +8,7 @@ import { Container as ContainerIcon, Eye, LayoutGrid, + Navigation, Package, Route as RouteIcon, Send, @@ -771,17 +772,32 @@ export default function TrainScheduleV2DetailPage() { - {schedule.status !== "DISPATCHED" ? ( - - ) : null} + + {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? ( + + ) : null} + {["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( + + ) : null} + @@ -790,6 +806,13 @@ export default function TrainScheduleV2DetailPage() { icon={Train} label="Locomotive" value={schedule.trainSet?.locomotive?.code ?? "—"} + hint={ + schedule.trainSet?.locomotive?.readiness === "EXPORT_READY" + ? "Export-ready" + : schedule.trainSet?.locomotive?.readiness === "IMPORT_READY" + ? "Import-ready" + : undefined + } /> Open + {["DISPATCHED", "ARRIVED"].includes(row.original.status) ? ( + + ) : null} {["DRAFT", "SCHEDULED"].includes(row.original.status) ? ( + + + {canTrack ? ( + + ) : null} + ); diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 208df7bd6..b3f2c7b7d 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -8,12 +8,14 @@ import type { FreightType, LocomotiveRecord, PinWagonsPayload, + RecordCheckpointPayload, TrainScheduleDetail, TrainScheduleFilters, TrainScheduleListItem, TrainSchedulePreviewPayload, TrainSchedulePreviewResponse, TrainSchedulingGlobalRules, + TrainTrackResponse, YardOption, } from '@/types/trainScheduling'; @@ -137,6 +139,32 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getTrack: async (scheduleId: string): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId), + ); + return unwrap(response.data); + }, + + recordCheckpoint: async ( + scheduleId: string, + payload: RecordCheckpointPayload, + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId), + payload, + ); + return unwrap(response.data); + }, + + arriveSchedule: async (scheduleId: string): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId), + {}, + ); + return unwrap(response.data); + }, + cancelSchedule: async ( id: string, freightType: FreightType = "CONTAINER", diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index ba28221be..e444f126b 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -127,6 +127,8 @@ export interface TrainSchedulePreviewResponse { containerSlotSequenceNos?: number[]; } +export type Readiness = "IMPORT_READY" | "EXPORT_READY"; + export interface LocomotiveRecord { id: string; code: string; @@ -134,6 +136,7 @@ export interface LocomotiveRecord { maxPullWeightTons: number; maxTrainLengthMeters: number; status: "AVAILABLE" | "ASSIGNED" | "MAINTENANCE" | "OUT_OF_SERVICE"; + readiness?: Readiness | null; locomotiveType?: "DIESEL" | "ELECTRIC"; } @@ -150,6 +153,7 @@ export interface TrainScheduleListItem { id: string; code: string; name?: string | null; + readiness?: Readiness | null; } | null; wagonCount: number; @@ -197,6 +201,7 @@ export interface TrainScheduleDetail { scheduledDepartureDate: string; scheduledArrivalDate?: string | null; actualDepartureAt?: string | null; + actualArrivalAt?: string | null; originStation?: { id: string; label?: string; @@ -218,6 +223,7 @@ export interface TrainScheduleDetail { code: string; name?: string | null; status: string; + readiness?: Readiness | null; maxPullWeightTons: number; maxTrainLengthMeters?: number; } | null; @@ -249,6 +255,46 @@ export interface TrainScheduleDetail { warnings?: string[]; } +export type TrainCheckpointKind = "DEPARTED" | "PASSED" | "ARRIVED"; + +export interface TrackStation { + sequenceNo: number; + yardId: string; + label: string; + code: string; +} + +export interface TrainCheckpoint { + id: string; + sequenceNo: number; + yardId: string; + label: string | null; + kind: TrainCheckpointKind; + occurredAt: string; + note: string | null; +} + +export interface TrainTrackResponse { + scheduleId: string; + status: TrainScheduleStatus | string; + direction?: string | null; + trainNumber?: string | null; + actualDepartureAt?: string | null; + actualArrivalAt?: string | null; + origin: string | null; + destination: string | null; + stations: TrackStation[]; + currentSequenceNo: number; + checkpoints: TrainCheckpoint[]; +} + +export interface RecordCheckpointPayload { + sequenceNo: number; + kind?: TrainCheckpointKind; + occurredAt?: string; + note?: string; +} + export interface TrainScheduleFilters { originStationId?: string; destinationStationId?: string; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 0bd2a5ed7..0909179c2 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -146,6 +146,22 @@ export enum WagonReadiness { export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC"; +export enum TrainCheckpointKind { + Departed = "DEPARTED", + Passed = "PASSED", + Arrived = "ARRIVED", +} + +export interface ITrainCheckpointEvent extends BaseEntity { + trainScheduleId: string; + yardId: string; + sequenceNo: number; + kind: TrainCheckpointKind; + occurredAt: string; + note?: string | null; + recordedByUserId?: string | null; +} + export enum BulkPricingUnit { PerWagon = "PER_WAGON", PerTon = "PER_TON",