From 6c4969f3fd62d9d7a7972714081d0aff4997a2cf Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 15 Aug 2026 07:51:31 +0000 Subject: [PATCH] feat(filters): migrate FleetCrudPages to the pill filter bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers 6 pages at once: the shared FleetCrudPage 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. --- .../src/pages/fleet/FleetCrudPages.tsx | 165 +++++++++++------- 1 file changed, 101 insertions(+), 64 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index 74ea2c3eb..cf86434ea 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -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 = { 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) => @@ -172,9 +181,16 @@ function FleetCrudPage({ removeSuccessMessage, hideViewAction = false, rowActions, + statusOptions, }: FleetCrudPageProps) { - 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(''); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [formOpen, setFormOpen] = useState(false); @@ -184,11 +200,13 @@ function FleetCrudPage({ const [fieldErrors, setFieldErrors] = useState>({}); 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({ 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({ -
- - { - setSearch(event.target.value); - setPage(1); - }} - /> -
+
@@ -379,10 +391,10 @@ function FleetCrudPage({ Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of {sorted.length}
- -
@@ -487,6 +499,47 @@ const statusBadge = (status?: string) => {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('code'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [formOpen, setFormOpen] = useState(false); @@ -546,18 +603,15 @@ export function WagonTypesCrudPage() { }); const [fieldErrors, setFieldErrors] = useState>({}); - 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), - ); - }, [query.data, search]); + 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], + ); 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() { - } - placeholder="Search wagon types" - value={search} - onChange={(event) => { - setSearch(event.currentTarget.value); - setPage(1); - }} - /> + @@ -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} - + @@ -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 },