mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(filter-bar): fix pageSize bug, add client bridge, migrate 4 more pages
fix(useFilters): pageSize was a hardcoded constant, never read from the URL, with no setter — the DataTable/RuleEngineListFooter page-size dropdown silently did nothing on every page using the filter bar, including the already-shipped ContractRequestsPage pilot. Added a `size` URL param (mirrors `page`), `setPageSize`, and wired `tableProps().onPaginationChange` to route page-size vs page-index changes to the right setter instead of only ever calling setPage(). feat(clientFilter): applyClientFilters — the Family-B bridge the plan called for. Generalizes ListControls'/useListControls' one hardcoded search box + one date range to every FilterDef, matched against `row[def.key]`. Reuses matchesDayRange/toDayString from hooks/useListControls.ts (imported, not duplicated) so the inclusive- range/timezone-safe semantics stay defined in exactly one place. Lets a page ship the full pill-bar UI immediately and flip to server-side filtering later by deleting one function call — no endpoint changes required up front. Migrated to the filter bar (mechanical, pattern established by ContractRequestsPage): - WarehouseInvoicesPage, LoadedInventoryPage, TrucksOnSitePage — Family B (ListControls/useListControls → FilterBar + applyClientFilters) - CustomersPage — Family A (useState bag → useFilters), no filter pills needed here (search + sort only), the SegmentedControl "view" stays page-level tab state (like ContractStatusTabs), not a filter pill — it now resets the page via controls.setPage(1) on change, the same hazard useFilters already guards its own filters against
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { matchesDayRange, toDayString } from "@/hooks/useListControls";
|
||||
import type { FilterDef, FilterValue } from "./types";
|
||||
|
||||
export { matchesDayRange, toDayString };
|
||||
|
||||
const readField = (row: unknown, key: string): unknown =>
|
||||
row && typeof row === "object" ? (row as Record<string, unknown>)[key] : undefined;
|
||||
|
||||
export interface ClientFilterOptions<T> {
|
||||
/** Row fields matched against the free-text search box. */
|
||||
searchKeys?: (keyof T)[];
|
||||
/** Custom search extractor when the value isn't a top-level field. */
|
||||
searchValue?: (row: T) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side bridge for pages whose endpoint doesn't (yet) accept
|
||||
* filter/sort/pagination params — the Family-B pages this app inherited from
|
||||
* `ListControls`/`useListControls`. Same idea, generalized: instead of one
|
||||
* hardcoded search box + one date range, every `FilterDef` is matched
|
||||
* against `row[def.key]` (override the def's `key` to line up with the row
|
||||
* shape, or filter/map the rows before calling this).
|
||||
*
|
||||
* Flip a page to server mode later by deleting the `applyClientFilters` call
|
||||
* and passing `controls.params` straight to the API — `useFilters`'s output
|
||||
* shape doesn't change either way.
|
||||
*
|
||||
* ponytail: linear scan per keystroke, no debounce — matches
|
||||
* `useListControls`'s existing behavior at this data size (~1k rows,
|
||||
* `useListControls.ts:4-18`). Move to server-side filtering if a list
|
||||
* outgrows that.
|
||||
*/
|
||||
export function applyClientFilters<T>(
|
||||
rows: T[],
|
||||
defs: FilterDef[],
|
||||
values: Record<string, FilterValue>,
|
||||
searchText: string,
|
||||
options: ClientFilterOptions<T> = {},
|
||||
): T[] {
|
||||
const term = searchText.trim().toLowerCase();
|
||||
const { searchKeys = [], searchValue } = options;
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (term) {
|
||||
const haystack = searchValue
|
||||
? searchValue(row)
|
||||
: searchKeys.map((k) => String(readField(row, String(k)) ?? "")).join(" ");
|
||||
if (!haystack.toLowerCase().includes(term)) return false;
|
||||
}
|
||||
for (const def of defs) {
|
||||
const value = values[def.key];
|
||||
if (!value) continue;
|
||||
if (!matchesFilter(def, value, readField(row, def.key))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function matchesFilter(def: FilterDef, value: FilterValue, raw: unknown): boolean {
|
||||
switch (def.type) {
|
||||
case "enum": {
|
||||
const inSet = value.v.includes(String(raw ?? ""));
|
||||
return value.op === "isNot" ? !inSet : inSet;
|
||||
}
|
||||
case "date": {
|
||||
if (value.op === "between") {
|
||||
return matchesDayRange(raw, value.v[0]?.slice(0, 10) ?? null, value.v[1]?.slice(0, 10) ?? null);
|
||||
}
|
||||
const day = toDayString(raw);
|
||||
const target = value.v[0]?.slice(0, 10);
|
||||
if (!day || !target) return false;
|
||||
return value.op === "before" ? day <= target : day >= target;
|
||||
}
|
||||
case "number": {
|
||||
const num = Number(raw);
|
||||
if (Number.isNaN(num)) return false;
|
||||
if (value.op === "between") {
|
||||
const [min, max] = value.v.map(Number);
|
||||
return num >= min && num <= max;
|
||||
}
|
||||
return value.op === "isNot" ? num !== Number(value.v[0]) : num === Number(value.v[0]);
|
||||
}
|
||||
case "boolean":
|
||||
return Boolean(raw) === (value.v[0] === "true");
|
||||
case "text": {
|
||||
const rawStr = String(raw ?? "").toLowerCase();
|
||||
const target = (value.v[0] ?? "").toLowerCase();
|
||||
return value.op === "isNot" ? !rawStr.includes(target) : rawStr.includes(target);
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from "./types";
|
||||
export * from "./url";
|
||||
export * from "./dates";
|
||||
export * from "./format";
|
||||
export * from "./clientFilter";
|
||||
export * from "./useFilters";
|
||||
export * from "./useSavedViews";
|
||||
export { FilterBar } from "./FilterBar";
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface UseFilters {
|
||||
clearFilters: () => void;
|
||||
setSort: (s: string) => void;
|
||||
setPage: (p: number) => void;
|
||||
setPageSize: (size: number) => void;
|
||||
activeCount: number;
|
||||
/** Spread onto <DataTable/>. Same shape useListControls.tableProps returns today. */
|
||||
tableProps: (total: number) => Pick<DataTableProps<any, any>, "pagination" | "tableOptions">;
|
||||
@@ -55,11 +56,12 @@ export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}):
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const searchKey = ns ? `${ns}.q` : "q";
|
||||
const pageKey = ns ? `${ns}.page` : "page";
|
||||
const sizeKey = ns ? `${ns}.size` : "size";
|
||||
|
||||
const values = useMemo(() => parseFilters(defs, sp, ns), [defs, sp, ns]);
|
||||
const sort = sp.get(ns ? `${ns}.sort` : "sort") ?? defaultSort;
|
||||
const page = Math.max(1, Number(sp.get(pageKey)) || 1);
|
||||
const pageSize = defaultPageSize;
|
||||
const pageSize = Math.max(1, Number(sp.get(sizeKey)) || defaultPageSize);
|
||||
|
||||
// Free text: local draft debounced into the URL with `replace`, so typing
|
||||
// leaves exactly one history entry instead of one per keystroke.
|
||||
@@ -144,6 +146,19 @@ export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}):
|
||||
[pageKey, setSp],
|
||||
);
|
||||
|
||||
const setPageSize = useCallback(
|
||||
(size: number) => {
|
||||
setSp((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (size === defaultPageSize) next.delete(sizeKey);
|
||||
else next.set(sizeKey, String(size));
|
||||
next.delete(pageKey); // a different page size invalidates the current page index
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[defaultPageSize, sizeKey, pageKey, setSp],
|
||||
);
|
||||
|
||||
const params = useMemo(() => {
|
||||
const filterParams = toApiParams(defs, values);
|
||||
const base: Record<string, string | number | undefined> =
|
||||
@@ -177,12 +192,13 @@ export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}):
|
||||
onPaginationChange: (updater) => {
|
||||
const current = { pageIndex: page - 1, pageSize };
|
||||
const next = typeof updater === "function" ? updater(current) : updater;
|
||||
setPage(next.pageIndex + 1);
|
||||
if (next.pageSize !== pageSize) setPageSize(next.pageSize);
|
||||
else if (next.pageIndex !== current.pageIndex) setPage(next.pageIndex + 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
[page, pageSize, setPage],
|
||||
[page, pageSize, setPage, setPageSize],
|
||||
);
|
||||
|
||||
const applyQueryString = useCallback(
|
||||
@@ -209,6 +225,7 @@ export function useFilters(defs: FilterDef[], options: UseFiltersOptions = {}):
|
||||
clearFilters,
|
||||
setSort,
|
||||
setPage,
|
||||
setPageSize,
|
||||
activeCount,
|
||||
tableProps,
|
||||
applyQueryString,
|
||||
|
||||
@@ -5,13 +5,10 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Building2,
|
||||
@@ -22,10 +19,8 @@ import {
|
||||
Mail,
|
||||
Phone,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldOff,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -40,12 +35,8 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyStatus } from "@/types/customer";
|
||||
import { isOnboardingDraft } from "@/types/customer";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
|
||||
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
|
||||
|
||||
/**
|
||||
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
|
||||
@@ -92,28 +83,29 @@ const SORT_OPTIONS = [
|
||||
{ value: "name:DESC", label: "Name (Z–A)" },
|
||||
] as const;
|
||||
|
||||
/** No filter pills — search/sort/page are the only real filter dimensions;
|
||||
* `view` below is a tab (mutually exclusive, navigational), not a filter. */
|
||||
const NO_FILTER_DEFS: FilterDef[] = [];
|
||||
|
||||
export default function CustomersPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [view, setView] = useState<CustomerView>("all");
|
||||
const [sort, setSort] = useState<string>("review:DESC");
|
||||
const controls = useFilters(NO_FILTER_DEFS, { defaultSort: "review:DESC", pageSize: 10 });
|
||||
|
||||
const filter = useMemo(() => {
|
||||
const [sortBy, sortOrder] = sort.split(":") as [
|
||||
const [sortBy, sortOrder] = controls.sort.split(":") as [
|
||||
"review" | "name" | "createdAt" | "updatedAt",
|
||||
"ASC" | "DESC",
|
||||
];
|
||||
return {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
page: controls.page,
|
||||
pageSize: controls.pageSize,
|
||||
search: String(controls.params.search ?? ""),
|
||||
sortBy,
|
||||
sortOrder,
|
||||
...VIEW_FILTERS[view],
|
||||
};
|
||||
}, [pagination.pageIndex, pagination.pageSize, debouncedQuery, view, sort]);
|
||||
}, [controls.page, controls.pageSize, controls.params.search, controls.sort, view]);
|
||||
|
||||
const { data: stats } = useQuery(
|
||||
api.customers.stats.queryOptions({ input: {} }),
|
||||
@@ -125,7 +117,6 @@ export default function CustomersPage() {
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const columns: ColumnDef<Company>[] = useMemo(
|
||||
() => [
|
||||
@@ -295,35 +286,24 @@ export default function CustomersPage() {
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search by company, TIN, email or profile reference…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<FilterBar
|
||||
defs={NO_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search by company, TIN, email or profile reference…"
|
||||
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||||
viewId="customers"
|
||||
>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={view}
|
||||
onChange={(v) => {
|
||||
// `view` lives outside useFilters (it's a tab, not a
|
||||
// filter pill), so switching it needs its own page reset —
|
||||
// the same "stranded on page 5" hazard useFilters guards
|
||||
// against for its own filters.
|
||||
setView(v as CustomerView);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
controls.setPage(1);
|
||||
}}
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
@@ -333,21 +313,7 @@ export default function CustomersPage() {
|
||||
{ label: "Active", value: "active" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="md"
|
||||
w={160}
|
||||
allowDeselect={false}
|
||||
aria-label="Sort customers"
|
||||
value={sort}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
setSort(v);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||||
/>
|
||||
</Group>
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
@@ -358,7 +324,7 @@ export default function CustomersPage() {
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
|
||||
emptyMessage={
|
||||
debouncedQuery
|
||||
controls.searchText
|
||||
? "No companies match your search."
|
||||
: "No companies yet."
|
||||
}
|
||||
@@ -370,18 +336,7 @@ export default function CustomersPage() {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
{...controls.tableProps(total)}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Badge, Card, Group, Text } from '@mantine/core';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -63,16 +62,27 @@ const columns: ColumnDef<Loading>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const LOADING_FILTER_DEFS: FilterDef[] = [{ key: 'loadedAt', label: 'Loaded', type: 'date' }];
|
||||
|
||||
/** Record of every inventory item loaded onto a wagon. */
|
||||
export default function LoadedInventoryPage() {
|
||||
const { data, isLoading } = useQuery(
|
||||
api.warehouses.loadings.queryOptions({ input: {} }),
|
||||
);
|
||||
const loadings = data ?? [];
|
||||
const controls = useListControls(loadings, {
|
||||
searchKeys: ['wagonNumber'],
|
||||
dateKey: 'loadedAt',
|
||||
});
|
||||
const controls = useFilters(LOADING_FILTER_DEFS, { pageSize: 10 });
|
||||
// Endpoint takes no params at all — everything filters client-side.
|
||||
const filteredLoadings = applyClientFilters(
|
||||
loadings,
|
||||
LOADING_FILTER_DEFS,
|
||||
controls.values,
|
||||
controls.searchText,
|
||||
{ searchKeys: ['wagonNumber'] },
|
||||
);
|
||||
const pagedLoadings = filteredLoadings.slice(
|
||||
(controls.page - 1) * controls.pageSize,
|
||||
controls.page * controls.pageSize,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -90,24 +100,18 @@ export default function LoadedInventoryPage() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
<FilterBar
|
||||
defs={LOADING_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search by wagon…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Loaded"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
viewId="loaded-inventory"
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={controls.pagedRows}
|
||||
data={pagedLoadings}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
{...controls.tableProps(filteredLoadings.length)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -11,11 +11,10 @@ import {
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path; reused here rather than adding a second one.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from "@/components/filters";
|
||||
import { useTrucksOnSite } from "@/hooks/useWarehouses";
|
||||
import type { TruckOnSite } from "@/types/warehouse";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
@@ -142,10 +141,14 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
const TRUCK_FILTER_DEFS: FilterDef[] = [{ key: "arrivedAt", label: "Arrived", type: "date" }];
|
||||
|
||||
export default function TrucksOnSitePage() {
|
||||
const { data: trucks = [], isLoading } = useTrucksOnSite();
|
||||
// The dashboard's "Trucks on-site" card counts only arrived trucks, so it
|
||||
// deep-links here with ?scope=ON_SITE to land on the matching tab.
|
||||
// deep-links here with ?scope=ON_SITE to land on the matching tab. Read
|
||||
// once at mount, same as before — scope/source stay page-level tab state
|
||||
// (mutually exclusive, dashboard-linked), not filter pills.
|
||||
const [searchParams] = useSearchParams();
|
||||
const scopeParam = searchParams.get("scope");
|
||||
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">(
|
||||
@@ -153,7 +156,7 @@ export default function TrucksOnSitePage() {
|
||||
);
|
||||
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
|
||||
|
||||
// Scope/source are page filters and run first; the shared control then does
|
||||
// Scope/source are page filters and run first; the filter bar then does
|
||||
// search + arrival-date range + pagination over what they leave.
|
||||
const scoped = useMemo(
|
||||
() =>
|
||||
@@ -163,10 +166,14 @@ export default function TrucksOnSitePage() {
|
||||
[trucks, scope, source],
|
||||
);
|
||||
|
||||
const controls = useListControls(scoped, {
|
||||
const controls = useFilters(TRUCK_FILTER_DEFS, { pageSize: 10 });
|
||||
const filteredTrucks = applyClientFilters(scoped, TRUCK_FILTER_DEFS, controls.values, controls.searchText, {
|
||||
searchKeys: ["plateNumber", "driverName", "bookingReference", "customerName", "containers"],
|
||||
dateKey: "arrivedAt",
|
||||
});
|
||||
const pagedTrucks = filteredTrucks.slice(
|
||||
(controls.page - 1) * controls.pageSize,
|
||||
controls.page * controls.pageSize,
|
||||
);
|
||||
|
||||
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
|
||||
const inboundCount = trucks.length - onSiteCount;
|
||||
@@ -185,7 +192,10 @@ export default function TrucksOnSitePage() {
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={scope}
|
||||
onChange={(v) => setScope(v as typeof scope)}
|
||||
onChange={(v) => {
|
||||
setScope(v as typeof scope);
|
||||
controls.setPage(1);
|
||||
}}
|
||||
data={[
|
||||
{ label: `All (${trucks.length})`, value: "ALL" },
|
||||
{ label: `On site (${onSiteCount})`, value: "ON_SITE" },
|
||||
@@ -195,7 +205,10 @@ export default function TrucksOnSitePage() {
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={source}
|
||||
onChange={(v) => setSource(v as typeof source)}
|
||||
onChange={(v) => {
|
||||
setSource(v as typeof source);
|
||||
controls.setPage(1);
|
||||
}}
|
||||
data={[
|
||||
{ label: "All", value: "ALL" },
|
||||
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
|
||||
@@ -205,30 +218,29 @@ export default function TrucksOnSitePage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
<FilterBar
|
||||
defs={TRUCK_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Plate, driver, booking, container…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Arrived"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
viewId="trucks-on-site"
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Text size="sm">Loading…</Text>
|
||||
) : (
|
||||
<>
|
||||
<Rows rows={controls.pagedRows} />
|
||||
<Rows rows={pagedTrucks} />
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
pagination={{ pageIndex: controls.page - 1, pageSize: controls.pageSize }}
|
||||
pageCount={Math.max(1, Math.ceil(filteredTrucks.length / controls.pageSize))}
|
||||
totalCount={filteredTrucks.length}
|
||||
itemLabel="trucks"
|
||||
onPaginationChange={controls.setPagination}
|
||||
onPaginationChange={(updater) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { AccrualDashboard } from '@/components/warehouses';
|
||||
@@ -54,9 +53,21 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
const fmt = (n: number, c: string) => formatMoney(n, c, 2);
|
||||
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
|
||||
|
||||
const INVOICE_FILTER_DEFS: FilterDef[] = [
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
type: 'enum',
|
||||
multiple: false,
|
||||
options: WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') })),
|
||||
},
|
||||
{ key: 'issuedAt', label: 'Issued', type: 'date' },
|
||||
];
|
||||
|
||||
export default function WarehouseInvoicesPage() {
|
||||
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
const controls = useFilters(INVOICE_FILTER_DEFS, { pageSize: 10 });
|
||||
const status = (controls.values.status?.v[0] as WarehouseInvoiceStatus | undefined) ?? null;
|
||||
|
||||
const { data, isLoading } = useQuery(
|
||||
api.warehouses.invoices.queryOptions({
|
||||
@@ -65,10 +76,20 @@ export default function WarehouseInvoicesPage() {
|
||||
);
|
||||
const invoices = data ?? [];
|
||||
|
||||
const controls = useListControls(invoices, {
|
||||
searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'],
|
||||
dateKey: 'issuedAt',
|
||||
});
|
||||
// Endpoint only filters by `status`; search + issued-date range are
|
||||
// applied client-side (the client bridge — see components/filters/clientFilter.ts).
|
||||
// Flip to server mode by deleting this call once the endpoint takes more params.
|
||||
const filteredInvoices = applyClientFilters(
|
||||
invoices,
|
||||
INVOICE_FILTER_DEFS,
|
||||
controls.values,
|
||||
controls.searchText,
|
||||
{ searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'] },
|
||||
);
|
||||
const pagedInvoices = useMemo(
|
||||
() => filteredInvoices.slice((controls.page - 1) * controls.pageSize, controls.page * controls.pageSize),
|
||||
[filteredInvoices, controls.page, controls.pageSize],
|
||||
);
|
||||
|
||||
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
|
||||
{
|
||||
@@ -135,36 +156,20 @@ export default function WarehouseInvoicesPage() {
|
||||
</Stack>
|
||||
|
||||
<Card>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
<FilterBar
|
||||
defs={INVOICE_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search invoice no / booking / customer"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Issued"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="All statuses"
|
||||
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
|
||||
value={status}
|
||||
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
</ListControls>
|
||||
viewId="warehouse-invoices"
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={invoiceColumns}
|
||||
data={controls.pagedRows}
|
||||
data={pagedInvoices}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No invoices found."
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
{...controls.tableProps(filteredInvoices.length)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user