From 1b086665271f33abc93654ea66c8ea4657d3f7f2 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 11:37:02 +0000 Subject: [PATCH] refactor(train-scheduling): move the schedule list onto the shared FilterBar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../trainScheduling/scheduleVisuals.tsx | 46 ++ .../TrainScheduleV2ListPage.tsx | 581 +++++++----------- 2 files changed, 264 insertions(+), 363 deletions(-) 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() { - - { - 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)" } }} - /> - { - setDestinationFilter(v ?? "ALL"); - resetPage(); - }} - data={[ - { value: "ALL", label: "All destinations" }, - ...yardOptions, - ]} - w={170} - styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} - /> -