mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Implemented utility to calculate wagon usage metrics for train schedules. - Created for sending wagons to maintenance with optional notes. - Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes. - Developed component for merging train schedules with detailed previews and reasons for merging. - Introduced component for selecting wagons with search functionality and selection limits. - Created for displaying and filtering audit logs, including detailed views of individual log entries. - Added for handling API interactions related to audit logs, including fetching logs and entity types.
235 lines
7.6 KiB
TypeScript
235 lines
7.6 KiB
TypeScript
import {
|
|
Button,
|
|
Group,
|
|
Modal,
|
|
MultiSelect,
|
|
Select,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
Textarea,
|
|
} from "@mantine/core";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { isAxiosError } from "axios";
|
|
import { useEffect, useState } from "react";
|
|
|
|
import { api } from "@/services/api";
|
|
import type { TrainComposition } from "@/services/trainBuilder.service";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { useImportTrainNumberOptions } from "@/hooks/useImportTrainNumberOptions";
|
|
import { exportRunFor } from "@/constants/trainRuns";
|
|
|
|
const parseError = (error: unknown, fallback: string) => {
|
|
if (isAxiosError(error)) {
|
|
const message = error.response?.data?.message;
|
|
if (Array.isArray(message)) return message.join(", ");
|
|
if (typeof message === "string") return message;
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
/**
|
|
* Step one of the Train Builder: pick the yard it is being assembled in and
|
|
* couple at least one locomotive 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 [exportTrainNumber, setExportTrainNumber] = useState("");
|
|
const [importTrainNumber, setImportTrainNumber] = useState("");
|
|
const [trainName, setTrainName] = useState("");
|
|
const [yardId, setYardId] = useState("");
|
|
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
|
|
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, and not already
|
|
// coupled to another built train, can be picked. A new train owns none yet, so
|
|
// no train to keep-exclude.
|
|
const locomotivesQuery = useQuery(
|
|
api.locomotives.listFiltered.queryOptions({
|
|
input: {
|
|
filters: {
|
|
status: "AVAILABLE",
|
|
currentYardId: yardId,
|
|
excludeCoupled: true,
|
|
},
|
|
},
|
|
enabled: Boolean(yardId),
|
|
}),
|
|
);
|
|
const build = useMutation(api.trainBuilder.build.mutationOptions());
|
|
|
|
// A locomotive belongs to one yard — switching yards invalidates the pick.
|
|
useEffect(() => {
|
|
setLocomotiveIds([]);
|
|
}, [yardId]);
|
|
|
|
// The export run is fixed by the import run, so it tracks it rather than
|
|
// being entered by hand (and clears back to empty when the import is cleared).
|
|
useEffect(() => {
|
|
setExportTrainNumber(exportRunFor(importTrainNumber));
|
|
}, [importTrainNumber]);
|
|
|
|
useEffect(() => {
|
|
if (!opened) {
|
|
setExportTrainNumber("");
|
|
setImportTrainNumber("");
|
|
setTrainName("");
|
|
setYardId("");
|
|
setLocomotiveIds([]);
|
|
setNotes("");
|
|
}
|
|
}, [opened]);
|
|
|
|
const handleBuild = async () => {
|
|
if (!trainName.trim()) {
|
|
toast({
|
|
title: "Enter the voyage number",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
if (!yardId || locomotiveIds.length < 1) {
|
|
toast({
|
|
title: "Pick a yard and couple at least one locomotive",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
// Both numbers come from the fixed run pairs, so parity cannot be wrong —
|
|
// only "nothing picked" is reachable here.
|
|
if (!exportTrainNumber || !importTrainNumber) {
|
|
toast({
|
|
title: "Pick an import train number (e.g. 8002) — the export run follows it",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
const composition = await build.mutateAsync({
|
|
exportTrainNumber: exportTrainNumber.trim(),
|
|
importTrainNumber: importTrainNumber.trim(),
|
|
currentYardId: yardId,
|
|
locomotiveIds,
|
|
trainName: trainName.trim(),
|
|
...(notes.trim() ? { notes: notes.trim() } : {}),
|
|
});
|
|
toast({ title: `Train ${composition.code} built` });
|
|
onClose();
|
|
onBuilt(composition);
|
|
} catch (err) {
|
|
toast({
|
|
title: "Build failed",
|
|
description: parseError(err, "Could not build the train"),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const locomotiveOptions = (locomotivesQuery.data ?? []).map((loco) => ({
|
|
value: loco.id,
|
|
label: `${loco.code}${loco.name ? ` — ${loco.name}` : ""} · pulls ${loco.maxPullWeightTons}T`,
|
|
}));
|
|
|
|
return (
|
|
<Modal
|
|
opened={opened}
|
|
onClose={onClose}
|
|
title={<Text fw={600}>Build a train</Text>}
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<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. The train code is assigned automatically;
|
|
wagons are attached on the next screen.
|
|
</Text>
|
|
<TextInput
|
|
label="Voyage number"
|
|
placeholder="Enter voyage number"
|
|
value={trainName}
|
|
onChange={(e) => setTrainName(e.currentTarget.value)}
|
|
maxLength={100}
|
|
required
|
|
/>
|
|
<Group grow>
|
|
{/* Fixed by the import run — derived, never typed. */}
|
|
<TextInput
|
|
label="Export train number"
|
|
description="Odd — Ethiopia → Djibouti runs"
|
|
placeholder="e.g. 8001"
|
|
value={exportTrainNumber}
|
|
readOnly
|
|
variant="filled"
|
|
/>
|
|
<Select
|
|
label="Import train number"
|
|
description="Even — Djibouti → Ethiopia runs"
|
|
placeholder={importNumbers.isLoading ? "Loading…" : "e.g. 8002"}
|
|
data={importNumbers.options}
|
|
value={importTrainNumber || null}
|
|
onChange={(value) => setImportTrainNumber(value ?? "")}
|
|
searchable
|
|
clearable
|
|
nothingFoundMessage={importNumbers.emptyMessage}
|
|
error={importNumbers.settingMissing ? importNumbers.emptyMessage : undefined}
|
|
/>
|
|
</Group>
|
|
<Select
|
|
label="Build yard"
|
|
placeholder="Select the yard the train is assembled in"
|
|
data={(yardsQuery.data ?? []).map((y) => ({
|
|
value: y.id,
|
|
label: y.label ?? y.code,
|
|
}))}
|
|
value={yardId || null}
|
|
onChange={(v) => setYardId(v ?? "")}
|
|
searchable
|
|
/>
|
|
<MultiSelect
|
|
label="Locomotives"
|
|
description="A train must be pulled by at least one locomotive. First pick becomes the lead."
|
|
placeholder={yardId ? "Select at least one locomotive" : "Select a yard first"}
|
|
data={locomotiveOptions}
|
|
value={locomotiveIds}
|
|
onChange={setLocomotiveIds}
|
|
searchable
|
|
disabled={!yardId}
|
|
error={
|
|
locomotiveIds.length < 1 ? "Select at least one locomotive" : undefined
|
|
}
|
|
nothingFoundMessage={
|
|
yardId ? "No available locomotives in this yard" : "Select a yard first"
|
|
}
|
|
/>
|
|
<Textarea
|
|
label="Notes (optional)"
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.currentTarget.value)}
|
|
autosize
|
|
minRows={2}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button loading={build.isPending} onClick={handleBuild}>
|
|
Build train
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export interface BuildTrainModalProps {
|
|
opened: boolean;
|
|
onClose: () => void;
|
|
onBuilt: (composition: TrainComposition) => void;
|
|
}
|