leg-aware capacity and wagon sharing

This commit is contained in:
Marshal
2026-07-30 17:21:44 +00:00
parent 8f25fb3e8b
commit acef6870e9
15 changed files with 653 additions and 119 deletions

View File

@@ -739,6 +739,7 @@ export function AllocateBookingWizard({
{displayWagonPlan.length || assignedSchedule?.trainSet?.wagons?.length ? (
<TrainCompositionDiagram
locomotive={assignedSchedule?.trainSet?.locomotive}
locomotives={assignedSchedule?.trainSet?.locomotives}
wagons={
assignedSchedule?.trainSet?.wagons?.length
? assignedSchedule.trainSet.wagons

View File

@@ -1,4 +1,4 @@
import { memo, useMemo, useState } from "react";
import { memo, useMemo, useState, type ReactNode } from "react";
import {
Box,
Group,
@@ -248,6 +248,96 @@ function RankedCard({
);
}
/**
* Consecutive-run grouping of an already-ranked lane by boarding class:
* government first, then each booking-window cycle (windowCycleNo is 0-based,
* so cycle 0 renders as "1st cycle window"). rankBookings sorts gov → cycle
* asc, so consecutive runs are exactly the cycle groups.
*/
type CycleGroup = {
key: string;
color: string;
label: string;
sub: string | null;
items: BatchBoardBookingDetail[];
};
const CYCLE_COLORS = ["indigo", "cyan", "teal"];
const ordinal = (n: number) =>
n === 1 ? "1st" : n === 2 ? "2nd" : n === 3 ? "3rd" : `${n}th`;
function groupMeta(b: BatchBoardBookingDetail): Omit<CycleGroup, "items"> {
if (b.isGovernment)
return { key: "gov", color: "grape", label: "Government", sub: "boards first" };
if (b.windowCycleNo == null)
return {
key: "none",
color: "gray",
label: "No cycle yet",
sub: "contract not signed",
};
const n = b.windowCycleNo + 1;
return {
key: `c${b.windowCycleNo}`,
color: CYCLE_COLORS[b.windowCycleNo % CYCLE_COLORS.length],
label: `${ordinal(n)} cycle window`,
sub: n === 1 ? "booked in the first window" : "boards after earlier cycles",
};
}
function groupByCycle(items: BatchBoardBookingDetail[]): CycleGroup[] {
const groups: CycleGroup[] = [];
for (const b of items) {
const meta = groupMeta(b);
const last = groups[groups.length - 1];
if (last && last.key === meta.key) last.items.push(b);
else groups.push({ ...meta, items: [b] });
}
return groups;
}
/** Tinted wrapper card holding one cycle's ranked bookings. */
function CycleSection({
group,
children,
}: {
group: CycleGroup;
children: ReactNode;
}) {
const wagons = group.items.reduce((s, b) => s + b.wagons, 0);
return (
<Paper
radius="md"
p="sm"
withBorder
style={{
borderColor: cardVar(group.color, 2),
background: cardVar(group.color, 0),
}}
>
<Group gap={8} mb={8} wrap="nowrap">
<ThemeIcon size="sm" radius="xl" variant="light" color={group.color}>
{group.key === "gov" ? <Crown size={12} /> : <Layers size={12} />}
</ThemeIcon>
<Text size="xs" fw={700} c={`${group.color}.8`}>
{group.label}
</Text>
{group.sub ? (
<Text size="xs" c="dimmed">
{group.sub}
</Text>
) : null}
<Text size="xs" fw={600} c="dimmed" ml="auto" style={{ flexShrink: 0 }}>
{group.items.length} booking{group.items.length === 1 ? "" : "s"} ·{" "}
{wagons}w
</Text>
</Group>
<Stack gap={6}>{children}</Stack>
</Paper>
);
}
/** The capacity cut line drawn between "in the batch" and "waiting list". */
function CapacityDivider({ used, max }: { used: number; max: number | null }) {
const full = max != null && used >= max;
@@ -482,19 +572,23 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
</Text>
</Text>
</Group>
{lanes.inBatch.map((b) => {
rankNo += 1;
return (
<RankedCard
key={b.id}
rank={rankNo}
booking={b}
scoreMax={scoreMax}
phase={phase}
isPayPhase={isPayPhase}
/>
);
})}
{groupByCycle(lanes.inBatch).map((g) => (
<CycleSection key={g.key} group={g}>
{g.items.map((b) => {
rankNo += 1;
return (
<RankedCard
key={b.id}
rank={rankNo}
booking={b}
scoreMax={scoreMax}
phase={phase}
isPayPhase={isPayPhase}
/>
);
})}
</CycleSection>
))}
</Stack>
) : null}
@@ -514,19 +608,23 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
</Text>
</Text>
</Group>
{lanes.waiting.map((b) => {
rankNo += 1;
return (
<RankedCard
key={b.id}
rank={rankNo}
booking={b}
scoreMax={scoreMax}
phase={phase}
isPayPhase={isPayPhase}
/>
);
})}
{groupByCycle(lanes.waiting).map((g) => (
<CycleSection key={g.key} group={g}>
{g.items.map((b) => {
rankNo += 1;
return (
<RankedCard
key={b.id}
rank={rankNo}
booking={b}
scoreMax={scoreMax}
phase={phase}
isPayPhase={isPayPhase}
/>
);
})}
</CycleSection>
))}
</Stack>
) : null}

View File

@@ -288,8 +288,11 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : ""
}`;
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
const blocks = wagon.containerNumbers.slice(0, 2);
// container blocks: one per container number, up to 4 — cross-leg TEU
// sharing can put two 20ft pairs (riding different legs) on one wagon.
// 12 sit side by side; 34 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" }}>
@@ -376,15 +379,21 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
</Text>
</Stack>
) : (
<Group gap={4} justify="center" wrap="nowrap" style={{ width: "100%" }}>
<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={{
flex: 1,
minWidth: 0,
height: 28,
borderRadius: 5,
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)",
@@ -395,23 +404,25 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
padding: "0 3px",
}}
>
{/* corrugation lines */}
<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,
}}
/>
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
{/* 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>
))}
</Group>
</Box>
)}
</Box>

View File

@@ -26,6 +26,8 @@ type DragState = { sourceWagonId: string } | null;
interface InteractiveTrainConsistProps {
wagons: Wagon[];
locomotive: Locomotive | null | undefined;
/** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */
locomotives?: NonNullable<TrainScheduleDetail["trainSet"]>["locomotives"] | null;
/** Resolve the customer/company name for a booking id (joined from schedule bookings). */
getCompany: (bookingId: string | undefined) => string | null;
selectedWagonId: string | null;
@@ -557,6 +559,7 @@ function WagonCar({
export const InteractiveTrainConsist = ({
wagons,
locomotive,
locomotives,
getCompany,
selectedWagonId,
onSelectWagon,
@@ -565,6 +568,7 @@ export const InteractiveTrainConsist = ({
onMoveLoad,
}: InteractiveTrainConsistProps) => {
const [drag, setDrag] = useState<DragState>(null);
const locos = locomotives?.length ? locomotives : locomotive ? [locomotive] : [];
return (
<Box
style={{
@@ -589,7 +593,12 @@ export const InteractiveTrainConsist = ({
) : null}
<Group gap={0} wrap="nowrap" align="flex-start" style={{ minWidth: "min-content" }}>
{locomotive ? <LocomotiveCar locomotive={locomotive} /> : null}
{locos.map((loco, i) => (
<Group key={loco.code ?? i} gap={0} wrap="nowrap" align="flex-start">
{i > 0 ? <Coupler /> : null}
<LocomotiveCar locomotive={loco} />
</Group>
))}
{wagons.length === 0 ? (
<Text size="sm" c="dimmed" pl="md" pt="lg">
No wagons assigned
@@ -599,7 +608,7 @@ export const InteractiveTrainConsist = ({
const bookingId = wagon.allocations?.[0]?.bookingId;
return (
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-start">
{i > 0 || locomotive ? <Coupler /> : null}
{i > 0 || locos.length ? <Coupler /> : null}
<WagonCar
wagon={wagon}
company={getCompany(bookingId)}

View File

@@ -135,13 +135,27 @@ export const TrainConsistView = ({
const weightUsed = cargoUsed + tareUsed;
const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0);
// Weakest locomotive caps the set — same rule the allocation engine applies.
const locos = trainSet?.locomotives?.length
? trainSet.locomotives
: trainSet?.locomotive
? [trainSet.locomotive]
: [];
const weightMax = locos.length
? Math.min(...locos.map((l) => l.maxPullWeightTons))
: null;
const lengthCaps = locos
.map((l) => l.maxTrainLengthMeters)
.filter((v): v is number => v != null);
const lengthMax = lengthCaps.length ? Math.min(...lengthCaps) : null;
return (
<Stack gap="md" style={{ width: "100%" }}>
<TrainStatsBar
weightUsed={weightUsed}
weightMax={trainSet?.locomotive?.maxPullWeightTons ?? null}
weightMax={weightMax}
lengthUsed={lengthUsed}
lengthMax={trainSet?.locomotive?.maxTrainLengthMeters ?? null}
lengthMax={lengthMax}
wagonCount={loadedCount}
wagonMax={maxWagons}
/>
@@ -202,6 +216,7 @@ export const TrainConsistView = ({
<InteractiveTrainConsist
wagons={wagons}
locomotive={trainSet?.locomotive}
locomotives={trainSet?.locomotives}
getCompany={(bookingId) => (bookingId ? companyByBooking.get(bookingId) ?? null : null)}
selectedWagonId={selectedWagonId}
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}