mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
enhance train scheduling and booking management
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box, Center, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { FileText, FolderOpen } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
interface LabeledFile {
|
||||
label: string;
|
||||
file: { id: string; name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every document tied to a booking, in one tab: the customer/GL clearance
|
||||
* documents, the customs workflow files (declaration/duty/transit/Djibouti),
|
||||
* the duty-tax notice, and the final invoice + payment slip. All fetched from
|
||||
* the booking's clearance view (the only endpoint that surfaces booking files),
|
||||
* each with inline view + download.
|
||||
*/
|
||||
export function BookingDocumentsPanel({ bookingId }: { bookingId: string }) {
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: clearance, isLoading, isError } = useQuery({
|
||||
queryKey: ["clearance", bookingId],
|
||||
queryFn: () => bookingsService.getClearance(bookingId),
|
||||
});
|
||||
|
||||
const onDownload = (f: { id: string; name: string }) =>
|
||||
void downloadBookingFile(f.id, f.name);
|
||||
|
||||
// Uploaded customer + GL clearance documents (skip the not-yet-uploaded slots).
|
||||
const clearanceDocs = useMemo<
|
||||
Array<{ doc: Freight.ClearanceDocument; file: { id: string; name: string } }>
|
||||
>(
|
||||
() =>
|
||||
(clearance?.documents ?? [])
|
||||
.filter((d) => d.file)
|
||||
.map((d) => ({ doc: d, file: d.file! })),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
const workflowFiles = useMemo(
|
||||
() => (clearance?.workflowFiles ?? []).filter((f) => f.file),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
// Duty notice + final invoice + payment slip — loose files that don't ride in
|
||||
// the documents/workflow arrays.
|
||||
const otherFiles = useMemo<LabeledFile[]>(() => {
|
||||
const rows: LabeledFile[] = [];
|
||||
const notice = clearance?.dutyAdvice?.noticeFile;
|
||||
if (notice) rows.push({ label: "Duty & tax notice", file: notice });
|
||||
const inv = clearance?.finalInvoice;
|
||||
if (inv?.invoiceFile)
|
||||
rows.push({ label: `Final invoice · ${inv.invoiceNumber}`, file: inv.invoiceFile });
|
||||
if (inv?.slipFile)
|
||||
rows.push({ label: "Final invoice payment slip", file: inv.slipFile });
|
||||
return rows;
|
||||
}, [clearance]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py={60}>
|
||||
<Group gap={10}>
|
||||
<Loader color="edr-green" />
|
||||
<Text c="dimmed">Loading documents…</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAny =
|
||||
clearanceDocs.length > 0 || workflowFiles.length > 0 || otherFiles.length > 0;
|
||||
|
||||
if (isError || !hasAny) {
|
||||
return (
|
||||
<SectionCard icon={FolderOpen} title="Documents" accent="edr-green">
|
||||
<Center py={28}>
|
||||
<Stack align="center" gap={6}>
|
||||
<FolderOpen size={26} color="var(--mantine-color-gray-5)" />
|
||||
<Text fw={600}>No documents yet</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={360}>
|
||||
{isError
|
||||
? "Couldn’t load this booking’s documents."
|
||||
: "Documents attached to this booking will appear here as they’re uploaded."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{clearanceDocs.length > 0 && (
|
||||
<SectionCard icon={FileText} title="Clearance documents" accent="edr-green">
|
||||
<Stack gap={8}>
|
||||
{clearanceDocs.map(({ doc, file }) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={doc.fileKey}
|
||||
label={`${doc.label}${doc.uploadedBy === "gl" ? " · GL" : ""}`}
|
||||
file={file}
|
||||
onView={view}
|
||||
onDownload={onDownload}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{workflowFiles.length > 0 && (
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={workflowFiles}
|
||||
onView={view}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{otherFiles.length > 0 && (
|
||||
<SectionCard icon={FileText} title="Invoices & notices" accent="edr-green">
|
||||
<Stack gap={8}>
|
||||
{otherFiles.map((row) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={row.file.id}
|
||||
label={row.label}
|
||||
file={row.file}
|
||||
onView={view}
|
||||
onDownload={onDownload}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<Box>{viewer}</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./booking-detail.styles";
|
||||
export * from "./SectionCard";
|
||||
export * from "./ClearanceReviewSection";
|
||||
export * from "./BookingDocumentsPanel";
|
||||
export * from "./ContractOrdersPanel";
|
||||
export * from "./MetricTile";
|
||||
export * from "./BookingDetailToolbar";
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Box,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Boxes,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Container,
|
||||
Crown,
|
||||
Hourglass,
|
||||
Layers,
|
||||
ListOrdered,
|
||||
TrainFront,
|
||||
Trophy,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
|
||||
import type {
|
||||
BatchBoardBookingDetail,
|
||||
BatchBoardBookingState,
|
||||
BatchBoardScheduleDetail,
|
||||
} from "@/types/trainScheduling";
|
||||
import { WindowPhasePill } from "./batchVisuals";
|
||||
|
||||
/**
|
||||
* 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";
|
||||
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* 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;
|
||||
@@ -130,6 +130,13 @@ export function useBookingWindowSocket(enabled: boolean = true) {
|
||||
},
|
||||
);
|
||||
|
||||
// Refresh the batch-board DETAIL for the schedule that transitioned so the
|
||||
// Priority Tracking tab reranks + updates its countdowns immediately (the
|
||||
// detail is a different shape from the list — invalidate, don't patch).
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(event.scheduleId),
|
||||
});
|
||||
|
||||
// Always refresh the batch board (different shape, not patched). When the
|
||||
// schedule wasn't in any window list either, refresh those too so a newly
|
||||
// announced window surfaces. Both debounced — no per-push stampede.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FolderOpen,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
BookingContractSummaryCard,
|
||||
BookingContainerUnitsCard,
|
||||
ClearanceReviewSection,
|
||||
BookingDocumentsPanel,
|
||||
ContractOrdersPanel,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
@@ -142,14 +144,17 @@ export default function BookingRequestDetailPage() {
|
||||
// A general contract drives an "Orders" tab: each drawdown order spawns a
|
||||
// child booking that staff manage (clearance/approval) independently.
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
const showTabs = showClearanceTab || isGeneralContract;
|
||||
// The Documents tab is always available — every booking can accrue clearance,
|
||||
// customs-workflow, invoice or notice files — so the tab bar always renders.
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const activeTab =
|
||||
requestedTab === "clearance" && showClearanceTab
|
||||
? "clearance"
|
||||
: requestedTab === "orders" && isGeneralContract
|
||||
? "orders"
|
||||
: "overview";
|
||||
: requestedTab === "documents"
|
||||
? "documents"
|
||||
: "overview";
|
||||
const setActiveTab = (tab: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (tab && tab !== "overview") next.set("tab", tab);
|
||||
@@ -187,10 +192,10 @@ export default function BookingRequestDetailPage() {
|
||||
)}
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content, split into tabs to keep each view focused */}
|
||||
{/* LEFT — primary content, split into tabs to keep each view focused.
|
||||
The Documents tab is always present, so the tab bar always renders. */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{showTabs ? (
|
||||
<Tabs
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
variant="pills"
|
||||
@@ -217,6 +222,12 @@ export default function BookingRequestDetailPage() {
|
||||
Customer clearance
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<FolderOpen size={16} />}
|
||||
>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
@@ -238,10 +249,10 @@ export default function BookingRequestDetailPage() {
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
<Tabs.Panel value="documents">
|
||||
<BookingDocumentsPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
<OverviewPanel booking={booking} row={row} />
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky action / summary rail */}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
TrainFront,
|
||||
Trophy,
|
||||
Weight,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
@@ -55,6 +56,8 @@ import {
|
||||
WindowStatusPill,
|
||||
} from "@/components/trainScheduling/batchVisuals";
|
||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { PriorityTrackingTab } from "@/components/trainScheduling/PriorityTrackingTab";
|
||||
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
||||
import { BookingsManager } from "./BookingsManager";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
@@ -578,9 +581,23 @@ export default function BatchScheduleDetailPage() {
|
||||
api.trainScheduling.batchBoardDetail.queryOptions({
|
||||
input: { scheduleId: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
refetchInterval: 30_000,
|
||||
// Poll fast while a window cycle is actively moving (open / doc-review /
|
||||
// payment) so the priority ranking + pay countdowns stay live; back off to
|
||||
// 30s once the cycle is idle (pre-window / closed / done).
|
||||
refetchInterval: (query) => {
|
||||
const phase = (query.state.data as BatchBoardScheduleDetail | undefined)
|
||||
?.windowPhase;
|
||||
return phase === "OPEN" ||
|
||||
phase === "DOC_REVIEW" ||
|
||||
phase === "PAYMENT"
|
||||
? 5_000
|
||||
: 30_000;
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Keep the board in sync with server-pushed window-phase transitions too
|
||||
// (invalidates the batch-board list + patches window carousels).
|
||||
useBookingWindowSocket(Boolean(scheduleId));
|
||||
const runAllocation = useMutation(
|
||||
api.trainScheduling.runAllocation.mutationOptions(),
|
||||
);
|
||||
@@ -735,6 +752,13 @@ export default function BatchScheduleDetailPage() {
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="priority"
|
||||
leftSection={<Trophy size={14} />}
|
||||
>
|
||||
Priority Tracking{" "}
|
||||
{allBookings.length > 0 && `(${allBookings.length})`}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="composition">
|
||||
Train Composition{" "}
|
||||
{scheduleDetailQuery.data?.trainSet?.wagons &&
|
||||
@@ -1095,6 +1119,10 @@ export default function BatchScheduleDetailPage() {
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="priority" pt="lg">
|
||||
<PriorityTrackingTab data={data} bookings={allBookings} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="composition" pt="lg">
|
||||
{scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? (
|
||||
<Group align="stretch" gap="md" wrap="nowrap">
|
||||
|
||||
@@ -225,6 +225,10 @@ export interface BatchBoardBooking {
|
||||
lengthMeters: number;
|
||||
paymentDeadline: string | null;
|
||||
state: BatchBoardBookingState;
|
||||
/** Rule-engine priority score used to rank the batch (higher = boards first). */
|
||||
priorityScore: number;
|
||||
/** CONTAINER | BULK — for the priority-tracking visuals. */
|
||||
freightType: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user