implement wagon maintenance feature: add functionality to detach wagons and move them to maintenance status

This commit is contained in:
Marshal
2026-07-15 10:32:09 +00:00
parent 11771e5f92
commit c71a0043d6
9 changed files with 166 additions and 50 deletions

View File

@@ -10,11 +10,6 @@ import {
} from 'class-validator'; } from 'class-validator';
export class BuildTrainDto { 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)' }) @ApiProperty({ example: '8001', description: 'EXPORT run number (odd, unique across trains)' })
@IsString() @IsString()
@MaxLength(20) @MaxLength(20)

View File

@@ -85,6 +85,16 @@ export class TrainBuilderController {
return this.trainBuilderService.removeWagon(id, wagonId); 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') @Post(':id/reorder-wagons')
@FleetManage() @FleetManage()
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' }) @ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })

View File

@@ -59,11 +59,7 @@ export class TrainBuilderService {
} }
const trainId = await this.dataSource.transaction(async (manager) => { const trainId = await this.dataSource.transaction(async (manager) => {
const code = dto.code.trim(); const code = await this.generateTrainCode(manager);
const existing = await manager.getRepository(Train).findOne({ where: { code } });
if (existing) {
throw new ConflictException(`Train code ${code} is already in use`);
}
// Friendly 409 before the partial unique indexes (the race-proof backstop): // 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. // 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); 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. */ /** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) { async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
@@ -469,6 +492,29 @@ export class TrainBuilderService {
// ---------------------------------------------------------------- internals // ---------------------------------------------------------------- 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<string> {
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) { private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) {
const locomotives = [...(train.locomotives ?? [])] const locomotives = [...(train.locomotives ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo) .sort((a, b) => a.sequenceNo - b.sequenceNo)

View File

@@ -50,7 +50,15 @@ export default function AvailableWagonsPanel({
const typeOptions = useMemo(() => { const typeOptions = useMemo(() => {
const byId = new Map<string, string>(); const byId = new Map<string, string>();
for (const wagon of wagonsQuery.data ?? []) { 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 [ return [
{ value: "ALL", label: "All types" }, { 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 = () => { const handleAssign = () => {
if (!selected.length) return; if (!selected.length) return;
onAssign(selected); onAssign(selected);
@@ -88,6 +112,16 @@ export default function AvailableWagonsPanel({
/> />
</Group> </Group>
{wagons.length ? (
<Checkbox
size="sm"
label={`Select all (${wagons.length})`}
checked={allSelected}
indeterminate={!allSelected && someSelected}
onChange={(e) => toggleAll(e.currentTarget.checked)}
/>
) : null}
<ScrollArea.Autosize mah={380} type="auto"> <ScrollArea.Autosize mah={380} type="auto">
<Stack gap={6}> <Stack gap={6}>
{wagonsQuery.isLoading ? ( {wagonsQuery.isLoading ? (

View File

@@ -31,13 +31,12 @@ const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim());
const isEvenNumber = (value: string) => /^\d*[02468]$/.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 * Step one of the Train Builder: pick the yard it is being assembled in and
* yard it is being assembled in, and couple at least two locomotives from that * couple at least two locomotives from that yard. The train code is assigned by
* yard. Wagons are attached afterwards on the composition page. * the system. Wagons are attached afterwards on the composition page.
*/ */
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) { export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
const { toast } = useToast(); const { toast } = useToast();
const [code, setCode] = useState("");
const [exportTrainNumber, setExportTrainNumber] = useState(""); const [exportTrainNumber, setExportTrainNumber] = useState("");
const [importTrainNumber, setImportTrainNumber] = useState(""); const [importTrainNumber, setImportTrainNumber] = useState("");
const [trainName, setTrainName] = useState(""); const [trainName, setTrainName] = useState("");
@@ -62,7 +61,6 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
useEffect(() => { useEffect(() => {
if (!opened) { if (!opened) {
setCode("");
setExportTrainNumber(""); setExportTrainNumber("");
setImportTrainNumber(""); setImportTrainNumber("");
setTrainName(""); setTrainName("");
@@ -73,9 +71,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
}, [opened]); }, [opened]);
const handleBuild = async () => { const handleBuild = async () => {
if (!code.trim() || !yardId || locomotiveIds.length < 2) { if (!yardId || locomotiveIds.length < 2) {
toast({ 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", variant: "destructive",
}); });
return; return;
@@ -89,7 +87,6 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
} }
try { try {
const composition = await build.mutateAsync({ const composition = await build.mutateAsync({
code: code.trim(),
exportTrainNumber: exportTrainNumber.trim(), exportTrainNumber: exportTrainNumber.trim(),
importTrainNumber: importTrainNumber.trim(), importTrainNumber: importTrainNumber.trim(),
currentYardId: yardId, currentYardId: yardId,
@@ -125,24 +122,16 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
<Stack gap="md"> <Stack gap="md">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
A train is assembled in one yard: two or more locomotives plus wagons 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.
</Text> </Text>
<Group grow> <TextInput
<TextInput label="Name (optional)"
label="Train code" placeholder="e.g. Fertilizer block"
placeholder="e.g. 81001" value={trainName}
value={code} onChange={(e) => setTrainName(e.currentTarget.value)}
onChange={(e) => setCode(e.currentTarget.value)} maxLength={100}
maxLength={32} />
/>
<TextInput
label="Name (optional)"
placeholder="e.g. Fertilizer block"
value={trainName}
onChange={(e) => setTrainName(e.currentTarget.value)}
maxLength={100}
/>
</Group>
<Group grow> <Group grow>
<TextInput <TextInput
label="Export train number" label="Export train number"

View File

@@ -7,7 +7,7 @@ import {
type DropResult, type DropResult,
} from "@hello-pangea/dnd"; } from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core"; import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { GripVertical, Trash2 } from "lucide-react"; import { GripVertical, Trash2, Wrench } from "lucide-react";
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -36,6 +36,7 @@ export default function ConsistWagonList({
editable, editable,
onReorder, onReorder,
onRemove, onRemove,
onMaintenance,
busy = false, busy = false,
}: ConsistWagonListProps) { }: ConsistWagonListProps) {
const onDragEnd = (result: DropResult) => { const onDragEnd = (result: DropResult) => {
@@ -78,6 +79,7 @@ export default function ConsistWagonList({
editable={editable} editable={editable}
busy={busy} busy={busy}
onRemove={onRemove} onRemove={onRemove}
onMaintenance={onMaintenance}
/> />
)} )}
</Draggable> </Draggable>
@@ -95,6 +97,8 @@ export interface ConsistWagonListProps {
editable: boolean; editable: boolean;
onReorder: (wagonIds: string[]) => void; onReorder: (wagonIds: string[]) => void;
onRemove: (wagonId: string) => void; onRemove: (wagonId: string) => void;
/** Detach the wagon and move it to MAINTENANCE status. */
onMaintenance: (wagonId: string) => void;
busy?: boolean; busy?: boolean;
} }
@@ -106,6 +110,7 @@ function WagonRow({
editable, editable,
busy, busy,
onRemove, onRemove,
onMaintenance,
}: { }: {
wagon: TrainCompositionWagon; wagon: TrainCompositionWagon;
index: number; index: number;
@@ -114,6 +119,7 @@ function WagonRow({
editable: boolean; editable: boolean;
busy: boolean; busy: boolean;
onRemove: (wagonId: string) => void; onRemove: (wagonId: string) => void;
onMaintenance: (wagonId: string) => void;
}) { }) {
return ( return (
<PortalAwareRow snapshot={snapshot}> <PortalAwareRow snapshot={snapshot}>
@@ -153,17 +159,30 @@ function WagonRow({
</Text> </Text>
</Stack> </Stack>
{editable ? ( {editable ? (
<Tooltip label="Detach wagon" withArrow> <Group gap={4} wrap="nowrap">
<ActionIcon <Tooltip label="Send to maintenance (detaches)" withArrow>
variant="subtle" <ActionIcon
color="red" variant="subtle"
disabled={busy} color="orange"
onClick={() => onRemove(wagon.id)} disabled={busy}
aria-label={`Detach wagon ${wagon.wagonNumber}`} onClick={() => onMaintenance(wagon.id)}
> aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`}
<Trash2 size={16} /> >
</ActionIcon> <Wrench size={16} />
</Tooltip> </ActionIcon>
</Tooltip>
<Tooltip label="Detach wagon" withArrow>
<ActionIcon
variant="subtle"
color="red"
disabled={busy}
onClick={() => onRemove(wagon.id)}
aria-label={`Detach wagon ${wagon.wagonNumber}`}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
) : null} ) : null}
</Group> </Group>
</PortalAwareRow> </PortalAwareRow>

View File

@@ -76,12 +76,18 @@ export default function TrainBuilderDetailPage() {
); );
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions()); const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions()); const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
const maintenanceWagon = useMutation(
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
);
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 composition = compositionQuery.data; const composition = compositionQuery.data;
const busy = const busy =
assignWagons.isPending || removeWagon.isPending || reorderWagons.isPending; assignWagons.isPending ||
removeWagon.isPending ||
maintenanceWagon.isPending ||
reorderWagons.isPending;
const withToast = async (action: () => Promise<unknown>, failTitle: string) => { const withToast = async (action: () => Promise<unknown>, failTitle: string) => {
try { try {
@@ -284,6 +290,12 @@ export default function TrainBuilderDetailPage() {
"Could not detach wagon", "Could not detach wagon",
) )
} }
onMaintenance={(wagonId) =>
void withToast(
() => maintenanceWagon.mutateAsync({ id: composition.id, wagonId }),
"Could not send wagon to maintenance",
)
}
/> />
</Stack> </Stack>
</Card> </Card>

View File

@@ -1850,6 +1850,15 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS, () => 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>( reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder", "train-builder",
"reorderWagons", "reorderWagons",

View File

@@ -129,7 +129,6 @@ export interface BuiltTrainListResponse {
} }
export interface BuildTrainPayload { export interface BuildTrainPayload {
code: string;
/** EXPORT run number — odd, unique across trains (e.g. 8001). */ /** EXPORT run number — odd, unique across trains (e.g. 8001). */
exportTrainNumber: string; exportTrainNumber: string;
/** IMPORT run number — even, unique across trains (e.g. 8002). */ /** IMPORT run number — even, unique across trains (e.g. 8002). */
@@ -249,6 +248,9 @@ export const trainBuilderService = {
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }), apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) => removeWagon: (id: string, wagonId: string) =>
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`), apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
/** Detach a wagon and move it to MAINTENANCE status. */
sendWagonToMaintenance: (id: string, wagonId: string) =>
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 }),
disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`), disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),