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

Covers 6 pages at once: the shared FleetCrudPage<T> factory
(TrainMasterDataPage, WagonsCrudPage, ContainersCrudPage,
CargoesCrudPage, LocomotivesCrudPage) plus the standalone
WagonTypesCrudPage.

- FleetCrudPage gains an optional `statusOptions` prop; when passed it
  builds a Status enum FilterDef and swaps the old plain search Input
  for FilterBar + useFilters + applyClientFilters (client-bridge —
  these endpoints return bare arrays). Status option lists sourced
  from the actual entity/enum definitions, not guessed, and reused in
  each page's create/edit form Select instead of duplicating them.
  Column-header click-to-sort is untouched (separate mechanism).
- WagonTypesCrudPage (hand-rolled, not on the factory) gets a boolean
  Active/Inactive filter the same way, replacing its search-text hack
  that string-matched "active"/"inactive" against the query.
This commit is contained in:
Nathnael
2026-08-15 07:51:31 +00:00
parent 4b4ab21ea1
commit 6c4969f3fd

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { Edit, Eye, Plus, Trash2 } from 'lucide-react';
import { FormEvent, ReactNode, useMemo, useState } from 'react';
import { api } from '@/services/api';
@@ -44,6 +44,13 @@ import type { Train } from '@/services/trains.service';
import type { WagonType } from '@/services/wagon-types.service';
import type { Wagon } from '@/services/wagon.service';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import {
applyClientFilters,
FilterBar,
useFilters,
type FilterDef,
type FilterOption,
} from '@/components/filters';
type FormValue = string | number | boolean | string[];
@@ -86,6 +93,8 @@ type FleetCrudPageProps<T extends { id: string }> = {
hideViewAction?: boolean;
/** Optional custom actions rendered before the view/edit/delete buttons in each row. */
rowActions?: (item: T) => React.ReactNode;
/** Enables the Status filter pill; the item's `status` field is matched against these. */
statusOptions?: FilterOption[];
};
const normalizePayload = (values: Record<string, FormValue>) =>
@@ -172,9 +181,16 @@ function FleetCrudPage<T extends { id: string }>({
removeSuccessMessage,
hideViewAction = false,
rowActions,
statusOptions,
}: FleetCrudPageProps<T>) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const filterDefs: FilterDef[] = useMemo(
() =>
statusOptions
? [{ key: 'status', label: 'Status', type: 'enum', multiple: false, options: statusOptions }]
: [],
[statusOptions],
);
const controls = useFilters(filterDefs, { pageSize: 10 });
const [sortKey, setSortKey] = useState<string>('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
@@ -184,11 +200,13 @@ function FleetCrudPage<T extends { id: string }>({
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const { toast } = useToast();
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return data ?? [];
return (data ?? []).filter((item) => searchText(item).toLowerCase().includes(query));
}, [data, search, searchText]);
const filtered = useMemo(
() =>
applyClientFilters(data ?? [], filterDefs, controls.values, controls.searchText, {
searchValue: searchText,
}),
[data, filterDefs, controls.values, controls.searchText, searchText],
);
const sorted = useMemo(() => {
if (!sortKey) return filtered;
return [...filtered].sort((a, b) => {
@@ -198,12 +216,13 @@ function FleetCrudPage<T extends { id: string }>({
return sortDirection === 'asc' ? result : -result;
});
}, [filtered, sortDirection, sortKey]);
const pageSize = 10;
const pageSize = controls.pageSize;
const page = controls.page;
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
const toggleSort = (key: string) => {
setPage(1);
controls.setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
@@ -298,18 +317,11 @@ function FleetCrudPage<T extends { id: string }>({
</Button>
</div>
<div className="flex max-w-md items-center gap-2 rounded-md border bg-background px-3">
<Search className="size-4 text-muted-foreground" />
<Input
className="border-0 px-0 shadow-none focus-visible:ring-0"
placeholder={`Search ${title.toLowerCase()}`}
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
<FilterBar
defs={filterDefs}
controls={controls}
searchPlaceholder={`Search ${title.toLowerCase()}`}
/>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
@@ -379,10 +391,10 @@ function FleetCrudPage<T extends { id: string }>({
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of {sorted.length}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => setPage((current) => current - 1)}>
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => controls.setPage(page - 1)}>
Previous
</Button>
<Button variant="outline" size="sm" disabled={page === pageCount} onClick={() => setPage((current) => current + 1)}>
<Button variant="outline" size="sm" disabled={page === pageCount} onClick={() => controls.setPage(page + 1)}>
Next
</Button>
</div>
@@ -487,6 +499,47 @@ const statusBadge = (status?: string) => <Badge variant="outline">{status ?? '-'
const optionLabel = (options: { value: string; label: string }[], value?: string | null) =>
options.find((option) => option.value === value)?.label ?? value ?? '-';
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 WAGON_STATUS_OPTIONS: FilterOption[] = [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'IMPORT_READY', label: 'Import ready' },
{ value: 'EXPORT_READY', label: 'Export ready' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'DETAINED', label: 'Detained' },
];
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 LOCOMOTIVE_STATUS_OPTIONS: FilterOption[] = [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
];
export function TrainMasterDataPage() {
const query = useQuery(api.trains.list.queryOptions());
return (
@@ -499,6 +552,7 @@ export function TrainMasterDataPage() {
create={useMutation(api.trains.create.mutationOptions())}
update={useMutation(api.trains.update.mutationOptions())}
remove={useMutation(api.trains.remove.mutationOptions())}
statusOptions={TRAIN_STATUS_OPTIONS}
searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')}
columns={[
{ key: 'code', label: 'Code' },
@@ -522,14 +576,17 @@ export function TrainMasterDataPage() {
);
}
const WAGON_TYPE_FILTER_DEFS: FilterDef[] = [
{ key: 'isActive', label: 'Status', type: 'boolean', trueLabel: 'Active', falseLabel: 'Inactive' },
];
export function WagonTypesCrudPage() {
const query = useQuery(api.wagonTypes.list.queryOptions());
const create = useMutation(api.wagonTypes.create.mutationOptions());
const update = useMutation(api.wagonTypes.update.mutationOptions());
const remove = useMutation(api.wagonTypes.remove.mutationOptions());
const { toast } = useToast();
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const controls = useFilters(WAGON_TYPE_FILTER_DEFS, { pageSize: 10 });
const [sortKey, setSortKey] = useState<keyof WagonType>('code');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
@@ -546,18 +603,15 @@ export function WagonTypesCrudPage() {
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const pageSize = 10;
const filtered = useMemo(() => {
const queryText = search.trim().toLowerCase();
const rows = query.data ?? [];
if (!queryText) return rows;
return rows.filter((type) =>
[type.code, type.name, type.supportedLoadTypes?.join(' '), type.isActive ? 'active' : 'inactive']
.join(' ')
.toLowerCase()
.includes(queryText),
const pageSize = controls.pageSize;
const page = controls.page;
const filtered = useMemo(
() =>
applyClientFilters(query.data ?? [], WAGON_TYPE_FILTER_DEFS, controls.values, controls.searchText, {
searchValue: (type) => [type.code, type.name, type.supportedLoadTypes?.join(' ')].join(' '),
}),
[query.data, controls.values, controls.searchText],
);
}, [query.data, search]);
const sorted = useMemo(() => {
return [...filtered].sort((left, right) => {
@@ -573,7 +627,7 @@ export function WagonTypesCrudPage() {
const isSaving = create.isPending || update.isPending;
const toggleSort = (key: keyof WagonType) => {
setPage(1);
controls.setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
@@ -689,16 +743,7 @@ export function WagonTypesCrudPage() {
</MantineButton>
</Group>
<TextInput
maw={420}
leftSection={<Search size={16} />}
placeholder="Search wagon types"
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<FilterBar defs={WAGON_TYPE_FILTER_DEFS} controls={controls} searchPlaceholder="Search wagon types" />
<Paper withBorder radius="md">
<ScrollArea>
@@ -789,7 +834,7 @@ export function WagonTypesCrudPage() {
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of{' '}
{sorted.length}
</Text>
<Pagination total={pageCount} value={page} onChange={setPage} size="sm" />
<Pagination total={pageCount} value={page} onChange={controls.setPage} size="sm" />
</Group>
</Stack>
@@ -906,6 +951,7 @@ export function WagonsCrudPage() {
create={useMutation(api.wagons.create.mutationOptions())}
update={useMutation(api.wagons.update.mutationOptions())}
remove={useMutation(api.wagons.remove.mutationOptions())}
statusOptions={WAGON_STATUS_OPTIONS}
searchText={(wagon) => [
wagon.wagonNumber,
wagon.wagonTypeId,
@@ -959,14 +1005,7 @@ export function WagonsCrudPage() {
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'IMPORT_READY', label: 'Import ready' },
{ value: 'EXPORT_READY', label: 'Export ready' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'DETAINED', label: 'Detained' },
],
options: WAGON_STATUS_OPTIONS,
},
{ key: 'notes', label: 'Notes' },
]}
@@ -999,6 +1038,7 @@ export function ContainersCrudPage() {
create={useMutation(api.containers.create.mutationOptions())}
update={useMutation(api.containers.update.mutationOptions())}
remove={useMutation(api.containers.remove.mutationOptions())}
statusOptions={CONTAINER_STATUS_OPTIONS}
searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')}
columns={[
{ key: 'containerNumber', label: 'Number' },
@@ -1058,6 +1098,7 @@ export function CargoesCrudPage() {
create={useMutation(api.cargoes.create.mutationOptions())}
update={useMutation(api.cargoes.update.mutationOptions())}
remove={useMutation(api.cargoes.remove.mutationOptions())}
statusOptions={CARGO_STATUS_OPTIONS}
searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')}
columns={[
{ key: 'cargoReference', label: 'Reference' },
@@ -1122,6 +1163,7 @@ export function LocomotivesCrudPage() {
removeActionLabel="Decommission"
removeConfirmMessage="Decommission this locomotive?"
removeSuccessMessage="Locomotive decommissioned"
statusOptions={LOCOMOTIVE_STATUS_OPTIONS}
searchText={(locomotive) =>
[
locomotive.code,
@@ -1166,12 +1208,7 @@ export function LocomotivesCrudPage() {
label: 'Status',
type: 'select',
required: true,
options: [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
],
options: LOCOMOTIVE_STATUS_OPTIONS,
},
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },