Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx
2026-06-22 08:11:11 +00:00

727 lines
23 KiB
TypeScript

import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
Alert,
Box,
Button,
Card,
Group,
Menu,
Paper,
RingProgress,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertTriangle,
ArrowRight,
CalendarClock,
CalendarDays,
Eye,
Inbox,
MoreHorizontal,
Package,
Ruler,
Train,
TrainFront,
Weight,
} from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import {
BookingPipeline,
HeroChip,
totalBookingCount,
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor } 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";
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtMeters = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
const fmtScheduleDate = (iso: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
weekday: "short",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).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";
return "#F2A516";
}
/** Capacity ring that keeps the explicit allocated/max numbers underneath. */
function CapacityRing({
pct,
label,
current,
max,
}: {
pct: number;
label: string;
current: string;
max: string;
}) {
const clamped = Math.min(100, Math.max(0, pct));
const color = ringColor(pct);
return (
<Stack gap={4} align="center" style={{ flex: 1 }}>
<RingProgress
size={104}
thickness={9}
roundCaps
sections={[{ value: clamped, color }]}
rootColor="var(--mantine-color-gray-1)"
label={
<Stack gap={0} align="center">
<Text ta="center" size="lg" fw={800} lh={1} style={{ color }}>
{Math.round(pct)}%
</Text>
<Text ta="center" size="9px" c="dimmed" fw={600}>
{label}
</Text>
</Stack>
}
/>
<Stack gap={0} align="center">
<Text size="xs" fw={700} c="dark.4">
{current}
</Text>
<Text size="xs" c="dimmed">
of {max}
</Text>
</Stack>
</Stack>
);
}
/** 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 = lengthPctOf(schedule);
const weightPct = weightPctOf(schedule);
const totalBookings = totalBookingCount(counts);
return (
<Paper
className="bb-card"
radius="lg"
withBorder
style={{
borderColor: "var(--mantine-color-gray-2)",
background: "white",
overflow: "hidden",
cursor: "pointer",
display: "flex",
flexDirection: "column",
}}
onClick={() =>
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
}
>
<Stack gap="md" p="lg" style={{ flex: 1 }}>
{/* header */}
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={44} radius="md" variant="light" color="#F2A516">
<Train size={22} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={800} size="md" truncate style={{ letterSpacing: 0.2 }}>
{schedule.trainNumber ?? schedule.routeName ?? "Schedule"}
</Text>
<Text
size="10px"
fw={700}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: 0.8 }}
>
Freight schedule · {schedule.status}
</Text>
</Box>
</Group>
<WindowStatusPill status={schedule.bookingWindowStatus} />
</Group>
<RouteCorridor
origin={schedule.origin}
destination={schedule.destination}
variant="compact"
/>
<Group gap={6} wrap="wrap">
<HeroChip icon={<CalendarDays size={12} />}>
{fmtScheduleDate(schedule.scheduleDate)}
</HeroChip>
{locomotive ? (
<HeroChip icon={<TrainFront size={12} />}>
{locomotive.code} · {fmtTons(locomotive.maxPullWeightTons)}
</HeroChip>
) : null}
</Group>
{!locomotive ? (
<Alert
color="red"
radius="md"
icon={<AlertTriangle size={15} />}
py={6}
styles={{ message: { fontSize: 12 } }}
>
No locomotive assigned wagon allocation cannot run.
</Alert>
) : null}
{/* capacity: weight + length rings + wagons (numbers preserved) */}
<Box
py="sm"
px="xs"
style={{
borderRadius: 14,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-1)",
}}
>
<Group justify="space-around" align="center" wrap="nowrap" gap="xs">
{weightPct != null ? (
<CapacityRing
pct={weightPct}
label="WEIGHT"
current={fmtTons(capacity.usedWeightTons)}
max={fmtTons(capacity.maxWeightTons ?? 0)}
/>
) : null}
<Stack gap={0} align="center" style={{ flex: 1 }}>
<ThemeIcon size={34} radius="md" variant="light" color="gray">
<Package size={17} />
</ThemeIcon>
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
{capacity.allocatedWagons}
</Text>
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
Wagons
</Text>
<Text size="xs" c="dimmed">
allocated
</Text>
</Stack>
{lengthPct != null ? (
<CapacityRing
pct={lengthPct}
label="LENGTH"
current={fmtMeters(capacity.allocatedLengthMeters)}
max={fmtMeters(capacity.maxLengthMeters ?? 0)}
/>
) : null}
</Group>
</Box>
{/* booking pipeline */}
<Box>
<Group justify="space-between" mb={6}>
<Group gap={5} wrap="nowrap">
<Package size={13} color="var(--mantine-color-gray-6)" />
<Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}>
Booking pipeline
</Text>
</Group>
<Text size="xs" fw={700} c="dark.4">
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
</Text>
</Group>
<BookingPipeline counts={counts} />
</Box>
</Stack>
{/* CTA */}
<Box px="lg" pb="lg">
<Button
fullWidth
radius="md"
variant="gradient"
gradient={{ from: FREIGHT_BRAND, to: FREIGHT_BRAND_DARK, deg: 135 }}
rightSection={<ArrowRight size={16} className="bb-arrow" />}
onClick={(e) => {
e.stopPropagation();
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`);
}}
>
View batch windows
</Button>
</Box>
</Paper>
);
}
function CardSkeleton() {
return (
<Paper radius="lg" withBorder style={{ overflow: "hidden" }}>
<Stack gap="md" p="lg">
<Group justify="space-between">
<Group gap="sm">
<Skeleton height={44} width={44} radius="md" />
<Skeleton height={32} width={120} />
</Group>
<Skeleton height={22} width={90} radius="xl" />
</Group>
<Skeleton height={120} radius="md" />
<Skeleton height={10} radius="xl" />
<Skeleton height={36} radius="md" />
</Stack>
</Paper>
);
}
export default function BatchBoardPage() {
const navigate = useNavigate();
const { data, isLoading, isError, 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(() => {
const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length;
const totalBookings = schedules.reduce((sum, s) => sum + totalBookingCount(s.counts), 0);
const totalWagons = schedules.reduce((sum, s) => sum + s.capacity.allocatedWagons, 0);
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-edr-green-0)",
border: "1px solid var(--mantine-color-edr-green-1)",
}}
>
<Package size={12} color="var(--mantine-color-edr-green-7)" />
<Text size="xs" fw={700} c="edr-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" onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal shadow="md" width={180}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Row actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Eye size={15} />}
onClick={() =>
navigate(`/dashboard/operations/batch-board/${row.original.scheduleId}`)
}
>
View windows
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
),
},
];
}, [navigate]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
return (
<PageContainer>
<PageHeader
title="Batch Board"
subtitle="Active schedules and their booking windows."
action={
<Button variant="default" loading={isFetching} onClick={() => void refetch()}>
Refresh
</Button>
}
/>
<KpiStrip
loading={isLoading}
items={[
{
label: "Active schedules",
value: schedules.length,
hint: "on the board right now",
icon: Train,
},
{
label: "Open windows",
value: summary.openWindows,
hint: "accepting bookings",
icon: CalendarDays,
},
{
label: "Bookings in play",
value: summary.totalBookings,
hint: `${summary.totalWagons} wagons allocated`,
icon: Package,
},
]}
/>
<Card p={0}>
<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}
onRowClick={(schedule) =>
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
}
error={
isError
? {
message: "Failed to load the batch board.",
onRetry: () => void refetch(),
}
: undefined
}
emptyMessage="No active schedules"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filtered.length,
}}
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)",
}}
>
<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>
</PageContainer>
);
}