import { Alert, Badge, Button, Checkbox, Divider, Grid, Group, Modal, Progress, ScrollArea, Stack, Text, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isAxiosError } from "axios"; import { AlertTriangle, History, Minus, Plus } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { api } from "@/services/api"; import type { ConsistWagonRef } from "@/services/trainBuilder.service"; import { useToast } from "@/hooks/use-toast"; 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; }; const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0; const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0; const round2 = (v: number) => Math.round(v * 100) / 100; /** * Adjust the built train's consist from a schedule: trim free wagons (their * tare no longer rides — the fix when gross weight beats the pull limit) or * couple extra yard wagons while weight/length headroom remains. Changes are * permanent on the train and logged on the schedule. */ export default function AdjustConsistModal({ scheduleId, opened, onClose, }: AdjustConsistModalProps) { const { toast } = useToast(); const [removeIds, setRemoveIds] = useState([]); const [addIds, setAddIds] = useState([]); const consistQuery = useQuery( api.trainScheduling.scheduleConsist.queryOptions({ input: { scheduleId }, enabled: opened && Boolean(scheduleId), }), ); const adjust = useMutation(api.trainScheduling.adjustConsist.mutationOptions()); const data = consistQuery.data; useEffect(() => { if (opened) { setRemoveIds([]); setAddIds([]); } }, [opened]); // Live projection: gross = cargo + tare of (consist − trims + adds). const projection = useMemo(() => { if (!data) return null; const removed = new Set(removeIds); const keptTare = data.wagons .filter((w) => !removed.has(w.id)) .reduce((s, w) => s + tareOf(w), 0); const keptLength = data.wagons .filter((w) => !removed.has(w.id)) .reduce((s, w) => s + lengthOf(w), 0); const addedWagons = data.addableWagons.filter((w) => addIds.includes(w.id)); const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0); const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0); const gross = round2(data.totals.cargoTons + tare); return { wagonCount: data.totals.wagonCount - removeIds.length + addIds.length, tare: round2(tare), gross, length: round2(length), grossPct: data.limits.pullCapTons ? Math.round((gross / data.limits.pullCapTons) * 100) : null, lengthPct: data.limits.lengthCapMeters ? Math.round((length / data.limits.lengthCapMeters) * 100) : null, overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons, overLength: data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters, }; }, [data, removeIds, addIds]); const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) => setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id))); const handleSubmit = async () => { if (!removeIds.length && !addIds.length) return; try { await adjust.mutateAsync({ scheduleId, payload: { ...(addIds.length ? { addWagonIds: addIds } : {}), ...(removeIds.length ? { removeWagonIds: removeIds } : {}), }, }); toast({ title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${ removeIds.length && addIds.length ? ", " : "" }${addIds.length ? `${addIds.length} added` : ""}`, }); setRemoveIds([]); setAddIds([]); } catch (err) { toast({ title: "Adjustment failed", description: parseError(err, "Could not adjust the consist"), variant: "destructive", }); } }; return ( Adjust consist{data ? ` — train ${data.train.code}` : ""} } radius="lg" size={860} centered > {consistQuery.isLoading || !data ? ( {consistQuery.isError ? "This schedule has no built train to adjust." : "Loading consist…"} ) : ( {!data.editable ? ( }> The consist is frozen once the train is dispatched. ) : null} Trim coupled wagons ({data.totals.wagonCount}) Only free (unloaded, unpinned) wagons can be detached. Detaching is permanent — the wagon returns to the yard as available. {data.wagons.map((wagon) => ( ))} Couple yard wagons ({data.addableWagons.length} available) AVAILABLE wagons standing in the train's yard. Blocked when they push gross weight or length past the locomotive limits incl. tolerance. {data.addableWagons.length ? ( data.addableWagons.map((wagon) => ( )) ) : ( No available wagons in this yard )} {data.adjustments.length ? ( <> Adjustment history {data.adjustments.map((log) => ( {log.action === "ADD" ? "Added" : "Trimmed"} {log.wagonNumber} {new Date(log.occurredAt).toLocaleString()} ))} ) : null} Projected consist: {projection?.wagonCount} wagons )} ); } export interface AdjustConsistModalProps { scheduleId: string; opened: boolean; onClose: () => void; } function LimitGauge({ label, detail, pct, over, }: { label: string; detail: string; pct: number | null; over: boolean; }) { return ( {label} {pct != null ? `${pct}%` : "—"} 85 ? "yellow" : "edr-green"} striped={over} animated={over} /> {detail} ); } function WagonRow({ wagon, checked, disabled, badge, onToggle, }: { wagon: ConsistWagonRef; checked: boolean; disabled: boolean; badge: string | null; onToggle: (id: string, checked: boolean) => void; }) { return ( onToggle(wagon.id, e.currentTarget.checked)} aria-label={`Select wagon ${wagon.wagonNumber}`} /> {wagon.wagonNumber} {wagon.wagonType ? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m` : "Unknown type"} {badge ? ( {badge} ) : null} ); }