refactor(train-scheduling): move the schedule list onto the shared FilterBar

The list was the last major board still on ad-hoc filtering: usePagination
plus FleetToolbar's hand-rolled Selects, a debounced search and a manual
sort Select, assembled into a filters object by hand. It now uses
useFilters/FilterBar like every other list, so pagination, search, sort
and filters all travel as URL params and a link reproduces the view.

Origin and destination ride the shared Route filter, which no longer
requires both ends — filtering by origin alone stays possible, and each
end now takes several stations.

The card/table view toggle moves into FilterBar's children slot, and the
row tinting for shipping-line and direction is untouched.
This commit is contained in:
Nathnael
2026-08-20 11:37:02 +00:00
parent d6f1c5fb43
commit 1b08666527
2 changed files with 264 additions and 363 deletions

View File

@@ -201,16 +201,20 @@ export function StatTile({
/**
* Origin → destination corridor visual: two anchored stops joined by a rail
* line. `variant="compact"` is for dense table rows; `default` for cards.
* `orientation="vertical"` stacks the stops as waypoints, which keeps a long
* yard name off one wide line in a table cell.
*/
export function RouteCorridor({
origin,
destination,
variant = "default",
orientation = "horizontal",
onDark = false,
}: {
origin?: string | null;
destination?: string | null;
variant?: "default" | "compact";
orientation?: "horizontal" | "vertical";
onDark?: boolean;
}) {
const compact = variant === "compact";
@@ -218,6 +222,48 @@ export function RouteCorridor({
const strong = onDark ? "white" : "var(--mantine-color-gray-8)";
const lineColor = onDark ? "rgba(255,255,255,0.4)" : "var(--mantine-color-gray-3)";
const accent = onDark ? "white" : freightBrand.primary;
const dot = compact ? 7 : 9;
if (orientation === "vertical") {
return (
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={dot}
h={dot}
style={{
borderRadius: 999,
flexShrink: 0,
border: `2px solid ${accent}`,
background: onDark ? "transparent" : "white",
}}
/>
<Text size="sm" fw={600} c={strong} lh={1.2} truncate>
{origin ?? "—"}
</Text>
</Group>
{/* Rail between the stops, aligned to the dot centres. */}
<Box
ml={dot / 2 - 1}
style={{
width: 0,
height: compact ? 10 : 14,
borderLeft: `2px dashed ${lineColor}`,
}}
/>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={dot}
h={dot}
style={{ borderRadius: 999, flexShrink: 0, background: accent }}
/>
<Text size="sm" fw={600} c={strong} lh={1.2} truncate>
{destination ?? "—"}
</Text>
</Group>
</Stack>
);
}
return (
<Group gap={compact ? 6 : 8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>

View File

@@ -5,11 +5,13 @@ import {
Box,
Button,
Card,
Center,
Checkbox,
Divider,
Group,
Menu,
Modal,
SegmentedControl,
Select,
SimpleGrid,
Stack,
@@ -19,7 +21,6 @@ import {
ThemeIcon,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { isAxiosError } from "axios";
import {
ArrowRight,
@@ -27,27 +28,33 @@ import {
CalendarClock,
Clock,
Eye,
LayoutGrid,
MoreHorizontal,
Navigation,
Pencil,
Play,
Send,
Table2,
Train,
Weight,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import {
FilterBar,
routeParams,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
type SortOption,
} from "@/components/filters";
import { useFleetViewMode, type FleetViewMode } from "@/components/fleet/useFleetViewMode";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
directionColor,
directionRowStyle,
} from "@/components/trainBuilder/trainStatus";
import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import CreateScheduleWindowFields, {
buildWindowRulePayload,
@@ -56,10 +63,7 @@ import CreateScheduleWindowFields, {
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
import { ExportButton } from "@/components/export/ExportButton";
import {
RouteCorridor,
StatusPill,
} from "@/components/trainScheduling/scheduleVisuals";
import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { formatRouteLabel } from "@/services/routes.service";
@@ -68,12 +72,34 @@ import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, canCreateSchedule, hasPermission } from "@/lib/permissions";
import type {
CreateScheduleWindowRulePayload,
FreightType,
TrainScheduleListFilters,
TrainScheduleListItem,
TrainScheduleStatus,
} from "@/types/trainScheduling";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { DataTable, DataTableFooter } from "@edr/ui-common";
const SCHEDULE_STATUS_OPTIONS = [
{ value: "DRAFT", label: "Draft" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "DISPATCHED", label: "Dispatched" },
{ value: "ARRIVED", label: "Arrived" },
{ value: "CANCELLED", label: "Cancelled" },
];
const FREIGHT_TYPE_OPTIONS = [
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
{ value: "MIXED", label: "Mixed" },
];
/** Server sort fields (TRAIN_SCHEDULE_SORT_FIELDS) in the shared "field:DIR" form. */
const SORT_OPTIONS: SortOption[] = [
{ value: "createdAt:DESC", label: "Newest created" },
{ value: "createdAt:ASC", label: "Oldest created" },
{ value: "scheduledDepartureDate:DESC", label: "Departure ↓" },
{ value: "scheduledDepartureDate:ASC", label: "Departure ↑" },
{ value: "reference:ASC", label: "Reference ↑" },
{ value: "reference:DESC", label: "Reference ↓" },
];
/** `min` for a `datetime-local` input: now, in the browser's local zone. */
const nowLocalDateTime = () => {
@@ -115,34 +141,16 @@ export default function TrainScheduleV2ListPage() {
const canCreate = canCreateSchedule(user);
const canDispatch = hasPermission(user, FREIGHT_PERMS.trainScheduling.dispatch);
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [statusFilter, setStatusFilter] = useState<"ALL" | TrainScheduleStatus>("ALL");
const [freightFilter, setFreightFilter] = useState<"ALL" | FreightType>("ALL");
// Origin/destination hold yard IDs ("ALL" = no filter); the server matches
// the schedule's origin_station_id / destination_station_id exactly.
const [originFilter, setOriginFilter] = useState("ALL");
const [destinationFilter, setDestinationFilter] = useState("ALL");
// Default: newest-created first, matching the API's default order. Values
// are the server sort fields (see TRAIN_SCHEDULE_SORT_FIELDS).
const [sortBy, setSortBy] = useState<
"createdAt" | "scheduledDepartureDate" | "reference"
>("createdAt");
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
// Dispatch is irreversible from this screen, so it goes through an explicit
// confirmation.
const [dispatchTarget, setDispatchTarget] =
useState<TrainScheduleListItem | null>(null);
const [dispatchTarget, setDispatchTarget] = useState<TrainScheduleListItem | null>(null);
// Actual departure — defaults to now when the dialog opens; past is fine.
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
// Cancelling is likewise irreversible — confirmed before the mutation fires.
const [cancelTarget, setCancelTarget] =
useState<TrainScheduleListItem | null>(null);
const [editDateSchedule, setEditDateSchedule] =
useState<TrainScheduleListItem | null>(null);
const [cancelTarget, setCancelTarget] = useState<TrainScheduleListItem | null>(null);
const [editDateSchedule, setEditDateSchedule] = useState<TrainScheduleListItem | null>(null);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [trainId, setTrainId] = useState("");
@@ -156,51 +164,59 @@ export default function TrainScheduleV2ListPage() {
const [windowForm, setWindowForm] = useState<WindowFormState | null>(null);
// Recomputed each time the create modal opens so a long-lived tab can't keep
// offering a stale "now" as the earliest selectable departure.
const minScheduleDate = useMemo(
() => (createOpen ? nowLocalDateTime() : ""),
[createOpen],
const minScheduleDate = useMemo(() => (createOpen ? nowLocalDateTime() : ""), [createOpen]);
// Yard options for the origin/destination filters (shared routes reference
// list, so the choices don't shrink to whatever the current page shows).
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
})),
[yardsQuery.data],
);
const resetPage = useCallback(() => {
setPagination((prev) =>
prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 },
);
}, [setPagination]);
// One Route pill covering both ends. It is the paired `route` type — which
// no longer forces both sides to be filled — so filtering by origin alone,
// by destination alone, or by several yards per side all still work, and the
// two ends read as the one thing an operator is actually picking.
const scheduleFilterDefs: FilterDef[] = useMemo(
() => [
{
key: "status",
label: "Status",
type: "enum",
multiple: false,
options: SCHEDULE_STATUS_OPTIONS,
},
{
key: "freightType",
label: "Freight",
type: "enum",
multiple: false,
options: FREIGHT_TYPE_OPTIONS,
},
{
key: "route",
label: "Route",
type: "route",
options: yardOptions,
toParams: routeParams("originStationId", "destinationStationId"),
},
],
[yardOptions],
);
// Search resets the page only once the debounced value settles — resetting
// per keystroke would refetch page 1 mid-typing.
useEffect(() => {
resetPage();
}, [debouncedSearch, resetPage]);
const controls = useFilters(scheduleFilterDefs, {
defaultSort: "createdAt:DESC",
pageSize: 10,
});
// Fully server-driven list: pagination, search, filters, and sort all travel
// as query params; the response envelope carries the page + totals.
const filters = useMemo<TrainScheduleListFilters>(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
...(freightFilter !== "ALL" ? { freightType: freightFilter } : {}),
...(originFilter !== "ALL" ? { originStationId: originFilter } : {}),
...(destinationFilter !== "ALL"
? { destinationStationId: destinationFilter }
: {}),
sortBy,
sortOrder: sortDir === "asc" ? "ASC" : "DESC",
}),
[
pagination.pageIndex,
pagination.pageSize,
debouncedSearch,
statusFilter,
freightFilter,
originFilter,
destinationFilter,
sortBy,
sortDir,
],
);
const filters = controls.params as unknown as TrainScheduleListFilters;
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({
@@ -212,14 +228,7 @@ export default function TrainScheduleV2ListPage() {
staleTime: 30_000,
}),
);
// Yard options for the origin/destination filters (shared routes reference
// list, so the choices don't shrink to whatever the current page shows).
const yardsQuery = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
);
const routesQuery = useQuery(
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
);
const routesQuery = useQuery(api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }));
const trainsQuery = useQuery(
api.trainScheduling.availableTrains.queryOptions({
input: { routeId },
@@ -236,9 +245,7 @@ export default function TrainScheduleV2ListPage() {
}),
);
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
const dispatchSchedule = useMutation(
api.trainScheduling.dispatchSchedule.mutationOptions(),
);
const dispatchSchedule = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
@@ -253,9 +260,7 @@ export default function TrainScheduleV2ListPage() {
const trainYardHint = useMemo(() => {
if (!selectedRoute) return "Select a route first";
const originLabel =
selectedRoute.originYard?.label ??
selectedRoute.originYard?.code ??
"the route origin yard";
selectedRoute.originYard?.label ?? selectedRoute.originYard?.code ?? "the route origin yard";
return `All schedulable built trains are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
}, [selectedRoute]);
@@ -267,7 +272,6 @@ export default function TrainScheduleV2ListPage() {
// current page, and the meta envelope carries the totals.
const schedules = schedulesQuery.data?.items ?? [];
const totalSchedules = schedulesQuery.data?.meta.total ?? 0;
const pageCount = Math.max(1, schedulesQuery.data?.meta.totalPages ?? 1);
// Status/weight tiles count the visible page only — board-wide numbers would
// need a dedicated summary endpoint now that the list is server-paginated.
@@ -287,92 +291,62 @@ export default function TrainScheduleV2ListPage() {
return base;
}, [schedules]);
// Corridor filter options: every yard from the shared reference list, sent
// to the server as origin/destination station IDs.
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
})),
[yardsQuery.data],
);
const columns = useMemo((): ColumnDef<TrainScheduleListItem>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "reference",
header: "Ref",
// Train, reference and status share one identity column — three
// stacked lines cost the width of the widest, not three columns.
id: "train",
header: "Train",
size: 170,
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm" fw={600} ff="monospace" c="edr-green.8">
{row.original.reference ?? "—"}
</Text>
),
cell: ({ row }) => <TrainIdentityCell schedule={row.original} />,
},
{
id: "date",
header: "Departure",
size: 110,
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-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
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>
<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: "route",
header: "Route",
size: 280,
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Stack gap={4}>
<Stack gap={6}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
{row.original.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(row.original.direction)}
>
<Badge size="xs" variant="light" color={directionColor(row.original.direction)}>
{row.original.direction}
</Badge>
) : null}
<ShippingLineBadge schedule={row.original} />
</Group>
<Box maw={220}>
<Box maw={260}>
<RouteCorridor
origin={row.original.origin}
destination={row.original.destination}
variant="compact"
orientation="vertical"
/>
</Box>
</Stack>
@@ -384,59 +358,6 @@ export default function TrainScheduleV2ListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => <FreightTypeBadge freightType={row.original.freightType} />,
},
{
id: "train",
header: "Train",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
// Schedules created from the Train Builder show the direction-matched
// run number first (falling back to the train code); legacy rows fall
// back to their locomotive set.
if (row.original.train) {
const subtitle = [row.original.trainNumber ? row.original.train.code : null,
row.original.train.trainName]
.filter(Boolean)
.join(" · ");
return (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Stack gap={0}>
<Text size="sm" fw={600} ff="monospace" lh={1.2}>
{row.original.trainNumber ?? row.original.train.code}
</Text>
{subtitle ? (
<Text size="xs" c="dimmed" lh={1.2}>
{subtitle}
</Text>
) : null}
</Stack>
</Group>
);
}
const locos =
row.original.locomotives && row.original.locomotives.length > 0
? row.original.locomotives
: row.original.locomotive
? [row.original.locomotive]
: [];
if (!locos.length) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{locos[0].code}
{locos.length > 1 ? ` +${locos.length - 1}` : ""}
</Text>
</Group>
);
},
},
{
id: "metrics",
header: "Load",
@@ -449,15 +370,9 @@ export default function TrainScheduleV2ListPage() {
</Group>
),
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <StatusPill status={row.original.status} />,
},
{
id: "actions",
size:32,
size: 32,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => {
const schedule = row.original;
@@ -482,9 +397,7 @@ export default function TrainScheduleV2ListPage() {
<Menu.Item
leftSection={<Navigation size={15} />}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
)
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
}
>
Track
@@ -561,10 +474,7 @@ export default function TrainScheduleV2ListPage() {
toast({ title: "Booking window settings are still loading", variant: "destructive" });
return;
}
const built = buildWindowRulePayload(
windowForm,
selectedRoute?.direction === "EXPORT",
);
const built = buildWindowRulePayload(windowForm, selectedRoute?.direction === "EXPORT");
if ("error" in built) {
toast({ title: built.error, variant: "destructive" });
return;
@@ -632,115 +542,42 @@ export default function TrainScheduleV2ListPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
<FilterBar
defs={scheduleFilterDefs}
controls={controls}
searchPlaceholder="Search schedules…"
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
<>
<Select
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => {
if (!v) return;
setStatusFilter(v as "ALL" | TrainScheduleStatus);
resetPage();
}}
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={freightFilter}
onChange={(v) => {
if (!v) return;
setFreightFilter(v as "ALL" | FreightType);
resetPage();
}}
data={[
{ value: "ALL", label: "All freight" },
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
{ value: "MIXED", label: "Mixed" },
]}
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
placeholder="Origin"
searchable
value={originFilter}
onChange={(v) => {
setOriginFilter(v ?? "ALL");
resetPage();
}}
data={[
{ value: "ALL", label: "All origins" },
...yardOptions,
]}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
placeholder="Destination"
searchable
value={destinationFilter}
onChange={(v) => {
setDestinationFilter(v ?? "ALL");
resetPage();
}}
data={[
{ value: "ALL", label: "All destinations" },
...yardOptions,
]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={`${sortBy}:${sortDir}`}
onChange={(v) => {
if (!v) return;
const [by, dir] = v.split(":") as [
typeof sortBy,
typeof sortDir,
];
setSortBy(by);
setSortDir(dir);
resetPage();
}}
data={[
{ value: "createdAt:desc", label: "Newest created" },
{ value: "createdAt:asc", label: "Oldest created" },
{ value: "scheduledDepartureDate:desc", label: "Departure ↓" },
{ value: "scheduledDepartureDate:asc", label: "Departure ↑" },
{ value: "reference:asc", label: "Reference ↑" },
{ value: "reference:desc", label: "Reference ↓" },
]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<ExportButton datasetKey="train-schedules" params={filters} size="sm" />
</>
}
/>
sortOptions={SORT_OPTIONS}
viewId="train-schedules"
>
<Group gap="sm" wrap="nowrap">
<SegmentedControl
size="xs"
radius="lg"
value={viewMode}
onChange={(v) => setViewMode(v as FleetViewMode)}
data={[
{
value: "table",
label: (
<Center>
<Table2 size={14} />
</Center>
),
},
{
value: "cards",
label: (
<Center>
<LayoutGrid size={14} />
</Center>
),
},
]}
aria-label="View mode"
/>
<ExportButton datasetKey="train-schedules" params={controls.params} size="sm" />
</Group>
</FilterBar>
</Box>
{viewMode === "table" ? (
@@ -765,18 +602,7 @@ export default function TrainScheduleV2ListPage() {
: undefined
}
emptyMessage="No train schedules found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: totalSchedules,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
{...controls.tableProps(totalSchedules)}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
@@ -799,25 +625,18 @@ export default function TrainScheduleV2ListPage() {
key={schedule.id}
schedule={schedule}
onOpen={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}`,
)
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
onTrack={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
)
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
}
/>
))}
</SimpleGrid>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={totalSchedules}
{...toRuleEngineFooterProps(controls, totalSchedules)}
itemLabel="schedules"
onPaginationChange={setPagination}
/>
</Stack>
)}
@@ -959,13 +778,12 @@ export default function TrainScheduleV2ListPage() {
{dispatchTarget?.trainNumber ?? dispatchTarget?.reference ?? "This train"}
</Text>{" "}
departs {dispatchTarget?.origin ?? "its origin"} for{" "}
{dispatchTarget?.destination ?? "its destination"} and its booking
window closes. This cannot be undone.
{dispatchTarget?.destination ?? "its destination"} and its booking window closes. This
cannot be undone.
</Text>
<Text size="xs" c="dimmed">
Open the schedule detail first if you want to check for unassigned
wagons or cargo not yet marked loaded those warnings are shown
there, not here.
Open the schedule detail first if you want to check for unassigned wagons or cargo not
yet marked loaded those warnings are shown there, not here.
</Text>
<DateTimePicker
label="Actual departure"
@@ -990,9 +808,7 @@ export default function TrainScheduleV2ListPage() {
try {
await dispatchSchedule.mutateAsync({
id: dispatchTarget.id,
payload: dispatchAt
? { actualDepartureAt: dispatchAt.toISOString() }
: {},
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
});
toast({ title: "Train dispatched" });
setDispatchTarget(null);
@@ -1026,14 +842,13 @@ export default function TrainScheduleV2ListPage() {
<Text span fw={600} c="dark">
{cancelTarget?.trainNumber ?? cancelTarget?.reference ?? "This train"}
</Text>{" "}
will be cancelled and removed from the active schedule board. This
cannot be undone.
will be cancelled and removed from the active schedule board. This cannot be undone.
</Text>
{cancelTarget?.bookingsCount ? (
<Text size="sm" c="red.7" fw={500}>
{cancelTarget.bookingsCount} booking
{cancelTarget.bookingsCount === 1 ? "" : "s"} on this train will
need to be moved to another schedule.
{cancelTarget.bookingsCount === 1 ? "" : "s"} on this train will need to be moved to
another schedule.
</Text>
) : null}
<Group justify="flex-end" gap="sm">
@@ -1085,6 +900,56 @@ const SHIPPING_LINE_ROW_STYLE = {
backgroundColor: "var(--mantine-color-edr-green-0)",
} as const;
/**
* The row's identity: which train is running, under what reference, in what
* state. Stacked into one column so the three read as a unit and cost one
* column's width between them.
*/
function TrainIdentityCell({ schedule }: { schedule: TrainScheduleListItem }) {
// Schedules created from the Train Builder show the direction-matched run
// number first (falling back to the train code); legacy rows fall back to
// their locomotive set.
const locos =
schedule.locomotives && schedule.locomotives.length > 0
? schedule.locomotives
: schedule.locomotive
? [schedule.locomotive]
: [];
let title = "—";
let subtitle = "";
if (schedule.train) {
title = schedule.trainNumber ?? schedule.train.code;
subtitle = [schedule.trainNumber ? schedule.train.code : null, schedule.train.trainName]
.filter(Boolean)
.join(" · ");
} else if (locos.length) {
title = `${locos[0].code}${locos.length > 1 ? ` +${locos.length - 1}` : ""}`;
}
return (
<Stack gap={3} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Train size={14} color="var(--mantine-color-gray-5)" style={{ flexShrink: 0 }} />
<Text size="sm" fw={600} ff="monospace" lh={1.2} truncate>
{title}
</Text>
</Group>
{subtitle ? (
<Text size="xs" c="dimmed" lh={1.2} truncate>
{subtitle}
</Text>
) : null}
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} ff="monospace" c="edr-green.8" lh={1.2} truncate>
{schedule.reference ?? "—"}
</Text>
<StatusPill status={schedule.status} />
</Group>
</Stack>
);
}
function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
if (!schedule.shippingLineCompanyId) return null;
return (
@@ -1112,12 +977,8 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
value={`${used}/${total}`}
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
/>
{reserved > used ? (
<MetricChip value={reserved} label="reserved" subtle />
) : null}
{remaining != null ? (
<MetricChip value={remaining} label="bookable" subtle />
) : null}
{reserved > used ? <MetricChip value={reserved} label="reserved" subtle /> : null}
{remaining != null ? <MetricChip value={remaining} label="bookable" subtle /> : null}
</>
);
}
@@ -1138,9 +999,7 @@ function MetricChip({
style={{
padding: "2px 8px",
borderRadius: 8,
background: subtle
? "var(--mantine-color-gray-1)"
: "var(--mantine-color-edr-green-0)",
background: subtle ? "var(--mantine-color-gray-1)" : "var(--mantine-color-edr-green-0)",
border: `1px solid ${
subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-edr-green-1)"
}`,
@@ -1217,11 +1076,7 @@ function ScheduleCard({
<Group gap={6} wrap="nowrap">
<FreightTypeBadge freightType={schedule.freightType} />
{schedule.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(schedule.direction)}
>
<Badge size="xs" variant="light" color={directionColor(schedule.direction)}>
{schedule.direction}
</Badge>
) : null}