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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-08 12:09:40 +03:00
committed by GitHub
8 changed files with 895 additions and 30 deletions

View File

@@ -0,0 +1,404 @@
import { useMemo } from "react";
import {
Alert,
Badge,
Box,
Group,
Paper,
Progress,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
Crown,
Container,
Boxes,
FlaskConical,
Layers,
Ruler,
Scale,
Sparkles,
TrainFront,
Trophy,
XCircle,
} from "lucide-react";
import type { BatchBoardScheduleDetail } from "@/types/trainScheduling";
import {
simulateBatch,
limitsFromDetail,
type BlockingAxis,
type ForecastRow,
} from "./batchForecast";
type Props = {
data: BatchBoardScheduleDetail;
bookings: BatchBoardScheduleDetail["pendingContract"]["bookings"];
};
const cardVar = (color: string, shade: number) =>
`var(--mantine-color-${color}-${shade})`;
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtMeters = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
const AXIS_LABEL: Record<BlockingAxis, string> = {
wagons: "wagon slots full",
weight: "over max pull weight",
length: "over train length",
};
/** One capacity axis as a labelled meter (used vs cap). */
function AxisMeter({
icon: Icon,
label,
used,
cap,
fmt,
color,
}: {
icon: typeof Scale;
label: string;
used: number;
cap: number | null;
fmt: (n: number) => string;
color: string;
}) {
const pct = cap && cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
const near = pct >= 90;
return (
<Box style={{ flex: 1, minWidth: 150 }}>
<Group justify="space-between" mb={4} wrap="nowrap">
<Group gap={5} wrap="nowrap">
<Icon size={13} color={cardVar(color, 6)} />
<Text size="xs" c="dimmed" fw={600}>
{label}
</Text>
</Group>
<Text size="xs" fw={700} c={near ? `${color}.8` : "dark.4"}>
{fmt(used)}
{cap != null ? ` / ${fmt(cap)}` : ""}
</Text>
</Group>
<Progress
value={pct}
size="md"
radius="xl"
color={near ? color : "edr-green"}
/>
</Box>
);
}
function FreightIcon({ type }: { type: string | null }) {
const Icon = type === "BULK" ? Boxes : Container;
return (
<Tooltip label={type === "BULK" ? "Bulk" : "Container"} withArrow>
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
</Tooltip>
);
}
/** A single forecast row: rank, booking, capacity contribution, projected verdict. */
function ForecastCard({ row }: { row: ForecastRow }) {
const { booking, rank, selected, blockedBy } = row;
const gov = booking.isGovernment;
return (
<Paper
radius="md"
p="sm"
withBorder
style={{
borderColor: selected
? cardVar("edr-green", 3)
: cardVar("gray", 2),
background: selected
? `linear-gradient(90deg, ${cardVar("edr-green", 0)} 0%, var(--mantine-color-white) 55%)`
: "var(--mantine-color-white)",
opacity: selected ? 1 : 0.92,
}}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0 }}>
<ThemeIcon
size={32}
radius="xl"
variant={selected && rank <= 3 ? "filled" : "light"}
color={gov ? "grape" : selected ? "edr-green" : "gray"}
style={{ flexShrink: 0, fontWeight: 800 }}
>
{gov ? (
<Crown size={15} />
) : (
<Text fw={800} size="sm">
{rank}
</Text>
)}
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fw={700} size="sm" truncate>
{booking.reference}
</Text>
<FreightIcon type={booking.freightType} />
{gov ? (
<Tooltip label="Government — boards first" withArrow>
<ThemeIcon size="xs" radius="sm" variant="light" color="grape">
<Crown size={10} />
</ThemeIcon>
</Tooltip>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{booking.company}
</Text>
</Stack>
</Group>
<Group wrap="nowrap" gap="lg" style={{ flexShrink: 0 }}>
{/* score */}
<Group gap={4} wrap="nowrap" w={70} justify="flex-end">
<Trophy size={12} color={cardVar("edr-green", 6)} />
<Text fw={800} size="sm" c="edr-green.7">
{booking.priorityScore}
</Text>
</Group>
{/* wagons + weight this booking adds */}
<Group gap={4} wrap="nowrap" w={64} justify="flex-end">
<TrainFront size={13} color={cardVar("gray", 6)} />
<Text fw={700} size="sm">
{booking.wagons}w
</Text>
</Group>
<Text size="xs" c="dimmed" w={64} ta="right">
{fmtTons(booking.weightTons)}
</Text>
{/* verdict */}
<Box w={150} style={{ textAlign: "right" }}>
{selected ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<Sparkles size={11} />}
>
Would board
</Badge>
) : (
<Tooltip
label={
blockedBy
? `Doesn't fit — ${AXIS_LABEL[blockedBy]}`
: "Below the capacity line"
}
withArrow
>
<Badge variant="light" color="gray" radius="sm">
Waiting list
</Badge>
</Tooltip>
)}
</Box>
</Group>
</Group>
</Paper>
);
}
/** Cut line between the simulated batch and the simulated waiting list. */
function CutLine({ full }: { full: boolean }) {
return (
<Group gap="xs" my={2} wrap="nowrap">
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
<Group gap={6} wrap="nowrap">
<ThemeIcon size="sm" radius="xl" variant="light" color="orange">
<Layers size={12} />
</ThemeIcon>
<Text size="xs" fw={700} c="orange.7">
Forecast capacity line{full ? " · TRAIN FULL" : ""}
</Text>
</Group>
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
</Group>
);
}
/**
* Forecast / "what-if" panel. Simulates the batch engine's greedy fill on the
* current pool and shows the projected winners + waiting list BEFORE document
* review closes. Not the real selection — the engine commits that when staff run
* the batch after the review window ends.
*/
export function ForecastPanel({ data, bookings }: Props) {
const limits = useMemo(() => limitsFromDetail(data), [data]);
const sim = useMemo(
() => simulateBatch(bookings, limits),
[bookings, limits],
);
const noCaps =
limits.maxWagons == null &&
limits.maxWeightTons == null &&
limits.maxLengthMeters == null;
return (
<Stack gap="lg">
{/* Header + explainer */}
<Paper radius="lg" withBorder p="lg">
<Group justify="space-between" wrap="wrap" gap="md" mb="md">
<Group gap="sm">
<ThemeIcon variant="light" color="violet" radius="md" size="lg">
<FlaskConical size={18} />
</ThemeIcon>
<Stack gap={2}>
<Group gap={8}>
<Text fw={700}>Forecast batch (simulated)</Text>
<Badge variant="light" color="violet" radius="sm" size="sm">
Preview
</Badge>
</Group>
<Text size="xs" c="dimmed" maw={520}>
What the batch engine would pick if it ran now greedy fill by
priority until the train is full. The real selection happens when
document review ends and staff run the batch.
</Text>
</Stack>
</Group>
<Group gap="lg">
<Stack gap={0} align="flex-end">
<Text size="xl" fw={800} c="edr-green.7">
{sim.selected.length}
</Text>
<Text size="xs" c="dimmed">
would board
</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xl" fw={800} c="gray.7">
{sim.waiting.length}
</Text>
<Text size="xs" c="dimmed">
waiting list
</Text>
</Stack>
</Group>
</Group>
{/* Three capacity axes */}
<Group gap="lg" align="flex-end" wrap="wrap">
<AxisMeter
icon={TrainFront}
label="Wagon slots"
used={sim.usedWagons}
cap={limits.maxWagons}
fmt={(n) => `${n}`}
color="edr-green"
/>
<AxisMeter
icon={Scale}
label="Max pull weight"
used={sim.usedWeightTons}
cap={limits.maxWeightTons}
fmt={fmtTons}
color="orange"
/>
<AxisMeter
icon={Ruler}
label="Train length"
used={sim.usedLengthMeters}
cap={limits.maxLengthMeters}
fmt={fmtMeters}
color="blue"
/>
</Group>
{noCaps ? (
<Alert
color="yellow"
mt="md"
radius="md"
icon={<XCircle size={16} />}
>
No locomotive / capacity limits on this schedule yet forecast can't
draw the capacity line. Assign a locomotive to simulate the fill.
</Alert>
) : null}
</Paper>
{sim.rows.length === 0 ? (
<Paper radius="lg" withBorder p="xl">
<Text c="dimmed" ta="center">
No eligible bookings to forecast yet.
</Text>
</Paper>
) : (
<Stack gap={6}>
{/* WOULD BOARD */}
{sim.selected.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon
size="sm"
radius="sm"
variant="light"
color="edr-green"
>
<Sparkles size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
Projected batch{" "}
<Text span c="dimmed" fw={500}>
({sim.selected.length}) top priority, fits capacity
</Text>
</Text>
</Group>
{sim.selected.map((r) => (
<ForecastCard key={r.booking.id} row={r} />
))}
</Stack>
) : null}
<CutLine full={sim.full} />
{/* WAITING LIST */}
{sim.waiting.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
<Layers size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
Projected waiting list{" "}
<Text span c="dimmed" fw={500}>
({sim.waiting.length}) boards only if a slot frees up
</Text>
</Text>
</Group>
{sim.waiting.map((r) => (
<ForecastCard key={r.booking.id} row={r} />
))}
</Stack>
) : null}
{/* INELIGIBLE (expired / pending contract) */}
{sim.ineligible.length > 0 ? (
<Text size="xs" c="dimmed" mt={4}>
{sim.ineligible.length} booking
{sim.ineligible.length === 1 ? "" : "s"} not in the forecast
(expired or contract not signed).
</Text>
) : null}
</Stack>
)}
</Stack>
);
}
export default ForecastPanel;

View File

@@ -1,9 +1,10 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import {
Box,
Group,
Paper,
Progress,
SegmentedControl,
Stack,
Text,
ThemeIcon,
@@ -15,9 +16,11 @@ import {
Clock,
Container,
Crown,
FlaskConical,
Hourglass,
Layers,
ListOrdered,
Radio,
TrainFront,
Trophy,
XCircle,
@@ -30,6 +33,8 @@ import type {
BatchBoardScheduleDetail,
} from "@/types/trainScheduling";
import { WindowPhasePill } from "./batchVisuals";
import { ForecastPanel } from "./ForecastPanel";
import { forecastIsLive } from "./batchForecast";
/**
* Priority Tracking tab — live, glanceable ranking of every booking on this
@@ -266,6 +271,15 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
const phase = data.windowPhase;
const isPayPhase = phase === "PAYMENT";
// Before the batch is committed (pre-window / open / doc-review) the real
// selection doesn't exist yet — offer a simulated forecast of who WOULD board.
// Default to it while it's live; let staff flip to the current live state.
const forecastAvailable = forecastIsLive(phase);
const [view, setView] = useState<"forecast" | "live">(
forecastAvailable ? "forecast" : "live",
);
const showForecast = forecastAvailable && view === "forecast";
// Rank exactly as the batch engine does: government first, then priority score
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
// backend uses). The board already returns them in this order, but re-sort
@@ -320,8 +334,51 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
let rankNo = 0;
const viewToggle = forecastAvailable ? (
<SegmentedControl
value={view}
onChange={(v) => setView(v as "forecast" | "live")}
size="sm"
radius="md"
data={[
{
value: "forecast",
label: (
<Group gap={6} wrap="nowrap">
<FlaskConical size={13} />
<Text size="xs" fw={600}>
Forecast
</Text>
</Group>
),
},
{
value: "live",
label: (
<Group gap={6} wrap="nowrap">
<Radio size={13} />
<Text size="xs" fw={600}>
Live state
</Text>
</Group>
),
},
]}
/>
) : null;
if (showForecast) {
return (
<Stack gap="lg">
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
<ForecastPanel data={data} bookings={ranked} />
</Stack>
);
}
return (
<Stack gap="lg">
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
{/* Header: phase + capacity meter */}
<Paper radius="lg" withBorder p="lg">
<Group justify="space-between" wrap="wrap" gap="md">

View File

@@ -0,0 +1,189 @@
import type {
BatchBoardBookingDetail,
BatchBoardScheduleDetail,
} from "@/types/trainScheduling";
/**
* Client-side forecast of what the batch engine WOULD select if it ran right now.
*
* The real selection only happens once the document-review window closes and staff
* hit "run batch". Before that, operations can only see the *current* per-booking
* state (READY / SELECTED / …). This module simulates the engine's greedy fill so
* the board can show the likely winners + waiting list live, during OPEN and
* DOC_REVIEW, before anything is committed.
*
* It mirrors the engine (booking-batch.service): rank government-first, then
* priority score desc, then oldest booked; greedily board each booking while it
* fits ALL THREE capacity axes at once — wagon slots, max pull weight (tons), and
* train length (metres). The first booking that busts any axis, and everyone after
* it, drops to the waiting list. Purely a projection; the server stays the source
* of truth for the real run.
*/
export interface ForecastLimits {
/** Wagon-slot cap (schedule.maxWagons), or null if unknown. */
maxWagons: number | null;
/** Locomotive max pull weight in tons, or null. */
maxWeightTons: number | null;
/** Max train length in metres, or null. */
maxLengthMeters: number | null;
}
/** Which capacity axis stopped a booking from boarding (for the "why not" hint). */
export type BlockingAxis = "wagons" | "weight" | "length";
export interface ForecastRow {
booking: BatchBoardBookingDetail;
/** 1-based rank across the whole eligible pool. */
rank: number;
/** True → boards in the simulated batch; false → simulated waiting list. */
selected: boolean;
/** Cumulative wagons/weight/length AFTER this booking (only when selected). */
cumulativeWagons: number;
cumulativeWeightTons: number;
cumulativeLengthMeters: number;
/** If not selected, the first axis that would have overflowed. */
blockedBy: BlockingAxis | null;
}
export interface ForecastResult {
rows: ForecastRow[];
selected: ForecastRow[];
waiting: ForecastRow[];
/** Bookings excluded from the sim entirely (expired / no signed contract). */
ineligible: BatchBoardBookingDetail[];
limits: ForecastLimits;
/** Totals of the simulated batch. */
usedWagons: number;
usedWeightTons: number;
usedLengthMeters: number;
/** True once any axis is at/over its cap — train is "full" in the sim. */
full: boolean;
}
/** Engine rank order: government first, then priority desc, then oldest booked. */
export function rankBookings(
bookings: BatchBoardBookingDetail[],
): BatchBoardBookingDetail[] {
const time = (b: BatchBoardBookingDetail) =>
b.fullyExecutedAt
? new Date(b.fullyExecutedAt).getTime()
: Number.MAX_SAFE_INTEGER;
return [...bookings].sort((a, b) => {
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
if (b.priorityScore !== a.priorityScore)
return b.priorityScore - a.priorityScore;
return time(a) - time(b);
});
}
/**
* A booking can compete in the batch only once its contract is signed. Expired
* bookings and pending-contract bookings never board, so they're pulled out of the
* sim (surfaced separately so they don't vanish from the board).
*/
function isEligible(b: BatchBoardBookingDetail): boolean {
return b.state !== "EXPIRED" && b.state !== "PENDING_CONTRACT";
}
const round2 = (n: number) => Math.round(n * 100) / 100;
/** Would adding `add` to `used` exceed `cap`? (cap null ⇒ axis unconstrained.) */
function overflows(used: number, add: number, cap: number | null): boolean {
return cap != null && used + add > cap;
}
export function simulateBatch(
bookings: BatchBoardBookingDetail[],
limits: ForecastLimits,
): ForecastResult {
const ranked = rankBookings(bookings);
const eligible = ranked.filter(isEligible);
const ineligible = ranked.filter((b) => !isEligible(b));
const rows: ForecastRow[] = [];
let wagons = 0;
let weight = 0;
let length = 0;
// Once the train is full we stop boarding, but keep ranking the rest as waiting.
let full = false;
eligible.forEach((booking, i) => {
let blockedBy: BlockingAxis | null = null;
if (!full) {
if (overflows(wagons, booking.wagons, limits.maxWagons))
blockedBy = "wagons";
else if (overflows(weight, booking.weightTons, limits.maxWeightTons))
blockedBy = "weight";
else if (overflows(length, booking.lengthMeters, limits.maxLengthMeters))
blockedBy = "length";
}
// Strict fill: the first booking that doesn't fit closes the train, so lower-
// priority bookings can't leapfrog it even if they'd individually fit. Matches
// the engine's greedy pass.
const selected = !full && blockedBy === null;
if (selected) {
wagons += booking.wagons;
weight = round2(weight + booking.weightTons);
length = round2(length + booking.lengthMeters);
} else {
full = true;
}
rows.push({
booking,
rank: i + 1,
selected,
cumulativeWagons: selected ? wagons : 0,
cumulativeWeightTons: selected ? weight : 0,
cumulativeLengthMeters: selected ? length : 0,
blockedBy: selected ? null : (blockedBy ?? firstBindingAxis(limits)),
});
});
return {
rows,
selected: rows.filter((r) => r.selected),
waiting: rows.filter((r) => !r.selected),
ineligible,
limits,
usedWagons: wagons,
usedWeightTons: weight,
usedLengthMeters: length,
full,
};
}
/** When the train closed on an earlier booking, name the tightest axis for the hint. */
function firstBindingAxis(limits: ForecastLimits): BlockingAxis {
if (limits.maxWagons != null) return "wagons";
if (limits.maxWeightTons != null) return "weight";
return "length";
}
/** Pull the three capacity caps off the board detail response. */
export function limitsFromDetail(
data: BatchBoardScheduleDetail,
): ForecastLimits {
return {
maxWagons: data.capacity.maxWagons ?? null,
maxWeightTons:
data.capacity.maxWeightTons ??
data.locomotive?.maxPullWeightTons ??
null,
maxLengthMeters:
data.capacity.maxLengthMeters ??
data.locomotive?.maxTrainLengthMeters ??
null,
};
}
/**
* The forecast is meaningful before the batch is committed — i.e. while bookings
* are still being taken or reviewed. Once the engine has run (PAYMENT onward) the
* real per-booking state is the truth, so we stop showing the projection.
*/
export function forecastIsLive(
phase: BatchBoardScheduleDetail["windowPhase"],
): boolean {
return phase === "PRE_WINDOW" || phase === "OPEN" || phase === "DOC_REVIEW";
}