Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx
marshalyordanos 5da36eb128 feat: add wagon usage computation and maintenance logging features
- 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.
2026-08-12 09:36:50 +03:00

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;
}