mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +00:00
implement wagon maintenance feature: add functionality to detach wagons and move them to maintenance status
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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<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) {
|
||||
const locomotives = [...(train.locomotives ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
|
||||
@@ -50,7 +50,15 @@ export default function AvailableWagonsPanel({
|
||||
const typeOptions = useMemo(() => {
|
||||
const byId = new Map<string, string>();
|
||||
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({
|
||||
/>
|
||||
</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">
|
||||
<Stack gap={6}>
|
||||
{wagonsQuery.isLoading ? (
|
||||
|
||||
@@ -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
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
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>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Train code"
|
||||
placeholder="e.g. 81001"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.currentTarget.value)}
|
||||
maxLength={32}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name (optional)"
|
||||
placeholder="e.g. Fertilizer block"
|
||||
value={trainName}
|
||||
onChange={(e) => setTrainName(e.currentTarget.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Name (optional)"
|
||||
placeholder="e.g. Fertilizer block"
|
||||
value={trainName}
|
||||
onChange={(e) => setTrainName(e.currentTarget.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Export train number"
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type DropResult,
|
||||
} from "@hello-pangea/dnd";
|
||||
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 { createPortal } from "react-dom";
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function ConsistWagonList({
|
||||
editable,
|
||||
onReorder,
|
||||
onRemove,
|
||||
onMaintenance,
|
||||
busy = false,
|
||||
}: ConsistWagonListProps) {
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
@@ -78,6 +79,7 @@ export default function ConsistWagonList({
|
||||
editable={editable}
|
||||
busy={busy}
|
||||
onRemove={onRemove}
|
||||
onMaintenance={onMaintenance}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
@@ -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 (
|
||||
<PortalAwareRow snapshot={snapshot}>
|
||||
@@ -153,17 +159,30 @@ function WagonRow({
|
||||
</Text>
|
||||
</Stack>
|
||||
{editable ? (
|
||||
<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 gap={4} wrap="nowrap">
|
||||
<Tooltip label="Send to maintenance (detaches)" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
disabled={busy}
|
||||
onClick={() => onMaintenance(wagon.id)}
|
||||
aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`}
|
||||
>
|
||||
<Wrench size={16} />
|
||||
</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}
|
||||
</Group>
|
||||
</PortalAwareRow>
|
||||
|
||||
@@ -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<unknown>, 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",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||
removeWagon: (id: string, wagonId: string) =>
|
||||
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[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
||||
disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),
|
||||
|
||||
Reference in New Issue
Block a user