diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index b4548edbb..5ba77fb10 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -10,11 +10,6 @@ import { } from 'class-validator'; export class BuildTrainDto { - @ApiProperty({ example: '81001', description: 'Operator-assigned train code (unique)' }) - @IsString() - @MaxLength(32) - code!: string; - @ApiProperty({ example: '8001', description: 'EXPORT run number (odd, unique across trains)' }) @IsString() @MaxLength(20) 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 7ed3e4c42..eec68fcfc 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 @@ -85,6 +85,16 @@ export class TrainBuilderController { return this.trainBuilderService.removeWagon(id, wagonId); } + @Post(':id/wagons/:wagonId/maintenance') + @FleetManage() + @ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' }) + sendWagonToMaintenance( + @Param('id', ParseUUIDPipe) id: string, + @Param('wagonId', ParseUUIDPipe) wagonId: string, + ) { + return this.trainBuilderService.sendWagonToMaintenance(id, wagonId); + } + @Post(':id/reorder-wagons') @FleetManage() @ApiOperation({ summary: 'Persist a drag-reorder of the full consist' }) 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 d385a424c..c875433fe 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 @@ -59,11 +59,7 @@ export class TrainBuilderService { } const trainId = await this.dataSource.transaction(async (manager) => { - const code = dto.code.trim(); - const existing = await manager.getRepository(Train).findOne({ where: { code } }); - if (existing) { - throw new ConflictException(`Train code ${code} is already in use`); - } + const code = await this.generateTrainCode(manager); // Friendly 409 before the partial unique indexes (the race-proof backstop): // the typed pair may not collide with any train's pair or legacy number. @@ -418,6 +414,33 @@ export class TrainBuilderService { return this.getComposition(id); } + /** + * Detach one wagon AND flag it for maintenance: it leaves the consist and + * moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until + * it clears maintenance. The freed sequence gap is closed. + */ + async sendWagonToMaintenance(id: string, wagonId: string) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + if (!wagon || wagon.trainId !== train.id) { + throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); + } + if (wagon.currentTrainScheduleId) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, + ); + } + await manager.getRepository(Wagon).update(wagon.id, { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Maintenance, + }); + await this.resequenceWagons(manager, train.id); + }); + return this.getComposition(id); + } + /** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */ async reorderWagons(id: string, dto: ReorderTrainWagonsDto) { await this.dataSource.transaction(async (manager) => { @@ -469,6 +492,29 @@ export class TrainBuilderService { // ---------------------------------------------------------------- internals + /** + * System-assigned train code `TR-NNNNN`. Draws the next number from the + * highest existing `TR-` code and probes past any manual collision so the + * unique constraint never rejects the build. + */ + private async generateTrainCode(manager: EntityManager): Promise { + const [row]: { max_seq: string | null }[] = await manager.query( + `SELECT MAX(CAST(SUBSTRING(code FROM '^TR-([0-9]+)$') AS INTEGER)) AS max_seq + FROM freight.trains + WHERE code ~ '^TR-[0-9]+$'`, + ); + let seq = Number(row?.max_seq ?? 0) + 1; + for (let attempt = 0; attempt < 50; attempt += 1) { + const code = `TR-${String(seq).padStart(5, '0')}`; + const exists = await manager + .getRepository(Train) + .findOne({ where: { code }, withDeleted: true }); + if (!exists) return code; + seq += 1; + } + throw new ConflictException('Could not allocate a unique train code'); + } + private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) { const locomotives = [...(train.locomotives ?? [])] .sort((a, b) => a.sequenceNo - b.sequenceNo) diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx index a75449a9d..c121acd36 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx @@ -50,7 +50,15 @@ export default function AvailableWagonsPanel({ const typeOptions = useMemo(() => { const byId = new Map(); for (const wagon of wagonsQuery.data ?? []) { - if (wagon.wagonType) byId.set(wagon.wagonType.id, wagon.wagonType.name); + if (wagon.wagonType) { + // e.g. "Flat wagon (NW5)" — name with its type code. + byId.set( + wagon.wagonType.id, + wagon.wagonType.code + ? `${wagon.wagonType.name} (${wagon.wagonType.code})` + : wagon.wagonType.name, + ); + } } return [ { value: "ALL", label: "All types" }, @@ -64,6 +72,22 @@ export default function AvailableWagonsPanel({ ); }; + const allSelected = + wagons.length > 0 && wagons.every((w) => selected.includes(w.id)); + const someSelected = wagons.some((w) => selected.includes(w.id)); + + const toggleAll = (checked: boolean) => { + setSelected((prev) => { + if (checked) { + const ids = new Set(prev); + wagons.forEach((w) => ids.add(w.id)); + return [...ids]; + } + const visible = new Set(wagons.map((w) => w.id)); + return prev.filter((id) => !visible.has(id)); + }); + }; + const handleAssign = () => { if (!selected.length) return; onAssign(selected); @@ -88,6 +112,16 @@ export default function AvailableWagonsPanel({ /> + {wagons.length ? ( + toggleAll(e.currentTarget.checked)} + /> + ) : null} + {wagonsQuery.isLoading ? ( 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 6eb2ab9a8..7f60cabe9 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx @@ -31,13 +31,12 @@ const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim()); const isEvenNumber = (value: string) => /^\d*[02468]$/.test(value.trim()); /** - * Step one of the Train Builder: give the train its operator code, pick the - * yard it is being assembled in, and couple at least two locomotives from that - * yard. Wagons are attached afterwards on the composition page. + * Step one of the Train Builder: pick the yard it is being assembled in and + * couple at least two locomotives from that yard. The train code is assigned by + * the system. Wagons are attached afterwards on the composition page. */ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) { const { toast } = useToast(); - const [code, setCode] = useState(""); const [exportTrainNumber, setExportTrainNumber] = useState(""); const [importTrainNumber, setImportTrainNumber] = useState(""); const [trainName, setTrainName] = useState(""); @@ -62,7 +61,6 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain useEffect(() => { if (!opened) { - setCode(""); setExportTrainNumber(""); setImportTrainNumber(""); setTrainName(""); @@ -73,9 +71,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain }, [opened]); const handleBuild = async () => { - if (!code.trim() || !yardId || locomotiveIds.length < 2) { + if (!yardId || locomotiveIds.length < 2) { toast({ - title: "Enter a train code, pick a yard, and couple at least two locomotives", + title: "Pick a yard and couple at least two locomotives", variant: "destructive", }); return; @@ -89,7 +87,6 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain } try { const composition = await build.mutateAsync({ - code: code.trim(), exportTrainNumber: exportTrainNumber.trim(), importTrainNumber: importTrainNumber.trim(), currentYardId: yardId, @@ -125,24 +122,16 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain A train is assembled in one yard: two or more locomotives plus wagons - standing in that same yard. Wagons are attached on the next screen. + standing in that same yard. The train code is assigned automatically; + wagons are attached on the next screen. - - setCode(e.currentTarget.value)} - maxLength={32} - /> - setTrainName(e.currentTarget.value)} - maxLength={100} - /> - + setTrainName(e.currentTarget.value)} + maxLength={100} + /> { @@ -78,6 +79,7 @@ export default function ConsistWagonList({ editable={editable} busy={busy} onRemove={onRemove} + onMaintenance={onMaintenance} /> )} @@ -95,6 +97,8 @@ export interface ConsistWagonListProps { editable: boolean; onReorder: (wagonIds: string[]) => void; onRemove: (wagonId: string) => void; + /** Detach the wagon and move it to MAINTENANCE status. */ + onMaintenance: (wagonId: string) => void; busy?: boolean; } @@ -106,6 +110,7 @@ function WagonRow({ editable, busy, onRemove, + onMaintenance, }: { wagon: TrainCompositionWagon; index: number; @@ -114,6 +119,7 @@ function WagonRow({ editable: boolean; busy: boolean; onRemove: (wagonId: string) => void; + onMaintenance: (wagonId: string) => void; }) { return ( @@ -153,17 +159,30 @@ function WagonRow({ {editable ? ( - - onRemove(wagon.id)} - aria-label={`Detach wagon ${wagon.wagonNumber}`} - > - - - + + + onMaintenance(wagon.id)} + aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`} + > + + + + + onRemove(wagon.id)} + aria-label={`Detach wagon ${wagon.wagonNumber}`} + > + + + + ) : null} 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 a288d02c1..f4e65266f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx @@ -76,12 +76,18 @@ export default function TrainBuilderDetailPage() { ); const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions()); const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions()); + const maintenanceWagon = useMutation( + api.trainBuilder.sendWagonToMaintenance.mutationOptions(), + ); const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions()); const disband = useMutation(api.trainBuilder.disband.mutationOptions()); const composition = compositionQuery.data; const busy = - assignWagons.isPending || removeWagon.isPending || reorderWagons.isPending; + assignWagons.isPending || + removeWagon.isPending || + maintenanceWagon.isPending || + reorderWagons.isPending; const withToast = async (action: () => Promise, failTitle: string) => { try { @@ -284,6 +290,12 @@ export default function TrainBuilderDetailPage() { "Could not detach wagon", ) } + onMaintenance={(wagonId) => + void withToast( + () => maintenanceWagon.mutateAsync({ id: composition.id, wagonId }), + "Could not send wagon to maintenance", + ) + } /> diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 6a35fd49c..e2e0e1d9f 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -1850,6 +1850,15 @@ export const api = { () => TRAIN_BUILDER_INVALIDATIONS, ), + sendWagonToMaintenance: endpoint<{ id: string; wagonId: string }, TrainComposition>( + "train-builder", + "sendWagonToMaintenance", + ({ id, wagonId }) => + trainBuilderService.sendWagonToMaintenance(id, wagonId).then((r) => r.data), + undefined, + () => TRAIN_BUILDER_INVALIDATIONS, + ), + reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>( "train-builder", "reorderWagons", 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 e6af4c7dc..f78792b81 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts @@ -129,7 +129,6 @@ export interface BuiltTrainListResponse { } export interface BuildTrainPayload { - code: string; /** EXPORT run number — odd, unique across trains (e.g. 8001). */ exportTrainNumber: string; /** IMPORT run number — even, unique across trains (e.g. 8002). */ @@ -249,6 +248,9 @@ export const trainBuilderService = { apiClient.post(`${BASE}/${id}/wagons`, { wagonIds }), removeWagon: (id: string, wagonId: string) => apiClient.delete(`${BASE}/${id}/wagons/${wagonId}`), + /** Detach a wagon and move it to MAINTENANCE status. */ + sendWagonToMaintenance: (id: string, wagonId: string) => + apiClient.post(`${BASE}/${id}/wagons/${wagonId}/maintenance`), reorderWagons: (id: string, wagonIds: string[]) => apiClient.post(`${BASE}/${id}/reorder-wagons`, { wagonIds }), disband: (id: string) => apiClient.delete(`${BASE}/${id}`),