From 8ba8376f45f0e035ab5618b07c4f6da5f1191870 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 15 Aug 2026 08:39:59 +0000 Subject: [PATCH] feat(filters): migrate FleetResourcePage to the pill filter bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real live fleet page — FleetCrudPages.tsx (previous commit) turned out to be dead code; every fleet route (locomotives/trains/wagons/ containers/cargoes/vehicles/drivers) actually renders this one, config-driven off resources.ts. Highest-leverage single file in the remaining inventory: 7 routes at once. - filterDefs are built per slug from `config.listFilters` (status/yard/ wagon type/train…, wired to real server params via each def's default `{key: value}` toParams) plus a "Registered" date range. The 3 slugs with no server list filters (trains/containers/cargoes) fall back to a plain client-only Status filter off a fixed enum, not "whatever status exists in the currently-loaded rows" — the latter would be circular (filterDefs feeds useFilters, which feeds the query that produces those rows). - serverListFilters/pagedFilters derive straight from `controls.params` instead of hand-picking each field off local state — the def keys already match the API's param names, so this is mostly free. - filteredRows keeps the original's exact "usesServerListFilters ⇒ trust the server, don't re-check client-side" short-circuit — some server list filters (a wagon's `trainNumber` matches either of two different columns) aren't expressible as a plain client-side field equality, and re-applying them would silently break. - Pagination moved from a local `usePagination()` (component state) to useFilters' URL-backed page/pageSize — the whole point of this pass. DataTable takes `controls.tableProps(total)` directly; FleetCardGrid (not a DataTable) is fed the same pieces by hand. - The two manual "reset on slug/filter change" effects are dropped — a same-app nav Link to a bare path already clears the query string, and useFilters already deletes `page` on every filter/search change. - FleetToolbar's view-mode SegmentedControl moves into FilterBar's `children` slot; its search/filters props are no longer used here. --- .../src/pages/fleet/FleetResourcePage.tsx | 399 ++++++++---------- 1 file changed, 168 insertions(+), 231 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index d837ee712..23dc6b76b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -1,7 +1,5 @@ import type { ColumnDef } from "@edr/ui-common"; -import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core"; -import { DatePickerInput } from "@mantine/dates"; -import { getDateRangePresets } from "@/components/common/dateRangePresets"; +import { Box, Button, Card, Container, Group, Modal, SegmentedControl, Select, Stack, Text, TextInput, Title } from "@mantine/core"; import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -13,7 +11,7 @@ import { FREIGHT_PERMS, } from "@/lib/permissions"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import { Inbox, Plus, Warehouse } from "lucide-react"; +import { Inbox, LayoutGrid, Plus, Table2, Warehouse } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link, Navigate, useLocation } from "react-router-dom"; @@ -21,13 +19,12 @@ import FleetCardGrid from "@/components/fleet/FleetCardGrid"; import FleetFormDialog from "@/components/fleet/FleetFormDialog"; import FleetHistoryModal from "@/components/fleet/FleetHistoryModal"; import FleetRecordActions from "@/components/fleet/FleetRecordActions"; -import FleetToolbar from "@/components/fleet/FleetToolbar"; import { matchesDayRange } from "@/hooks/useListControls"; import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal"; import WagonStatusActions from "@/components/wagons/WagonStatusActions"; import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal"; import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat"; -import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; +import { useFleetViewMode, type FleetViewMode } from "@/components/fleet/useFleetViewMode"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { useToast } from "@/hooks/use-toast"; import { @@ -43,11 +40,46 @@ import { type FleetListFilters, type FleetRecord, } from "@/services/fleet/fleet.service"; -import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; -import { useDebouncedValue } from "@mantine/hooks"; +import { DataTable, DataTableFooter } from "@edr/ui-common"; +import { dateRangeParams, FilterBar, useFilters, type FilterDef, type FilterOption } from "@/components/filters"; const DEFAULT_SLUG: FleetResourceSlug = "locomotives"; +const SERVER_FILTERED_SLUGS: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"]; + +// trains/containers/cargoes have no server-side `listFilters` config (see +// resources.ts) — they get a plain client-only Status filter instead, off a +// fixed enum rather than "whatever status happens to exist in the currently +// loaded rows" (which would create a circular dependency: filterDefs feeds +// useFilters, which feeds the query that produces those rows). +const TRAIN_STATUS_OPTIONS: FilterOption[] = [ + { value: "AVAILABLE", label: "Available" }, + { value: "SCHEDULED", label: "Scheduled" }, + { value: "IN_SERVICE", label: "In service" }, + { value: "UNDER_MAINTENANCE", label: "Under maintenance" }, + { value: "OUT_OF_SERVICE", label: "Out of service" }, + { value: "DEACTIVATED", label: "Deactivated" }, +]; +const CONTAINER_STATUS_OPTIONS: FilterOption[] = [ + { value: "AVAILABLE", label: "Available" }, + { value: "LOADED", label: "Loaded" }, + { value: "IN_TRANSIT", label: "In transit" }, + { value: "MAINTENANCE", label: "Maintenance" }, + { value: "DAMAGED", label: "Damaged" }, +]; +const CARGO_STATUS_OPTIONS: FilterOption[] = [ + { value: "PENDING", label: "Pending" }, + { value: "LOADED", label: "Loaded" }, + { value: "IN_TRANSIT", label: "In transit" }, + { value: "DELIVERED", label: "Delivered" }, + { value: "UNLOADED", label: "Unloaded" }, +]; +const FALLBACK_STATUS_OPTIONS: Partial> = { + trains: TRAIN_STATUS_OPTIONS, + containers: CONTAINER_STATUS_OPTIONS, + cargoes: CARGO_STATUS_OPTIONS, +}; + const FleetResourcePage = () => { const location = useLocation(); const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG; @@ -70,18 +102,9 @@ const FleetResourcePage = () => { hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill) || hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll); - const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [search, setSearch] = useState(""); - const [debouncedSearch] = useDebouncedValue(search, 300); // Wagons and locomotives page in the database; the rest still list in full // and page in the browser (see `pagedHandlers` in fleet.service). const serverPaged = isFleetServerPaginated(slug); - const [statusFilter, setStatusFilter] = useState("ALL"); - // Registration date range. Server-side list filters (status/yard/train) are - // applied by the API; this narrows what comes back, alongside search. - const [dateFrom, setDateFrom] = useState(null); - const [dateTo, setDateTo] = useState(null); - const [listFilterValues, setListFilterValues] = useState>({}); const [formOpen, setFormOpen] = useState(false); const [editing, setEditing] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); @@ -95,77 +118,6 @@ const FleetResourcePage = () => { const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false); const { viewMode, setViewMode } = useFleetViewMode(slug); - const serverListFilters = useMemo((): FleetListFilters | undefined => { - const serverFilteredSlugs: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"]; - if (!serverFilteredSlugs.includes(slug)) return undefined; - const filters: FleetListFilters = {}; - const status = listFilterValues.status; - const currentYardId = listFilterValues.currentYardId; - const availability = listFilterValues.availability; - const trainNumber = listFilterValues.trainNumber; - const trainId = listFilterValues.trainId; - if (status && status !== "ALL") { - (filters as { status?: string }).status = status; - } - if (currentYardId && currentYardId !== "ALL") { - filters.currentYardId = currentYardId; - } - if (availability && availability !== "ALL") { - (filters as { availability?: string }).availability = availability; - } - if (trainNumber && trainNumber !== "ALL") { - (filters as { trainNumber?: string }).trainNumber = trainNumber; - } - if (trainId && trainId !== "ALL") { - filters.trainId = trainId; - } - // Wagons only: narrow the fleet to one wagon type (the API filters on it). - const wagonTypeId = listFilterValues.wagonTypeId; - if (wagonTypeId && wagonTypeId !== "ALL") { - (filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId; - } - // The plain locomotives list has no server-side search — its page window - // does, so the term is only sent on the paginated path. - if ((serverPaged || slug !== "locomotives") && debouncedSearch.trim()) { - filters.search = debouncedSearch.trim(); - } - return filters; - }, [slug, listFilterValues, debouncedSearch, serverPaged]); - - // On the server-paged path the page window, the search and the registration - // date range are all resolved by the API — nothing is filtered client-side. - const pagedFilters = useMemo( - (): FleetListFilters => ({ - ...serverListFilters, - page: pagination.pageIndex + 1, - pageSize: pagination.pageSize, - ...(dateFrom ? { createdFrom: dateFrom } : {}), - ...(dateTo ? { createdTo: dateTo } : {}), - }), - [serverListFilters, pagination.pageIndex, pagination.pageSize, dateFrom, dateTo], - ); - - const listQuery = useQuery({ - ...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }), - enabled: !serverPaged, - }); - const pagedQuery = useQuery({ - ...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }), - enabled: serverPaged, - placeholderData: keepPreviousData, - }); - - const activeQuery = serverPaged ? pagedQuery : listQuery; - const { isLoading, isError, error } = activeQuery; - const allRows = useMemo( - () => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])), - [serverPaged, pagedQuery.data, listQuery.data], - ); - const create = useMutation(api.fleet.create.mutationOptions()); - const update = useMutation(api.fleet.update.mutationOptions()); - const remove = useMutation(api.fleet.remove.mutationOptions()); - const purge = useMutation(api.fleet.purge.mutationOptions()); - const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery( api.wagonTypes.list.queryOptions(), ); @@ -202,40 +154,8 @@ const FleetResourcePage = () => { enabled: slug === "wagons", }); - useEffect(() => { - setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); - setSearch(""); - setStatusFilter("ALL"); - setListFilterValues({}); - }, [slug, setPagination]); - - useEffect(() => { - setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); - }, [search, listFilterValues, dateFrom, dateTo, setPagination]); - - const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status")); const usesServerListFilters = Boolean(config?.listFilters?.length); - const statusFilterOptions = useMemo(() => { - if (!hasStatusColumn || usesServerListFilters) return []; - if (slug === "vehicles" || slug === "drivers") { - return [ - { value: "ALL", label: "All statuses" }, - { value: "ACTIVE", label: "Active" }, - { value: "INACTIVE", label: "Inactive" }, - ]; - } - const statuses = new Set( - allRows - .map((row) => String((row as unknown as Record).status ?? "")) - .filter(Boolean), - ); - return [ - { value: "ALL", label: "All statuses" }, - ...[...statuses].sort().map((status) => ({ value: status, label: status })), - ]; - }, [allRows, hasStatusColumn, usesServerListFilters, slug]); - const dynamicOptions = useMemo(() => { const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map( (t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }), @@ -291,25 +211,78 @@ const FleetResourcePage = () => { }; }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]); - const listFilterSelects = useMemo(() => { - if (!config?.listFilters?.length) return null; - return config.listFilters.map((filter) => { - const dynamicOpts = filter.dynamicOptions - ? (dynamicOptions[filter.dynamicOptions] ?? []) - : []; - const staticOpts = - filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? []; - const opts = filter.dynamicOptions ? dynamicOpts : staticOpts; - return { - ...filter, - value: listFilterValues[filter.key] ?? "ALL", - data: [ - { value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` }, - ...opts, - ], - }; - }); - }, [config?.listFilters, listFilterValues, dynamicOptions]); + // One pill per configured server list filter (status/yard/wagon type/train…), + // built off `config.listFilters` — same source the old plain ` { - setListFilterValues((prev) => ({ - ...prev, - [filter.key]: value ?? "ALL", - })); - setPagination((prev) => ({ ...prev, pageIndex: 0 })); - }} - size="sm" - radius="lg" - w={200} - searchable={filter.data.length > 8} - comboboxProps={{ withinPortal: true }} - styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} - /> - ))} - - ) : hasStatusColumn && statusFilterOptions.length > 1 ? ( - - Status: - - {[{ value: "ALL", label: "All" }, ...statusFilterOptions].map((option) => ( - - ))} - - - ) : null} - - } - /> + viewId={`fleet-${slug}`} + > + setViewMode(value as FleetViewMode)} + size="sm" + radius="lg" + data={[ + { + value: "table", + label: ( + + + Table + + ), + }, + { + value: "cards", + label: ( + + + Cards + + ), + }, + ]} + styles={{ root: { background: "var(--mantine-color-gray-1)" } }} + /> + {viewMode === "table" ? ( @@ -696,18 +643,8 @@ const FleetResourcePage = () => { : undefined } emptyMessage={`No ${itemLabel} found`} - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount, - }} - tableOptions={{ - manualPagination: true, - pageCount, - state: { pagination }, - onPaginationChange: setPagination, - }} + pagination={dtPagination} + tableOptions={dtTableOptions} containerClassName="border-0 shadow-none bg-transparent" footer={({ table, pagination: footerPagination }) => ( { rows={pagedRows} status={tableStatus} emptyMessage={`No ${itemLabel} found`} - pagination={pagination} + pagination={{ pageIndex: controls.page - 1, pageSize: controls.pageSize }} pageCount={pageCount} totalCount={totalCount} - onPaginationChange={setPagination} + onPaginationChange={dtTableOptions!.onPaginationChange!} onEdit={ canUpdate ? (record) => {