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(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 ( Merge another train into this one {scheduleReference ?? "This departure survives the merge"} } > } value={search} onChange={(e) => setSearch(e.currentTarget.value)} radius="md" /> {trainsLoading ? ( ) : options.length === 0 ? ( No other trains available to merge. ) : ( {options.map((t) => { const on = t.id === selectedTrainId; return ( setSelectedTrainId(t.id)} style={{ cursor: "pointer", borderColor: on ? "var(--mantine-color-edr-green-5)" : undefined, background: on ? "var(--mantine-color-edr-green-0)" : undefined, }} > {t.code} {t.trainNumber ? `No. ${t.trainNumber}` : "—"} ); })} )} {selectedTrainId && previewLoading ? ( ) : null} {selectedTrainId && preview && !previewLoading ? ( {preview.blockers.length ? ( } title="This merge is blocked" > {preview.blockers.map((b) => ( {b} ))} ) : ( } > This cannot be undone. Wagons are appended last — reorder them afterwards in the train builder. )} {preview.wagons.current} wagons {preview.wagons.merged} wagons +{preview.wagons.incoming} from {preview.targetTrain.code} {preview.absorbedSchedule ? ( {fmtDate(preview.absorbedSchedule.scheduledDepartureDate)} {preview.absorbedSchedule.reference ?? "Same-day schedule"} —{" "} {preview.absorbedSchedule.bookingsMoving} booking(s) {" "} move here, then it is removed ) : null} {preview.affectedSchedules.map((s) => ( {fmtDate(s.scheduledDepartureDate)} {s.reference ?? s.id.slice(0, 8)} — gains the wagons, keeps its own bookings ))} {preview.untouchedSchedules.map((s) => ( {fmtDate(s.scheduledDepartureDate)} {s.reference ?? s.id.slice(0, 8)} — {s.status.toLowerCase()}, not affected ))} {preview.sourceTrainWillDeactivate ? ( This schedule's current train is emptied and deactivated. ) : null} {preview.canMerge ? (