import { Alert, Badge, Button, Group, Loader, NumberInput, Paper, Select, SimpleGrid, Stack, Table, Text, Tooltip, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isAxiosError } from "axios"; import { AlertTriangle, Lock, MapPin } 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>({}); 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]); 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 perStop = useMemo( () => (data?.stops ?? []).map((s) => ({ ...s, planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId) .length, })), [data, pending], ); 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 = Object.keys(pending).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; }); }; const handleSave = async () => { if (!pendingCount) return; try { const result = await save.mutateAsync({ scheduleId, payload: { moves: Object.entries(pending).map(([wagonId, yardId]) => ({ wagonId, yardId })), }, }); setPending({}); 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). 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} ))} {editable ? ( setPending((prev) => { const next = { ...prev }; if (!v || v === w.plannedYardId) delete next[w.id]; else next[w.id] = v; return next; }) } w={180} /> ) : ( {yardLabel(planned)} {w.locked ? ( ) : null} )} {planned === w.physicalYardId ? ( Aligned ) : ( Needs move )} ); })} {editable ? ( {pendingCount} pending change(s) ) : null} ); }