feat(filter-bar): migrate 3 more fleet pages, extract footer adapter

- ruleEngineFooterProps.ts: toRuleEngineFooterProps() — the
  useFilters-to-RuleEngineListFooter pagination adapter had already been
  hand-written twice (TrucksOnSitePage, CompliancePage) with the same
  pageSize-drop risk useFilters itself just had fixed; extracted before a
  third copy could drift.
- CompliancePage, FuelPurchasePage, IncidentsPage: ListControls ->
  FilterBar + applyClientFilters, same mechanical pattern as the
  warehouse pages (search + one date range, endpoints take no params at
  all per the inventory sweep — confirmed correct bucket, not assumed).
This commit is contained in:
Nathnael
2026-08-14 14:13:11 +00:00
parent fa32fa6a96
commit e5e75b4166
6 changed files with 122 additions and 73 deletions

View File

@@ -3,6 +3,7 @@ export * from "./url";
export * from "./dates"; export * from "./dates";
export * from "./format"; export * from "./format";
export * from "./clientFilter"; export * from "./clientFilter";
export * from "./ruleEngineFooterProps";
export * from "./useFilters"; export * from "./useFilters";
export * from "./useSavedViews"; export * from "./useSavedViews";
export { FilterBar } from "./FilterBar"; export { FilterBar } from "./FilterBar";

View File

@@ -0,0 +1,32 @@
import type { OnChangeFn, PaginationState } from "@edr/ui-common";
import type { UseFilters } from "./useFilters";
/**
* Adapts `useFilters`'s URL-backed page/pageSize to `RuleEngineListFooter`'s
* prop shape, for the client-bridge pages that render a plain `<Table>` +
* that footer instead of `<DataTable>` (which has `tableProps()` for this).
* Routes page-index vs page-size changes to the right setter — the same
* pageSize-gets-silently-dropped bug `tableProps()` had before it was fixed.
*/
export function toRuleEngineFooterProps(
controls: Pick<UseFilters, "page" | "pageSize" | "setPage" | "setPageSize">,
totalCount: number,
): {
pagination: PaginationState;
pageCount: number;
totalCount: number;
onPaginationChange: OnChangeFn<PaginationState>;
} {
const { page, pageSize, setPage, setPageSize } = controls;
return {
pagination: { pageIndex: page - 1, pageSize },
pageCount: Math.max(1, Math.ceil(totalCount / pageSize)),
totalCount,
onPaginationChange: (updater) => {
const current = { pageIndex: page - 1, pageSize };
const next = typeof updater === "function" ? updater(current) : updater;
if (next.pageSize !== pageSize) setPageSize(next.pageSize);
else if (next.pageIndex !== current.pageIndex) setPage(next.pageIndex + 1);
},
};
}

View File

@@ -18,11 +18,16 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { Plus, AlertTriangle } from "lucide-react"; import { Plus, AlertTriangle } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists // Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path. // despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls"; import {
applyClientFilters,
FilterBar,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
} from "@/components/filters";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { import {
complianceService, complianceService,
@@ -52,6 +57,8 @@ const statusColor = (status: ComplianceRecord["status"]) => {
const formatDate = (value?: string | null) => const formatDate = (value?: string | null) =>
value ? new Date(value).toLocaleDateString() : "—"; value ? new Date(value).toLocaleDateString() : "—";
const COMPLIANCE_FILTER_DEFS: FilterDef[] = [{ key: "expiryDate", label: "Expiry", type: "date" }];
const emptyForm = { const emptyForm = {
vehicleId: "", vehicleId: "",
type: "INSPECTION" as ComplianceType, type: "INSPECTION" as ComplianceType,
@@ -91,10 +98,18 @@ export default function CompliancePage() {
}, },
}); });
const controls = useListControls(records as ComplianceRecord[], { const controls = useFilters(COMPLIANCE_FILTER_DEFS, { pageSize: 10 });
searchKeys: ["type", "status", "documentNumber"], const filteredRecords = applyClientFilters(
dateKey: "expiryDate", records as ComplianceRecord[],
}); COMPLIANCE_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ["type", "status", "documentNumber"] },
);
const pagedRecords = filteredRecords.slice(
(controls.page - 1) * controls.pageSize,
controls.page * controls.pageSize,
);
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (data: typeof formData) => { mutationFn: async (data: typeof formData) => {
@@ -220,17 +235,11 @@ export default function CompliancePage() {
Compliance Records Compliance Records
</Title> </Title>
<Card withBorder> <Card withBorder>
<ListControls <FilterBar
search={controls.search} defs={COMPLIANCE_FILTER_DEFS}
onSearchChange={controls.setSearch} controls={controls}
searchPlaceholder="Search type, status, document no…" searchPlaceholder="Search type, status, document no…"
dateFrom={controls.dateFrom} viewId="fleet-compliance"
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Expiry"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/> />
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead> <Table.Thead>
@@ -261,7 +270,7 @@ export default function CompliancePage() {
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
) : null} ) : null}
{controls.pagedRows.map((record) => ( {pagedRecords.map((record) => (
<Table.Tr key={record.id}> <Table.Tr key={record.id}>
<Table.Td>{vehicleLabel(record)}</Table.Td> <Table.Td>{vehicleLabel(record)}</Table.Td>
<Table.Td> <Table.Td>
@@ -282,11 +291,8 @@ export default function CompliancePage() {
</Table.Tbody> </Table.Tbody>
</Table> </Table>
<RuleEngineListFooter <RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="records" itemLabel="records"
onPaginationChange={controls.setPagination} {...toRuleEngineFooterProps(controls, filteredRecords.length)}
/> />
</Card> </Card>

View File

@@ -19,11 +19,16 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists // Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path. // despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls"; import {
applyClientFilters,
FilterBar,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
} from "@/components/filters";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { api } from "@/auth/http"; import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -45,6 +50,8 @@ interface FuelPurchase {
} }
const FUEL_FILTER_DEFS: FilterDef[] = [{ key: "purchaseDate", label: "Purchased", type: "date" }];
export default function FuelPurchasePage() { export default function FuelPurchasePage() {
const { toast } = useToast(); const { toast } = useToast();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -123,10 +130,18 @@ export default function FuelPurchasePage() {
const totalCost = formData.liters * formData.costPerLiter; const totalCost = formData.liters * formData.costPerLiter;
// Aggregate stats (guarded against divide-by-zero when there are no purchases) // Aggregate stats (guarded against divide-by-zero when there are no purchases)
const controls = useListControls(purchasesData as FuelPurchase[], { const controls = useFilters(FUEL_FILTER_DEFS, { pageSize: 10 });
searchKeys: ["fuelStation", "paymentMethod"], const filteredPurchases = applyClientFilters(
dateKey: "purchaseDate", purchasesData as FuelPurchase[],
}); FUEL_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ["fuelStation", "paymentMethod"] },
);
const pagedPurchases = filteredPurchases.slice(
(controls.page - 1) * controls.pageSize,
controls.page * controls.pageSize,
);
const totalLiters = (purchasesData as FuelPurchase[]).reduce( const totalLiters = (purchasesData as FuelPurchase[]).reduce(
(sum, p) => sum + Number(p.liters), (sum, p) => sum + Number(p.liters),
@@ -195,17 +210,11 @@ export default function FuelPurchasePage() {
{/* Purchases Table */} {/* Purchases Table */}
<Card withBorder> <Card withBorder>
<ListControls <FilterBar
search={controls.search} defs={FUEL_FILTER_DEFS}
onSearchChange={controls.setSearch} controls={controls}
searchPlaceholder="Search station or payment method…" searchPlaceholder="Search station or payment method…"
dateFrom={controls.dateFrom} viewId="fleet-fuel-purchases"
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Purchased"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/> />
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead> <Table.Thead>
@@ -237,7 +246,7 @@ export default function FuelPurchasePage() {
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
) : null} ) : null}
{controls.pagedRows.map((purchase) => ( {pagedPurchases.map((purchase) => (
<Table.Tr key={purchase.id}> <Table.Tr key={purchase.id}>
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td> <Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td> <Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
@@ -253,11 +262,8 @@ export default function FuelPurchasePage() {
</Table.Tbody> </Table.Tbody>
</Table> </Table>
<RuleEngineListFooter <RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="purchases" itemLabel="purchases"
onPaginationChange={controls.setPagination} {...toRuleEngineFooterProps(controls, filteredPurchases.length)}
/> />
</Card> </Card>

View File

@@ -20,11 +20,16 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists // Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path. // despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls"; import {
applyClientFilters,
FilterBar,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
} from "@/components/filters";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { import {
incidentsService, incidentsService,
@@ -89,6 +94,8 @@ const initialForm = {
reportedBy: "", reportedBy: "",
}; };
const INCIDENT_FILTER_DEFS: FilterDef[] = [{ key: "occurredAt", label: "Occurred", type: "date" }];
export default function IncidentsPage() { export default function IncidentsPage() {
const { toast } = useToast(); const { toast } = useToast();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -166,10 +173,18 @@ export default function IncidentsPage() {
})) || []; })) || [];
const incidents = incidentsData as Incident[]; const incidents = incidentsData as Incident[];
const controls = useListControls(incidents, { const controls = useFilters(INCIDENT_FILTER_DEFS, { pageSize: 10 });
searchKeys: ["type", "severity", "status"], const filteredIncidents = applyClientFilters(
dateKey: "occurredAt", incidents,
}); INCIDENT_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ["type", "severity", "status"] },
);
const pagedIncidents = filteredIncidents.slice(
(controls.page - 1) * controls.pageSize,
controls.page * controls.pageSize,
);
const totalCount = incidents.length; const totalCount = incidents.length;
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length; const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length; const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
@@ -246,17 +261,11 @@ export default function IncidentsPage() {
{/* Incidents Table */} {/* Incidents Table */}
<Card withBorder> <Card withBorder>
<ListControls <FilterBar
search={controls.search} defs={INCIDENT_FILTER_DEFS}
onSearchChange={controls.setSearch} controls={controls}
searchPlaceholder="Search type, severity, status…" searchPlaceholder="Search type, severity, status…"
dateFrom={controls.dateFrom} viewId="fleet-incidents"
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Occurred"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/> />
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead> <Table.Thead>
@@ -288,7 +297,7 @@ export default function IncidentsPage() {
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
) : null} ) : null}
{controls.pagedRows.map((incident) => ( {pagedIncidents.map((incident) => (
<Table.Tr key={incident.id}> <Table.Tr key={incident.id}>
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td> <Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
<Table.Td> <Table.Td>
@@ -316,11 +325,8 @@ export default function IncidentsPage() {
</Table.Tbody> </Table.Tbody>
</Table> </Table>
<RuleEngineListFooter <RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="incidents" itemLabel="incidents"
onPaginationChange={controls.setPagination} {...toRuleEngineFooterProps(controls, filteredIncidents.length)}
/> />
</Card> </Card>

View File

@@ -14,7 +14,13 @@ import { PageContainer, PageHeader } from "@/components/page";
// Generic list footer — already shared by the fleet and train-scheduling lists // Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path; reused here rather than adding a second one. // despite the ruleEngine path; reused here rather than adding a second one.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from "@/components/filters"; import {
applyClientFilters,
FilterBar,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
} from "@/components/filters";
import { useTrucksOnSite } from "@/hooks/useWarehouses"; import { useTrucksOnSite } from "@/hooks/useWarehouses";
import type { TruckOnSite } from "@/types/warehouse"; import type { TruckOnSite } from "@/types/warehouse";
import { formatDateTime } from "@/lib/format"; import { formatDateTime } from "@/lib/format";
@@ -231,16 +237,8 @@ export default function TrucksOnSitePage() {
<> <>
<Rows rows={pagedTrucks} /> <Rows rows={pagedTrucks} />
<RuleEngineListFooter <RuleEngineListFooter
pagination={{ pageIndex: controls.page - 1, pageSize: controls.pageSize }}
pageCount={Math.max(1, Math.ceil(filteredTrucks.length / controls.pageSize))}
totalCount={filteredTrucks.length}
itemLabel="trucks" itemLabel="trucks"
onPaginationChange={(updater) => { {...toRuleEngineFooterProps(controls, filteredTrucks.length)}
const current = { pageIndex: controls.page - 1, pageSize: controls.pageSize };
const next = typeof updater === "function" ? updater(current) : updater;
if (next.pageSize !== controls.pageSize) controls.setPageSize(next.pageSize);
else controls.setPage(next.pageIndex + 1);
}}
/> />
</> </>
)} )}