diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx index 8f98b337a..6a89af519 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx @@ -20,6 +20,8 @@ type DiagramWagonInput = { slotLoadType?: string | null; wagonType?: { code?: string | null } | null; wagonTypeCode?: string | null; + /** Pinned physical wagon — slots sharing one (cross-leg TEU) draw as ONE car. */ + physicalWagonId?: string | null; physicalWagonNumber?: string | null; allocations?: Array<{ bookingReference?: string | null; @@ -545,9 +547,40 @@ export function TrainCompositionDiagram({ [locomotives, locomotive], ); + // One car per PHYSICAL wagon: cross-leg TEU sharing pins two plan slots + // (e.g. intercity + export on disjoint legs) onto the same wagon — merge + // their cargo into one drawn car. Tare counts once; cargo and containers + // combine. Slots without a pinned wagon stay their own car. + const merged = useMemo(() => { + const groups: DiagramWagonInput[][] = []; + const byPhysical = new Map(); + for (const w of wagons) { + const existing = w.physicalWagonId ? byPhysical.get(w.physicalWagonId) : undefined; + if (existing) { + existing.push(w); + continue; + } + const group = [w]; + groups.push(group); + if (w.physicalWagonId) byPhysical.set(w.physicalWagonId, group); + } + return groups.map((group) => + group.length === 1 + ? group[0]! + : { + ...group[0]!, + assignedWeightTons: group.reduce( + (s, w) => s + (Number(w.assignedWeightTons) || 0), + 0, + ), + allocations: group.flatMap((w) => w.allocations ?? []), + }, + ); + }, [wagons]); + const normalized = useMemo( - () => wagons.map((w) => normalizeWagon(w, freightType)), - [wagons, freightType], + () => merged.map((w) => normalizeWagon(w, freightType)), + [merged, freightType], ); const stats = useMemo(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index badf4a73d..4c1185cb2 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -165,52 +165,71 @@ function LocomotiveCar({ locomotive }: { locomotive: Locomotive }) { } function WagonCar({ - wagon, - company, - selected, - highlighted, - onSelect, + slots, + getCompany, + selectedWagonId, + highlightBookingId, + onSelectSlot, drag, onDragChange, onMoveLoad, canRearrange, }: { - wagon: Wagon; - company: string | null; - selected: boolean; - highlighted: boolean; - onSelect: () => void; + /** + * All plan slots riding this PHYSICAL wagon. Cross-leg sharing puts two + * slots (e.g. intercity + export, disjoint legs) on one wagon — drawn as + * one car with a row per slot, top/bottom. + */ + slots: Wagon[]; + getCompany: (bookingId: string | undefined) => string | null; + selectedWagonId: string | null; + highlightBookingId?: string | null; + onSelectSlot: (wagon: Wagon) => void; drag: DragState; onDragChange: (drag: DragState) => void; onMoveLoad?: (move: WagonLoadMove) => void; canRearrange: boolean; }) { const [dropHover, setDropHover] = useState(false); - const allocation = wagon.allocations?.[0]; - const isEmpty = !allocation; - const isBulk = (wagon.allocations ?? []).some((a) => - (a.loadType ?? "").toUpperCase().includes("BULK"), + const wagon = slots[0]!; + const loaded = slots.filter((s) => (s.allocations?.length ?? 0) > 0); + const shared = loaded.length > 1; + const isEmpty = !loaded.length; + const isBulk = loaded.some((s) => + (s.allocations ?? []).some((a) => (a.loadType ?? "").toUpperCase().includes("BULK")), ); - // GROSS on both sides: cargo + tare vs rated payload + tare. + // GROSS on both sides: cargo across every slot + tare (counted ONCE — the + // slots share the same physical wagon) vs rated payload + tare. const tare = wagon.tareWeightTons ?? 0; - const assigned = - (allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0) + tare; + const cargo = loaded.reduce( + (sum, s) => + sum + + ((s.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) || + s.assignedWeightTons || + 0), + 0, + ); + const assigned = cargo + tare; const capacity = (wagon.capacityTons ?? 0) + tare; const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0; const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan"; const accentVar = `var(--mantine-color-${accent}-6)`; - const items = wagonItems(wagon); - const blocks = items.slice(0, 2); - const containerNumbers = items.map((c) => c.containerNumber?.trim() || "—"); + const selected = slots.some((s) => s.id === selectedWagonId); + const highlighted = Boolean( + highlightBookingId && + slots.some((s) => (s.allocations ?? []).some((a) => a.bookingId === highlightBookingId)), + ); // The whole load drags as one unit (a 20ft pair never splits). Any OTHER // wagon is a drop target: empty → the load moves onto it, loaded → the two // loads swap. Either way the wagons stay exactly where they are coupled — // only the cargo changes wagon. The API validates type + payload weight. - const draggable = canRearrange && !isEmpty; + // ponytail: shared (2-slot) wagons opt out of drag & drop — move/swap APIs + // target one slot; per-row dragging can come when ops actually asks for it. + const draggable = canRearrange && !isEmpty && !shared; const beingDragged = drag?.sourceWagonId === wagon.id; - const dropEligible = Boolean(drag && !beingDragged); + const dropEligible = Boolean(drag && !beingDragged && !shared); // Say which of the two it will be BEFORE the drop — a swap displaces this // wagon's own load, so it should never come as a surprise. const dropIntent = dropEligible ? (isEmpty ? "move" : "swap") : null; @@ -230,7 +249,7 @@ function WagonCar({ onSelectSlot(loaded[0] ?? wagon)} style={{ width: 120, flexShrink: 0, cursor: "pointer" }} > Available - ) : isBulk ? ( - - - - - ) : ( - - {(blocks.length ? blocks.map((c) => c.containerNumber?.trim() || "—") : ["—"]).map( - (cn, i) => ( - + {loaded.map((slot, r) => { + const rowBulk = (slot.allocations ?? []).some((a) => + (a.loadType ?? "").toUpperCase().includes("BULK"), + ); + const rowBlocks = wagonItems(slot).slice(0, 2); + const rowSelected = shared && slot.id === selectedWagonId; + const rowHeight = shared ? 13 : 26; + return ( + { + e.stopPropagation(); + onSelectSlot(slot); + } + : undefined + } style={{ - flex: 1, - minWidth: 0, - height: 26, - borderRadius: 4, - background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length], - border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`, - boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)", - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "0 2px", + width: "100%", + borderRadius: 5, + outline: rowSelected + ? `2px solid ${freightBrand.primary}` + : "none", + outlineOffset: 1, }} > - - {cn} - - - ), - )} - + {rowBulk ? ( + + + + ) : ( + (rowBlocks.length + ? rowBlocks.map((c) => c.containerNumber?.trim() || "—") + : ["—"] + ).map((cn, i) => ( + + + {cn} + + + )) + )} + + ); + })} + )} @@ -484,41 +547,56 @@ function WagonCar({ ) : ( - {company ? ( - - - - {company} - - - ) : null} - - - - {allocation?.bookingReference ?? "Unknown booking"} - - - - {containerNumbers.length ? ( -
- - Containers - - - {containerNumbers.map((cn, i) => ( - - {cn} - - ))} - -
- ) : null} - - {isBulk && allocation?.bulkLoad?.cargoDescription ? ( - - {allocation.bulkLoad.cargoDescription} - - ) : null} + {loaded.map((slot) => { + const slotAllocation = slot.allocations?.[0]; + const slotCompany = getCompany(slotAllocation?.bookingId); + const slotContainers = wagonItems(slot).map( + (c) => c.containerNumber?.trim() || "—", + ); + return ( + + {slotCompany ? ( + + + + {slotCompany} + + + ) : null} + + + + {slotAllocation?.bookingReference ?? "Unknown booking"} + + + {slotContainers.length ? ( + + {slotContainers.map((cn, i) => ( + + {cn} + + ))} + + ) : null} + {slotAllocation?.bulkLoad?.cargoDescription ? ( + + {slotAllocation.bulkLoad.cargoDescription} + + ) : null} + + ); + })} @@ -569,6 +647,23 @@ export const InteractiveTrainConsist = ({ }: InteractiveTrainConsistProps) => { const [drag, setDrag] = useState(null); const locos = locomotives?.length ? locomotives : locomotive ? [locomotive] : []; + // One drawn car per PHYSICAL wagon: cross-leg sharing pins two plan slots + // onto the same wagon (intercity + export on disjoint legs) — they must + // draw as one car, not two. Unpinned slots stay their own car. + const groups: Wagon[][] = []; + const groupByPhysical = new Map(); + for (const w of wagons) { + const existing = w.physicalWagonId + ? groupByPhysical.get(w.physicalWagonId) + : undefined; + if (existing) { + existing.push(w); + continue; + } + const group = [w]; + groups.push(group); + if (w.physicalWagonId) groupByPhysical.set(w.physicalWagonId, group); + } return ( ))} - {wagons.length === 0 ? ( + {groups.length === 0 ? ( No wagons assigned ) : ( - wagons.map((wagon, i) => { - const bookingId = wagon.allocations?.[0]?.bookingId; - return ( - - {i > 0 || locos.length ? : null} - onSelectWagon(wagon)} - drag={drag} - onDragChange={setDrag} - onMoveLoad={onMoveLoad} - canRearrange={canRearrange} - /> - - ); - }) + groups.map((slots, i) => ( + + {i > 0 || locos.length ? : null} + + + )) )} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx index 299edec03..d29807ecc 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -100,7 +100,14 @@ export const TrainConsistView = ({ }, [scheduleDetail.bookings]); const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null; - const loadedCount = wagons.filter((w) => (w.allocations?.length ?? 0) > 0).length; + // Physical wagons, not plan slots — cross-leg sharing pins two slots onto + // one wagon, and staff count what's actually coupled. + const physicalCount = new Set(wagons.map((w) => w.physicalWagonId ?? w.id)).size; + const loadedPhysicalCount = new Set( + wagons + .filter((w) => (w.allocations?.length ?? 0) > 0) + .map((w) => w.physicalWagonId ?? w.id), + ).size; const handleRemoveBooking = (wagon: Wagon) => { setSelectedWagonId(wagon.id); @@ -139,7 +146,7 @@ export const TrainConsistView = ({ const weightUsed = heaviest?.grossWeightTons ?? cargoUsed + tareUsed; const lengthUsed = heaviest?.lengthMeters ?? wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0); - const wagonsUsed = heaviest?.loadedWagonCount ?? loadedCount; + const wagonsUsed = heaviest?.loadedWagonCount ?? loadedPhysicalCount; // Engine rule (combinedLocomotiveLimits): coupled locomotives pull TOGETHER, // so pull caps SUM; the track doesn't lengthen, so the length cap is the MIN. @@ -200,7 +207,8 @@ export const TrainConsistView = ({ Train consist - {wagons.length} wagons · {loadedCount} loaded · {wagons.length - loadedCount} empty + {physicalCount} wagons · {loadedPhysicalCount} loaded ·{" "} + {physicalCount - loadedPhysicalCount} empty