mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
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.
This commit is contained in:
@@ -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<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TYPE "freight"."train_status" ADD VALUE IF NOT EXISTS 'DEACTIVATED'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Enum values cannot be removed in Postgres; leaving the label is harmless.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [
|
||||||
|
this.code,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yard soft-delete now appends `@<epoch-ms>` 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<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."yards" ALTER COLUMN "code" TYPE varchar(40)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Narrowing would fail on suffixed codes; keep 40.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Inject, Injectable } from "@nestjs/common";
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
import { In, Not } from "typeorm";
|
|
||||||
|
|
||||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||||
@@ -34,8 +33,6 @@ import {
|
|||||||
BookingReferenceYardDto,
|
BookingReferenceYardDto,
|
||||||
} from "./dto/booking-reference-data.dto";
|
} from "./dto/booking-reference-data.dto";
|
||||||
|
|
||||||
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
|
|
||||||
|
|
||||||
export function buildCargoTypeTree(
|
export function buildCargoTypeTree(
|
||||||
rows: CargoType[],
|
rows: CargoType[],
|
||||||
): BookingReferenceCargoTypeGroupDto[] {
|
): BookingReferenceCargoTypeGroupDto[] {
|
||||||
@@ -134,10 +131,7 @@ export class BookingReferenceDataService {
|
|||||||
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.yardsRepository.findAll({
|
this.yardsRepository.findAll({
|
||||||
where: {
|
where: { isActive: true },
|
||||||
isActive: true,
|
|
||||||
code: Not(In([...LEGACY_YARD_CODES])),
|
|
||||||
},
|
|
||||||
order: { displayOrder: "ASC", code: "ASC" },
|
order: { displayOrder: "ASC", code: "ASC" },
|
||||||
}),
|
}),
|
||||||
this.containerTypesRepository.findAll({
|
this.containerTypesRepository.findAll({
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { Column, Entity, Index } from 'typeorm';
|
|||||||
@Index(['country'])
|
@Index(['country'])
|
||||||
@Index(['isActive'])
|
@Index(['isActive'])
|
||||||
export class Yard extends BaseEntity {
|
export class Yard extends BaseEntity {
|
||||||
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
|
// 40 leaves room for the `@<epoch-ms>` suffix soft-delete appends to free the code.
|
||||||
|
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
@Column({ name: 'label', type: 'varchar', length: 100 })
|
@Column({ name: 'label', type: 'varchar', length: 100 })
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class YardsService {
|
|||||||
|
|
||||||
/** Create a yard. */
|
/** Create a yard. */
|
||||||
async create(dto: CreateYardDto): Promise<Yard> {
|
async create(dto: CreateYardDto): Promise<Yard> {
|
||||||
const code = generateCode(dto.label);
|
const code = generateCode(dto.label).slice(0, 40);
|
||||||
const existing = await this.repository.findByCode(code);
|
const existing = await this.repository.findByCode(code);
|
||||||
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Soft-delete a yard. */
|
/**
|
||||||
|
* Soft-delete a yard. The unique `code` (and the label) get a `@<epoch-ms>`
|
||||||
|
* 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<void> {
|
async remove(id: string): Promise<void> {
|
||||||
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);
|
await this.repository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1283,7 +1283,8 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
builtTrain.status === Freight.TrainStatus.OutOfService ||
|
builtTrain.status === Freight.TrainStatus.OutOfService ||
|
||||||
builtTrain.status === Freight.TrainStatus.UnderMaintenance
|
builtTrain.status === Freight.TrainStatus.UnderMaintenance ||
|
||||||
|
builtTrain.status === Freight.TrainStatus.Deactivated
|
||||||
) {
|
) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`,
|
`Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||||
@@ -5219,7 +5220,11 @@ export class TrainSchedulingService {
|
|||||||
const trains = await this.dataSource.getRepository(Train).find({
|
const trains = await this.dataSource.getRepository(Train).find({
|
||||||
where: {
|
where: {
|
||||||
status: Not(
|
status: Not(
|
||||||
In([Freight.TrainStatus.OutOfService, Freight.TrainStatus.UnderMaintenance]),
|
In([
|
||||||
|
Freight.TrainStatus.OutOfService,
|
||||||
|
Freight.TrainStatus.UnderMaintenance,
|
||||||
|
Freight.TrainStatus.Deactivated,
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
relations: {
|
relations: {
|
||||||
@@ -5619,8 +5624,8 @@ export class TrainSchedulingService {
|
|||||||
* Re-derive a built train's lifecycle status from its schedules after one of
|
* Re-derive a built train's lifecycle status from its schedules after one of
|
||||||
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
|
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
|
||||||
* SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival
|
* SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival
|
||||||
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE)
|
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE
|
||||||
* keep their status — staff own that flag, not the scheduler.
|
* / DEACTIVATED) keep their status — staff own that flag, not the scheduler.
|
||||||
*/
|
*/
|
||||||
private async syncBuiltTrainAfterScheduleChange(
|
private async syncBuiltTrainAfterScheduleChange(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ export class TrainBuilderController {
|
|||||||
return this.trainBuilderService.listBuilt(query);
|
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')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' })
|
@ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' })
|
||||||
composition(@Param('id', ParseUUIDPipe) id: string) {
|
composition(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
@@ -115,6 +124,22 @@ export class TrainBuilderController {
|
|||||||
return this.trainBuilderService.reorderWagons(id, dto);
|
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')
|
@Delete(':id')
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
const exportTrainNumbers = new Set<string>();
|
||||||
|
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,
|
* One ACTIVE schedule per train for the page (prefer the DISPATCHED run,
|
||||||
* else the earliest upcoming departure) — feeds the list's direction tint
|
* else the earliest upcoming departure) — feeds the list's direction tint
|
||||||
@@ -532,6 +564,50 @@ export class TrainBuilderService {
|
|||||||
return this.getComposition(id);
|
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. */
|
/** Disband the train: release wagons and locomotives, then delete it. */
|
||||||
async disband(id: string): Promise<void> {
|
async disband(id: string): Promise<void> {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ const DEFAULT_DROPDOWN_SETTINGS: DefaultDropdownSetting[] = [
|
|||||||
"Minimum days between today and the vessel departure date on an export Release Order.",
|
"Minimum days between today and the vessel departure date on an export Release Order.",
|
||||||
multiple: false,
|
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()
|
@Injectable()
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { useEffect, useState } from "react";
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { TrainComposition } from "@/services/trainBuilder.service";
|
import type { TrainComposition } from "@/services/trainBuilder.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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) => {
|
const parseError = (error: unknown, fallback: string) => {
|
||||||
if (isAxiosError(error)) {
|
if (isAxiosError(error)) {
|
||||||
@@ -42,6 +43,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
|||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
|
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.
|
// Only serviceable locomotives standing in the selected yard can be coupled.
|
||||||
const locomotivesQuery = useQuery(
|
const locomotivesQuery = useQuery(
|
||||||
api.locomotives.listFiltered.queryOptions({
|
api.locomotives.listFiltered.queryOptions({
|
||||||
@@ -150,12 +154,13 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
|||||||
<Select
|
<Select
|
||||||
label="Import train number"
|
label="Import train number"
|
||||||
description="Even — Djibouti → Ethiopia runs"
|
description="Even — Djibouti → Ethiopia runs"
|
||||||
placeholder="e.g. 8002"
|
placeholder={importNumbers.isLoading ? "Loading…" : "e.g. 8002"}
|
||||||
data={IMPORT_TRAIN_OPTIONS}
|
data={importNumbers.options}
|
||||||
value={importTrainNumber || null}
|
value={importTrainNumber || null}
|
||||||
onChange={(value) => setImportTrainNumber(value ?? "")}
|
onChange={(value) => setImportTrainNumber(value ?? "")}
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
|
nothingFoundMessage="No free run numbers — add more in Dropdown Settings"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core";
|
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { exportRunFor } from "@/constants/trainRuns";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useImportTrainNumberOptions } from "@/hooks/useImportTrainNumberOptions";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { BuiltTrainSummary } from "@/services/trainBuilder.service";
|
import type { BuiltTrainSummary } from "@/services/trainBuilder.service";
|
||||||
|
|
||||||
@@ -15,9 +17,11 @@ export interface EditTrainDetailsModalProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit a built train's display identity from the list: its name and its fixed
|
* Edit a built train's display identity from the list: its name and its fixed
|
||||||
* import/export run numbers. Composition (yard, locomotives, wagons) is edited
|
* import/export run numbers. The import number comes from the admin-managed
|
||||||
* on the detail page. Number collisions come back as a 409 with the owning
|
* dropdown setting (numbers on other trains are disabled; this train's own
|
||||||
* train's code and surface verbatim.
|
* number stays pickable) and the export number follows it. Composition (yard,
|
||||||
|
* locomotives, wagons) is edited on the detail page. Number collisions come
|
||||||
|
* back as a 409 with the owning train's code and surface verbatim.
|
||||||
*/
|
*/
|
||||||
const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) => {
|
const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -25,6 +29,8 @@ const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) =
|
|||||||
const [importNo, setImportNo] = useState("");
|
const [importNo, setImportNo] = useState("");
|
||||||
const [exportNo, setExportNo] = useState("");
|
const [exportNo, setExportNo] = useState("");
|
||||||
|
|
||||||
|
const importNumbers = useImportTrainNumberOptions(train?.importTrainNumber);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (train) {
|
if (train) {
|
||||||
setName(train.trainName ?? "");
|
setName(train.trainName ?? "");
|
||||||
@@ -86,20 +92,29 @@ const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) =
|
|||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<Select
|
||||||
label="Import train no."
|
label="Import train no."
|
||||||
placeholder="e.g. 8002"
|
placeholder={importNumbers.isLoading ? "Loading…" : "e.g. 8002"}
|
||||||
value={importNo}
|
data={importNumbers.options}
|
||||||
onChange={(e) => setImportNo(e.currentTarget.value)}
|
value={importNo || null}
|
||||||
maxLength={20}
|
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"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Export train no."
|
label="Export train no."
|
||||||
|
description="Follows the import run"
|
||||||
placeholder="e.g. 8001"
|
placeholder="e.g. 8001"
|
||||||
value={exportNo}
|
value={exportNo}
|
||||||
onChange={(e) => setExportNo(e.currentTarget.value)}
|
readOnly
|
||||||
maxLength={20}
|
variant="filled"
|
||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const trainStatusColor = (status: BuiltTrainStatus | string): string => {
|
|||||||
return "yellow";
|
return "yellow";
|
||||||
case "OUT_OF_SERVICE":
|
case "OUT_OF_SERVICE":
|
||||||
return "red";
|
return "red";
|
||||||
|
case "DEACTIVATED":
|
||||||
|
return "gray";
|
||||||
default:
|
default:
|
||||||
return "gray";
|
return "gray";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =>
|
* The import run implied by an export run; empty string when unset/unknown.
|
||||||
TRAIN_RUN_PAIRS[String(exportRun ?? "")] ?? "";
|
* 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 =>
|
* The export run implied by an import run; empty string when unset/unknown.
|
||||||
EXPORT_BY_IMPORT[String(importRun ?? "")] ?? "";
|
* 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) : "";
|
||||||
|
};
|
||||||
|
|||||||
@@ -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<ImportTrainNumberOption[]>(() => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
CalendarClock,
|
CalendarClock,
|
||||||
MapPin,
|
MapPin,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
Replace,
|
Replace,
|
||||||
Ruler,
|
Ruler,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -70,6 +72,7 @@ export default function TrainBuilderDetailPage() {
|
|||||||
const [locoModalOpen, setLocoModalOpen] = useState(false);
|
const [locoModalOpen, setLocoModalOpen] = useState(false);
|
||||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||||
|
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||||
|
|
||||||
const compositionQuery = useQuery(
|
const compositionQuery = useQuery(
|
||||||
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
|
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 reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
|
||||||
const disband = useMutation(api.trainBuilder.disband.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 composition = compositionQuery.data;
|
||||||
const busy =
|
const busy =
|
||||||
@@ -172,6 +177,27 @@ export default function TrainBuilderDetailPage() {
|
|||||||
>
|
>
|
||||||
Change yard
|
Change yard
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
{composition.status === "DEACTIVATED" ? (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Power size={15} />}
|
||||||
|
onClick={() =>
|
||||||
|
void withToast(async () => {
|
||||||
|
await activate.mutateAsync(composition.id);
|
||||||
|
toast({ title: `Train ${composition.code} reactivated` });
|
||||||
|
}, "Could not reactivate train")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Reactivate train
|
||||||
|
</Menu.Item>
|
||||||
|
) : (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<PowerOff size={15} />}
|
||||||
|
disabled={composition.activeSchedules.length > 0}
|
||||||
|
onClick={() => setDeactivateOpen(true)}
|
||||||
|
>
|
||||||
|
Deactivate train
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
color="red"
|
color="red"
|
||||||
leftSection={<Trash2 size={15} />}
|
leftSection={<Trash2 size={15} />}
|
||||||
@@ -356,6 +382,39 @@ export default function TrainBuilderDetailPage() {
|
|||||||
onClose={() => setYardModalOpen(false)}
|
onClose={() => setYardModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={deactivateOpen}
|
||||||
|
onClose={() => setDeactivateOpen(false)}
|
||||||
|
title={<Text fw={600}>Deactivate train {composition.code}?</Text>}
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
The train is parked and cannot be picked for new schedules until it is
|
||||||
|
reactivated. Its locomotives and wagons stay coupled.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setDeactivateOpen(false)}>
|
||||||
|
Keep active
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="gray"
|
||||||
|
loading={deactivate.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
void withToast(async () => {
|
||||||
|
await deactivate.mutateAsync(composition.id);
|
||||||
|
toast({ title: `Train ${composition.code} deactivated` });
|
||||||
|
setDeactivateOpen(false);
|
||||||
|
}, "Could not deactivate train")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Deactivate
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={disbandOpen}
|
opened={disbandOpen}
|
||||||
onClose={() => setDisbandOpen(false)}
|
onClose={() => setDisbandOpen(false)}
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ export default function TrainBuilderListPage() {
|
|||||||
{ value: "IN_SERVICE", label: "In service" },
|
{ value: "IN_SERVICE", label: "In service" },
|
||||||
{ value: "UNDER_MAINTENANCE", label: "Under maintenance" },
|
{ value: "UNDER_MAINTENANCE", label: "Under maintenance" },
|
||||||
{ value: "OUT_OF_SERVICE", label: "Out of service" },
|
{ value: "OUT_OF_SERVICE", label: "Out of service" },
|
||||||
|
{ value: "DEACTIVATED", label: "Deactivated" },
|
||||||
]}
|
]}
|
||||||
w={180}
|
w={180}
|
||||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ import {
|
|||||||
type ScheduleConsist,
|
type ScheduleConsist,
|
||||||
type TrainComposition,
|
type TrainComposition,
|
||||||
type UpdateTrainDetailsPayload,
|
type UpdateTrainDetailsPayload,
|
||||||
|
type UsedTrainNumbers,
|
||||||
} from "./trainBuilder.service";
|
} from "./trainBuilder.service";
|
||||||
import { trainSchedulingService } from "./trainScheduling.service";
|
import { trainSchedulingService } from "./trainScheduling.service";
|
||||||
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
||||||
@@ -1825,6 +1826,14 @@ export const api = {
|
|||||||
({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id),
|
({ 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<void, UsedTrainNumbers>(
|
||||||
|
"train-builder",
|
||||||
|
"usedTrainNumbers",
|
||||||
|
() => trainBuilderService.usedTrainNumbers().then((r) => r.data),
|
||||||
|
),
|
||||||
|
|
||||||
build: endpoint<BuildTrainPayload, TrainComposition>(
|
build: endpoint<BuildTrainPayload, TrainComposition>(
|
||||||
"train-builder",
|
"train-builder",
|
||||||
"build",
|
"build",
|
||||||
@@ -1902,6 +1911,22 @@ export const api = {
|
|||||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
deactivate: endpoint<string, TrainComposition>(
|
||||||
|
"train-builder",
|
||||||
|
"deactivate",
|
||||||
|
(id) => trainBuilderService.deactivate(id).then((r) => r.data),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
|
activate: endpoint<string, TrainComposition>(
|
||||||
|
"train-builder",
|
||||||
|
"activate",
|
||||||
|
(id) => trainBuilderService.activate(id).then((r) => r.data),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
disband: endpoint<string, void>(
|
disband: endpoint<string, void>(
|
||||||
"train-builder",
|
"train-builder",
|
||||||
"disband",
|
"disband",
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export type BuiltTrainStatus =
|
|||||||
| "SCHEDULED"
|
| "SCHEDULED"
|
||||||
| "IN_SERVICE"
|
| "IN_SERVICE"
|
||||||
| "UNDER_MAINTENANCE"
|
| "UNDER_MAINTENANCE"
|
||||||
| "OUT_OF_SERVICE";
|
| "OUT_OF_SERVICE"
|
||||||
|
| "DEACTIVATED";
|
||||||
|
|
||||||
export interface YardRefLite {
|
export interface YardRefLite {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -140,6 +141,12 @@ export interface BuildTrainPayload {
|
|||||||
notes?: string;
|
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. */
|
/** Edit a built train's display identity; omitted fields keep their value. */
|
||||||
export interface UpdateTrainDetailsPayload {
|
export interface UpdateTrainDetailsPayload {
|
||||||
/** Empty string clears the name. */
|
/** Empty string clears the name. */
|
||||||
@@ -261,6 +268,8 @@ export const trainBuilderService = {
|
|||||||
list: (filters: BuiltTrainListFilters = {}) =>
|
list: (filters: BuiltTrainListFilters = {}) =>
|
||||||
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||||
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`),
|
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`),
|
||||||
|
/** Import/export run numbers already claimed by existing trains. */
|
||||||
|
usedTrainNumbers: () => apiClient.get<UsedTrainNumbers>(`${BASE}/used-train-numbers`),
|
||||||
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
||||||
setLocomotives: (id: string, locomotiveIds: string[]) =>
|
setLocomotives: (id: string, locomotiveIds: string[]) =>
|
||||||
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
|
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
|
||||||
@@ -279,6 +288,11 @@ export const trainBuilderService = {
|
|||||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
|
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
|
||||||
reorderWagons: (id: string, wagonIds: string[]) =>
|
reorderWagons: (id: string, wagonIds: string[]) =>
|
||||||
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
||||||
|
/** Park the train indefinitely — only allowed with no active schedule. */
|
||||||
|
deactivate: (id: string) =>
|
||||||
|
apiClient.post<TrainComposition>(`${BASE}/${id}/deactivate`),
|
||||||
|
/** Bring a DEACTIVATED train back to AVAILABLE. */
|
||||||
|
activate: (id: string) => apiClient.post<TrainComposition>(`${BASE}/${id}/activate`),
|
||||||
disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),
|
disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),
|
||||||
/** Built trains schedulable on a route (train-scheduling picker). */
|
/** Built trains schedulable on a route (train-scheduling picker). */
|
||||||
availableTrains: (routeId: string) =>
|
availableTrains: (routeId: string) =>
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ export enum TrainStatus {
|
|||||||
InService = "IN_SERVICE",
|
InService = "IN_SERVICE",
|
||||||
UnderMaintenance = "UNDER_MAINTENANCE",
|
UnderMaintenance = "UNDER_MAINTENANCE",
|
||||||
OutOfService = "OUT_OF_SERVICE",
|
OutOfService = "OUT_OF_SERVICE",
|
||||||
|
/** Parked indefinitely by staff; only allowed with no active schedule. */
|
||||||
|
Deactivated = "DEACTIVATED",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CargoType {
|
export enum CargoType {
|
||||||
|
|||||||
Reference in New Issue
Block a user