From 771aa2a605fb88f9f6077940cb8d1ca9e302f5a5 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 20 Jul 2026 07:04:23 +0000 Subject: [PATCH] Add train deactivation feature and import train number management - Introduced DEACTIVATED status for trains, allowing staff to park trains indefinitely. - Implemented methods to deactivate and reactivate trains in the TrainBuilderService. - Added UI components for train deactivation and reactivation in TrainBuilderDetailPage. - Created a dropdown setting for admin-managed import train numbers, with corresponding migrations. - Updated yard code length to accommodate soft-delete suffix. - Enhanced train status handling to include DEACTIVATED state. --- ...2380000000000-AddTrainDeactivatedStatus.ts | 23 ++++++ .../2390000000000-SeedImportTrainNumbers.ts | 62 +++++++++++++++ ...000000-WidenYardCodeForSoftDeleteSuffix.ts | 21 +++++ .../booking-reference-data.service.ts | 8 +- .../rule-engine/entities/yard.entity.ts | 3 +- .../rule-engine/services/yards.service.ts | 16 +++- .../train-scheduling.service.ts | 13 +++- .../trains/train-builder.controller.ts | 25 ++++++ .../modules/trains/train-builder.service.ts | 76 +++++++++++++++++++ .../src/seed/dropdown-settings.seeder.ts | 8 ++ .../trainBuilder/BuildTrainModal.tsx | 11 ++- .../trainBuilder/EditTrainDetailsModal.tsx | 37 ++++++--- .../components/trainBuilder/trainStatus.ts | 2 + .../backoffice/src/constants/trainRuns.ts | 28 +++++-- .../src/hooks/useImportTrainNumberOptions.ts | 67 ++++++++++++++++ .../trainBuilder/TrainBuilderDetailPage.tsx | 59 ++++++++++++++ .../trainBuilder/TrainBuilderListPage.tsx | 1 + .../backoffice/src/services/api.ts | 25 ++++++ .../src/services/trainBuilder.service.ts | 16 +++- packages/types/src/freight/index.ts | 2 + 20 files changed, 467 insertions(+), 36 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2380000000000-AddTrainDeactivatedStatus.ts create mode 100644 apps/edr-freight-api/src/migrations/2390000000000-SeedImportTrainNumbers.ts create mode 100644 apps/edr-freight-api/src/migrations/2390000000000-WidenYardCodeForSoftDeleteSuffix.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useImportTrainNumberOptions.ts diff --git a/apps/edr-freight-api/src/migrations/2380000000000-AddTrainDeactivatedStatus.ts b/apps/edr-freight-api/src/migrations/2380000000000-AddTrainDeactivatedStatus.ts new file mode 100644 index 000000000..2c252f127 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2380000000000-AddTrainDeactivatedStatus.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * New built-train lifecycle status DEACTIVATED: staff park a train indefinitely + * (only allowed while it has no DRAFT/SCHEDULED/DISPATCHED schedule). Like + * UNDER_MAINTENANCE / OUT_OF_SERVICE it is staff-owned — the scheduler never + * overwrites it and refuses to schedule a deactivated train. + * + * Postgres cannot drop an enum value, so down() is a no-op. + */ +export class AddTrainDeactivatedStatus2380000000000 implements MigrationInterface { + name = 'AddTrainDeactivatedStatus2380000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "freight"."train_status" ADD VALUE IF NOT EXISTS 'DEACTIVATED'`, + ); + } + + public async down(): Promise { + // Enum values cannot be removed in Postgres; leaving the label is harmless. + } +} diff --git a/apps/edr-freight-api/src/migrations/2390000000000-SeedImportTrainNumbers.ts b/apps/edr-freight-api/src/migrations/2390000000000-SeedImportTrainNumbers.ts new file mode 100644 index 000000000..99c764806 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2390000000000-SeedImportTrainNumbers.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Admin-managed catalog of IMPORT run numbers (even, Djibouti → Ethiopia) + * selectable in the Train Builder. The paired EXPORT number is derived + * (import − 1), so only the import side is configured. Seeded with the runs + * historically hardcoded in the backoffice's trainRuns constants; admins add + * new runs from the Dropdown Settings editor. + */ +export class SeedImportTrainNumbers2390000000000 implements MigrationInterface { + name = 'SeedImportTrainNumbers2390000000000'; + private readonly code = 'import_train_numbers'; + private readonly options: string[] = [ + '8002', + '8102', + '8202', + '8302', + '8402', + '8502', + '8602', + '8702', + '8802', + '8902', + '9002', + ]; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`, + [this.code], + ); + if (existing.length > 0) return; + + const inserted = await queryRunner.query( + `INSERT INTO freight.dropdown_settings (code, label, description, multiple, meta) + VALUES ($1, $2, $3, false, $4::jsonb) + RETURNING id;`, + [ + this.code, + 'Import train numbers', + 'Even IMPORT run numbers (Djibouti → Ethiopia) selectable when building a train. The paired export number is derived automatically (import − 1).', + JSON.stringify({ searchable: true, clearable: true }), + ], + ); + const settingId = inserted[0].id; + + for (let i = 0; i < this.options.length; i++) { + const value = this.options[i]; + await queryRunner.query( + `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order) + VALUES ($1, $2, $3, $4);`, + [settingId, value, value, i], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [ + this.code, + ]); + } +} diff --git a/apps/edr-freight-api/src/migrations/2390000000000-WidenYardCodeForSoftDeleteSuffix.ts b/apps/edr-freight-api/src/migrations/2390000000000-WidenYardCodeForSoftDeleteSuffix.ts new file mode 100644 index 000000000..31263f4b2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2390000000000-WidenYardCodeForSoftDeleteSuffix.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Yard soft-delete now appends `@` to the unique code (SEBETA → + * SEBETA@1755612345678) so the name can be reused by a new yard while + * UQ_yards_code still spans soft-deleted rows. varchar(20) can't hold long + * codes plus the 14-char suffix, so widen to 40. + */ +export class WidenYardCodeForSoftDeleteSuffix2390000000000 implements MigrationInterface { + name = 'WidenYardCodeForSoftDeleteSuffix2390000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."yards" ALTER COLUMN "code" TYPE varchar(40)`, + ); + } + + public async down(): Promise { + // Narrowing would fail on suffixed codes; keep 40. + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index a978fd22b..507ef43d8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from "@nestjs/common"; -import { In, Not } from "typeorm"; import { CargoType } from "../rule-engine/entities/cargo-type.entity"; import { ContainerType } from "../rule-engine/entities/container-type.entity"; @@ -34,8 +33,6 @@ import { BookingReferenceYardDto, } from "./dto/booking-reference-data.dto"; -const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const; - export function buildCargoTypeTree( rows: CargoType[], ): BookingReferenceCargoTypeGroupDto[] { @@ -134,10 +131,7 @@ export class BookingReferenceDataService { const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] = await Promise.all([ this.yardsRepository.findAll({ - where: { - isActive: true, - code: Not(In([...LEGACY_YARD_CODES])), - }, + where: { isActive: true }, order: { displayOrder: "ASC", code: "ASC" }, }), this.containerTypesRepository.findAll({ diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts index 808a102e5..226b1114d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts @@ -7,7 +7,8 @@ import { Column, Entity, Index } from 'typeorm'; @Index(['country']) @Index(['isActive']) export class Yard extends BaseEntity { - @Column({ name: 'code', type: 'varchar', length: 20, unique: true }) + // 40 leaves room for the `@` suffix soft-delete appends to free the code. + @Column({ name: 'code', type: 'varchar', length: 40, unique: true }) code!: string; @Column({ name: 'label', type: 'varchar', length: 100 }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index 69eab608a..b698b456c 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -31,7 +31,7 @@ export class YardsService { /** Create a yard. */ async create(dto: CreateYardDto): Promise { - const code = generateCode(dto.label); + const code = generateCode(dto.label).slice(0, 40); const existing = await this.repository.findByCode(code); if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`); @@ -58,9 +58,19 @@ export class YardsService { return updated; } - /** Soft-delete a yard. */ + /** + * Soft-delete a yard. The unique `code` (and the label) get a `@` + * suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the + * same name can be created later without tripping UQ_yards_code, which spans + * soft-deleted rows too. + */ async remove(id: string): Promise { - await this.findById(id); + const yard = await this.findById(id); + const suffix = `@${Date.now()}`; + await this.repository.update(id, { + code: `${yard.code.slice(0, 40 - suffix.length)}${suffix}`, + label: `${yard.label.slice(0, 100 - suffix.length)}${suffix}`, + }); await this.repository.softDelete(id); } 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 1edd27792..cccf9e57e 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 @@ -1283,7 +1283,8 @@ export class TrainSchedulingService { } if ( builtTrain.status === Freight.TrainStatus.OutOfService || - builtTrain.status === Freight.TrainStatus.UnderMaintenance + builtTrain.status === Freight.TrainStatus.UnderMaintenance || + builtTrain.status === Freight.TrainStatus.Deactivated ) { throw new ConflictException( `Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`, @@ -5219,7 +5220,11 @@ export class TrainSchedulingService { const trains = await this.dataSource.getRepository(Train).find({ where: { status: Not( - In([Freight.TrainStatus.OutOfService, Freight.TrainStatus.UnderMaintenance]), + In([ + Freight.TrainStatus.OutOfService, + Freight.TrainStatus.UnderMaintenance, + Freight.TrainStatus.Deactivated, + ]), ), }, relations: { @@ -5619,8 +5624,8 @@ export class TrainSchedulingService { * Re-derive a built train's lifecycle status from its schedules after one of * them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED → * SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival - * at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE) - * keep their status — staff own that flag, not the scheduler. + * at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE + * / DEACTIVATED) keep their status — staff own that flag, not the scheduler. */ private async syncBuiltTrainAfterScheduleChange( manager: EntityManager, diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 9b7454f36..babf11ef1 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -44,6 +44,15 @@ export class TrainBuilderController { return this.trainBuilderService.listBuilt(query); } + // Must be declared before @Get(':id') so the path isn't captured as an id. + @Get('used-train-numbers') + @ApiOperation({ + summary: 'Import/export run numbers already claimed by existing trains', + }) + usedTrainNumbers() { + return this.trainBuilderService.usedTrainNumbers(); + } + @Get(':id') @ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' }) composition(@Param('id', ParseUUIDPipe) id: string) { @@ -115,6 +124,22 @@ export class TrainBuilderController { return this.trainBuilderService.reorderWagons(id, dto); } + @Post(':id/deactivate') + @FleetManage() + @ApiOperation({ + summary: 'Deactivate the train (park it) — only allowed with no active schedule', + }) + deactivate(@Param('id', ParseUUIDPipe) id: string) { + return this.trainBuilderService.deactivate(id); + } + + @Post(':id/activate') + @FleetManage() + @ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' }) + activate(@Param('id', ParseUUIDPipe) id: string) { + return this.trainBuilderService.activate(id); + } + @Delete(':id') @FleetManage() @HttpCode(HttpStatus.NO_CONTENT) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 10b400993..3658f4f37 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -153,6 +153,38 @@ export class TrainBuilderService { }; } + /** + * Run numbers already claimed by live (non-deleted) trains, split by + * direction. Legacy single `train_number` values are sorted into a side by + * parity (even = import, odd = export) so the pickers can grey them out too. + */ + async usedTrainNumbers() { + const rows: { + import_train_number: string | null; + export_train_number: string | null; + train_number: string | null; + }[] = await this.dataSource.query( + `SELECT import_train_number, export_train_number, train_number + FROM freight.trains + WHERE deleted_at IS NULL`, + ); + + const importTrainNumbers = new Set(); + const exportTrainNumbers = new Set(); + for (const row of rows) { + if (row.import_train_number) importTrainNumbers.add(row.import_train_number); + if (row.export_train_number) exportTrainNumbers.add(row.export_train_number); + const legacy = row.train_number?.trim(); + if (legacy && /^\d+$/.test(legacy)) { + (Number(legacy) % 2 === 0 ? importTrainNumbers : exportTrainNumbers).add(legacy); + } + } + return { + importTrainNumbers: [...importTrainNumbers].sort(), + exportTrainNumbers: [...exportTrainNumbers].sort(), + }; + } + /** * One ACTIVE schedule per train for the page (prefer the DISPATCHED run, * else the earliest upcoming departure) — feeds the list's direction tint @@ -532,6 +564,50 @@ export class TrainBuilderService { return this.getComposition(id); } + /** + * Park the train indefinitely (status DEACTIVATED). Blocked while it still + * has a live (DRAFT/SCHEDULED/DISPATCHED) schedule. The consist stays + * coupled; like UNDER_MAINTENANCE / OUT_OF_SERVICE the flag is staff-owned — + * the scheduler never overwrites it and refuses the train for new schedules. + */ + async deactivate(id: string) { + await this.dataSource.transaction(async (manager) => { + const train = await manager.getRepository(Train).findOne({ where: { id } }); + if (!train) throw new NotFoundException(`Train ${id} not found`); + if (train.status === Freight.TrainStatus.Deactivated) return; + const active: { count: string }[] = await manager.query( + `SELECT COUNT(*)::text AS count + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`, + [id], + ); + if (Number(active[0]?.count ?? 0) > 0) { + throw new ConflictException( + 'Train has active schedules; cancel them before deactivating the train', + ); + } + await manager + .getRepository(Train) + .update(id, { status: Freight.TrainStatus.Deactivated }); + }); + return this.getComposition(id); + } + + /** Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled again. */ + async activate(id: string) { + const train = await this.dataSource.getRepository(Train).findOne({ where: { id } }); + if (!train) throw new NotFoundException(`Train ${id} not found`); + if (train.status === Freight.TrainStatus.Deactivated) { + await this.dataSource + .getRepository(Train) + .update(id, { status: Freight.TrainStatus.Available }); + } + return this.getComposition(id); + } + /** Disband the train: release wagons and locomotives, then delete it. */ async disband(id: string): Promise { await this.dataSource.transaction(async (manager) => { diff --git a/apps/edr-freight-api/src/seed/dropdown-settings.seeder.ts b/apps/edr-freight-api/src/seed/dropdown-settings.seeder.ts index 4df5d9028..f717e73d6 100644 --- a/apps/edr-freight-api/src/seed/dropdown-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/dropdown-settings.seeder.ts @@ -48,6 +48,14 @@ const DEFAULT_DROPDOWN_SETTINGS: DefaultDropdownSetting[] = [ "Minimum days between today and the vessel departure date on an export Release Order.", multiple: false, }, + { + code: "import_train_numbers", + label: "Import train numbers", + description: + "Even IMPORT run numbers (Djibouti → Ethiopia) selectable when building a train. The paired export number is derived automatically (import − 1).", + multiple: false, + meta: { searchable: true, clearable: true }, + }, ]; @Injectable() diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx index 2cedad1eb..05b4bd546 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx @@ -16,7 +16,8 @@ import { useEffect, useState } from "react"; import { api } from "@/services/api"; import type { TrainComposition } from "@/services/trainBuilder.service"; import { useToast } from "@/hooks/use-toast"; -import { IMPORT_TRAIN_OPTIONS, exportRunFor } from "@/constants/trainRuns"; +import { useImportTrainNumberOptions } from "@/hooks/useImportTrainNumberOptions"; +import { exportRunFor } from "@/constants/trainRuns"; const parseError = (error: unknown, fallback: string) => { if (isAxiosError(error)) { @@ -42,6 +43,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain const [notes, setNotes] = useState(""); const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 })); + // Admin-managed run list (dropdown settings); numbers already on a train + // come back disabled so they cannot be picked twice. + const importNumbers = useImportTrainNumberOptions(); // Only serviceable locomotives standing in the selected yard can be coupled. const locomotivesQuery = useQuery( api.locomotives.listFiltered.queryOptions({ @@ -150,12 +154,13 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain { const { toast } = useToast(); @@ -25,6 +29,8 @@ const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) = const [importNo, setImportNo] = useState(""); const [exportNo, setExportNo] = useState(""); + const importNumbers = useImportTrainNumberOptions(train?.importTrainNumber); + useEffect(() => { if (train) { setName(train.trainName ?? ""); @@ -86,20 +92,29 @@ const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) = radius="md" /> - setImportNo(e.currentTarget.value)} - maxLength={20} + placeholder={importNumbers.isLoading ? "Loading…" : "e.g. 8002"} + data={importNumbers.options} + value={importNo || null} + onChange={(value) => { + // Clearing keeps the stored numbers (empty inputs are dropped on + // save); a pick re-derives the paired export run. + setImportNo(value ?? ""); + setExportNo(value ? exportRunFor(value) : (train?.exportTrainNumber ?? "")); + }} + searchable + clearable + nothingFoundMessage="No free run numbers — add more in Dropdown Settings" radius="md" /> setExportNo(e.currentTarget.value)} - maxLength={20} + readOnly + variant="filled" radius="md" /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/trainStatus.ts b/apps/edr-freight-web/backoffice/src/components/trainBuilder/trainStatus.ts index 7cbdfcc52..ef5a661e8 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/trainStatus.ts +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/trainStatus.ts @@ -15,6 +15,8 @@ export const trainStatusColor = (status: BuiltTrainStatus | string): string => { return "yellow"; case "OUT_OF_SERVICE": return "red"; + case "DEACTIVATED": + return "gray"; default: return "gray"; } diff --git a/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts b/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts index b7af36639..1ae998c23 100644 --- a/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts +++ b/apps/edr-freight-web/backoffice/src/constants/trainRuns.ts @@ -54,10 +54,26 @@ export const TRAIN_RUN_FILTER_OPTIONS = Object.entries(TRAIN_RUN_PAIRS).map( }), ); -/** The import run implied by an export run; empty string when unset/unknown. */ -export const importRunFor = (exportRun: unknown): string => - TRAIN_RUN_PAIRS[String(exportRun ?? "")] ?? ""; +/** + * The import run implied by an export run; empty string when unset/unknown. + * Runs outside the hardcoded pairs (admin-added via dropdown settings) fall + * back to the numeric convention: import = export + 1. + */ +export const importRunFor = (exportRun: unknown): string => { + const run = String(exportRun ?? ""); + const paired = TRAIN_RUN_PAIRS[run]; + if (paired) return paired; + return /^\d*[13579]$/.test(run) ? String(Number(run) + 1) : ""; +}; -/** The export run implied by an import run; empty string when unset/unknown. */ -export const exportRunFor = (importRun: unknown): string => - EXPORT_BY_IMPORT[String(importRun ?? "")] ?? ""; +/** + * The export run implied by an import run; empty string when unset/unknown. + * Runs outside the hardcoded pairs (admin-added via dropdown settings) fall + * back to the numeric convention: export = import − 1. + */ +export const exportRunFor = (importRun: unknown): string => { + const run = String(importRun ?? ""); + const paired = EXPORT_BY_IMPORT[run]; + if (paired) return paired; + return /^\d*[02468]$/.test(run) && Number(run) > 0 ? String(Number(run) - 1) : ""; +}; diff --git a/apps/edr-freight-web/backoffice/src/hooks/useImportTrainNumberOptions.ts b/apps/edr-freight-web/backoffice/src/hooks/useImportTrainNumberOptions.ts new file mode 100644 index 000000000..a26e127f3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useImportTrainNumberOptions.ts @@ -0,0 +1,67 @@ +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { IMPORT_TRAIN_OPTIONS } from "@/constants/trainRuns"; +import { api } from "@/services/api"; + +/** Dropdown-settings code holding the admin-managed IMPORT run numbers. */ +export const IMPORT_TRAIN_NUMBERS_CODE = "import_train_numbers"; + +export interface ImportTrainNumberOption { + value: string; + label: string; + disabled?: boolean; +} + +/** + * Selectable IMPORT run numbers for the Train Builder, sourced from the + * admin-managed `import_train_numbers` dropdown setting (admins add new runs + * from the Dropdown Settings editor). Falls back to the legacy hardcoded run + * list while the setting is missing or has no options. + * + * Numbers already claimed by an existing train are kept in the list but + * disabled and tagged "in use". Pass `currentNumber` when editing a train so + * its own number stays pickable, and so a legacy number that was removed from + * the setting still renders. + */ +export function useImportTrainNumberOptions(currentNumber?: string | null) { + const settingQuery = useQuery( + api.dropdownSettings.getByCode.queryOptions({ + input: { code: IMPORT_TRAIN_NUMBERS_CODE }, + staleTime: 5 * 60_000, + retry: false, + }), + ); + const usedQuery = useQuery( + api.trainBuilder.usedTrainNumbers.queryOptions({ staleTime: 30_000 }), + ); + + const options = useMemo(() => { + const configured = [...(settingQuery.data?.children ?? [])] + .filter((option) => !option.disabled) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((option) => ({ + value: option.value, + label: option.label || option.value, + })); + const base = configured.length ? configured : IMPORT_TRAIN_OPTIONS; + + const used = new Set(usedQuery.data?.importTrainNumbers ?? []); + if (currentNumber) used.delete(currentNumber); + + const items: ImportTrainNumberOption[] = base.map((option) => + used.has(option.value) + ? { ...option, label: `${option.label} — in use`, disabled: true } + : option, + ); + if (currentNumber && !items.some((option) => option.value === currentNumber)) { + items.unshift({ value: currentNumber, label: currentNumber }); + } + return items; + }, [settingQuery.data, usedQuery.data, currentNumber]); + + return { + options, + isLoading: settingQuery.isLoading || usedQuery.isLoading, + }; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx index 378bb5aad..16f55cf00 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx @@ -18,6 +18,8 @@ import { CalendarClock, MapPin, MoreHorizontal, + Power, + PowerOff, Replace, Ruler, Trash2, @@ -70,6 +72,7 @@ export default function TrainBuilderDetailPage() { const [locoModalOpen, setLocoModalOpen] = useState(false); const [yardModalOpen, setYardModalOpen] = useState(false); const [disbandOpen, setDisbandOpen] = useState(false); + const [deactivateOpen, setDeactivateOpen] = useState(false); const compositionQuery = useQuery( api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }), @@ -81,6 +84,8 @@ export default function TrainBuilderDetailPage() { ); const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions()); const disband = useMutation(api.trainBuilder.disband.mutationOptions()); + const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions()); + const activate = useMutation(api.trainBuilder.activate.mutationOptions()); const composition = compositionQuery.data; const busy = @@ -172,6 +177,27 @@ export default function TrainBuilderDetailPage() { > Change yard + {composition.status === "DEACTIVATED" ? ( + } + onClick={() => + void withToast(async () => { + await activate.mutateAsync(composition.id); + toast({ title: `Train ${composition.code} reactivated` }); + }, "Could not reactivate train") + } + > + Reactivate train + + ) : ( + } + disabled={composition.activeSchedules.length > 0} + onClick={() => setDeactivateOpen(true)} + > + Deactivate train + + )} } @@ -356,6 +382,39 @@ export default function TrainBuilderDetailPage() { onClose={() => setYardModalOpen(false)} /> + setDeactivateOpen(false)} + title={Deactivate train {composition.code}?} + radius="lg" + centered + > + + + The train is parked and cannot be picked for new schedules until it is + reactivated. Its locomotives and wagons stay coupled. + + + + + + + + setDisbandOpen(false)} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx index ec54c9aba..c980d55b6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx @@ -315,6 +315,7 @@ export default function TrainBuilderListPage() { { value: "IN_SERVICE", label: "In service" }, { value: "UNDER_MAINTENANCE", label: "Under maintenance" }, { value: "OUT_OF_SERVICE", label: "Out of service" }, + { value: "DEACTIVATED", label: "Deactivated" }, ]} w={180} styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index fe323a472..6c6245ee6 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -193,6 +193,7 @@ import { type ScheduleConsist, type TrainComposition, type UpdateTrainDetailsPayload, + type UsedTrainNumbers, } from "./trainBuilder.service"; import { trainSchedulingService } from "./trainScheduling.service"; import { wagonTypesService, type WagonType } from "./wagon-types.service"; @@ -1825,6 +1826,14 @@ export const api = { ({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id), ), + // Key derives to ["train-builder", "usedTrainNumbers"], so the shared + // TRAIN_BUILDER.ROOT invalidation refreshes it after every build/edit. + usedTrainNumbers: endpoint( + "train-builder", + "usedTrainNumbers", + () => trainBuilderService.usedTrainNumbers().then((r) => r.data), + ), + build: endpoint( "train-builder", "build", @@ -1902,6 +1911,22 @@ export const api = { () => TRAIN_BUILDER_INVALIDATIONS, ), + deactivate: endpoint( + "train-builder", + "deactivate", + (id) => trainBuilderService.deactivate(id).then((r) => r.data), + undefined, + () => TRAIN_BUILDER_INVALIDATIONS, + ), + + activate: endpoint( + "train-builder", + "activate", + (id) => trainBuilderService.activate(id).then((r) => r.data), + undefined, + () => TRAIN_BUILDER_INVALIDATIONS, + ), + disband: endpoint( "train-builder", "disband", diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts index 083122a10..027c8295e 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts @@ -9,7 +9,8 @@ export type BuiltTrainStatus = | "SCHEDULED" | "IN_SERVICE" | "UNDER_MAINTENANCE" - | "OUT_OF_SERVICE"; + | "OUT_OF_SERVICE" + | "DEACTIVATED"; export interface YardRefLite { id: string; @@ -140,6 +141,12 @@ export interface BuildTrainPayload { notes?: string; } +/** Run numbers already claimed by existing (non-deleted) trains. */ +export interface UsedTrainNumbers { + importTrainNumbers: string[]; + exportTrainNumbers: string[]; +} + /** Edit a built train's display identity; omitted fields keep their value. */ export interface UpdateTrainDetailsPayload { /** Empty string clears the name. */ @@ -261,6 +268,8 @@ export const trainBuilderService = { list: (filters: BuiltTrainListFilters = {}) => apiClient.get(`${BASE}${toQuery(filters)}`), getComposition: (id: string) => apiClient.get(`${BASE}/${id}`), + /** Import/export run numbers already claimed by existing trains. */ + usedTrainNumbers: () => apiClient.get(`${BASE}/used-train-numbers`), build: (payload: BuildTrainPayload) => apiClient.post(BASE, payload), setLocomotives: (id: string, locomotiveIds: string[]) => apiClient.put(`${BASE}/${id}/locomotives`, { locomotiveIds }), @@ -279,6 +288,11 @@ export const trainBuilderService = { apiClient.post(`${BASE}/${id}/wagons/${wagonId}/maintenance`), reorderWagons: (id: string, wagonIds: string[]) => apiClient.post(`${BASE}/${id}/reorder-wagons`, { wagonIds }), + /** Park the train indefinitely — only allowed with no active schedule. */ + deactivate: (id: string) => + apiClient.post(`${BASE}/${id}/deactivate`), + /** Bring a DEACTIVATED train back to AVAILABLE. */ + activate: (id: string) => apiClient.post(`${BASE}/${id}/activate`), disband: (id: string) => apiClient.delete(`${BASE}/${id}`), /** Built trains schedulable on a route (train-scheduling picker). */ availableTrains: (routeId: string) => diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index a98c09b47..8b50fecba 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -136,6 +136,8 @@ export enum TrainStatus { InService = "IN_SERVICE", UnderMaintenance = "UNDER_MAINTENANCE", OutOfService = "OUT_OF_SERVICE", + /** Parked indefinitely by staff; only allowed with no active schedule. */ + Deactivated = "DEACTIVATED", } export enum CargoType {