import { Alert, Badge, Button, Checkbox, Group, Loader, Modal, NumberInput, Pagination, Paper, ScrollArea, Select, SimpleGrid, Stack, Table, Text, TextInput, Tooltip, } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Freight } from "@edr/types"; import { isAxiosError } from "axios"; import { AlertTriangle, Link2, Lock, MapPin, Plus, Search } from "lucide-react"; import { useMemo, useState } from "react"; import { useToast } from "@/hooks/use-toast"; import { api } from "@/services/api"; import type { ScheduleWagonYardRow } from "@/services/trainBuilder.service"; /** * Schedule yards tab: where THIS departure plans to board each consist wagon, * side by side with where the wagon physically stands (the train builder's * truth). Booking capacity per origin reads the plan; dispatch refuses to * leave until plan and physical yards agree. Edits are queued locally and * saved in one PATCH. */ 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; }; interface Props { scheduleId: string; canEdit: boolean; } export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) { const { toast } = useToast(); const query = useQuery( api.trainScheduling.scheduleWagonYards.queryOptions({ input: { scheduleId } }), ); const save = useMutation(api.trainScheduling.updateScheduleWagonYards.mutationOptions()); const data = query.data; /** wagonId → yardId queued but not yet saved. */ const [pending, setPending] = useState>({}); /** wagonId → cut yard queued but not yet saved; null = queued clear (rides to destination). */ const [pendingCut, setPendingCut] = useState>({}); /** wagonId → real-cut flag queued but not yet saved. */ const [pendingRealCut, setPendingRealCut] = useState>({}); /** Loose wagons queued to couple: wagonId → couple stop + display data. */ const [pendingCouples, setPendingCouples] = useState< Record >({}); /** Already-planned couples queued for removal. */ const [pendingUncouple, setPendingUncouple] = useState([]); // "Add wagon" modal + its filters. const [coupleModalOpen, setCoupleModalOpen] = useState(false); const [coupleYardFilter, setCoupleYardFilter] = useState(null); const [coupleType, setCoupleType] = useState(null); const [coupleSearch, setCoupleSearch] = useState(""); const [couplePage, setCouplePage] = useState(1); const [debouncedCoupleSearch] = useDebouncedValue(coupleSearch, 300); const [bulkType, setBulkType] = useState(null); const [bulkFrom, setBulkFrom] = useState(null); const [bulkTo, setBulkTo] = useState(null); const [bulkCount, setBulkCount] = useState(1); const editable = Boolean(canEdit && data?.editable); const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]); /** Mid-route stops only — wagons are coupled between the origin and the destination. */ const intermediateStops = useMemo(() => { const stops = data?.stops ?? []; return stops.slice(1, -1).filter((s) => s.pickup); }, [data]); // Loose-wagon list for the "Add wagon" modal. A wagon can only be coupled // where it physically stands, and only at a pickup stop of this route — the // Add button carries that yard; off-route wagons render disabled. const coupleListQuery = useQuery( api.wagons.listPaged.queryOptions({ input: { filters: { status: Freight.WagonStatus.Available, unassigned: true, currentYardId: coupleYardFilter ?? undefined, wagonTypeId: coupleType ?? undefined, search: debouncedCoupleSearch || undefined, page: couplePage, pageSize: 8, }, }, enabled: editable && coupleModalOpen, placeholderData: (prev) => prev, }), ); const coupleCandidates = coupleListQuery.data?.items ?? []; const coupleTotalPages = Math.max(1, coupleListQuery.data?.meta.totalPages ?? 1); const yardsQuery = useQuery( api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: coupleModalOpen }), ); const wagonTypesQuery = useQuery( api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000, enabled: coupleModalOpen }), ); const yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label })); const yardLabel = (id: string | null) => (data?.stops ?? []).find((s) => s.yardId === id)?.label ?? data?.wagons.find((w) => w.plannedYardId === id)?.plannedYardLabel ?? data?.wagons.find((w) => w.physicalYardId === id)?.physicalYardLabel ?? id ?? "—"; const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId; const effectiveCut = (w: ScheduleWagonYardRow) => w.id in pendingCut ? pendingCut[w.id] : w.cutYardId; const effectiveRealCut = (w: ScheduleWagonYardRow) => (pendingRealCut[w.id] ?? w.realCut) && effectiveCut(w) != null; const stopIndexOf = (yardId: string | null) => yardId == null ? -1 : (data?.stops ?? []).findIndex((s) => s.yardId === yardId); /** Drop stops a wagon boarding at `boardYardId` can be cut at — strictly after * boarding, excluding the destination (that's the cleared/default state). */ const cutOptionsFor = (boardYardId: string | null) => { const stops = data?.stops ?? []; const boardIdx = Math.max(0, stopIndexOf(boardYardId)); return stops .slice(boardIdx + 1, stops.length - 1) .map((s) => ({ value: s.yardId, label: s.label })); }; /** Board-yard changes can invalidate a cut (server rejects cut ≤ board) — queue a clear. */ const clearInvalidCut = ( next: Record, w: ScheduleWagonYardRow, boardYardId: string | null, ) => { const cut = w.id in next ? next[w.id] : w.cutYardId; if (cut != null && stopIndexOf(cut) <= stopIndexOf(boardYardId)) { if (w.cutYardId == null) delete next[w.id]; else next[w.id] = null; } return next; }; const perStop = useMemo( () => (data?.stops ?? []).map((s) => ({ ...s, planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId) .length, cut: (data?.wagons ?? []).filter( (w) => (w.id in pendingCut ? pendingCut[w.id] : w.cutYardId) === s.yardId, ).length, coupled: (data?.wagons ?? []).filter( (w) => w.coupledYardId === s.yardId && !pendingUncouple.includes(w.id), ).length + Object.values(pendingCouples).filter((c) => c.yardId === s.yardId).length, })), [data, pending, pendingCut, pendingCouples, pendingUncouple], ); const typeOptions = useMemo(() => { const seen = new Map(); for (const w of data?.wagons ?? []) seen.set(w.wagonType.id, w.wagonType.code); return [...seen].map(([value, label]) => ({ value, label })); }, [data]); const pendingCount = new Set([ ...Object.keys(pending), ...Object.keys(pendingCut), ...Object.keys(pendingRealCut), ]).size + Object.keys(pendingCouples).length + pendingUncouple.length; const queueBulk = () => { if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return; const n = Number(bulkCount) || 0; const picked = data.wagons .filter( (w) => !w.locked && effectiveYard(w) === bulkFrom && (!bulkType || w.wagonType.id === bulkType), ) .slice(0, n); if (!picked.length) { toast({ title: "No free wagons match", variant: "destructive" }); return; } setPending((prev) => { const next = { ...prev }; for (const w of picked) { if (w.plannedYardId === bulkTo) delete next[w.id]; else next[w.id] = bulkTo; } return next; }); setPendingCut((prev) => { let next = { ...prev }; for (const w of picked) next = clearInvalidCut(next, w, bulkTo); return next; }); }; const handleSave = async () => { if (!pendingCount) return; try { const wagonIds = [ ...new Set([ ...Object.keys(pending), ...Object.keys(pendingCut), ...Object.keys(pendingRealCut), ]), ]; const result = await save.mutateAsync({ scheduleId, payload: { moves: wagonIds.map((wagonId) => ({ wagonId, ...(wagonId in pending ? { yardId: pending[wagonId] } : {}), ...(wagonId in pendingCut ? { cutYardId: pendingCut[wagonId] } : {}), ...(wagonId in pendingRealCut ? { realCut: pendingRealCut[wagonId] } : {}), })), ...(Object.keys(pendingCouples).length ? { couple: Object.entries(pendingCouples).map(([wagonId, c]) => ({ wagonId, yardId: c.yardId, })), } : {}), ...(pendingUncouple.length ? { uncouple: pendingUncouple } : {}), }, }); setPending({}); setPendingCut({}); setPendingRealCut({}); setPendingCouples({}); setPendingUncouple([]); toast({ title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`, description: result.warnings.length ? result.warnings.join(" ") : undefined, variant: result.warnings.length ? "destructive" : undefined, }); } catch (err) { toast({ title: "Update failed", description: parseError(err, "Could not update the schedule's wagon yards"), variant: "destructive", }); } }; if (query.isLoading) return ; if (query.isError || !data) { return ( }> {parseError( query.error, "This schedule has no wagon yard plan (not created from a built train).", )} ); } return ( } variant="light"> Planned = where this departure boards the wagon (what customers can book per origin). Physical = where the wagon stands now (train builder). Cut at ={" "} where this departure detaches the wagon and leaves it — blank means it rides to the destination; booking capacity past the cut shrinks accordingly. Tick Real cut to remove the wagon from the train build permanently at that yard (untick = it sits out this trip only). Coupled wagons are loose wagons joining the train at a stop — they become part of the build for good. Dispatch is blocked until every wagon stands at its planned yard. {data.misaligned > 0 ? ( {" "} {data.misaligned} wagon(s) currently misaligned. ) : null} {perStop.map((s) => ( {s.label} {!s.pickup ? ( destination ) : null} Planned {s.planned} Physical {s.physical} {s.cut > 0 ? ( Cut {s.cut} ) : null} {s.coupled > 0 ? ( +{s.coupled} coupled ) : null} {!s.pickup ? ( Through{" "} {data.wagons.filter( (w) => !w.coupledYardId || !pendingUncouple.includes(w.id), ).length + Object.keys(pendingCouples).length - perStop.reduce((sum, p) => sum + p.cut, 0)} ) : null} ))} {editable ? ( y.id !== data.stops[0]?.yardId && y.id !== data.stops[data.stops.length - 1]?.yardId, ) .slice() .sort((a, b) => a.label.localeCompare(b.label)) .map((y) => ({ value: y.id, label: intermediateStops.some((s) => s.yardId === y.id) ? `${y.label} · route stop` : y.label, }))} value={coupleYardFilter} onChange={(v) => { setCoupleYardFilter(v); setCouplePage(1); }} w={220} /> { setPending((prev) => { const next = { ...prev }; if (!v || v === w.plannedYardId) delete next[w.id]; else next[w.id] = v; return next; }); setPendingCut((prev) => clearInvalidCut({ ...prev }, w, v ?? w.plannedYardId), ); }} w={180} /> ) : ( {yardLabel(planned)} {w.locked ? ( ) : null} )} {editable ? ( // Locked wagons stay editable here — the server enforces the // cargo-destination floor and the toast explains a 409.