add detail batch and allocation monitoring page

This commit is contained in:
Marshal
2026-06-13 18:28:39 +00:00
parent e3cd369b01
commit b73bf2154e
30 changed files with 3336 additions and 72 deletions

View File

@@ -1,13 +1,15 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Alert,
Box,
Button,
Card,
Container,
Group,
Paper,
RingProgress,
Select,
SimpleGrid,
Skeleton,
Stack,
@@ -17,17 +19,21 @@ import {
import {
AlertTriangle,
ArrowRight,
CalendarClock,
CalendarDays,
Inbox,
Package,
RefreshCw,
Ruler,
Train,
TrainFront,
Weight,
} from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import {
BookingPipeline,
HeroChip,
@@ -35,6 +41,7 @@ import {
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
import type { BatchBoardSchedule } from "@/types/trainScheduling";
@@ -58,6 +65,27 @@ const fmtScheduleDate = (iso: string | null) =>
}).format(new Date(iso)) + " EAT"
: "No date";
const splitDate = (iso: string | null) => {
if (!iso) return { day: "—", time: "" };
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return { day: "—", time: "" };
return {
day: new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "Africa/Addis_Ababa",
}).format(date),
time:
new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(date) + " EAT",
};
};
/** Capacity ring color: gold normally, red once over capacity. */
function ringColor(pct: number) {
if (pct >= 100) return "#fa5252";
@@ -109,18 +137,56 @@ function CapacityRing({
);
}
/** Small percent chip used in the table's capacity column. */
function CapacityChip({
icon: Icon,
pct,
text,
}: {
icon: typeof Weight;
pct: number | null;
text: string;
}) {
const over = pct != null && pct >= 100;
return (
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 8,
background: over ? "var(--mantine-color-red-0)" : "var(--mantine-color-gray-1)",
border: `1px solid ${over ? "var(--mantine-color-red-2)" : "var(--mantine-color-gray-2)"}`,
}}
>
<Icon size={12} color={over ? "var(--mantine-color-red-6)" : "var(--mantine-color-gray-6)"} />
<Text size="xs" fw={700} c={over ? "red.7" : "gray.7"} lh={1.2}>
{pct != null ? `${Math.round(pct)}%` : "—"}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
{text}
</Text>
</Group>
);
}
function weightPctOf(s: BatchBoardSchedule) {
return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0
? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100
: null;
}
function lengthPctOf(s: BatchBoardSchedule) {
return s.capacity.maxLengthMeters && s.capacity.maxLengthMeters > 0
? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100
: null;
}
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
const navigate = useNavigate();
const { capacity, counts, locomotive } = schedule;
const lengthPct =
capacity.maxLengthMeters && capacity.maxLengthMeters > 0
? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100
: null;
const weightPct =
capacity.maxWeightTons && capacity.maxWeightTons > 0
? (capacity.usedWeightTons / capacity.maxWeightTons) * 100
: null;
const lengthPct = lengthPctOf(schedule);
const weightPct = weightPctOf(schedule);
const totalBookings = totalBookingCount(counts);
@@ -298,7 +364,13 @@ function CardSkeleton() {
}
export default function BatchBoardPage() {
const navigate = useNavigate();
const { data, isLoading, isFetching, refetch } = useBatchBoard();
const { viewMode, setViewMode } = useFleetViewMode("batch-board");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [windowFilter, setWindowFilter] = useState("ALL");
const schedules = data ?? [];
const summary = useMemo(() => {
@@ -308,22 +380,199 @@ export default function BatchBoardPage() {
return { openWindows, totalBookings, totalWagons };
}, [schedules]);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return schedules.filter((s) => {
if (windowFilter !== "ALL" && s.bookingWindowStatus !== windowFilter) return false;
if (!query) return true;
const haystack = [
s.trainNumber,
s.routeName,
s.origin,
s.destination,
s.locomotive?.code,
s.status,
s.bookingWindowStatus,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}, [schedules, search, windowFilter]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
const paged = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filtered.slice(start, start + pagination.pageSize);
}, [filtered, pagination]);
const columns = useMemo((): ColumnDef<BatchBoardSchedule>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "train",
header: "Train / Route",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={34} radius="md" variant="light" color="#F2A516">
<Train size={17} />
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={700} lh={1.2} truncate>
{row.original.trainNumber ?? row.original.routeName ?? "Schedule"}
</Text>
<Box maw={220}>
<RouteCorridor
origin={row.original.origin}
destination={row.original.destination}
variant="compact"
/>
</Box>
</Stack>
</Group>
),
},
{
id: "date",
header: "Departure",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const { day, time } = splitDate(row.original.scheduleDate);
return (
<Group gap="sm" wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 9,
background: "var(--mantine-color-orange-0)",
color: "#B26C09",
flexShrink: 0,
}}
>
<CalendarClock size={16} />
</Box>
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{day}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{time || "—"}
</Text>
</Stack>
</Group>
);
},
},
{
id: "window",
header: "Window",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <WindowStatusPill status={row.original.bookingWindowStatus} />,
},
{
id: "loco",
header: "Locomotive",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
row.original.locomotive ? (
<Group gap={6} wrap="nowrap">
<TrainFront size={14} color="var(--mantine-color-gray-5)" />
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{row.original.locomotive.code}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
{fmtTons(row.original.locomotive.maxPullWeightTons)} pull
</Text>
</Stack>
</Group>
) : (
<Text size="xs" c="red.6" fw={600}>
No loco
</Text>
),
},
{
id: "capacity",
header: "Capacity",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="wt" />
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 8,
background: "var(--mantine-color-green-0)",
border: "1px solid var(--mantine-color-green-1)",
}}
>
<Package size={12} color="var(--mantine-color-green-7)" />
<Text size="xs" fw={700} c="green.8" lh={1.2}>
{row.original.capacity.allocatedWagons}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
wgn
</Text>
</Group>
</Group>
),
},
{
id: "bookings",
header: "Bookings",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const total = totalBookingCount(row.original.counts);
return (
<Stack gap={4} style={{ minWidth: 130 }}>
<Text size="sm" fw={700} c="dark.4" lh={1.2}>
{total} booking{total === 1 ? "" : "s"}
</Text>
<BookingPipeline counts={row.original.counts} size={10} />
</Stack>
);
},
},
{
id: "actions",
header: "",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<Group justify="flex-end" wrap="nowrap">
<Button
variant="light"
color="green"
size="compact-sm"
rightSection={<ArrowRight size={14} />}
onClick={() =>
navigate(`/dashboard/operations/batch-board/${row.original.scheduleId}`)
}
>
View windows
</Button>
</Group>
),
},
];
}, [navigate]);
const tableStatus = isLoading ? "loading" : "success";
return (
<Container fluid py="lg" px="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
<Group justify="flex-end" mt="md">
<Button
variant="default"
radius="lg"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
</Group>
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="md" mt="md">
<StatTile
icon={Train}
@@ -359,46 +608,121 @@ export default function BatchBoardPage() {
/>
</SimpleGrid>
{isLoading ? (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</SimpleGrid>
) : schedules.length === 0 ? (
<Paper radius="lg" withBorder p={48} mt="lg" bg="gray.0">
<Stack align="center" gap="sm">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 20,
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 4px 12px rgba(15,23,42,0.06)",
<Card
radius="lg"
padding={0}
withBorder
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search schedules…"
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
<Select
size="sm"
radius="lg"
value={windowFilter}
onChange={(v) => v && setWindowFilter(v)}
data={[
{ value: "ALL", label: "All windows" },
{ value: "OPEN", label: "Open" },
{ value: "FULL", label: "Full" },
{ value: "CLOSED", label: "Closed" },
]}
w={150}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
}
/>
</Box>
{viewMode === "table" ? (
<DataTable
columns={columns}
data={paged}
status={tableStatus}
emptyMessage="No active schedules"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filtered.length,
}}
>
<Inbox size={28} color="var(--mantine-color-gray-5)" />
</Box>
<Text fw={700} c="gray.7">
No active schedules
</Text>
<Text size="sm" c="dimmed" ta="center" maw={380}>
Schedules with an open booking window appear here as cards. Create or activate
a schedule to get started.
</Text>
</Stack>
</Paper>
) : (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg">
{schedules.map((s) => (
<ScheduleCard key={s.scheduleId} schedule={s} />
))}
</SimpleGrid>
)}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: "schedules" } }}
/>
)}
/>
) : isLoading ? (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</SimpleGrid>
) : filtered.length === 0 ? (
<Paper radius="lg" p={48} m="md" bg="gray.0">
<Stack align="center" gap="sm">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 20,
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 4px 12px rgba(15,23,42,0.06)",
}}
>
<Inbox size={28} color="var(--mantine-color-gray-5)" />
</Box>
<Text fw={700} c="gray.7">
No active schedules
</Text>
<Text size="sm" c="dimmed" ta="center" maw={380}>
Schedules with an open booking window appear here. Create or activate a
schedule to get started.
</Text>
</Stack>
</Paper>
) : (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
{filtered.map((s) => (
<ScheduleCard key={s.scheduleId} schedule={s} />
))}
</SimpleGrid>
)}
</Stack>
</Card>
<Group justify="flex-end" mt="md">
<Button
variant="subtle"
color="gray"
size="xs"
loading={isFetching}
onClick={() => void refetch()}
>
Refresh
</Button>
</Group>
</Container>
);
}

View File

@@ -15,6 +15,7 @@ import {
SimpleGrid,
Stack,
Table,
Tabs,
Text,
ThemeIcon,
Title,
@@ -44,6 +45,7 @@ import type { LucideIcon } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { TrainConsistView, CompositionBookingTabs } from "@/components/trainScheduling/compositionEditor";
import {
BookingPipeline,
HeroChip,
@@ -491,10 +493,20 @@ export default function BatchScheduleDetailPage() {
[data],
);
const scheduleDetailQuery = useScheduleDetail(
hasAssignedWagons ? scheduleId : undefined,
"CONTAINER",
);
const scheduleDetailQuery = useScheduleDetail(scheduleId, "CONTAINER");
// Batch bookings by state for the composition side panel (payment / expired lists).
const batchBookings = useMemo(() => {
if (!data) return { awaitingPayment: [], expired: [] };
const all = [
...data.windows.flatMap((w) => w.bookings),
...data.pendingContract.bookings,
];
return {
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
expired: all.filter((b) => b.state === "EXPIRED"),
};
}, [data]);
// Group the flat window list into per-day sections (one per EAT calendar date).
const dayGroups = useMemo(() => {
@@ -568,6 +580,8 @@ export default function BatchScheduleDetailPage() {
// Date-stepper: which day is currently shown. Default to today, else the first
// day with bookings, else the first day. Keep the selection if still valid.
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string | null>("overview");
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
useEffect(() => {
if (!dayGroups.length) return;
if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return;
@@ -636,8 +650,17 @@ export default function BatchScheduleDetailPage() {
]}
/>
<Stack gap="lg" mt="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Tabs value={activeTab} onChange={setActiveTab} mt="md">
<Tabs.List>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="composition">
Train Composition {scheduleDetailQuery.data?.trainSet?.wagons && scheduleDetailQuery.data.trainSet.wagons.length > 0 && `(${scheduleDetailQuery.data.trainSet.wagons.length})`}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="lg">
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={9} style={{ minWidth: 0 }}>
<Group gap="sm" wrap="wrap" align="center">
<Button
@@ -755,7 +778,6 @@ export default function BatchScheduleDetailPage() {
variant="line"
/>
</SimpleGrid>
</Stack>
{/* Booking pipeline */}
<Paper
@@ -987,7 +1009,7 @@ export default function BatchScheduleDetailPage() {
) : null}
</Paper>
{/* Train composition */}
{/* Train composition diagram */}
{hasAssignedWagons && scheduleDetailQuery.data ? (
<Box mt="lg">
<TrainCompositionDiagram
@@ -999,6 +1021,48 @@ export default function BatchScheduleDetailPage() {
/>
</Box>
) : null}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="composition" pt="lg">
{scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? (
<Group align="stretch" gap="md" wrap="nowrap">
<Box style={{ flex: 1, minWidth: 0 }}>
<TrainConsistView
scheduleDetail={scheduleDetailQuery.data}
scheduleId={scheduleId ?? ""}
maxWagons={53}
highlightBookingId={selectedBookingId}
/>
</Box>
<Box style={{ width: 380, flexShrink: 0, minHeight: 520 }}>
<CompositionBookingTabs
scheduleDetail={scheduleDetailQuery.data}
scheduleId={scheduleId ?? ""}
awaitingPayment={batchBookings.awaitingPayment}
expired={batchBookings.expired}
selectedBookingId={selectedBookingId}
onSelectBooking={setSelectedBookingId}
/>
</Box>
</Group>
) : (
<Paper
radius="lg"
withBorder
py={64}
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group justify="center">
<Loader color="green" size="sm" />
<Text size="sm" c="dimmed">
Loading train composition
</Text>
</Group>
</Paper>
)}
</Tabs.Panel>
</Tabs>
</Container>
);
}