mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 11:18:17 +00:00
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.
964 lines
32 KiB
TypeScript
964 lines
32 KiB
TypeScript
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 / Z–A first" : "Oldest / A–Z 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>
|
||
);
|
||
}
|