mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
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.
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user