mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
intercity fix
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
|
||||
/**
|
||||
* Per-leg capacity workspace tab. A multi-stop corridor (A→B→C→D→E) is
|
||||
* capacity-checked edge by edge, so this shows, for EVERY adjacent leg, the
|
||||
* wagons/weight/length the consist actually uses there — and for every
|
||||
* possible origin→destination pair (A→C, B→E, …) the room left, which is the
|
||||
* minimum over the legs the pair rides.
|
||||
*/
|
||||
|
||||
interface Stop {
|
||||
yardId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface EdgeUsage {
|
||||
edge: number;
|
||||
from: Stop;
|
||||
to: Stop;
|
||||
wagons: number;
|
||||
grossTons: number;
|
||||
lengthMeters: number;
|
||||
bookingRefs: string[];
|
||||
}
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
function utilizationColor(used: number, cap: number | null): string {
|
||||
if (cap == null || cap <= 0) return "gray";
|
||||
const pct = used / cap;
|
||||
if (pct > 1) return "red";
|
||||
if (pct >= 0.9) return "orange";
|
||||
if (pct >= 0.75) return "yellow";
|
||||
return "teal";
|
||||
}
|
||||
|
||||
/** Mirrors the API's slotSpans: unknown/missing yard = the schedule endpoint. */
|
||||
function spanOf(
|
||||
boardYardId: string | null | undefined,
|
||||
alightYardId: string | null | undefined,
|
||||
indexOf: Map<string, number>,
|
||||
lastIdx: number,
|
||||
): { from: number; to: number } {
|
||||
const fromRaw = boardYardId ? indexOf.get(boardYardId) : 0;
|
||||
const toRaw = alightYardId ? indexOf.get(alightYardId) : lastIdx;
|
||||
const from = fromRaw != null && fromRaw >= 0 ? fromRaw : 0;
|
||||
const to = toRaw != null && toRaw > 0 ? toRaw : lastIdx;
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
function UsageCell({
|
||||
used,
|
||||
cap,
|
||||
unit,
|
||||
}: {
|
||||
used: number;
|
||||
cap: number | null;
|
||||
unit: string;
|
||||
}) {
|
||||
const color = utilizationColor(used, cap);
|
||||
const pct = cap ? Math.min(100, (used / cap) * 100) : 0;
|
||||
return (
|
||||
<Stack gap={4} miw={140}>
|
||||
<Text size="sm" fw={600} c={cap != null && used > cap ? "red.7" : undefined}>
|
||||
{round1(used)}
|
||||
{cap != null ? ` / ${round1(cap)}` : ""} {unit}
|
||||
</Text>
|
||||
{cap != null ? <Progress value={pct} color={color} size="sm" radius="xl" /> : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }) {
|
||||
const stops: Stop[] = schedule.stops ?? [];
|
||||
const wagons = schedule.trainSet?.wagons ?? [];
|
||||
const weightCap = schedule.maxGrossWeightTons ?? null;
|
||||
const lengthCap = schedule.maxLengthMeters ?? null;
|
||||
const wagonCap = schedule.maxWagons ?? null;
|
||||
|
||||
const edges: EdgeUsage[] = useMemo(() => {
|
||||
if (stops.length < 2) return [];
|
||||
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
|
||||
const lastIdx = stops.length - 1;
|
||||
const spans = wagons.map((w) =>
|
||||
spanOf(w.boardYardId, w.alightYardId, indexOf, lastIdx),
|
||||
);
|
||||
return stops.slice(0, -1).map((from, edge) => {
|
||||
const active = wagons.filter(
|
||||
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
||||
);
|
||||
const refs = new Set<string>();
|
||||
let grossTons = 0;
|
||||
let lengthMeters = 0;
|
||||
for (const w of active) {
|
||||
grossTons += (Number(w.tareWeightTons) || 0) + (Number(w.assignedWeightTons) || 0);
|
||||
lengthMeters += Number(w.lengthMeters) || 0;
|
||||
for (const a of w.allocations ?? []) {
|
||||
if (a.bookingReference) refs.add(a.bookingReference);
|
||||
}
|
||||
}
|
||||
return {
|
||||
edge,
|
||||
from,
|
||||
to: stops[edge + 1],
|
||||
wagons: active.length,
|
||||
grossTons: round1(grossTons),
|
||||
lengthMeters: round1(lengthMeters),
|
||||
bookingRefs: [...refs],
|
||||
};
|
||||
});
|
||||
}, [stops, wagons]);
|
||||
|
||||
if (stops.length < 2) {
|
||||
return (
|
||||
<Alert mt="lg" radius="lg" color="gray" icon={<Info size={16} />}>
|
||||
This schedule has no corridor stops to break into legs.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!wagons.length) {
|
||||
return (
|
||||
<Alert mt="lg" radius="lg" color="gray" icon={<Info size={16} />}>
|
||||
No wagon plan yet — leg utilization appears once bookings are allocated
|
||||
to wagons. The route strip in the header shows the booking-based
|
||||
estimate meanwhile.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const legStatus = (e: EdgeUsage) => {
|
||||
if (weightCap != null && e.grossTons > weightCap)
|
||||
return <Badge color="red" variant="filled">Overweight</Badge>;
|
||||
if (lengthCap != null && e.lengthMeters > lengthCap)
|
||||
return <Badge color="red" variant="filled">Over length</Badge>;
|
||||
const wagonsFree = wagonCap != null ? wagonCap - e.wagons : null;
|
||||
const tonsFree = weightCap != null ? round1(weightCap - e.grossTons) : null;
|
||||
if ((wagonsFree != null && wagonsFree <= 0) || (tonsFree != null && tonsFree <= 0))
|
||||
return <Badge color="orange" variant="filled">Full</Badge>;
|
||||
return (
|
||||
<Badge color="teal" variant="light">
|
||||
{tonsFree != null ? `${tonsFree}T free` : "Available"}
|
||||
{wagonsFree != null ? ` · ${wagonsFree} wagons` : ""}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// Availability for a span = the tightest leg it rides.
|
||||
const spanAvailability = (from: number, to: number) => {
|
||||
const slice = edges.slice(from, to);
|
||||
const wagonsUsed = Math.max(...slice.map((e) => e.wagons));
|
||||
const tonsUsed = Math.max(...slice.map((e) => e.grossTons));
|
||||
const binding = slice.reduce((worst, e) => (e.grossTons > worst.grossTons ? e : worst));
|
||||
return {
|
||||
wagonsFree: wagonCap != null ? wagonCap - wagonsUsed : null,
|
||||
tonsFree: weightCap != null ? round1(weightCap - tonsUsed) : null,
|
||||
tonsUsed,
|
||||
binding,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg" mt="lg">
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Text fw={700}>Per-leg utilization</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Each adjacent leg is checked as its own train — wagon tare + cargo
|
||||
against the locomotive limits{weightCap != null ? ` (${weightCap}T` : ""}
|
||||
{weightCap != null && lengthCap != null ? `, ${lengthCap}m` : ""}
|
||||
{weightCap != null ? " incl. tolerance)" : ""}.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table verticalSpacing="sm" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Leg</Table.Th>
|
||||
<Table.Th>Wagons</Table.Th>
|
||||
<Table.Th>Gross weight</Table.Th>
|
||||
<Table.Th>Length</Table.Th>
|
||||
<Table.Th>Bookings</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{edges.map((e) => (
|
||||
<Table.Tr key={e.edge}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
{e.from.label} → {e.to.label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.wagons} cap={wagonCap} unit="wagons" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.grossTons} cap={weightCap} unit="T" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.lengthMeters} cap={lengthCap} unit="m" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{e.bookingRefs.length ? (
|
||||
<Tooltip
|
||||
label={e.bookingRefs.join(", ")}
|
||||
multiline
|
||||
maw={320}
|
||||
withArrow
|
||||
>
|
||||
<Text size="sm" style={{ cursor: "help" }}>
|
||||
{e.bookingRefs.length}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
0
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{legStatus(e)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{stops.length > 2 ? (
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Text fw={700}>Availability by origin → destination</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Every bookable pair along the corridor. Room for a pair is the
|
||||
tightest leg it rides — hover a cell to see which leg binds.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Table.ScrollContainer minWidth={520}>
|
||||
<Table verticalSpacing="xs" withColumnBorders>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>From \ To</Table.Th>
|
||||
{stops.slice(1).map((s) => (
|
||||
<Table.Th key={s.yardId}>{s.label}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{stops.slice(0, -1).map((from, fi) => (
|
||||
<Table.Tr key={from.yardId}>
|
||||
<Table.Th>{from.label}</Table.Th>
|
||||
{stops.slice(1).map((to, ci) => {
|
||||
const ti = ci + 1;
|
||||
if (ti <= fi) {
|
||||
return (
|
||||
<Table.Td key={to.yardId}>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
—
|
||||
</Text>
|
||||
</Table.Td>
|
||||
);
|
||||
}
|
||||
const avail = spanAvailability(fi, ti);
|
||||
const over =
|
||||
weightCap != null && avail.tonsUsed > weightCap;
|
||||
const full =
|
||||
!over &&
|
||||
((avail.wagonsFree != null && avail.wagonsFree <= 0) ||
|
||||
(avail.tonsFree != null && avail.tonsFree <= 0));
|
||||
const color = over
|
||||
? "var(--mantine-color-red-1)"
|
||||
: full
|
||||
? "var(--mantine-color-orange-1)"
|
||||
: "var(--mantine-color-teal-0)";
|
||||
return (
|
||||
<Table.Td key={to.yardId} style={{ background: color }}>
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={`Binding leg: ${avail.binding.from.label} → ${avail.binding.to.label} (${avail.binding.grossTons}T${weightCap != null ? ` of ${weightCap}T` : ""})`}
|
||||
>
|
||||
<Box ta="center" style={{ cursor: "help" }}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
c={over ? "red.8" : full ? "orange.8" : "teal.8"}
|
||||
>
|
||||
{over
|
||||
? "Overweight"
|
||||
: full
|
||||
? "Full"
|
||||
: `${avail.tonsFree ?? "?"}T free`}
|
||||
</Text>
|
||||
{avail.wagonsFree != null && !over ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.max(0, avail.wagonsFree)} wagons free
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
);
|
||||
})}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user