import { Fragment, useEffect, useMemo, useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { isAxiosError } from "axios"; import { Alert, Badge, Button, Group, Paper, Stack, Table, Text, Tooltip, } from "@mantine/core"; import { ArrowLeftRight, Boxes, Info, MoveRight, Wheat, X } from "lucide-react"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { TrainScheduleDetail } from "@/types/trainScheduling"; type Slot = NonNullable["wagons"][number]; type Stop = { yardId: string; label: string }; type Span = [number, number]; /** One physical wagon of the consist with every slot (leg load) pinned to it. */ interface WagonRow { key: string; physicalWagonId: string | null; label: string; position: number; typeCode: string | null; capacityTons: number; slots: Array<{ slot: Slot; span: Span; loaded: boolean }>; } const round1 = (n: number) => Math.round(n * 10) / 10; const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1]; /** * Leg board: rows = physical wagons in coupling order, columns = corridor legs * (A→B, B→C, …). A wagon reused on disjoint legs shows one load per leg on the * same row, so a "53 full on A→B, 53 full on C→D" train reads at a glance. * Loads move by click: pick a load, then click a wagon that is free on that * load's legs (move) or another load (swap). Same API as the consist strip. */ export function LegLoadBoardPanel({ schedule, onChanged, }: { schedule: TrainScheduleDetail; onChanged?: () => void; }) { const { toast } = useToast(); const stops: Stop[] = schedule.stops ?? []; const legs = useMemo( () => stops.slice(0, -1).map((from, i) => ({ from, to: stops[i + 1], idx: i })), [stops], ); const canRearrange = !["DISPATCHED", "ARRIVED", "CANCELLED"].includes(schedule.status); const spanOf = (slot: Slot): Span => { const from = slot.boardYardId ? stops.findIndex((s) => s.yardId === slot.boardYardId) : 0; const to = slot.alightYardId ? stops.findIndex((s) => s.yardId === slot.alightYardId) : stops.length - 1; return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to]; }; const rows: WagonRow[] = useMemo(() => { const byKey = new Map(); for (const slot of schedule.trainSet?.wagons ?? []) { const key = slot.physicalWagonId ?? `slot:${slot.id}`; let row = byKey.get(key); if (!row) { row = { key, physicalWagonId: slot.physicalWagonId ?? null, label: slot.physicalWagonNumber ?? `#${slot.position ?? slot.sequenceNo}`, position: slot.position ?? slot.sequenceNo, typeCode: slot.wagonType?.code ?? null, capacityTons: slot.capacityTons ?? 0, slots: [], }; byKey.set(key, row); } row.position = Math.min(row.position, slot.position ?? slot.sequenceNo); // Coupled-but-empty consist wagons carry no slot row: they are a target only. if (!slot.consistOnly) { row.slots.push({ slot, span: spanOf(slot), loaded: (slot.allocations?.length ?? 0) > 0, }); } } return [...byKey.values()].sort((a, b) => a.position - b.position); // eslint-disable-next-line react-hooks/exhaustive-deps }, [schedule.trainSet?.wagons, stops]); const [picked, setPicked] = useState<{ slotId: string; rowKey: string; span: Span } | null>( null, ); useEffect(() => { if (!picked) return; const onKey = (e: KeyboardEvent) => e.key === "Escape" && setPicked(null); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [picked]); const moveMutation = useMutation(api.trainScheduling.moveWagonLoad.mutationOptions()); const doMove = async (targetWagonId: string, swap: boolean) => { if (!picked || moveMutation.isPending) return; try { await moveMutation.mutateAsync({ scheduleId: schedule.id, wagonId: picked.slotId, targetWagonId, }); toast({ title: swap ? "Loads swapped" : "Load moved" }); setPicked(null); onChanged?.(); } catch (error) { const message = isAxiosError(error) ? ((error.response?.data as { message?: string | string[] } | undefined)?.message ?? null) : null; toast({ title: "Could not move the load", description: Array.isArray(message) ? message.join(", ") : (message ?? "The move was rejected — check wagon type, payload and leg."), variant: "destructive", }); } }; if (stops.length < 2) { return ( } radius="md"> This schedule has no corridor stops yet — the leg board needs a route with at least two stops. ); } if (!rows.length) { return ( } radius="md"> No wagons on this train yet. ); } const sharedRows = rows.filter((r) => r.slots.filter((s) => s.loaded).length > 1).length; return ( Loads per wagon per leg One row per physical wagon, one column per leg. A wagon reused on different legs shows one load per leg.{" "} {canRearrange ? "Click a load to pick it up, then click a wagon free on those legs to move it, or another load to swap." : "Read-only — the train has departed."} {sharedRows > 0 ? ( {sharedRows} wagon{sharedRows === 1 ? "" : "s"} shared across legs ) : null} {picked ? ( ) : null} Wagon {legs.map((leg) => ( {leg.from.label} {leg.to.label} ))} Cargo {rows.map((row) => { const cargoTons = row.slots.reduce( (s, x) => s + ((x.slot.allocations ?? []).reduce( (a, al) => a + (al.allocatedWeightTons ?? 0), 0, ) || x.slot.assignedWeightTons || 0), 0, ); const isPickedRow = picked?.rowKey === row.key; // A row can take the picked load when nothing loaded on it rides // any of the picked load's legs. const rowFreeForPicked = !!picked && !isPickedRow && !row.slots.some((s) => s.loaded && overlaps(s.span, picked.span)); // Where a "move here" lands: an existing empty slot on those legs, // else the physical wagon itself (the API mints the slot). const emptyTargetSlot = picked ? row.slots.find((s) => !s.loaded && overlaps(s.span, picked.span)) : undefined; const moveTargetId = emptyTargetSlot?.slot.id ?? row.physicalWagonId ?? null; // Lay slots into leg columns; uncovered legs render as empty cells. const cells: React.ReactNode[] = []; let col = 0; const sorted = [...row.slots].sort((a, b) => a.span[0] - b.span[0]); // Empty cell = uncovered leg (target: the physical wagon) or an // empty slot (target: that slot). Both take the picked load when // the row is free on its legs. const emptyCell = (from: number, to: number, targetId = moveTargetId) => { const droppable = rowFreeForPicked && canRearrange && !!targetId && !!picked && overlaps([from, to], picked.span); return ( void doMove(targetId!, false) : undefined} style={{ cursor: droppable ? "pointer" : "default", background: droppable ? "var(--mantine-color-teal-0)" : undefined, outline: droppable ? "1px dashed var(--mantine-color-teal-5)" : undefined, outlineOffset: -3, borderRadius: 6, }} > {droppable ? ( Move here ) : ( )} ); }; for (const s of sorted) { if (s.span[0] > col) cells.push(emptyCell(col, s.span[0])); if (!s.loaded) { cells.push(emptyCell(s.span[0], s.span[1], s.slot.id)); col = Math.max(col, s.span[1]); continue; } const isPicked = picked?.slotId === s.slot.id; const swappable = !!picked && !isPicked && !isPickedRow && s.loaded && canRearrange; const allocs = s.slot.allocations ?? []; const bulk = allocs.some((a) => (a.loadType ?? "CONTAINER").toUpperCase() === "BULK"); const containers = allocs.flatMap((a) => a.containerItems ?? []); cells.push( setPicked({ slotId: s.slot.id, rowKey: row.key, span: s.span }) : swappable ? () => void doMove(s.slot.id, true) : isPicked ? () => setPicked(null) : undefined } style={{ cursor: canRearrange && (s.loaded || swappable) ? "pointer" : "default", padding: 4, }} > {s.loaded ? ( {bulk ? : } {[...new Set(allocs.map((a) => a.bookingReference ?? "—"))].join(", ")} {round1( allocs.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0), )}{" "} t {bulk ? allocs.map((a) => a.bulkLoad ? ( {a.bulkLoad.cargoDescription ?? "Bulk"} · {round1(a.bulkLoad.weightTons)} t ) : null, ) : containers.map((c) => ( {c.containerNumber ?? "no number"} ))} {swappable ? ( }> swap ) : null} ) : ( empty )} , ); col = Math.max(col, s.span[1]); } if (col < legs.length) cells.push(emptyCell(col, legs.length)); return ( #{row.position} {row.label} {row.typeCode ?? "—"} · {round1(row.capacityTons)} t {row.slots.filter((s) => s.loaded).length > 1 ? ( shared ) : null} {cells.map((c, i) => ( {c} ))} row.capacityTons + 0.001 ? "red.7" : undefined}> {round1(cargoTons)} / {round1(row.capacityTons)} t ); })}
); }