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

fix issue
This commit is contained in:
marshal
2026-08-22 03:50:34 +03:00
committed by GitHub
22 changed files with 1448 additions and 88 deletions

View File

@@ -22,6 +22,15 @@ type Slot = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
type Stop = { yardId: string; label: string };
type Span = [number, number];
/** The allocations of one slot that ride the same corridor — one drawn bar. */
type SlotPart = {
slot: Slot;
span: Span;
loaded: boolean;
/** Allocations riding THIS span (all of the slot's when it is not split). */
allocations: NonNullable<Slot["allocations"]>;
};
/** One physical wagon of the consist with every slot (leg load) pinned to it. */
interface WagonRow {
key: string;
@@ -30,12 +39,57 @@ interface WagonRow {
position: number;
typeCode: string | null;
capacityTons: number;
slots: Array<{ slot: Slot; span: Span; loaded: boolean }>;
slots: SlotPart[];
}
const round1 = (n: number) => Math.round(n * 10) / 10;
const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1];
/**
* One drawn bar per corridor a slot actually serves.
*
* A wagon reused across disjoint legs (containers Doraleh→Dire Dawa, bulk
* Dire Dawa→Gelan) is ONE slot whose stored board/alight yards are the UNION
* of its loads. Drawing that union as a single bar claims both loads ride the
* whole way and hides where each one actually sits. Each allocation carries
* its own booking yards, so group by corridor and draw one bar per group —
* the board then reads "containers on leg 1, bulk on leg 2" truthfully.
*
* Falls back to the slot's own span whenever the yards are missing or not on
* the stop list, which is exactly the previous behaviour.
*/
function splitByCorridor(slot: Slot, slotSpan: Span, stops: Stop[]): SlotPart[] {
const allocations = slot.allocations ?? [];
const whole: SlotPart[] = [
{ slot, span: slotSpan, loaded: allocations.length > 0, allocations },
];
if (allocations.length < 2) return whole;
const idx = (yardId?: string | null) =>
yardId ? stops.findIndex((s) => s.yardId === yardId) : -1;
const byCorridor = new Map<string, { span: Span; allocations: typeof allocations }>();
for (const allocation of allocations) {
const from = idx(allocation.originYardId);
const to = idx(allocation.destinationYardId);
// Any allocation without a usable corridor → keep the old single bar.
if (from < 0 || to <= from) return whole;
const key = `${from}-${to}`;
const entry = byCorridor.get(key);
if (entry) entry.allocations.push(allocation);
else byCorridor.set(key, { span: [from, to], allocations: [allocation] });
}
if (byCorridor.size < 2) return whole;
return [...byCorridor.values()]
.sort((a, b) => a.span[0] - b.span[0])
.map((part) => ({
slot,
span: part.span,
loaded: true,
allocations: part.allocations,
}));
}
/**
* Leg board: rows = physical wagons in coupling order, columns = corridor legs
* (A→B, B→C, …). A wagon reused on disjoint legs shows one load per leg on the
@@ -86,11 +140,7 @@ export function LegLoadBoardPanel({
row.position = Math.min(row.position, slot.position ?? slot.sequenceNo);
// Coupled-but-empty consist wagons carry no slot row: they are a target only.
if (!slot.consistOnly) {
row.slots.push({
slot,
span: spanOf(slot),
loaded: (slot.allocations?.length ?? 0) > 0,
});
row.slots.push(...splitByCorridor(slot, spanOf(slot), stops));
}
}
return [...byKey.values()].sort((a, b) => a.position - b.position);
@@ -211,15 +261,16 @@ export function LegLoadBoardPanel({
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const cargoTons = row.slots.reduce(
(s, x) =>
s +
((x.slot.allocations ?? []).reduce(
(a, al) => a + (al.allocatedWeightTons ?? 0),
0,
) || x.slot.assignedWeightTons || 0),
0,
);
// Heaviest single leg, not the sum of every bar: one slot may be
// drawn as several corridor bars, and a wagon reused on disjoint
// legs never carries both loads at once. Summing them reported a
// 60T wagon as 120T loaded and painted the capacity red.
const cargoTons = row.slots.reduce((max, part) => {
const tons =
part.allocations.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) ||
(row.slots.length === 1 ? part.slot.assignedWeightTons || 0 : 0);
return Math.max(max, tons);
}, 0);
const isPickedRow = picked?.rowKey === row.key;
// A row can take the picked load when nothing loaded on it rides
// any of the picked load's legs.
@@ -279,12 +330,14 @@ export function LegLoadBoardPanel({
const isPicked = picked?.slotId === s.slot.id;
const swappable =
!!picked && !isPicked && !isPickedRow && s.loaded && canRearrange;
const allocs = s.slot.allocations ?? [];
// The allocations riding THIS bar's corridor — not the whole
// slot's, so a leg-shared wagon labels each leg with its own load.
const allocs = s.allocations;
const bulk = allocs.some((a) => (a.loadType ?? "CONTAINER").toUpperCase() === "BULK");
const containers = allocs.flatMap((a) => a.containerItems ?? []);
cells.push(
<Table.Td
key={s.slot.id}
key={`${s.slot.id}-${s.span[0]}-${s.span[1]}`}
colSpan={Math.max(1, s.span[1] - s.span[0])}
onClick={
!canRearrange

View File

@@ -39,11 +39,6 @@ interface InteractiveTrainConsistProps {
onMoveLoad?: (move: WagonLoadMove) => void;
}
const wagonItems = (wagon: Wagon) =>
(wagon.allocations ?? [])
.flatMap((a) => a.containerItems ?? [])
.sort((a, b) => (a.positionOnWagon ?? 99) - (b.positionOnWagon ?? 99));
const CONTAINER_GRADIENTS = [
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
@@ -192,7 +187,31 @@ function WagonCar({
}) {
const [dropHover, setDropHover] = useState(false);
const wagon = slots[0]!;
const loaded = slots.filter((s) => (s.allocations?.length ?? 0) > 0);
const loadedSlots = slots.filter((s) => (s.allocations?.length ?? 0) > 0);
// One drawn row per LOAD, not per slot. A wagon reused across disjoint legs
// (containers to Dire Dawa, bulk onward) is ONE slot holding two allocations
// with different corridors — counting slots drew that as a single row and
// hid the second load entirely. Group the slot's allocations by their own
// booking corridor so each load gets its own row, stacked top/bottom.
const loaded = loadedSlots.flatMap((slot) => {
const allocations = slot.allocations ?? [];
const byCorridor = new Map<string, typeof allocations>();
for (const allocation of allocations) {
const key =
allocation.originYardId && allocation.destinationYardId
? `${allocation.originYardId}->${allocation.destinationYardId}`
: "whole-route";
byCorridor.set(key, [...(byCorridor.get(key) ?? []), allocation]);
}
if (byCorridor.size < 2) {
return [{ slot, allocations, corridorKey: null as string | null }];
}
return [...byCorridor.entries()].map(([key, group]) => ({
slot,
allocations: group,
corridorKey: key as string | null,
}));
});
const shared = loaded.length > 1;
const isEmpty = !loaded.length;
const isBulk = loaded.some((s) =>
@@ -201,12 +220,15 @@ function WagonCar({
// 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;
// Heaviest single load, not the sum: rows on disjoint legs never ride at the
// same time, so summing them would over-report what the wagon carries.
const cargo = loaded.reduce(
(sum, s) =>
sum +
((s.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) ||
s.assignedWeightTons ||
0),
(max, row) =>
Math.max(
max,
row.allocations.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0) ||
(loaded.length === 1 ? row.slot.assignedWeightTons || 0 : 0),
),
0,
);
const assigned = cargo + tare;
@@ -249,7 +271,7 @@ function WagonCar({
<HoverCard width={280} shadow="lg" radius="md" position="top" withArrow openDelay={120}>
<HoverCard.Target>
<Box
onClick={() => onSelectSlot(loaded[0] ?? wagon)}
onClick={() => onSelectSlot(loaded[0]?.slot ?? wagon)}
style={{ width: 148, flexShrink: 0, cursor: "pointer" }}
>
<Box
@@ -389,16 +411,25 @@ function WagonCar({
// 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) =>
{loaded.map((row, r) => {
const slot = row.slot;
const rowBulk = row.allocations.some((a) =>
(a.loadType ?? "").toUpperCase().includes("BULK"),
);
const rowBlocks = wagonItems(slot).slice(0, 2);
// Container blocks of THIS row's allocations only, so a
// leg-shared wagon shows each leg's own boxes.
const rowBlocks = row.allocations
.flatMap((a) => a.containerItems ?? [])
.slice()
.sort(
(a, b) => (a.positionOnWagon ?? 0) - (b.positionOnWagon ?? 0),
)
.slice(0, 2);
const rowSelected = shared && slot.id === selectedWagonId;
const rowHeight = shared ? 20 : 26;
return (
<Group
key={slot.id}
key={`${slot.id}-${row.corridorKey ?? "all"}`}
gap={3}
justify="center"
wrap="nowrap"
@@ -549,15 +580,18 @@ function WagonCar({
</Text>
) : (
<Stack gap={6}>
{loaded.map((slot) => {
const slotAllocation = slot.allocations?.[0];
{loaded.map((row) => {
const slot = row.slot;
const slotAllocation = row.allocations[0];
const slotCompany = getCompany(slotAllocation?.bookingId);
const slotContainers = wagonItems(slot).map(
(c) => c.containerNumber?.trim() || "—",
);
// This row's own containers, so a leg-shared wagon lists each
// leg's boxes under its own load rather than all of them twice.
const slotContainers = row.allocations
.flatMap((a) => a.containerItems ?? [])
.map((c) => c.containerNumber?.trim() || "—");
return (
<Stack
key={slot.id}
key={`${slot.id}-${row.corridorKey ?? "all"}`}
gap={4}
style={
shared