Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx
2026-07-08 09:08:53 +00:00

615 lines
19 KiB
TypeScript

import { useMemo, useState } from "react";
import {
Box,
Group,
Paper,
Progress,
SegmentedControl,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
Boxes,
CheckCircle2,
Clock,
Container,
Crown,
FlaskConical,
Hourglass,
Layers,
ListOrdered,
Radio,
TrainFront,
Trophy,
XCircle,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
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
* schedule in the exact order the batch engine boards them (government first,
* then rule-engine priority score, then oldest). Bookings above the train's
* wagon-capacity line render as "selected" (green), below it as the waiting
* list; during the PAYMENT phase selected bookings show a live pay-window
* countdown. Purely presentational — data comes from the batch-board detail
* response the page already polls (+ socket-invalidates).
*/
type Props = {
data: BatchBoardScheduleDetail;
bookings: BatchBoardBookingDetail[];
};
const STATE_STYLE: Record<
BatchBoardBookingState,
{ label: string; color: string; icon: typeof CheckCircle2 }
> = {
ALLOCATED: { label: "Allocated", color: "edr-green", icon: CheckCircle2 },
SELECTED_FOR_BATCH: { label: "Selected · pay now", color: "orange", icon: Clock },
READY: { label: "Ready", color: "teal", icon: Hourglass },
WAITING: { label: "Paid · waiting slot", color: "blue", icon: Hourglass },
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
};
/** States that occupy a wagon slot on this train (i.e. are "in" the batch). */
const OCCUPIES_SLOT: BatchBoardBookingState[] = [
"ALLOCATED",
"SELECTED_FOR_BATCH",
"WAITING",
];
const cardVar = (color: string, shade: number) =>
`var(--mantine-color-${color}-${shade})`;
/** Highest score across the ranked pool → used to scale the priority mini-bar. */
function maxScore(bookings: BatchBoardBookingDetail[]): number {
return bookings.reduce((m, b) => Math.max(m, b.priorityScore ?? 0), 0);
}
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>
);
}
/** One ranked booking row rendered as a card, colored by its batch state. */
function RankedCard({
rank,
booking,
scoreMax,
phase,
isPayPhase,
}: {
rank: number;
booking: BatchBoardBookingDetail;
scoreMax: number;
phase: string | null;
isPayPhase: boolean;
}) {
const style = STATE_STYLE[booking.state];
const Icon = style.icon;
const selected = booking.state === "SELECTED_FOR_BATCH";
const allocated = booking.state === "ALLOCATED";
const expired = booking.state === "EXPIRED";
// Green surface for the winners (allocated + selected); muted for the rest.
const surfaceColor = allocated
? "edr-green"
: selected
? "edr-green"
: expired
? "red"
: "gray";
const scorePct =
scoreMax > 0 ? Math.max(4, Math.round((booking.priorityScore / scoreMax) * 100)) : 0;
return (
<Paper
radius="md"
p="sm"
withBorder
style={{
borderColor: cardVar(surfaceColor, allocated || selected ? 4 : 2),
background:
allocated || selected
? `linear-gradient(90deg, ${cardVar("edr-green", 0)} 0%, var(--mantine-color-white) 60%)`
: expired
? cardVar("red", 0)
: "var(--mantine-color-white)",
opacity: expired ? 0.72 : 1,
transition: "background 200ms ease, border-color 200ms ease",
}}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0 }}>
{/* Rank medallion */}
<ThemeIcon
size={34}
radius="xl"
variant={rank <= 3 ? "filled" : "light"}
color={
booking.isGovernment
? "grape"
: rank <= 3
? "edr-green"
: "gray"
}
style={{ flexShrink: 0, fontWeight: 800 }}
>
{booking.isGovernment ? (
<Crown size={16} />
) : (
<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} />
{booking.isGovernment ? (
<Tooltip label="Government — boards first" withArrow>
<ThemeIcon size="xs" radius="sm" variant="light" color="grape">
<Crown size={11} />
</ThemeIcon>
</Tooltip>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{booking.company}
</Text>
</Stack>
</Group>
<Group wrap="nowrap" gap="lg" style={{ flexShrink: 0 }}>
{/* Priority score with a mini strength bar */}
<Tooltip
label={`Priority score ${booking.priorityScore}${booking.isGovernment ? " + government bonus" : ""}`}
withArrow
>
<Stack gap={2} align="flex-end" w={92}>
<Group gap={4} wrap="nowrap">
<Trophy size={12} color={cardVar("edr-green", 6)} />
<Text fw={800} size="sm" c="edr-green.7">
{booking.priorityScore}
</Text>
</Group>
<Progress
value={scorePct}
size="xs"
color="edr-green"
w={92}
radius="xl"
/>
</Stack>
</Tooltip>
{/* Wagons */}
<Group gap={4} wrap="nowrap" w={58} justify="flex-end">
<TrainFront size={13} color={cardVar("gray", 6)} />
<Text fw={700} size="sm">
{booking.wagons}w
</Text>
</Group>
{/* State chip / pay countdown */}
<Box w={168} style={{ textAlign: "right" }}>
{selected && isPayPhase && booking.paymentDeadline ? (
<CountdownTimer
deadline={booking.paymentDeadline}
label="Pay in"
expiredText="Window closed"
size="sm"
/>
) : (
<Group gap={5} justify="flex-end" wrap="nowrap">
<ThemeIcon
size="sm"
radius="sm"
variant="light"
color={style.color}
>
<Icon size={12} />
</ThemeIcon>
<Text size="xs" fw={600} c={`${style.color}.7`}>
{style.label}
</Text>
</Group>
)}
</Box>
</Group>
</Group>
{/* phase hint only used for the a11y title; keeps `phase` referenced */}
<span hidden aria-hidden>
{phase}
</span>
</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;
return (
<Group gap="xs" my={4} 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">
Capacity line{max != null ? ` · ${used}/${max} wagons` : ` · ${used} wagons`}
{full ? " · FULL" : ""}
</Text>
</Group>
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
</Group>
);
}
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
// defensively so the tab is correct even if the source order ever changes.
const ranked = useMemo(() => {
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);
});
}, [bookings]);
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
// maxWagons is not on the board DTO (capacity is length/weight-based), so the
// capacity line shows the wagons currently committed rather than a hard cap.
const maxWagons: number | null = null;
// Split the ranking at the capacity line: cumulative wagons of slot-occupying
// bookings (allocated + selected + paid-waiting) up to the train's wagon cap.
const capUsed = useMemo(
() =>
ranked
.filter((b) => OCCUPIES_SLOT.includes(b.state))
.reduce((sum, b) => sum + b.wagons, 0),
[ranked],
);
// Group for the lane layout.
const lanes = useMemo(() => {
const inBatch = ranked.filter((b) => OCCUPIES_SLOT.includes(b.state));
const waiting = ranked.filter(
(b) => b.state === "READY" || b.state === "PENDING_CONTRACT",
);
const expired = ranked.filter((b) => b.state === "EXPIRED");
return { inBatch, waiting, expired };
}, [ranked]);
if (ranked.length === 0) {
return (
<Paper radius="lg" withBorder p="xl">
<Group justify="center" gap="sm">
<ThemeIcon variant="light" color="gray" radius="xl" size="lg">
<ListOrdered size={18} />
</ThemeIcon>
<Text c="dimmed">No bookings on this schedule yet.</Text>
</Group>
</Paper>
);
}
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">
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
<Trophy size={18} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={700}>Priority ranking</Text>
<Text size="xs" c="dimmed">
Government first, then rule-engine score, then earliest booked.
</Text>
</Stack>
</Group>
<Group gap="md">
{phase ? (
<WindowPhasePill phase={phase} cycleNo={data.bookingCycleNo} />
) : null}
{isPayPhase && data.paymentPhaseEndsAt ? (
<CountdownTimer
deadline={data.paymentPhaseEndsAt}
label="Payment window"
expiredText="Window closed"
size="md"
/>
) : phase === "DOC_REVIEW" && data.docReviewEndsAt ? (
<CountdownTimer
deadline={data.docReviewEndsAt}
label="Doc review ends"
expiredText="Review over"
size="md"
/>
) : phase === "OPEN" && data.windowClosesAt ? (
<CountdownTimer
deadline={data.windowClosesAt}
label="Booking closes"
expiredText="Closed"
size="md"
/>
) : null}
</Group>
</Group>
{/* Capacity meter */}
<Box mt="md">
<Group justify="space-between" mb={4}>
<Text size="xs" c="dimmed" fw={600}>
Wagon capacity used
</Text>
<Text size="xs" fw={700}>
{data.capacity.allocatedWagons} allocated ·{" "}
{capUsed} in batch
</Text>
</Group>
<Progress.Root size="lg" radius="xl">
<Progress.Section
value={
capUsed > 0
? Math.min(100, (data.capacity.allocatedWagons / capUsed) * 100)
: 0
}
color="edr-green"
/>
<Progress.Section
value={
capUsed > 0
? Math.min(
100,
((capUsed - data.capacity.allocatedWagons) / capUsed) * 100,
)
: 0
}
color="orange"
/>
</Progress.Root>
</Box>
</Paper>
{/* Phase banner explaining what's happening now */}
<PhaseBanner phase={phase} />
{/* IN THE BATCH (green winners) — ranked */}
{lanes.inBatch.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon size="sm" radius="sm" variant="light" color="edr-green">
<CheckCircle2 size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
In the batch{" "}
<Text span c="dimmed" fw={500}>
({lanes.inBatch.length})
</Text>
</Text>
</Group>
{lanes.inBatch.map((b) => {
rankNo += 1;
return (
<RankedCard
key={b.id}
rank={rankNo}
booking={b}
scoreMax={scoreMax}
phase={phase}
isPayPhase={isPayPhase}
/>
);
})}
</Stack>
) : null}
<CapacityDivider used={capUsed} max={maxWagons} />
{/* WAITING LIST — ranked, below the line */}
{lanes.waiting.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon size="sm" radius="sm" variant="light" color="blue">
<Hourglass size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
Waiting list{" "}
<Text span c="dimmed" fw={500}>
({lanes.waiting.length}) next in line if a slot frees up
</Text>
</Text>
</Group>
{lanes.waiting.map((b) => {
rankNo += 1;
return (
<RankedCard
key={b.id}
rank={rankNo}
booking={b}
scoreMax={scoreMax}
phase={phase}
isPayPhase={isPayPhase}
/>
);
})}
</Stack>
) : null}
{/* EXPIRED */}
{lanes.expired.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon size="sm" radius="sm" variant="light" color="red">
<XCircle size={13} />
</ThemeIcon>
<Text fw={700} size="sm" c="red.7">
Expired{" "}
<Text span c="dimmed" fw={500}>
({lanes.expired.length}) missed the payment window
</Text>
</Text>
</Group>
{lanes.expired.map((b) => (
<RankedCard
key={b.id}
rank={0}
booking={b}
scoreMax={scoreMax}
phase={phase}
isPayPhase={isPayPhase}
/>
))}
</Stack>
) : null}
</Stack>
);
}
/** Contextual banner describing the current window phase in plain language. */
function PhaseBanner({ phase }: { phase: string | null }) {
const meta: Record<string, { color: string; text: string; icon: typeof Clock }> = {
OPEN: {
color: "edr-green",
icon: Clock,
text: "Booking window OPEN — new bookings are ranked live as they arrive and get accepted.",
},
DOC_REVIEW: {
color: "yellow",
icon: Hourglass,
text: "Document review — staff accept/reject; un-accepted bookings expire when review ends, then the batch runs.",
},
PAYMENT: {
color: "blue",
icon: Clock,
text: "Payment window — selected bookings must pay before their countdown ends; unpaid slots pass to the waiting list.",
},
PRE_WINDOW: {
color: "gray",
icon: Hourglass,
text: "Window not open yet — bookings are pre-ranked and will compete when it opens.",
},
CLOSED_FOR_DAY: {
color: "gray",
icon: Hourglass,
text: "Window closed for the day — reopens for the next cycle if the train isn't full.",
},
DONE: {
color: "gray",
icon: CheckCircle2,
text: "Booking cycles finished for this train.",
},
};
const m = phase ? meta[phase] : null;
if (!m) return null;
const Icon = m.icon;
return (
<Paper
radius="md"
p="sm"
withBorder
style={{
background: cardVar(m.color, 0),
borderColor: cardVar(m.color, 2),
}}
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color={m.color} radius="md">
<Icon size={16} />
</ThemeIcon>
<Text size="sm" fw={500} c={`${m.color}.8`}>
{m.text}
</Text>
</Group>
</Paper>
);
}
export default PriorityTrackingTab;