import { memo, useMemo, useState, type ReactNode } 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, rankBookings } 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 window cycle — bookings compete only within their own cycle — 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 (
);
}
/** 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 (
{/* Rank medallion */}
{booking.isGovernment ? (
) : (
{rank}
)}
{booking.reference}
{booking.isGovernment ? (
) : null}
{booking.company}
{/* Priority score with a mini strength bar */}
{booking.priorityScore}
{/* Wagons */}
{booking.wagons}w
{/* State chip / pay countdown */}
{selected && isPayPhase && booking.paymentDeadline ? (
) : (
{style.label}
)}
{/* phase hint only used for the a11y title; keeps `phase` referenced */}
{phase}
);
}
/**
* 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 {
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 (
{group.key === "gov" ? : }
{group.label}
{group.sub ? (
— {group.sub}
) : null}
{group.items.length} booking{group.items.length === 1 ? "" : "s"} ·{" "}
{wagons}w
{children}
);
}
/** 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 (
Capacity line{max != null ? ` · ${used}/${max} wagons` : ` · ${used} wagons`}
{full ? " · FULL" : ""}
);
}
// Memoized: mounted in a keep-mounted Tabs panel, so it re-renders with every
// page render; both props keep their identity across unrelated page state
// (React Query structural sharing + the page's useMemo'd bookings).
export const PriorityTrackingTab = memo(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 window cycle
// (bookings only compete within the cycle they arrived in — an earlier cycle
// boards before a later one regardless of score), then priority desc, then
// oldest. Shared with the forecast sim so both views agree.
const ranked = useMemo(() => rankBookings(bookings), [bookings]);
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
// Wagon-slot cap from the board DTO (derived from train length and the
// shortest wagon type); null on legacy rows without a computable cap.
const maxWagons: number | null = data.capacity.maxWagons ?? 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 (
No bookings on this schedule yet.
);
}
let rankNo = 0;
const viewToggle = forecastAvailable ? (
setView(v as "forecast" | "live")}
size="sm"
radius="md"
data={[
{
value: "forecast",
label: (
Forecast
),
},
{
value: "live",
label: (
Live state
),
},
]}
/>
) : null;
if (showForecast) {
return (
{viewToggle ? {viewToggle} : null}
);
}
return (
{viewToggle ? {viewToggle} : null}
{/* Header: phase + capacity meter */}
Priority ranking
Government first, then booking window (earlier cycles board
first), then rule-engine score, then earliest booked.
{phase ? (
) : null}
{isPayPhase && data.paymentPhaseEndsAt ? (
) : phase === "DOC_REVIEW" && data.docReviewEndsAt ? (
) : phase === "OPEN" && data.windowClosesAt ? (
) : null}
{/* Capacity meter */}
Wagon capacity used
{data.capacity.allocatedWagons} allocated ·{" "}
{capUsed} in batch
{maxWagons != null ? ` · ${maxWagons} max` : ""}
{/* Scale against the real wagon cap when the DTO carries one; fall back
to the in-batch total on legacy rows without a computable cap. */}
0
? Math.min(
100,
(data.capacity.allocatedWagons / (maxWagons ?? capUsed)) * 100,
)
: 0
}
color="edr-green"
/>
0
? Math.min(
100,
((capUsed - data.capacity.allocatedWagons) /
(maxWagons ?? capUsed)) *
100,
)
: 0
}
color="orange"
/>
{/* Phase banner explaining what's happening now */}
{/* IN THE BATCH (green winners) — ranked */}
{lanes.inBatch.length > 0 ? (
In the batch{" "}
({lanes.inBatch.length})
{groupByCycle(lanes.inBatch).map((g) => (
{g.items.map((b) => {
rankNo += 1;
return (
);
})}
))}
) : null}
{/* WAITING LIST — ranked, below the line */}
{lanes.waiting.length > 0 ? (
Waiting list{" "}
({lanes.waiting.length}) — next in line if a slot frees up
{groupByCycle(lanes.waiting).map((g) => (
{g.items.map((b) => {
rankNo += 1;
return (
);
})}
))}
) : null}
{/* EXPIRED */}
{lanes.expired.length > 0 ? (
Expired{" "}
({lanes.expired.length}) — missed the payment window
{lanes.expired.map((b) => (
))}
) : null}
);
});
/** Contextual banner describing the current window phase in plain language. */
function PhaseBanner({ phase }: { phase: string | null }) {
const meta: Record = {
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 (
{m.text}
);
}
export default PriorityTrackingTab;