mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Add migration for consolidation approvals table and status enum - Create ConsolidationApprovalService to handle approval logic - Implement repository for managing consolidation approvals - Add entity for consolidation approval with necessary fields - Develop frontend components for displaying and managing consolidation approvals - Create tests for consolidation approval service to ensure correct behavior
844 lines
29 KiB
TypeScript
844 lines
29 KiB
TypeScript
import { memo, useMemo } from "react";
|
||
import { Box, Group, Paper, Progress, Stack, Text, Tooltip } from "@mantine/core";
|
||
import { useElementSize } from "@mantine/hooks";
|
||
import { Box as BoxIcon, Container as ContainerIcon, Fuel, Gauge, TrainFront } from "lucide-react";
|
||
|
||
import { freightBrand } from "@/theme/freight-brand";
|
||
|
||
/**
|
||
* Visual train composition: a locomotive coupled to its wagons, drawn like a real
|
||
* consist. Long trains wrap into a serpentine (zig-zag) so the whole train stays on
|
||
* screen. Each wagon shows its load (container blocks or a bulk fill gauge), physical
|
||
* wagon number and tonnage.
|
||
*/
|
||
|
||
type DiagramWagonInput = {
|
||
sequenceNo: number;
|
||
capacityTons: number;
|
||
assignedWeightTons: number;
|
||
tareWeightTons?: number | null;
|
||
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;
|
||
bookingId?: string;
|
||
loadType?: string | null;
|
||
containerItems?: Array<{ containerNumber?: string | null }> | null;
|
||
bulkLoad?: { weightTons?: number | null; cargoDescription?: string | null } | null;
|
||
}> | null;
|
||
};
|
||
|
||
type NormalizedWagon = {
|
||
sequenceNo: number;
|
||
capacityTons: number;
|
||
assignedWeightTons: number;
|
||
tareWeightTons: number;
|
||
wagonTypeCode: string | null;
|
||
physicalWagonNumber: string | null;
|
||
isEmpty: boolean;
|
||
isBulk: boolean;
|
||
containerNumbers: string[];
|
||
bookingRefs: string[];
|
||
cargoDescription: string | null;
|
||
};
|
||
|
||
const CAR_WIDTH = 150; // car body + coupler footprint
|
||
|
||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||
|
||
function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): NormalizedWagon {
|
||
const allocations = w.allocations ?? [];
|
||
const firstLoad = (
|
||
w.slotLoadType ??
|
||
allocations[0]?.loadType ??
|
||
freightType ??
|
||
""
|
||
)
|
||
.toString()
|
||
.toUpperCase();
|
||
const isBulk = firstLoad.includes("BULK");
|
||
const containerNumbers: string[] = [];
|
||
const bookingRefs: string[] = [];
|
||
let cargoDescription: string | null = null;
|
||
for (const alloc of allocations) {
|
||
if (alloc.bookingReference) bookingRefs.push(alloc.bookingReference);
|
||
for (const item of alloc.containerItems ?? []) {
|
||
containerNumbers.push(item.containerNumber?.trim() || "—");
|
||
}
|
||
if (alloc.bulkLoad?.cargoDescription) cargoDescription = alloc.bulkLoad.cargoDescription;
|
||
}
|
||
return {
|
||
sequenceNo: w.sequenceNo,
|
||
capacityTons: Number(w.capacityTons) || 0,
|
||
assignedWeightTons: Number(w.assignedWeightTons) || 0,
|
||
tareWeightTons: Number(w.tareWeightTons) || 0,
|
||
wagonTypeCode: w.wagonType?.code ?? w.wagonTypeCode ?? null,
|
||
physicalWagonNumber: w.physicalWagonNumber ?? null,
|
||
isEmpty: allocations.length === 0,
|
||
isBulk,
|
||
containerNumbers,
|
||
bookingRefs,
|
||
cargoDescription,
|
||
};
|
||
}
|
||
|
||
function chunk<T>(items: T[], size: number): T[][] {
|
||
if (size <= 0) return [items];
|
||
const rows: T[][] = [];
|
||
for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size));
|
||
return rows;
|
||
}
|
||
|
||
function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean }) {
|
||
return (
|
||
<Group gap={count > 2 ? 10 : 18} justify="center" wrap="nowrap" mt={2}>
|
||
{Array.from({ length: count }).map((_, i) => (
|
||
<Box
|
||
key={i}
|
||
style={{
|
||
width: 13,
|
||
height: 13,
|
||
borderRadius: "50%",
|
||
background: dark
|
||
? "radial-gradient(circle at 35% 35%, #2c4a3a, #0f291b)"
|
||
: "radial-gradient(circle at 35% 35%, var(--mantine-color-gray-5), var(--mantine-color-gray-8))",
|
||
border: "2px solid var(--mantine-color-gray-4)",
|
||
boxShadow: "inset 0 0 0 2px rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.2)",
|
||
}}
|
||
/>
|
||
))}
|
||
</Group>
|
||
);
|
||
}
|
||
|
||
function Coupler() {
|
||
return (
|
||
<Box style={{ width: 16, height: 78, display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||
<Box
|
||
style={{
|
||
width: "100%",
|
||
height: 6,
|
||
borderRadius: 3,
|
||
background:
|
||
"linear-gradient(90deg, var(--mantine-color-gray-4), var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
|
||
boxShadow: "0 1px 1px rgba(0,0,0,0.15)",
|
||
}}
|
||
/>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
const LocomotiveCar = memo(function LocomotiveCar({
|
||
code,
|
||
name,
|
||
maxPullWeightTons,
|
||
}: {
|
||
code: string;
|
||
name?: string | null;
|
||
maxPullWeightTons?: number | null;
|
||
}) {
|
||
return (
|
||
<Tooltip
|
||
label={`Locomotive ${code}${name ? ` · ${name}` : ""}${
|
||
maxPullWeightTons ? ` · pulls up to ${maxPullWeightTons}T` : ""
|
||
}`}
|
||
withArrow
|
||
>
|
||
<Box style={{ width: 134, flexShrink: 0 }}>
|
||
<Box
|
||
style={{
|
||
position: "relative",
|
||
height: 78,
|
||
borderRadius: "14px 26px 10px 10px",
|
||
background: `linear-gradient(160deg, ${freightBrand.primaryLight} 0%, ${freightBrand.primary} 45%, ${freightBrand.primaryDark} 100%)`,
|
||
boxShadow: `${freightBrand.shadowSm}, inset 0 1px 0 rgba(255,255,255,0.25)`,
|
||
border: "1px solid rgba(0,0,0,0.1)",
|
||
overflow: "hidden",
|
||
padding: "10px 10px 8px",
|
||
color: "white",
|
||
}}
|
||
>
|
||
{/* roofline */}
|
||
<Box
|
||
style={{
|
||
position: "absolute",
|
||
top: 0,
|
||
left: 0,
|
||
right: 14,
|
||
height: 5,
|
||
background: "rgba(0,0,0,0.22)",
|
||
borderRadius: "14px 0 0 0",
|
||
}}
|
||
/>
|
||
{/* roof vents */}
|
||
<Box style={{ position: "absolute", top: 1, left: 16, display: "flex", gap: 5 }}>
|
||
{[0, 1, 2].map((i) => (
|
||
<Box
|
||
key={i}
|
||
style={{
|
||
width: 10,
|
||
height: 3,
|
||
borderRadius: 2,
|
||
background: "rgba(255,255,255,0.35)",
|
||
}}
|
||
/>
|
||
))}
|
||
</Box>
|
||
{/* cab windows */}
|
||
<Box style={{ position: "absolute", top: 10, right: 10, display: "flex", gap: 4 }}>
|
||
<Box
|
||
style={{
|
||
width: 15,
|
||
height: 13,
|
||
borderRadius: "3px 6px 3px 3px",
|
||
background: "linear-gradient(135deg, #E8FBFF 0%, #9ED9E8 100%)",
|
||
border: "1px solid rgba(0,0,0,0.15)",
|
||
}}
|
||
/>
|
||
<Box
|
||
style={{
|
||
width: 12,
|
||
height: 13,
|
||
borderRadius: 3,
|
||
background: "linear-gradient(135deg, rgba(232,251,255,0.8), rgba(158,217,232,0.6))",
|
||
border: "1px solid rgba(0,0,0,0.12)",
|
||
}}
|
||
/>
|
||
</Box>
|
||
{/* headlight */}
|
||
<Box
|
||
style={{
|
||
position: "absolute",
|
||
bottom: 14,
|
||
right: 5,
|
||
width: 8,
|
||
height: 8,
|
||
borderRadius: "50%",
|
||
background: "#fde68a",
|
||
boxShadow: "0 0 10px 3px rgba(253,230,138,0.9)",
|
||
}}
|
||
/>
|
||
{/* hazard stripe on the nose */}
|
||
<Box
|
||
style={{
|
||
position: "absolute",
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: 6,
|
||
background:
|
||
"repeating-linear-gradient(45deg, #fbbf24 0 7px, #1f2937 7px 14px)",
|
||
opacity: 0.9,
|
||
}}
|
||
/>
|
||
<Group gap={6} wrap="nowrap" align="center">
|
||
<TrainFront size={18} />
|
||
<Text size="sm" fw={800} style={{ letterSpacing: 0.4 }}>
|
||
{code}
|
||
</Text>
|
||
</Group>
|
||
<Text size="9px" mt={1} style={{ opacity: 0.85 }} lineClamp={1}>
|
||
{name ?? "Locomotive"}
|
||
</Text>
|
||
{maxPullWeightTons ? (
|
||
<Group gap={3} wrap="nowrap" mt={3} style={{ opacity: 0.95 }}>
|
||
<Gauge size={10} />
|
||
<Text size="9px" fw={700}>
|
||
{maxPullWeightTons}T pull
|
||
</Text>
|
||
</Group>
|
||
) : null}
|
||
</Box>
|
||
<Wheels count={3} dark />
|
||
<Text size="9px" ta="center" c="dimmed" mt={2} fw={700} style={{ letterSpacing: 1 }}>
|
||
HEAD
|
||
</Text>
|
||
</Box>
|
||
</Tooltip>
|
||
);
|
||
});
|
||
|
||
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))",
|
||
];
|
||
const CONTAINER_BORDERS = [
|
||
"var(--mantine-color-cyan-8)",
|
||
"var(--mantine-color-blue-8)",
|
||
];
|
||
|
||
const WagonCar = memo(function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
||
const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons);
|
||
const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons);
|
||
const utilization =
|
||
maxGrossTons > 0 ? Math.min(100, Math.round((grossTons / maxGrossTons) * 100)) : 0;
|
||
const accent = wagon.isEmpty ? "gray" : wagon.isBulk ? "orange" : "cyan";
|
||
const accentVar = `var(--mantine-color-${accent}-6)`;
|
||
|
||
const tooltipLabel = wagon.isEmpty
|
||
? `Wagon #${wagon.sequenceNo} · empty / available`
|
||
: `Wagon #${wagon.sequenceNo}${wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""}\n${
|
||
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
|
||
}${
|
||
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
|
||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nGross: ${grossTons}/${maxGrossTons}T (${utilization}%)\nCargo: ${wagon.assignedWeightTons}T${
|
||
wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : ""
|
||
}`;
|
||
|
||
// container blocks: one per container number, up to 4 — cross-leg TEU
|
||
// sharing can put two 20ft pairs (riding different legs) on one wagon.
|
||
// 1–2 sit side by side; 3–4 form a 2×2 grid (two rows, up/down).
|
||
const blocks = wagon.containerNumbers.slice(0, 4);
|
||
const twoRows = blocks.length > 2;
|
||
|
||
return (
|
||
<Tooltip label={tooltipLabel} withArrow multiline maw={240} style={{ whiteSpace: "pre-line" }}>
|
||
<Box style={{ width: 134, flexShrink: 0 }}>
|
||
<Box
|
||
style={{
|
||
position: "relative",
|
||
height: 78,
|
||
borderRadius: 12,
|
||
background: wagon.isEmpty
|
||
? "var(--mantine-color-gray-0)"
|
||
: "linear-gradient(180deg, white, var(--mantine-color-gray-0))",
|
||
border: wagon.isEmpty
|
||
? "1.5px dashed var(--mantine-color-gray-4)"
|
||
: "1px solid var(--mantine-color-gray-3)",
|
||
boxShadow: wagon.isEmpty ? "none" : "0 3px 10px rgba(15,41,27,0.08)",
|
||
overflow: "hidden",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
}}
|
||
>
|
||
{/* top accent strip */}
|
||
<Box
|
||
style={{
|
||
height: 4,
|
||
background: wagon.isEmpty
|
||
? "var(--mantine-color-gray-3)"
|
||
: `linear-gradient(90deg, ${accentVar}, var(--mantine-color-${accent}-4))`,
|
||
}}
|
||
/>
|
||
{/* header */}
|
||
<Group justify="space-between" px={8} pt={4} wrap="nowrap">
|
||
<Text size="10px" fw={800} c="gray.7">
|
||
#{wagon.sequenceNo}
|
||
</Text>
|
||
{wagon.isEmpty ? (
|
||
<Text size="9px" c="dimmed" fw={700} style={{ letterSpacing: 0.5 }}>
|
||
EMPTY
|
||
</Text>
|
||
) : (
|
||
<Group gap={3} wrap="nowrap">
|
||
{wagon.isBulk ? <Fuel size={11} color={accentVar} /> : <ContainerIcon size={11} color={accentVar} />}
|
||
<Text size="9px" fw={700} c={`${accent}.7`} style={{ letterSpacing: 0.4 }}>
|
||
{wagon.isBulk ? "BULK" : "CONT"}
|
||
</Text>
|
||
</Group>
|
||
)}
|
||
</Group>
|
||
|
||
{/* body */}
|
||
<Box style={{ flex: 1, padding: "4px 8px 4px", display: "flex", alignItems: "center" }}>
|
||
{wagon.isEmpty ? (
|
||
<Group gap={4} justify="center" style={{ width: "100%" }}>
|
||
<BoxIcon size={14} color="var(--mantine-color-gray-4)" />
|
||
<Text size="9px" c="dimmed">
|
||
Available
|
||
</Text>
|
||
</Group>
|
||
) : wagon.isBulk ? (
|
||
<Stack gap={3} style={{ width: "100%" }}>
|
||
<Box
|
||
style={{
|
||
height: 18,
|
||
borderRadius: 6,
|
||
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))",
|
||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.35)",
|
||
}}
|
||
/>
|
||
</Box>
|
||
<Text size="9px" c="dimmed" ta="center" fw={600}>
|
||
{grossTons}/{maxGrossTons}T
|
||
</Text>
|
||
</Stack>
|
||
) : (
|
||
<Box
|
||
style={{
|
||
width: "100%",
|
||
display: "grid",
|
||
gridTemplateColumns: blocks.length > 1 ? "1fr 1fr" : "1fr",
|
||
gap: 3,
|
||
}}
|
||
>
|
||
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
|
||
<Box
|
||
key={i}
|
||
style={{
|
||
minWidth: 0,
|
||
height: twoRows ? 14 : 28,
|
||
borderRadius: twoRows ? 4 : 5,
|
||
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), 0 1px 2px rgba(0,0,0,0.15)",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: "0 3px",
|
||
}}
|
||
>
|
||
{/* corrugation lines — dropped in two-row mode, no room */}
|
||
{!twoRows ? (
|
||
<Box
|
||
style={{
|
||
width: "80%",
|
||
height: 2,
|
||
marginBottom: 2,
|
||
background:
|
||
"repeating-linear-gradient(90deg, rgba(255,255,255,0.4) 0 3px, transparent 3px 6px)",
|
||
borderRadius: 1,
|
||
}}
|
||
/>
|
||
) : null}
|
||
<Text size={twoRows ? "7px" : "8px"} fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||
{cn}
|
||
</Text>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
|
||
{/* utilization hairline */}
|
||
{!wagon.isEmpty && !wagon.isBulk ? (
|
||
<Box style={{ height: 3, background: "var(--mantine-color-gray-1)" }}>
|
||
<Box
|
||
style={{
|
||
width: `${utilization}%`,
|
||
height: "100%",
|
||
background:
|
||
utilization >= 100
|
||
? "var(--mantine-color-red-5)"
|
||
: `var(--mantine-color-${accent}-5)`,
|
||
}}
|
||
/>
|
||
</Box>
|
||
) : null}
|
||
|
||
{/* footer */}
|
||
<Box
|
||
style={{
|
||
borderTop: "1px solid var(--mantine-color-gray-1)",
|
||
padding: "2px 8px",
|
||
background: wagon.isEmpty ? "transparent" : "var(--mantine-color-gray-0)",
|
||
}}
|
||
>
|
||
<Group justify="space-between" wrap="nowrap" gap={4}>
|
||
<Text size="8px" c="dimmed" fw={600} truncate>
|
||
{wagon.physicalWagonNumber ?? wagon.wagonTypeCode ?? "Wagon"}
|
||
</Text>
|
||
{!wagon.isEmpty ? (
|
||
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
|
||
{grossTons}T
|
||
</Text>
|
||
) : null}
|
||
</Group>
|
||
</Box>
|
||
</Box>
|
||
<Wheels count={2} />
|
||
</Box>
|
||
</Tooltip>
|
||
);
|
||
});
|
||
|
||
/** Railway track: two rails over evenly-spaced sleepers. */
|
||
function TrackBed() {
|
||
return (
|
||
<Box
|
||
style={{
|
||
position: "absolute",
|
||
left: 4,
|
||
right: 4,
|
||
bottom: 4,
|
||
height: 10,
|
||
}}
|
||
>
|
||
{/* sleepers */}
|
||
<Box
|
||
style={{
|
||
position: "absolute",
|
||
inset: 0,
|
||
background:
|
||
"repeating-linear-gradient(90deg, var(--mantine-color-gray-4) 0 5px, transparent 5px 20px)",
|
||
opacity: 0.6,
|
||
borderRadius: 2,
|
||
}}
|
||
/>
|
||
{/* rails */}
|
||
<Box
|
||
style={{
|
||
position: "absolute",
|
||
left: 0,
|
||
right: 0,
|
||
top: 1,
|
||
height: 2,
|
||
borderRadius: 1,
|
||
background: "var(--mantine-color-gray-6)",
|
||
}}
|
||
/>
|
||
<Box
|
||
style={{
|
||
position: "absolute",
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 1,
|
||
height: 2,
|
||
borderRadius: 1,
|
||
background: "var(--mantine-color-gray-6)",
|
||
}}
|
||
/>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
export const TrainCompositionDiagram = memo(function TrainCompositionDiagram({
|
||
locomotive,
|
||
locomotives,
|
||
wagons,
|
||
freightType,
|
||
trainNumber,
|
||
totalLengthMeters,
|
||
}: {
|
||
locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null;
|
||
/** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */
|
||
locomotives?: Array<{
|
||
code?: string | null;
|
||
name?: string | null;
|
||
maxPullWeightTons?: number | null;
|
||
}> | null;
|
||
wagons: DiagramWagonInput[];
|
||
freightType?: string | null;
|
||
trainNumber?: string | null;
|
||
totalLengthMeters?: number | null;
|
||
}) {
|
||
const { ref, width } = useElementSize();
|
||
|
||
const locos = useMemo(
|
||
() => (locomotives?.length ? locomotives : locomotive ? [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(
|
||
() => merged.map((w) => normalizeWagon(w, freightType)),
|
||
[merged, freightType],
|
||
);
|
||
|
||
const stats = useMemo(() => {
|
||
const assigned = normalized.filter((w) => !w.isEmpty).length;
|
||
const totalWeight = normalized.reduce((s, w) => s + w.assignedWeightTons, 0);
|
||
const totalCapacity = normalized.reduce((s, w) => s + w.capacityTons, 0);
|
||
// Every coupled wagon's tare is hauled — empty ones included — so the
|
||
// locomotive pull limit is measured against gross (tare + cargo), the same
|
||
// ceiling the allocation engine spends from.
|
||
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
|
||
const grossWeight = totalWeight + totalTare;
|
||
// Engine rule (combinedLocomotiveLimits): coupled locomotives pull
|
||
// TOGETHER, so their pull limits SUM.
|
||
const pullLimits = locos
|
||
.map((l) => Number(l.maxPullWeightTons))
|
||
.filter((v) => Number.isFinite(v) && v > 0);
|
||
const pullLimit = pullLimits.length
|
||
? pullLimits.reduce((sum, v) => sum + v, 0)
|
||
: null;
|
||
return {
|
||
total: normalized.length,
|
||
assigned,
|
||
empty: normalized.length - assigned,
|
||
totalWeight: Math.round(totalWeight * 100) / 100,
|
||
totalTare: Math.round(totalTare * 100) / 100,
|
||
grossWeight: Math.round(grossWeight * 100) / 100,
|
||
totalCapacity,
|
||
pullLimit,
|
||
pullUtil: pullLimit
|
||
? Math.min(100, Math.round((grossWeight / pullLimit) * 100))
|
||
: null,
|
||
};
|
||
}, [normalized, locos]);
|
||
|
||
// cars-per-row from measured width; each locomotive counts as one car
|
||
const perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH));
|
||
const cars = useMemo(
|
||
() => [
|
||
...locos.map((l) => ({ kind: "loco" as const, l })),
|
||
...normalized.map((w) => ({ kind: "wagon" as const, w })),
|
||
],
|
||
[locos, normalized],
|
||
);
|
||
const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]);
|
||
|
||
if (!locos.length && !wagons.length) return null;
|
||
|
||
return (
|
||
<Paper
|
||
radius="lg"
|
||
p="lg"
|
||
withBorder
|
||
style={{
|
||
borderColor: "var(--mantine-color-gray-2)",
|
||
background: "white",
|
||
}}
|
||
>
|
||
<Stack gap="md">
|
||
{/* Header */}
|
||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||
<Group gap="sm" wrap="nowrap">
|
||
<Box
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 11,
|
||
background: freightBrand.gradient,
|
||
boxShadow: freightBrand.shadowSm,
|
||
color: "white",
|
||
}}
|
||
>
|
||
<TrainFront size={21} />
|
||
</Box>
|
||
<Stack gap={0}>
|
||
<Text fw={800} style={{ letterSpacing: 0.2 }}>
|
||
Train composition
|
||
</Text>
|
||
<Text size="xs" c="dimmed">
|
||
{trainNumber ? `${trainNumber} · ` : ""}
|
||
{stats.total} wagons · {stats.assigned} loaded · {stats.empty} empty
|
||
{totalLengthMeters ? ` · ${totalLengthMeters}m` : ""}
|
||
</Text>
|
||
</Stack>
|
||
</Group>
|
||
|
||
<Group gap="md">
|
||
<Group
|
||
gap={6}
|
||
style={{
|
||
padding: "4px 12px",
|
||
borderRadius: 999,
|
||
background: "var(--mantine-color-gray-0)",
|
||
border: "1px solid var(--mantine-color-gray-2)",
|
||
}}
|
||
>
|
||
<Text size="xs" fw={700} c="dark.4">
|
||
{stats.totalWeight}T cargo
|
||
</Text>
|
||
<Text size="xs" c="dimmed">
|
||
of {stats.totalCapacity}T capacity
|
||
</Text>
|
||
</Group>
|
||
{stats.totalTare > 0 ? (
|
||
<Group
|
||
gap={6}
|
||
style={{
|
||
padding: "4px 12px",
|
||
borderRadius: 999,
|
||
background: "var(--mantine-color-gray-0)",
|
||
border: "1px solid var(--mantine-color-gray-2)",
|
||
}}
|
||
>
|
||
<Text size="xs" fw={700} c="dark.4">
|
||
{stats.grossWeight}T gross
|
||
</Text>
|
||
<Text size="xs" c="dimmed">
|
||
incl. {stats.totalTare}T tare
|
||
</Text>
|
||
</Group>
|
||
) : null}
|
||
<Group gap="xs">
|
||
<LegendDot color="cyan" label="Container" />
|
||
<LegendDot color="orange" label="Bulk" />
|
||
<LegendDot color="gray" label="Empty" />
|
||
</Group>
|
||
</Group>
|
||
</Group>
|
||
|
||
{/* Locomotive pull gauge */}
|
||
{stats.pullUtil != null ? (
|
||
<Box
|
||
p="sm"
|
||
style={{
|
||
borderRadius: 12,
|
||
background: "linear-gradient(135deg, var(--mantine-color-edr-green-0), #F2FBF7)",
|
||
border: "1px solid var(--mantine-color-edr-green-1)",
|
||
}}
|
||
>
|
||
<Group justify="space-between" mb={6}>
|
||
<Group gap={6} wrap="nowrap">
|
||
<Gauge size={14} color={freightBrand.primary} />
|
||
<Text size="xs" fw={700} c="edr-green.8">
|
||
Locomotive load ·{" "}
|
||
{stats.totalTare > 0
|
||
? `${stats.grossWeight}T of ${stats.pullLimit}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
|
||
: `${stats.totalWeight}T of ${stats.pullLimit}T`}
|
||
</Text>
|
||
</Group>
|
||
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
|
||
{stats.pullUtil}%
|
||
</Text>
|
||
</Group>
|
||
<Progress
|
||
value={stats.pullUtil}
|
||
size="md"
|
||
radius="xl"
|
||
striped={stats.pullUtil > 95}
|
||
animated={stats.pullUtil > 95}
|
||
color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "edr-green"}
|
||
/>
|
||
</Box>
|
||
) : null}
|
||
|
||
{/* The train */}
|
||
<Box ref={ref} style={{ width: "100%" }}>
|
||
<Stack gap={0}>
|
||
{rows.map((row, rowIndex) => {
|
||
const reversed = rowIndex % 2 === 1;
|
||
const isLast = rowIndex === rows.length - 1;
|
||
// side where this row's track ends / turns down to the next row
|
||
const turnSide: "left" | "right" = rowIndex % 2 === 0 ? "right" : "left";
|
||
return (
|
||
<Box key={rowIndex}>
|
||
<Box style={{ position: "relative", paddingBottom: 10 }}>
|
||
<TrackBed />
|
||
<Group
|
||
gap={0}
|
||
wrap="nowrap"
|
||
justify="flex-start"
|
||
style={{
|
||
flexDirection: reversed ? "row-reverse" : "row",
|
||
position: "relative",
|
||
}}
|
||
>
|
||
{row.map((car, carIndex) => (
|
||
<Group key={carIndex} gap={0} wrap="nowrap" style={{ flexDirection: reversed ? "row-reverse" : "row" }}>
|
||
{carIndex > 0 ? <Coupler /> : null}
|
||
{car.kind === "loco" ? (
|
||
<LocomotiveCar
|
||
code={car.l.code ?? "LOCO"}
|
||
name={car.l.name}
|
||
maxPullWeightTons={car.l.maxPullWeightTons}
|
||
/>
|
||
) : (
|
||
<WagonCar wagon={car.w} />
|
||
)}
|
||
</Group>
|
||
))}
|
||
</Group>
|
||
</Box>
|
||
|
||
{/* serpentine turn connector to the next row */}
|
||
{!isLast ? (
|
||
<Box style={{ position: "relative", height: 16 }}>
|
||
<Box
|
||
style={{
|
||
position: "absolute",
|
||
top: -10,
|
||
height: 26,
|
||
width: 22,
|
||
borderBottom: "4px solid var(--mantine-color-gray-4)",
|
||
...(turnSide === "right"
|
||
? {
|
||
right: 4,
|
||
borderRight: "4px solid var(--mantine-color-gray-4)",
|
||
borderBottomRightRadius: 16,
|
||
}
|
||
: {
|
||
left: 4,
|
||
borderLeft: "4px solid var(--mantine-color-gray-4)",
|
||
borderBottomLeftRadius: 16,
|
||
}),
|
||
}}
|
||
/>
|
||
</Box>
|
||
) : null}
|
||
</Box>
|
||
);
|
||
})}
|
||
</Stack>
|
||
</Box>
|
||
</Stack>
|
||
</Paper>
|
||
);
|
||
});
|
||
|
||
function LegendDot({ color, label }: { color: string; label: string }) {
|
||
return (
|
||
<Group gap={5} wrap="nowrap">
|
||
<Box
|
||
style={{
|
||
width: 10,
|
||
height: 10,
|
||
borderRadius: 3,
|
||
background:
|
||
color === "gray"
|
||
? "var(--mantine-color-gray-2)"
|
||
: `var(--mantine-color-${color}-5)`,
|
||
border: color === "gray" ? "1.5px dashed var(--mantine-color-gray-4)" : "none",
|
||
}}
|
||
/>
|
||
<Text size="xs" c="dimmed">
|
||
{label}
|
||
</Text>
|
||
</Group>
|
||
);
|
||
}
|