import { Alert, Badge, Button, Checkbox, Divider, Grid, Group, Modal, Progress, ScrollArea, Select, Stack, Text, Tooltip, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isAxiosError } from "axios"; import { AlertTriangle, ArrowLeftRight, History, MapPin, Minus, Plus } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { api } from "@/services/api"; import type { ConsistWagonRef, ScheduleConsist } 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; type ConsistWagon = ScheduleConsist["wagons"][number]; /** * 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), * couple extra yard wagons while weight/length headroom remains, or SWITCH a * wagon for a same-type replacement — the replacement inherits the slot, cargo * included, which is the only way a loaded wagon leaves the train. Works * before departure and mid-route while the train stands at a checkpointed * stop. 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([]); // fromWagonId → toWagonId. A switch is same-type, so it never moves the // weight/length/slot projections — it only changes which steel rides. const [switchMap, setSwitchMap] = 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([]); setSwitchMap({}); } }, [opened]); const switchCount = Object.keys(switchMap).length; const usedReplacementIds = useMemo( () => new Set(Object.values(switchMap)), [switchMap], ); // Live projection: gross = cargo + tare of (consist − trims + adds), plus // the schedule's wagon-slot picture — the consist IS the booking capacity // (weight/length only bind while assembling the consist), so trims/adds // move the FULL line in real time. Switches are same-type and cancel out. 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); const wagonCount = data.totals.wagonCount - removeIds.length + addIds.length; const cap = data.scheduleCapacity; const freeSlots = cap ? wagonCount - cap.allocatedWagons : null; return { wagonCount, 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, slots: cap && freeSlots != null ? { allocated: cap.allocatedWagons, free: freeSlots, pct: wagonCount > 0 ? Math.round((cap.allocatedWagons / wagonCount) * 100) : null, isFullNow: cap.bookingWindowStatus === "FULL", willBeFull: freeSlots <= 0, overAllocated: freeSlots < 0, willReopen: cap.bookingWindowStatus === "FULL" && freeSlots > 0, } : null, }; }, [data, removeIds, addIds]); const hasChanges = removeIds.length > 0 || addIds.length > 0 || switchCount > 0; const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) => setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id))); // Same-type replacements standing at the current stop, minus wagons already // spoken for by another switch or a couple selection. const switchOptionsFor = (wagon: ConsistWagon) => (data?.addableWagons ?? []) .filter( (candidate) => candidate.wagonType?.id === wagon.wagonType?.id && !addIds.includes(candidate.id) && (!usedReplacementIds.has(candidate.id) || switchMap[wagon.id] === candidate.id), ) .map((candidate) => ({ value: candidate.id, label: candidate.wagonNumber })); const setSwitch = (fromId: string, toId: string | null) => setSwitchMap((prev) => { const next = { ...prev }; if (toId) next[fromId] = toId; else delete next[fromId]; return next; }); const handleSubmit = async () => { if (!hasChanges) return; try { const result = await adjust.mutateAsync({ scheduleId, payload: { ...(addIds.length ? { addWagonIds: addIds } : {}), ...(removeIds.length ? { removeWagonIds: removeIds } : {}), ...(switchCount ? { switches: Object.entries(switchMap).map(([fromWagonId, toWagonId]) => ({ fromWagonId, toWagonId, })), } : {}), }, }); toast({ title: `Consist updated — ${[ removeIds.length ? `${removeIds.length} trimmed` : "", addIds.length ? `${addIds.length} added` : "", switchCount ? `${switchCount} switched` : "", ] .filter(Boolean) .join(", ")}`, }); // Schedule-impact warnings from the API: window reopened / now FULL / // consist trimmed below what bookings already hold. for (const warning of result.warnings ?? []) { toast({ title: "Schedule capacity", description: warning, duration: 8000, ...(warning.includes("over capacity") ? { variant: "destructive" as const } : {}), }); } setRemoveIds([]); setAddIds([]); setSwitchMap({}); } 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={920} centered > {consistQuery.isLoading || !data ? ( {consistQuery.isError ? "This schedule has no built train to adjust." : "Loading consist…"} ) : ( {data.currentStop?.isMidRoute ? ( }> Standing at {data.currentStop.label} — mid-route consist work is open: couple or switch wagons standing at this stop, trim wagons whose cargo was offloaded here. Detached wagons stay at {data.currentStop.label}. ) : null} {!data.editable ? ( }> {data.schedule.status === "DISPATCHED" ? "The train is rolling — consist changes are only possible while it stands at a route stop." : "The consist can no longer be adjusted — the run is over."} ) : null} {projection?.slots ? ( 0 ? ` — ${projection.slots.free} free` : projection.slots.free === 0 ? " — none free (FULL)" : "" }`} pct={projection.slots.pct} over={projection.slots.overAllocated} /> ) : null} {projection?.slots?.isFullNow && !hasChanges ? ( }> This schedule is FULL — all {projection.wagonCount} wagon slots are taken. You can still edit the train: coupling wagons adds capacity and reopens booking; trimming free wagons keeps it FULL. ) : null} {hasChanges && projection?.slots?.overAllocated ? ( }> This change leaves {-projection.slots.free} booked wagon(s) without a slot — bookings already hold {projection.slots.allocated} of the{" "} {projection.wagonCount} remaining. You can apply it, but couple wagons back or free bookings before departure. ) : null} {hasChanges && projection?.slots && !projection.slots.overAllocated && projection.slots.willBeFull && !projection.slots.isFullNow ? ( }> This change takes the last free wagon slot — the schedule becomes FULL and stops accepting bookings. ) : null} {hasChanges && projection?.slots?.willReopen ? ( }> This schedule is currently FULL — applying frees{" "} {projection.slots.free} wagon slot(s) and reopens its booking window. ) : null} Coupled wagons ({data.totals.wagonCount}) Trim only wagons carrying nothing beyond this stop. A loaded wagon can't leave — but it can be switched: the same-type replacement takes its position and its cargo slot. Detaching is permanent. {data.wagons.map((wagon) => ( ))} Couple yard wagons ({data.addableWagons.length} available) AVAILABLE wagons standing at{" "} {data.currentStop?.label ?? "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) => { const takenBySwitch = usedReplacementIds.has(wagon.id); return ( ); }) ) : ( No available wagons at this stop )} {switchCount ? ( } py={8}> {Object.entries(switchMap) .map(([fromId, toId]) => { const from = data.wagons.find((w) => w.id === fromId); const to = data.addableWagons.find((w) => w.id === toId); return `${from?.wagonNumber ?? "?"} → ${to?.wagonNumber ?? "?"}`; }) .join(" · ")}{" "} — cargo allocations move to the replacement wagon(s). ) : null} {data.adjustments.length ? ( <> Adjustment history {data.adjustments.map((log) => ( {log.action === "ADD" ? "Added" : log.action === "SWITCH" ? "Switched" : "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} ); } /** Coupled row: trim checkbox (reason-badged when blocked) + switch picker. */ function CoupledWagonRow({ wagon, checked, editable, switchValue, switchOptions, onToggleRemove, onSwitch, }: { wagon: ConsistWagon; checked: boolean; editable: boolean; switchValue: string | null; switchOptions: Array<{ value: string; label: string }>; onToggleRemove: (id: string, checked: boolean) => void; onSwitch: (fromId: string, toId: string | null) => void; }) { const badge = wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null; const checkbox = ( onToggleRemove(wagon.id, e.currentTarget.checked)} aria-label={`Trim wagon ${wagon.wagonNumber}`} /> ); return ( {wagon.blockReason ? ( {checkbox} ) : ( checkbox )} {wagon.wagonNumber} {wagon.wagonType ? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m` : "Unknown type"} {badge ? ( {badge} ) : null} {editable && wagon.switchable && switchOptions.length ? (