feat(filters): migrate FleetResourcePage to the pill filter bar

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.
This commit is contained in:
Nathnael
2026-08-15 08:39:59 +00:00
parent 69805315d9
commit 8ba8376f45

View File

@@ -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<Record<FleetResourceSlug, FilterOption[]>> = {
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<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(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<string, unknown>).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,
],
// One pill per configured server list filter (status/yard/wagon type/train…),
// built off `config.listFilters` — same source the old plain `<Select>` row
// read, just reshaped into FilterDefs. Falls back to a plain client-only
// Status filter for the 3 slugs with no server-side list filters at all.
const filterDefs: FilterDef[] = useMemo(() => {
const dateDef: FilterDef = {
key: "created",
label: "Registered",
type: "date",
secondary: true,
toParams: dateRangeParams("createdFrom", "createdTo"),
};
if (config?.listFilters?.length) {
return [
...config.listFilters.map((filter): FilterDef => ({
key: filter.key,
label: filter.label,
type: "enum",
multiple: false,
options: filter.dynamicOptions
? (dynamicOptions[filter.dynamicOptions] ?? [])
: (filter.options ?? []),
})),
dateDef,
];
}
const fallback = FALLBACK_STATUS_OPTIONS[slug];
return fallback
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef]
: [dateDef];
}, [config, dynamicOptions, slug]);
const controls = useFilters(filterDefs, { pageSize: 10 });
// 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.
// `controls.params` already carries every filter's mapped param name (status/
// currentYardId/wagonTypeId/… default to `{key: value}`, "created" maps to
// createdFrom/createdTo) plus search/page/pageSize — it IS the paged filter
// object; the unpaged one is the same minus pagination and the date range
// (which stays client-only for the non-server-paged slugs, see below).
const serverListFilters = useMemo((): FleetListFilters | undefined => {
if (!SERVER_FILTERED_SLUGS.includes(slug)) return undefined;
const { page: _page, pageSize: _pageSize, createdFrom: _cf, createdTo: _ct, ...rest } = controls.params;
return rest as FleetListFilters;
}, [slug, controls.params]);
const pagedFilters = useMemo(
(): FleetListFilters => controls.params as unknown as FleetListFilters,
[controls.params],
);
const listQuery = useQuery({
...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }),
enabled: !serverPaged,
});
}, [config?.listFilters, listFilterValues, dynamicOptions]);
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());
useEffect(() => {
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
@@ -349,16 +322,22 @@ const FleetResourcePage = () => {
// The API already applied every filter and cut the page — re-filtering here
// would drop rows the server deliberately returned.
if (serverPaged) return allRows;
const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record<string, unknown>;
// The date range applies even when the API already filtered the list —
// it is not one of the server-side filters.
if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false;
if (usesServerListFilters) return true;
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
const created = controls.values.created;
if (created && !matchesDayRange(record.createdAt, created.v[0]?.slice(0, 10) ?? null, created.v[1]?.slice(0, 10) ?? null)) {
return false;
}
// Every other filter (status/yard/wagon type/…) was already applied
// server-side for these slugs — re-checking here against a plain field
// equality would be wrong for one (a wagon's "trainNumber" filter
// matches either of two DIFFERENT columns server-side, not one).
if (usesServerListFilters) return true;
const status = controls.values.status;
if (status && String(record.status ?? "") !== status.v[0]) return false;
const term = controls.searchText.trim().toLowerCase();
if (!term) return true;
return config.searchKeys.some((key) =>
String(record[key] ?? "")
@@ -366,19 +345,23 @@ const FleetResourcePage = () => {
.includes(term),
);
});
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]);
}, [allRows, config, usesServerListFilters, controls.values, controls.searchText, serverPaged]);
const totalCount = serverPaged
? (pagedQuery.data?.meta.total ?? 0)
: filteredRows.length;
const pageCount = serverPaged
? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1)
: Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
: Math.max(1, Math.ceil(filteredRows.length / controls.pageSize));
const pagedRows = useMemo(() => {
if (serverPaged) return filteredRows;
const start = pagination.pageIndex * pagination.pageSize;
return filteredRows.slice(start, start + pagination.pageSize);
}, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]);
const start = (controls.page - 1) * controls.pageSize;
return filteredRows.slice(start, start + controls.pageSize);
}, [filteredRows, controls.page, controls.pageSize, serverPaged]);
// Same {pagination, tableOptions} shape DataTable takes directly; FleetCardGrid
// (not a DataTable) just needs the raw pieces out of it below.
const { pagination: dtPagination, tableOptions: dtTableOptions } = controls.tableProps(totalCount);
const columns = useMemo((): ColumnDef<FleetRecord>[] => {
if (!config) return [];
@@ -604,77 +587,41 @@ const FleetResourcePage = () => {
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="md" w="100%" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
<FleetToolbar
search={search}
onSearchChange={setSearch}
<FilterBar
defs={filterDefs}
controls={controls}
searchPlaceholder={config.searchPlaceholder}
showSearch={config.supportsSearch}
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
<Group gap="sm" wrap="wrap" align="center">
<DatePickerInput
type="range"
aria-label="Created date range"
placeholder="Created date range"
value={[dateFrom, dateTo]}
onChange={([from, to]) => {
setDateFrom(from);
setDateTo(to);
}}
presets={getDateRangePresets()}
clearable
size="sm"
radius="lg"
w={240}
/>
{listFilterSelects ? (
<Group gap="sm" wrap="wrap" align="center">
{listFilterSelects.map((filter) => (
<Select
key={filter.key}
aria-label={filter.label}
placeholder={filter.data[0]?.label ?? filter.label}
data={filter.data}
value={filter.value}
onChange={(value) => {
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)" } }}
/>
))}
</Group>
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
<Group gap={4} wrap="wrap">
<Text size="xs" fw={500} c="dimmed">Status:</Text>
<Group gap={4} wrap="wrap">
{[{ value: "ALL", label: "All" }, ...statusFilterOptions].map((option) => (
<Button
key={option.value}
size="xs"
radius="md"
variant={statusFilter === option.value ? "filled" : "outline"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setStatusFilter(option.value)}
viewId={`fleet-${slug}`}
>
{option.label}
</Button>
))}
<SegmentedControl
value={viewMode}
onChange={(value) => setViewMode(value as FleetViewMode)}
size="sm"
radius="lg"
data={[
{
value: "table",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Table2 size={14} />
<span>Table</span>
</Group>
),
},
{
value: "cards",
label: (
<Group gap={6} justify="center" wrap="nowrap">
<LayoutGrid size={14} />
<span>Cards</span>
</Group>
) : null}
</Group>
}
),
},
]}
styles={{ root: { background: "var(--mantine-color-gray-1)" } }}
/>
</FilterBar>
</Box>
{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 }) => (
<DataTableFooter
@@ -724,10 +661,10 @@ const FleetResourcePage = () => {
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) => {