mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user