mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
- Introduced ContractCourtBadge to display the responsible party for contract actions. - Updated ContractStatusBadge to include new court badge. - Enhanced ClearanceDocumentsPage with additional filters for trade direction, freight type, and ownership. - Modified ContractRequestDetailPage and ContractRequestsPage to utilize ContractCourtBadge.
623 lines
20 KiB
TypeScript
623 lines
20 KiB
TypeScript
import { memo, 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, 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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<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 booking window (earlier cycles board
|
|
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
|
|
{maxWagons != null ? ` · ${maxWagons} max` : ""}
|
|
</Text>
|
|
</Group>
|
|
{/* 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. */}
|
|
<Progress.Root size="lg" radius="xl">
|
|
<Progress.Section
|
|
value={
|
|
(maxWagons ?? capUsed) > 0
|
|
? Math.min(
|
|
100,
|
|
(data.capacity.allocatedWagons / (maxWagons ?? capUsed)) * 100,
|
|
)
|
|
: 0
|
|
}
|
|
color="edr-green"
|
|
/>
|
|
<Progress.Section
|
|
value={
|
|
(maxWagons ?? capUsed) > 0
|
|
? Math.min(
|
|
100,
|
|
((capUsed - data.capacity.allocatedWagons) /
|
|
(maxWagons ?? 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;
|