diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx index 883cda747..52c206c3c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx @@ -34,6 +34,10 @@ interface LegBookingUsage { reference: string; wagons: number; grossTons: number; + /** Linked to the schedule but has NO wagon allocation — its weight is on no consist slot. */ + unallocated?: boolean; + /** The booking's own origin → destination, so a sub-leg booking reads as such. */ + route?: string | null; } interface EdgeUsage { @@ -45,6 +49,8 @@ interface EdgeUsage { lengthMeters: number; bookingRefs: string[]; bookings: LegBookingUsage[]; + /** Gross tons of linked-but-unallocated bookings riding this leg — not yet on any slot. */ + pendingTons: number; } const round1 = (n: number) => Math.round(n * 10) / 10; @@ -106,9 +112,33 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } if (stops.length < 2) return []; const indexOf = new Map(stops.map((s, i) => [s.yardId, i])); const lastIdx = stops.length - 1; - const spans = wagons.map((w) => - spanOf(w.boardYardId, w.alightYardId, indexOf, lastIdx), - ); + // Booking legs, for slot-span fallback and for labelling dropdown rows. + const bookingById = new Map((schedule.bookings ?? []).map((b) => [b.id, b])); + const bookingSpan = (bookingId: string) => { + const b = bookingById.get(bookingId); + if (!b) return null; + const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0; + const toRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx; + const to = toRaw != null && toRaw > from ? toRaw : lastIdx; + return { from, to }; + }; + // A slot rides its stamped board→alight span. Slots without a stamp (an + // API build predating the fields, or plans written before spans existed) + // fall back to the union of their OWN bookings' legs — an a→b-only wagon + // must not count on the b→c leg. Empty stamped-less wagons ride everything. + const spans = wagons.map((w) => { + if (w.boardYardId || w.alightYardId) { + return spanOf(w.boardYardId, w.alightYardId, indexOf, lastIdx); + } + const legs = (w.allocations ?? []) + .map((a) => bookingSpan(a.bookingId)) + .filter((s): s is { from: number; to: number } => s != null); + if (!legs.length) return { from: 0, to: lastIdx }; + return { + from: Math.min(...legs.map((s) => s.from)), + to: Math.max(...legs.map((s) => s.to)), + }; + }); return stops.slice(0, -1).map((from, edge) => { const active = wagons.filter( (_, i) => spans[i].from <= edge && edge < spans[i].to, @@ -128,11 +158,16 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } for (const a of w.allocations ?? []) { if (!a.bookingReference) continue; refs.add(a.bookingReference); + const linked = bookingById.get(a.bookingId); const row = byBooking.get(a.bookingId) ?? { bookingId: a.bookingId, reference: a.bookingReference, wagons: 0, grossTons: 0, + route: + linked?.origin && linked?.destination + ? `${linked.origin} → ${linked.destination}` + : null, }; row.grossTons += Number(a.allocatedWeightTons) || 0; byBooking.set(a.bookingId, row); @@ -143,9 +178,35 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } if (row) row.wagons += 1; } } + // Linked bookings with NO wagon allocation ride their leg too — without + // this they vanish from the tab entirely (two Dire→DCT bookings hidden + // while a through booking showed alone). Flagged so staff see the gap; + // their tonnage is deliberately NOT in the leg totals, which reflect + // what is physically on consist slots. + for (const b of schedule.bookings ?? []) { + if (byBooking.has(b.id)) continue; + const bFrom = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0; + const bToRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx; + const bTo = bToRaw != null && bToRaw > bFrom ? bToRaw : lastIdx; + if (!(bFrom <= edge && edge < bTo)) continue; + const reference = b.reference ?? b.id; + refs.add(reference); + byBooking.set(b.id, { + bookingId: b.id, + reference, + wagons: Number(b.wagonsRequired) || 0, + grossTons: Number(b.weightTons) || 0, + unallocated: true, + route: + b.origin && b.destination ? `${b.origin} → ${b.destination}` : null, + }); + } const bookings = [...byBooking.values()] .map((b) => ({ ...b, grossTons: round1(b.grossTons) })) .sort((a, b) => b.grossTons - a.grossTons); + const pendingTons = round1( + bookings.filter((b) => b.unallocated).reduce((sum, b) => sum + b.grossTons, 0), + ); return { edge, from, @@ -155,9 +216,21 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } lengthMeters: round1(lengthMeters), bookingRefs: [...refs], bookings, + pendingTons, }; }); - }, [stops, wagons]); + }, [stops, wagons, schedule.bookings]); + + const unallocatedRefs = useMemo( + () => [ + ...new Set( + edges.flatMap((e) => + e.bookings.filter((b) => b.unallocated).map((b) => b.reference), + ), + ), + ], + [edges], + ); if (stops.length < 2) { return ( @@ -210,6 +283,14 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } return ( + {unallocatedRefs.length ? ( + }> + {unallocatedRefs.join(", ")}{" "} + {unallocatedRefs.length === 1 ? "is" : "are"} linked to this train but + have no wagons allocated — their weight is not on any leg yet. Re-run + allocation (or add them from the workspace) to place them. + + ) : null} @@ -267,6 +348,11 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } + {e.pendingTons > 0 ? ( + + +{e.pendingTons}T unallocated + + ) : null} @@ -303,21 +389,35 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } {e.bookings.map((b) => ( - - - {b.reference} + + + + {b.reference} + + {b.unallocated ? ( + + no wagons + + ) : null} + + + + + {b.route ?? "—"} - - + + - {b.wagons} wagon{b.wagons === 1 ? "" : "s"} + {b.wagons > 0 ? b.wagons : "—"} wagon + {b.wagons === 1 ? "" : "s"} + {b.unallocated && b.wagons > 0 ? " needed" : ""} - - + + {b.grossTons}T