diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/scheduleVisuals.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/scheduleVisuals.tsx
index 37f09dfd8..790397983 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/scheduleVisuals.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/scheduleVisuals.tsx
@@ -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 (
+
+
+
+
+ {origin ?? "—"}
+
+
+ {/* Rail between the stops, aligned to the dot centres. */}
+
+
+
+
+ {destination ?? "—"}
+
+
+
+ );
+ }
return (
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx
index b371d148a..b80822c56 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx
@@ -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(null);
// Dispatch is irreversible from this screen, so it goes through an explicit
// confirmation.
- const [dispatchTarget, setDispatchTarget] =
- useState(null);
+ const [dispatchTarget, setDispatchTarget] = useState(null);
// Actual departure — defaults to now when the dialog opens; past is fine.
const [dispatchAt, setDispatchAt] = useState(null);
// Cancelling is likewise irreversible — confirmed before the mutation fires.
- const [cancelTarget, setCancelTarget] =
- useState(null);
- const [editDateSchedule, setEditDateSchedule] =
- useState(null);
+ const [cancelTarget, setCancelTarget] = useState(null);
+ const [editDateSchedule, setEditDateSchedule] = useState(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(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(
- () => ({
- 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[] => {
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 }) => (
-
- {row.original.reference ?? "—"}
-
- ),
+ cell: ({ row }) => ,
},
{
id: "date",
header: "Departure",
+ size: 110,
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const { day, time } = splitDate(row.original.scheduleDate);
return (
-
-
-
-
-
-
- {day}
-
-
- {time || "—"}
-
-
-
+
+
+ {day}
+
+
+ {time || "—"}
+
+
);
},
},
{
id: "route",
header: "Route",
+ size: 280,
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
-
+
{row.original.routeName ?? "—"}
{row.original.direction ? (
-
+
{row.original.direction}
) : null}
-
+
@@ -384,59 +358,6 @@ export default function TrainScheduleV2ListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => ,
},
- {
- 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 (
-
-
-
-
- {row.original.trainNumber ?? row.original.train.code}
-
- {subtitle ? (
-
- {subtitle}
-
- ) : null}
-
-
- );
- }
- const locos =
- row.original.locomotives && row.original.locomotives.length > 0
- ? row.original.locomotives
- : row.original.locomotive
- ? [row.original.locomotive]
- : [];
- if (!locos.length) {
- return (
-
- —
-
- );
- }
- return (
-
-
-
- {locos[0].code}
- {locos.length > 1 ? ` +${locos.length - 1}` : ""}
-
-
- );
- },
- },
{
id: "metrics",
header: "Load",
@@ -449,15 +370,9 @@ export default function TrainScheduleV2ListPage() {
),
},
- {
- id: "status",
- header: "Status",
- meta: { headerClassName, cellClassName },
- cell: ({ row }) => ,
- },
{
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() {
}
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() {
-
-
{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 }) => (
- 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`)
}
/>
))}
)}
)}
@@ -959,13 +778,12 @@ export default function TrainScheduleV2ListPage() {
{dispatchTarget?.trainNumber ?? dispatchTarget?.reference ?? "This train"}
{" "}
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.
- 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.
{cancelTarget?.trainNumber ?? cancelTarget?.reference ?? "This train"}
{" "}
- 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.
{cancelTarget?.bookingsCount ? (
{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.
) : null}
@@ -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 (
+
+
+
+
+ {title}
+
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+
+ {schedule.reference ?? "—"}
+
+
+
+
+ );
+}
+
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 ? (
-
- ) : null}
- {remaining != null ? (
-
- ) : null}
+ {reserved > used ? : null}
+ {remaining != null ? : 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({
{schedule.direction ? (
-
+
{schedule.direction}
) : null}