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;
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(() => {

View File

@@ -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>

View File

@@ -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>