mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +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:
@@ -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
|
||||
<Select
|
||||
label="Import train number"
|
||||
description="Even — Djibouti → Ethiopia runs"
|
||||
placeholder="e.g. 8002"
|
||||
data={IMPORT_TRAIN_OPTIONS}
|
||||
placeholder={importNumbers.isLoading ? "Loading…" : "e.g. 8002"}
|
||||
data={importNumbers.options}
|
||||
value={importTrainNumber || null}
|
||||
onChange={(value) => setImportTrainNumber(value ?? "")}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage="No free run numbers — add more in Dropdown Settings"
|
||||
/>
|
||||
</Group>
|
||||
<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 { Pencil } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { exportRunFor } from "@/constants/trainRuns";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useImportTrainNumberOptions } from "@/hooks/useImportTrainNumberOptions";
|
||||
import { api } from "@/services/api";
|
||||
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
|
||||
* import/export run numbers. 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.
|
||||
* import/export run numbers. The import number comes from the admin-managed
|
||||
* dropdown setting (numbers on other trains are disabled; this train's own
|
||||
* 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 { 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"
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
<Select
|
||||
label="Import train no."
|
||||
placeholder="e.g. 8002"
|
||||
value={importNo}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<TextInput
|
||||
label="Export train no."
|
||||
description="Follows the import run"
|
||||
placeholder="e.g. 8001"
|
||||
value={exportNo}
|
||||
onChange={(e) => setExportNo(e.currentTarget.value)}
|
||||
maxLength={20}
|
||||
readOnly
|
||||
variant="filled"
|
||||
radius="md"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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) : "";
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
</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
|
||||
color="red"
|
||||
leftSection={<Trash2 size={15} />}
|
||||
@@ -356,6 +382,39 @@ export default function TrainBuilderDetailPage() {
|
||||
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
|
||||
opened={disbandOpen}
|
||||
onClose={() => setDisbandOpen(false)}
|
||||
|
||||
@@ -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)" } }}
|
||||
|
||||
@@ -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<void, UsedTrainNumbers>(
|
||||
"train-builder",
|
||||
"usedTrainNumbers",
|
||||
() => trainBuilderService.usedTrainNumbers().then((r) => r.data),
|
||||
),
|
||||
|
||||
build: endpoint<BuildTrainPayload, TrainComposition>(
|
||||
"train-builder",
|
||||
"build",
|
||||
@@ -1902,6 +1911,22 @@ export const api = {
|
||||
() => 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>(
|
||||
"train-builder",
|
||||
"disband",
|
||||
|
||||
@@ -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<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||
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),
|
||||
setLocomotives: (id: string, locomotiveIds: string[]) =>
|
||||
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
|
||||
@@ -279,6 +288,11 @@ export const trainBuilderService = {
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
|
||||
reorderWagons: (id: string, wagonIds: string[]) =>
|
||||
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}`),
|
||||
/** Built trains schedulable on a route (train-scheduling picker). */
|
||||
availableTrains: (routeId: string) =>
|
||||
|
||||
Reference in New Issue
Block a user