Merge pull request #1032 from Tria-plc/freight_feature/usermanagement

leg-aware capacity and wagon sharing
This commit is contained in:
marshal
2026-07-31 01:07:55 +03:00
committed by GitHub
3 changed files with 262 additions and 129 deletions

View File

@@ -20,6 +20,8 @@ type DiagramWagonInput = {
slotLoadType?: string | null; slotLoadType?: string | null;
wagonType?: { code?: string | null } | null; wagonType?: { code?: string | null } | null;
wagonTypeCode?: string | null; wagonTypeCode?: string | null;
/** Pinned physical wagon — slots sharing one (cross-leg TEU) draw as ONE car. */
physicalWagonId?: string | null;
physicalWagonNumber?: string | null; physicalWagonNumber?: string | null;
allocations?: Array<{ allocations?: Array<{
bookingReference?: string | null; bookingReference?: string | null;
@@ -545,9 +547,40 @@ export function TrainCompositionDiagram({
[locomotives, locomotive], [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( const normalized = useMemo(
() => wagons.map((w) => normalizeWagon(w, freightType)), () => merged.map((w) => normalizeWagon(w, freightType)),
[wagons, freightType], [merged, freightType],
); );
const stats = useMemo(() => { const stats = useMemo(() => {

View File

@@ -165,52 +165,71 @@ function LocomotiveCar({ locomotive }: { locomotive: Locomotive }) {
} }
function WagonCar({ function WagonCar({
wagon, slots,
company, getCompany,
selected, selectedWagonId,
highlighted, highlightBookingId,
onSelect, onSelectSlot,
drag, drag,
onDragChange, onDragChange,
onMoveLoad, onMoveLoad,
canRearrange, canRearrange,
}: { }: {
wagon: Wagon; /**
company: string | null; * All plan slots riding this PHYSICAL wagon. Cross-leg sharing puts two
selected: boolean; * slots (e.g. intercity + export, disjoint legs) on one wagon — drawn as
highlighted: boolean; * one car with a row per slot, top/bottom.
onSelect: () => void; */
slots: Wagon[];
getCompany: (bookingId: string | undefined) => string | null;
selectedWagonId: string | null;
highlightBookingId?: string | null;
onSelectSlot: (wagon: Wagon) => void;
drag: DragState; drag: DragState;
onDragChange: (drag: DragState) => void; onDragChange: (drag: DragState) => void;
onMoveLoad?: (move: WagonLoadMove) => void; onMoveLoad?: (move: WagonLoadMove) => void;
canRearrange: boolean; canRearrange: boolean;
}) { }) {
const [dropHover, setDropHover] = useState(false); const [dropHover, setDropHover] = useState(false);
const allocation = wagon.allocations?.[0]; const wagon = slots[0]!;
const isEmpty = !allocation; const loaded = slots.filter((s) => (s.allocations?.length ?? 0) > 0);
const isBulk = (wagon.allocations ?? []).some((a) => const shared = loaded.length > 1;
(a.loadType ?? "").toUpperCase().includes("BULK"), 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 tare = wagon.tareWeightTons ?? 0;
const assigned = const cargo = loaded.reduce(
(allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0) + tare; (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 capacity = (wagon.capacityTons ?? 0) + tare;
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0; const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan"; const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
const accentVar = `var(--mantine-color-${accent}-6)`; const accentVar = `var(--mantine-color-${accent}-6)`;
const items = wagonItems(wagon); const selected = slots.some((s) => s.id === selectedWagonId);
const blocks = items.slice(0, 2); const highlighted = Boolean(
const containerNumbers = items.map((c) => c.containerNumber?.trim() || "—"); 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 // 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 // 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 — // loads swap. Either way the wagons stay exactly where they are coupled —
// only the cargo changes wagon. The API validates type + payload weight. // 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 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 // 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. // wagon's own load, so it should never come as a surprise.
const dropIntent = dropEligible ? (isEmpty ? "move" : "swap") : null; 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 width={280} shadow="lg" radius="md" position="top" withArrow openDelay={120}>
<HoverCard.Target> <HoverCard.Target>
<Box <Box
onClick={onSelect} onClick={() => onSelectSlot(loaded[0] ?? wagon)}
style={{ width: 120, flexShrink: 0, cursor: "pointer" }} style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
> >
<Box <Box
@@ -363,56 +382,100 @@ function WagonCar({
<Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}> <Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}>
Available Available
</Text> </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%" }}> // One row per slot: 40ft = one full-width block, 20ft pair =
{(blocks.length ? blocks.map((c) => c.containerNumber?.trim() || "—") : ["—"]).map( // two side by side. A shared wagon stacks its slots top/bottom
(cn, i) => ( // (intercity above, export below); each row selects ITS slot.
<Box <Stack gap={3} style={{ width: "100%" }}>
key={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 (
<Group
key={slot.id}
gap={3}
justify="center"
wrap="nowrap"
onClick={
shared
? (e) => {
e.stopPropagation();
onSelectSlot(slot);
}
: undefined
}
style={{ style={{
flex: 1, width: "100%",
minWidth: 0, borderRadius: 5,
height: 26, outline: rowSelected
borderRadius: 4, ? `2px solid ${freightBrand.primary}`
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length], : "none",
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`, outlineOffset: 1,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 2px",
}} }}
> >
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}> {rowBulk ? (
{cn} <Box
</Text> style={{
</Box> flex: 1,
), height: rowHeight,
)} borderRadius: 4,
</Group> 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> </Box>
@@ -484,41 +547,56 @@ function WagonCar({
</Text> </Text>
) : ( ) : (
<Stack gap={6}> <Stack gap={6}>
{company ? ( {loaded.map((slot) => {
<Group gap={6} wrap="nowrap"> const slotAllocation = slot.allocations?.[0];
<Building2 size={13} color={freightBrand.primary} /> const slotCompany = getCompany(slotAllocation?.bookingId);
<Text size="xs" fw={700} truncate> const slotContainers = wagonItems(slot).map(
{company} (c) => c.containerNumber?.trim() || "—",
</Text> );
</Group> return (
) : null} <Stack
<Group gap={6} wrap="nowrap"> key={slot.id}
<Package size={13} color="var(--mantine-color-gray-6)" /> gap={4}
<Text size="xs" c="dimmed"> style={
{allocation?.bookingReference ?? "Unknown booking"} shared
</Text> ? {
</Group> borderLeft: "2px solid var(--mantine-color-gray-3)",
paddingLeft: 8,
{containerNumbers.length ? ( }
<div> : undefined
<Text size="10px" c="dimmed" fw={700} mb={3} tt="uppercase"> }
Containers >
</Text> {slotCompany ? (
<Group gap={4}> <Group gap={6} wrap="nowrap">
{containerNumbers.map((cn, i) => ( <Building2 size={13} color={freightBrand.primary} />
<Badge key={i} size="xs" variant="outline" color="cyan" radius="sm"> <Text size="xs" fw={700} truncate>
{cn} {slotCompany}
</Badge> </Text>
))} </Group>
</Group> ) : null}
</div> <Group gap={6} wrap="nowrap">
) : null} <Package size={13} color="var(--mantine-color-gray-6)" />
<Text size="xs" c="dimmed">
{isBulk && allocation?.bulkLoad?.cargoDescription ? ( {slotAllocation?.bookingReference ?? "Unknown booking"}
<Text size="xs" c="dimmed"> </Text>
{allocation.bulkLoad.cargoDescription} </Group>
</Text> {slotContainers.length ? (
) : null} <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"> <Group gap={6} wrap="nowrap">
<Weight size={13} color="var(--mantine-color-gray-6)" /> <Weight size={13} color="var(--mantine-color-gray-6)" />
@@ -569,6 +647,23 @@ export const InteractiveTrainConsist = ({
}: InteractiveTrainConsistProps) => { }: InteractiveTrainConsistProps) => {
const [drag, setDrag] = useState<DragState>(null); const [drag, setDrag] = useState<DragState>(null);
const locos = locomotives?.length ? locomotives : locomotive ? [locomotive] : []; 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 ( return (
<Box <Box
style={{ style={{
@@ -599,30 +694,27 @@ export const InteractiveTrainConsist = ({
<LocomotiveCar locomotive={loco} /> <LocomotiveCar locomotive={loco} />
</Group> </Group>
))} ))}
{wagons.length === 0 ? ( {groups.length === 0 ? (
<Text size="sm" c="dimmed" pl="md" pt="lg"> <Text size="sm" c="dimmed" pl="md" pt="lg">
No wagons assigned No wagons assigned
</Text> </Text>
) : ( ) : (
wagons.map((wagon, i) => { groups.map((slots, i) => (
const bookingId = wagon.allocations?.[0]?.bookingId; <Group key={slots[0]!.id} gap={0} wrap="nowrap" align="flex-start">
return ( {i > 0 || locos.length ? <Coupler /> : null}
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-start"> <WagonCar
{i > 0 || locos.length ? <Coupler /> : null} slots={slots}
<WagonCar getCompany={getCompany}
wagon={wagon} selectedWagonId={selectedWagonId}
company={getCompany(bookingId)} highlightBookingId={highlightBookingId}
selected={selectedWagonId === wagon.id} onSelectSlot={onSelectWagon}
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)} drag={drag}
onSelect={() => onSelectWagon(wagon)} onDragChange={setDrag}
drag={drag} onMoveLoad={onMoveLoad}
onDragChange={setDrag} canRearrange={canRearrange}
onMoveLoad={onMoveLoad} />
canRearrange={canRearrange} </Group>
/> ))
</Group>
);
})
)} )}
</Group> </Group>

View File

@@ -100,7 +100,14 @@ export const TrainConsistView = ({
}, [scheduleDetail.bookings]); }, [scheduleDetail.bookings]);
const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null; 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) => { const handleRemoveBooking = (wagon: Wagon) => {
setSelectedWagonId(wagon.id); setSelectedWagonId(wagon.id);
@@ -139,7 +146,7 @@ export const TrainConsistView = ({
const weightUsed = heaviest?.grossWeightTons ?? cargoUsed + tareUsed; const weightUsed = heaviest?.grossWeightTons ?? cargoUsed + tareUsed;
const lengthUsed = const lengthUsed =
heaviest?.lengthMeters ?? wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0); 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, // Engine rule (combinedLocomotiveLimits): coupled locomotives pull TOGETHER,
// so pull caps SUM; the track doesn't lengthen, so the length cap is the MIN. // 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 Train consist
</Text> </Text>
<Text size="11px" c="dimmed"> <Text size="11px" c="dimmed">
{wagons.length} wagons · {loadedCount} loaded · {wagons.length - loadedCount} empty {physicalCount} wagons · {loadedPhysicalCount} loaded ·{" "}
{physicalCount - loadedPhysicalCount} empty
</Text> </Text>
</div> </div>
</Group> </Group>