Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx
Nathnael 58b47318e9 feat(freight-backoffice): consolidate Mantine from/to date filters into range pickers
Replaces every remaining split from/to Mantine DateInput/DatePickerInput pair
with a single DatePickerInput type="range", sharing one preset list (Today,
Last 7/30 days, this/last month, YTD) via getDateRangePresets(). Covers
ListControls (18 consumers), FleetResourcePage, ContractRequestsPage,
BookingRequestsPage (created + scheduled ranges), WagonCancellationsPage,
BatchBoardPage, ClearanceDocumentsPage, ShipmentRequestsPage.

Native Mantine range picker, not the shadcn DateRangePicker, to match each
page's existing design system instead of clashing with it.

Reports date-range filter (ReportFilters.tsx) intentionally left untouched.
2026-08-13 08:45:29 +00:00

964 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Card,
Group,
Menu,
Paper,
RingProgress,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
ArrowDownWideNarrow,
ArrowRight,
ArrowUpNarrowWide,
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,
WindowPhasePill,
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 { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import type {
BatchBoardFilters,
BatchBoardSchedule,
BatchBoardSortField,
TrainScheduleStatus,
} from "@/types/trainScheduling";
const STATUS_BADGE: Record<string, { color: string; label: string }> = {
DRAFT: { color: "gray", label: "Draft" },
SCHEDULED: { color: "blue", label: "Scheduled" },
DISPATCHED: { color: "orange", label: "Dispatched" },
ARRIVED: { color: "green", label: "Arrived" },
CANCELLED: { color: "red", label: "Cancelled" },
};
function StatusBadge({ status }: { status: string }) {
const meta = STATUS_BADGE[status] ?? { color: "gray", label: status };
return (
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
);
}
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>
);
}
/** Gross weight (wagon tare + cargo) against the locomotive's pull limit. */
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 wagonPctOf(s: BatchBoardSchedule) {
return s.capacity.maxWagons && s.capacity.maxWagons > 0
? (s.capacity.allocatedWagons / s.capacity.maxWagons) * 100
: null;
}
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
const navigate = useNavigate();
const { capacity, counts, locomotive } = schedule;
const lengthPct = lengthPctOf(schedule);
const weightPct = weightPctOf(schedule);
const wagonPct = wagonPctOf(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 }}
>
{schedule.scheduleReference ?? "Freight schedule"} ·{" "}
{schedule.status}
</Text>
</Box>
</Group>
<Stack gap={4} align="flex-end">
<WindowStatusPill status={schedule.bookingWindowStatus} />
{schedule.windowPhase ? (
<WindowPhasePill
phase={schedule.windowPhase}
cycleNo={schedule.bookingCycleNo}
size="sm"
/>
) : null}
</Stack>
</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: the three axes a train is limited by — gross weight, wagon slots, length */}
<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="GROSS WT"
current={fmtTons(capacity.usedWeightTons)}
max={fmtTons(capacity.maxWeightTons ?? 0)}
/>
) : null}
{wagonPct != null ? (
<CapacityRing
pct={wagonPct}
label="WAGONS"
current={String(capacity.allocatedWagons)}
max={String(capacity.maxWagons ?? 0)}
/>
) : (
<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();
// Live board: phase + batch-changed pushes invalidate the list query below.
useBookingWindowSocket();
const { viewMode, setViewMode } = useFleetViewMode("batch-board");
const { pagination, setPagination } = usePagination({ pageSize: 12 });
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [statusFilter, setStatusFilter] = useState("ALL");
const [windowFilter, setWindowFilter] = useState("ALL");
const [sortBy, setSortBy] = useState<BatchBoardSortField>("createdAt");
const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC");
const [departureFrom, setDepartureFrom] = useState<Date | null>(null);
const [departureTo, setDepartureTo] = useState<Date | null>(null);
// Every knob maps straight onto the server-side batch-board query — the API
// filters, searches, sorts and paginates; this page just renders the page.
const filters = useMemo((): BatchBoardFilters => {
const endOfDay = (d: Date) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedSearch.trim() || undefined,
statuses:
statusFilter === "ALL"
? undefined
: [statusFilter as TrainScheduleStatus],
bookingWindowStatus:
windowFilter === "ALL"
? undefined
: (windowFilter as "OPEN" | "FULL" | "CLOSED"),
departureFrom: departureFrom ? departureFrom.toISOString() : undefined,
departureTo: departureTo ? endOfDay(departureTo).toISOString() : undefined,
sortBy,
sortOrder,
};
}, [
pagination,
debouncedSearch,
statusFilter,
windowFilter,
departureFrom,
departureTo,
sortBy,
sortOrder,
]);
// Any filter change restarts from the first page.
useEffect(() => {
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, [
debouncedSearch,
statusFilter,
windowFilter,
departureFrom,
departureTo,
sortBy,
sortOrder,
setPagination,
]);
const { data, isLoading, isError, isFetching, refetch } = useQuery({
...api.trainScheduling.batchBoard.queryOptions({ input: { filters } }),
// Real-time updates come from the booking-window socket (batch-board:changed
// + PHASE pushes invalidate this query); 60s is only a self-heal safety net
// for a missed emit.
refetchInterval: 60_000,
placeholderData: keepPreviousData,
});
const schedules = data?.items ?? [];
const total = data?.meta.total ?? 0;
// The table footer expects at least one page even when the board is empty.
const pageCount = Math.max(1, data?.meta.totalPages ?? 1);
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 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>
{row.original.scheduleReference ? (
<Text size="10px" fw={600} c="dimmed" lh={1.2}>
{row.original.scheduleReference}
</Text>
) : null}
<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: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "created",
header: "Created",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const { day, time } = splitDate(row.original.createdAt);
return (
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{day}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{time || "—"}
</Text>
</Stack>
);
},
},
{
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 }) => {
const { allocatedWagons, maxWagons } = row.original.capacity;
const wagonPct = wagonPctOf(row.original);
return (
<Group gap={6} wrap="nowrap">
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="gross" />
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
{wagonPct != null ? (
<CapacityChip
icon={Package}
pct={wagonPct}
text={`${allocatedWagons}/${maxWagons} wgn`}
/>
) : (
<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}>
{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="All import schedules — live and historical — with their booking windows."
action={
<Button variant="default" loading={isFetching} onClick={() => void refetch()}>
Refresh
</Button>
}
/>
<KpiStrip
loading={isLoading}
items={[
{
label: "Schedules",
value: total,
hint: "matching the current filters",
icon: Train,
},
{
label: "Open windows",
value: summary.openWindows,
hint: "accepting bookings (this page)",
icon: CalendarDays,
},
{
label: "Bookings in play",
value: summary.totalBookings,
hint: `${summary.totalWagons} wagons allocated (this page)`,
icon: Package,
},
]}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search train, route, station, loco…"
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
<Group gap="xs" wrap="wrap">
<Select
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => v && setStatusFilter(v)}
data={[
{ value: "ALL", label: "All statuses" },
{ value: "DRAFT", label: "Draft" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "DISPATCHED", label: "Dispatched" },
{ value: "ARRIVED", label: "Arrived" },
{ value: "CANCELLED", label: "Cancelled" },
]}
w={150}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<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={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<DatePickerInput
type="range"
size="sm"
radius="lg"
placeholder="Departure date range"
value={[departureFrom, departureTo]}
onChange={([from, to]) => {
setDepartureFrom(from ? new Date(from) : null);
setDepartureTo(to ? new Date(to) : null);
}}
presets={getDateRangePresets()}
clearable
w={230}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={sortBy}
onChange={(v) => v && setSortBy(v as BatchBoardSortField)}
data={[
{ value: "createdAt", label: "Sort: Created" },
{ value: "scheduledDepartureDate", label: "Sort: Departure" },
{ value: "trainNumber", label: "Sort: Train no." },
{ value: "status", label: "Sort: Status" },
]}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Tooltip
label={sortOrder === "DESC" ? "Newest / ZA first" : "Oldest / AZ first"}
withArrow
>
<ActionIcon
variant="default"
size={36}
radius="lg"
aria-label="Toggle sort direction"
onClick={() =>
setSortOrder((o) => (o === "DESC" ? "ASC" : "DESC"))
}
>
{sortOrder === "DESC" ? (
<ArrowDownWideNarrow size={16} />
) : (
<ArrowUpNarrowWide size={16} />
)}
</ActionIcon>
</Tooltip>
</Group>
}
/>
</Box>
{viewMode === "table" ? (
<DataTable
columns={columns}
data={schedules}
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 schedules match the current filters"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
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>
) : schedules.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 schedules match the current filters
</Text>
<Text size="sm" c="dimmed" ta="center" maw={380}>
Every import schedule live and historical appears here. Loosen the
filters or clear the search to see more.
</Text>
</Stack>
</Paper>
) : (
<>
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
{schedules.map((s) => (
<ScheduleCard key={s.scheduleId} schedule={s} />
))}
</SimpleGrid>
<Group justify="space-between" px="md" pb="md">
<Text size="sm" c="dimmed">
{total} schedule{total === 1 ? "" : "s"}
</Text>
<Group gap="xs">
<Button
variant="default"
size="xs"
disabled={pagination.pageIndex === 0}
onClick={() =>
setPagination((p) => ({ ...p, pageIndex: p.pageIndex - 1 }))
}
>
Previous
</Button>
<Text size="sm" c="dimmed">
Page {pagination.pageIndex + 1} of {pageCount}
</Text>
<Button
variant="default"
size="xs"
disabled={pagination.pageIndex + 1 >= pageCount}
onClick={() =>
setPagination((p) => ({ ...p, pageIndex: p.pageIndex + 1 }))
}
>
Next
</Button>
</Group>
</Group>
</>
)}
</Stack>
</Card>
</PageContainer>
);
}