mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
leg-aware capacity and wagon sharing
This commit is contained in:
@@ -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<string, DiagramWagonInput[]>();
|
||||
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(() => {
|
||||
|
||||
@@ -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({
|
||||
<HoverCard width={280} shadow="lg" radius="md" position="top" withArrow openDelay={120}>
|
||||
<HoverCard.Target>
|
||||
<Box
|
||||
onClick={onSelect}
|
||||
onClick={() => onSelectSlot(loaded[0] ?? wagon)}
|
||||
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
|
||||
>
|
||||
<Box
|
||||
@@ -363,56 +382,100 @@ function WagonCar({
|
||||
<Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}>
|
||||
Available
|
||||
</Text>
|
||||
) : isBulk ? (
|
||||
<Stack gap={2} style={{ width: "100%" }}>
|
||||
<Box
|
||||
style={{
|
||||
height: 16,
|
||||
borderRadius: 5,
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
border: "1px solid var(--mantine-color-orange-2)",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: `${utilization}%`,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-orange-6), var(--mantine-color-orange-4))",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
||||
{(blocks.length ? blocks.map((c) => c.containerNumber?.trim() || "—") : ["—"]).map(
|
||||
(cn, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
// One row per slot: 40ft = one full-width block, 20ft pair =
|
||||
// two side by side. A shared wagon stacks its slots top/bottom
|
||||
// (intercity above, export below); each row selects ITS slot.
|
||||
<Stack gap={3} style={{ width: "100%" }}>
|
||||
{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 (
|
||||
<Group
|
||||
key={slot.id}
|
||||
gap={3}
|
||||
justify="center"
|
||||
wrap="nowrap"
|
||||
onClick={
|
||||
shared
|
||||
? (e) => {
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||
{cn}
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
)}
|
||||
</Group>
|
||||
{rowBulk ? (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: rowHeight,
|
||||
borderRadius: 4,
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
border: "1px solid var(--mantine-color-orange-2)",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: `${utilization}%`,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-orange-6), var(--mantine-color-orange-4))",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
(rowBlocks.length
|
||||
? rowBlocks.map((c) => c.containerNumber?.trim() || "—")
|
||||
: ["—"]
|
||||
).map((cn, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: rowHeight,
|
||||
borderRadius: 4,
|
||||
background: CONTAINER_GRADIENTS[r % CONTAINER_GRADIENTS.length],
|
||||
border: `1px solid ${CONTAINER_BORDERS[r % CONTAINER_BORDERS.length]}`,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 2px",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size={shared ? "7px" : "8px"}
|
||||
fw={700}
|
||||
c="white"
|
||||
truncate
|
||||
style={{ maxWidth: "100%" }}
|
||||
>
|
||||
{cn}
|
||||
</Text>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -484,41 +547,56 @@ function WagonCar({
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{company ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Building2 size={13} color={freightBrand.primary} />
|
||||
<Text size="xs" fw={700} truncate>
|
||||
{company}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Package size={13} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{allocation?.bookingReference ?? "Unknown booking"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{containerNumbers.length ? (
|
||||
<div>
|
||||
<Text size="10px" c="dimmed" fw={700} mb={3} tt="uppercase">
|
||||
Containers
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{containerNumbers.map((cn, i) => (
|
||||
<Badge key={i} size="xs" variant="outline" color="cyan" radius="sm">
|
||||
{cn}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isBulk && allocation?.bulkLoad?.cargoDescription ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{allocation.bulkLoad.cargoDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
{loaded.map((slot) => {
|
||||
const slotAllocation = slot.allocations?.[0];
|
||||
const slotCompany = getCompany(slotAllocation?.bookingId);
|
||||
const slotContainers = wagonItems(slot).map(
|
||||
(c) => c.containerNumber?.trim() || "—",
|
||||
);
|
||||
return (
|
||||
<Stack
|
||||
key={slot.id}
|
||||
gap={4}
|
||||
style={
|
||||
shared
|
||||
? {
|
||||
borderLeft: "2px solid var(--mantine-color-gray-3)",
|
||||
paddingLeft: 8,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{slotCompany ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Building2 size={13} color={freightBrand.primary} />
|
||||
<Text size="xs" fw={700} truncate>
|
||||
{slotCompany}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Package size={13} color="var(--mantine-color-gray-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{slotAllocation?.bookingReference ?? "Unknown booking"}
|
||||
</Text>
|
||||
</Group>
|
||||
{slotContainers.length ? (
|
||||
<Group gap={4}>
|
||||
{slotContainers.map((cn, i) => (
|
||||
<Badge key={i} size="xs" variant="outline" color="cyan" radius="sm">
|
||||
{cn}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
{slotAllocation?.bulkLoad?.cargoDescription ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{slotAllocation.bulkLoad.cargoDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Weight size={13} color="var(--mantine-color-gray-6)" />
|
||||
@@ -569,6 +647,23 @@ export const InteractiveTrainConsist = ({
|
||||
}: InteractiveTrainConsistProps) => {
|
||||
const [drag, setDrag] = useState<DragState>(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<string, Wagon[]>();
|
||||
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 (
|
||||
<Box
|
||||
style={{
|
||||
@@ -599,30 +694,27 @@ export const InteractiveTrainConsist = ({
|
||||
<LocomotiveCar locomotive={loco} />
|
||||
</Group>
|
||||
))}
|
||||
{wagons.length === 0 ? (
|
||||
{groups.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" pl="md" pt="lg">
|
||||
No wagons assigned
|
||||
</Text>
|
||||
) : (
|
||||
wagons.map((wagon, i) => {
|
||||
const bookingId = wagon.allocations?.[0]?.bookingId;
|
||||
return (
|
||||
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-start">
|
||||
{i > 0 || locos.length ? <Coupler /> : null}
|
||||
<WagonCar
|
||||
wagon={wagon}
|
||||
company={getCompany(bookingId)}
|
||||
selected={selectedWagonId === wagon.id}
|
||||
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)}
|
||||
onSelect={() => onSelectWagon(wagon)}
|
||||
drag={drag}
|
||||
onDragChange={setDrag}
|
||||
onMoveLoad={onMoveLoad}
|
||||
canRearrange={canRearrange}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
})
|
||||
groups.map((slots, i) => (
|
||||
<Group key={slots[0]!.id} gap={0} wrap="nowrap" align="flex-start">
|
||||
{i > 0 || locos.length ? <Coupler /> : null}
|
||||
<WagonCar
|
||||
slots={slots}
|
||||
getCompany={getCompany}
|
||||
selectedWagonId={selectedWagonId}
|
||||
highlightBookingId={highlightBookingId}
|
||||
onSelectSlot={onSelectWagon}
|
||||
drag={drag}
|
||||
onDragChange={setDrag}
|
||||
onMoveLoad={onMoveLoad}
|
||||
canRearrange={canRearrange}
|
||||
/>
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -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
|
||||
</Text>
|
||||
<Text size="11px" c="dimmed">
|
||||
{wagons.length} wagons · {loadedCount} loaded · {wagons.length - loadedCount} empty
|
||||
{physicalCount} wagons · {loadedPhysicalCount} loaded ·{" "}
|
||||
{physicalCount - loadedPhysicalCount} empty
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
Reference in New Issue
Block a user