Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/MergeScheduleTrainModal.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

345 lines
10 KiB
TypeScript

import {
Alert,
Badge,
Box,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import {
ArrowRight,
Ban,
CircleAlert,
Merge,
Search,
TriangleAlert,
} from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
function parseError(error: unknown, fallback: string): 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;
}
const fmtDate = (iso: string) =>
new Date(iso).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
export interface MergeScheduleTrainModalProps {
scheduleId: string | null;
/** This schedule's current train — excluded from the picker. */
currentTrainId: string | null;
scheduleReference?: string | null;
opened: boolean;
onClose: () => void;
onMerged?: () => void;
}
/**
* Merge another train into this schedule.
*
* This schedule always survives: its train set is repointed at the chosen
* train, that train's wagons join this consist, and the emptied train is
* deactivated. When the chosen train also runs a schedule on the SAME DAY, that
* schedule's bookings move here and it is removed — its other-day schedules
* gain the wagons only. The server computes all of that in `previewMerge`, so
* the summary below is exactly what the commit will perform.
*/
export default function MergeScheduleTrainModal({
scheduleId,
currentTrainId,
scheduleReference,
opened,
onClose,
onMerged,
}: MergeScheduleTrainModalProps) {
const { toast } = useToast();
const [selectedTrainId, setSelectedTrainId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [reason, setReason] = useState("");
const { data: trains = [], isLoading: trainsLoading } = useQuery({
...api.trains.list.queryOptions(),
enabled: opened,
});
// The schedule's own train cannot be merged into itself.
const options = useMemo(() => {
const q = search.trim().toLowerCase();
return trains
.filter((t) => t.id !== currentTrainId)
.filter((t) =>
q
? `${t.code} ${t.trainNumber ?? ""} ${t.trainName ?? ""}`
.toLowerCase()
.includes(q)
: true,
);
}, [trains, currentTrainId, search]);
const { data: preview, isFetching: previewLoading } = useQuery({
...api.trainScheduling.previewScheduleMerge.queryOptions({
input: { id: scheduleId ?? "", targetTrainId: selectedTrainId ?? "" },
}),
enabled: opened && Boolean(scheduleId && selectedTrainId),
});
const merge = useMutation(api.trainScheduling.mergeScheduleTrain.mutationOptions());
const close = () => {
setSelectedTrainId(null);
setSearch("");
setReason("");
onClose();
};
const submit = async () => {
if (!scheduleId || !selectedTrainId || !preview?.canMerge) return;
try {
await merge.mutateAsync({
id: scheduleId,
targetTrainId: selectedTrainId,
...(reason.trim() ? { reason: reason.trim() } : {}),
});
toast({ title: "Trains merged" });
onMerged?.();
close();
} catch (err) {
toast({
title: "Merge failed",
description: parseError(err, "Could not merge the trains"),
variant: "destructive",
});
}
};
return (
<Modal
opened={opened}
onClose={close}
centered
size="lg"
radius="lg"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Merge size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Merge another train into this one
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{scheduleReference ?? "This departure survives the merge"}
</Text>
</Box>
</Group>
}
>
<Stack gap="md">
<TextInput
placeholder="Search train code or number…"
leftSection={<Search size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
radius="md"
/>
{trainsLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : options.length === 0 ? (
<Text size="sm" c="dimmed" py="sm">
No other trains available to merge.
</Text>
) : (
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
gap: 8,
maxHeight: 190,
overflowY: "auto",
}}
>
{options.map((t) => {
const on = t.id === selectedTrainId;
return (
<Card
key={t.id}
withBorder
radius="md"
padding="xs"
onClick={() => setSelectedTrainId(t.id)}
style={{
cursor: "pointer",
borderColor: on
? "var(--mantine-color-edr-green-5)"
: undefined,
background: on
? "var(--mantine-color-edr-green-0)"
: undefined,
}}
>
<Text size="sm" fw={on ? 700 : 600} truncate>
{t.code}
</Text>
<Text size="xs" c="dimmed" truncate>
{t.trainNumber ? `No. ${t.trainNumber}` : "—"}
</Text>
</Card>
);
})}
</Box>
)}
{selectedTrainId && previewLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : null}
{selectedTrainId && preview && !previewLoading ? (
<Stack gap="sm">
{preview.blockers.length ? (
<Alert
variant="light"
color="red"
icon={<CircleAlert size={16} />}
title="This merge is blocked"
>
<Stack gap={4}>
{preview.blockers.map((b) => (
<Text size="sm" key={b}>
{b}
</Text>
))}
</Stack>
</Alert>
) : (
<Alert
variant="light"
color="orange"
icon={<TriangleAlert size={16} />}
>
This cannot be undone. Wagons are appended last reorder them
afterwards in the train builder.
</Alert>
)}
<Card withBorder radius="md" padding="sm">
<Group gap={8} wrap="nowrap" mb={8}>
<Text size="sm" fw={700}>
{preview.wagons.current} wagons
</Text>
<ArrowRight size={15} />
<Text size="sm" fw={700} c="edr-green.8">
{preview.wagons.merged} wagons
</Text>
<Badge variant="light" color="edr-green" radius="sm">
+{preview.wagons.incoming} from {preview.targetTrain.code}
</Badge>
</Group>
{preview.absorbedSchedule ? (
<Group gap={6} wrap="nowrap" mb={6}>
<Badge size="sm" color="grape" variant="light" radius="sm">
{fmtDate(preview.absorbedSchedule.scheduledDepartureDate)}
</Badge>
<Text size="sm">
{preview.absorbedSchedule.reference ?? "Same-day schedule"} {" "}
<Text span fw={700}>
{preview.absorbedSchedule.bookingsMoving} booking(s)
</Text>{" "}
move here, then it is removed
</Text>
</Group>
) : null}
{preview.affectedSchedules.map((s) => (
<Group gap={6} wrap="nowrap" mb={4} key={s.id}>
<Badge size="sm" color="blue" variant="light" radius="sm">
{fmtDate(s.scheduledDepartureDate)}
</Badge>
<Text size="sm" c="dimmed">
{s.reference ?? s.id.slice(0, 8)} gains the wagons, keeps
its own bookings
</Text>
</Group>
))}
{preview.untouchedSchedules.map((s) => (
<Group gap={6} wrap="nowrap" mb={4} key={s.id}>
<Badge size="sm" color="gray" variant="light" radius="sm">
{fmtDate(s.scheduledDepartureDate)}
</Badge>
<Text size="sm" c="dimmed">
{s.reference ?? s.id.slice(0, 8)} {s.status.toLowerCase()},
not affected
</Text>
</Group>
))}
{preview.sourceTrainWillDeactivate ? (
<Group gap={6} mt={6} wrap="nowrap">
<Ban size={14} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red.7">
This schedule&apos;s current train is emptied and deactivated.
</Text>
</Group>
) : null}
</Card>
{preview.canMerge ? (
<Textarea
label="Reason"
placeholder="Why the trains are being merged (kept on the audit trail)"
maxLength={500}
autosize
minRows={2}
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
) : null}
</Stack>
) : null}
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={close}>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<Merge size={16} />}
loading={merge.isPending}
disabled={!preview?.canMerge || previewLoading}
onClick={() => void submit()}
>
Merge trains
</Button>
</Group>
</Stack>
</Modal>
);
}