feat(reports): use the shared FilterBar instead of ReportFilters

Swaps ReportView's bespoke ReportFilters for the same FilterBar/useFilters
combo BookingRequestsPage uses — pills, saved-view-ready state, URL sync.
toFilterDefs() maps the report catalog's own filter vocabulary
(daterange/date/select/multiselect/text) onto FilterDef, matched against
every report definition's backend param handling. The search box only
shows for reports that actually implement `search` server-side, so it's
not a dead control on the rest. ReportFilters.tsx is now dead, removed.
This commit is contained in:
Nathnael
2026-08-19 10:12:03 +00:00
parent 7c56607582
commit 6687def4af
2 changed files with 110 additions and 139 deletions

View File

@@ -1,110 +0,0 @@
import { Group, MultiSelect, Select, TextInput } from "@mantine/core";
import { DateInput, DatePickerInput } from "@mantine/dates";
import { Search } from "lucide-react";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import type { ReportFilterDef } from "@/types/reports";
export interface ReportFilterValues {
[param: string]: string | undefined;
}
interface ReportFiltersProps {
filters: ReportFilterDef[];
values: ReportFilterValues;
onChange: (values: ReportFilterValues) => void;
}
const toDate = (value: string | undefined): Date | null => (value ? new Date(value) : null);
const fromDate = (value: string | null): string | undefined => value ?? undefined;
/** Renders one widget per report-declared filter and reports raw param values back up. */
export function ReportFilters({ filters, values, onChange }: ReportFiltersProps) {
if (!filters.length) return null;
const set = (patch: ReportFilterValues) => onChange({ ...values, ...patch });
return (
<Group gap="sm" wrap="wrap">
{filters.map((filter) => {
switch (filter.type) {
case "daterange":
return (
<DatePickerInput
key={filter.key}
type="range"
placeholder={filter.label}
value={[values[`${filter.key}From`] ?? null, values[`${filter.key}To`] ?? null]}
onChange={([from, to]) =>
set({ [`${filter.key}From`]: fromDate(from), [`${filter.key}To`]: fromDate(to) })
}
presets={getDateRangePresets()}
radius="md"
size="sm"
clearable
w={230}
/>
);
case "date":
return (
<DateInput
key={filter.key}
placeholder={filter.label}
value={toDate(values[filter.key])}
onChange={(d) => set({ [filter.key]: fromDate(d) })}
radius="md"
size="sm"
clearable
w={150}
/>
);
case "select":
return (
<Select
key={filter.key}
placeholder={filter.label}
data={filter.options ?? []}
value={values[filter.key] ?? null}
onChange={(v) => set({ [filter.key]: v ?? undefined })}
radius="md"
size="sm"
clearable
w={170}
/>
);
case "multiselect":
return (
<MultiSelect
key={filter.key}
placeholder={filter.label}
data={filter.options ?? []}
value={values[filter.key]?.split(",").filter(Boolean) ?? []}
onChange={(v) => set({ [filter.key]: v.length ? v.join(",") : undefined })}
radius="md"
size="sm"
clearable
w={200}
/>
);
case "text":
return (
<TextInput
key={filter.key}
placeholder={filter.label}
leftSection={<Search size={16} />}
value={values[filter.key] ?? ""}
onChange={(e) => set({ [filter.key]: e.target.value || undefined })}
radius="md"
size="sm"
w={220}
/>
);
default:
return null;
}
})}
</Group>
);
}
export default ReportFilters;

View File

@@ -1,21 +1,55 @@
import { ActionIcon, Alert, Box, Card, Group, SegmentedControl, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import type { Column, SortingState } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ArrowUpDown, LayoutGrid, LineChart, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { PageHeader } from "@/components/page";
import { KpiStrip } from "@/components/page/KpiStrip";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
import { api } from "@/services/api";
import type { ReportRunParams } from "@/types/reports";
import type { ReportFilterDef, ReportRunParams } from "@/types/reports";
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
import { ReportChart } from "./ReportChart";
import { ReportExportButton } from "./ReportExportButton";
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
import { formatKpiValue, formatReportCell } from "./report-format";
/**
* Maps the report catalog's own filter vocabulary onto the shared FilterBar's
* `FilterDef`. "search" is skipped — FilterBar already renders its own search
* box wired to the same `search` param, so keeping the catalog's declared
* "search" filter too would just double it up as a redundant pill.
*/
function toFilterDefs(filters: ReportFilterDef[]): FilterDef[] {
return filters
.filter((f) => f.key !== "search")
.map((f): FilterDef => {
switch (f.type) {
case "daterange":
return {
key: f.key,
label: f.label,
type: "date",
operators: ["between", "before", "after"],
toParams: dateRangeParams(`${f.key}From`, `${f.key}To`),
};
case "date":
// Every report's single-date filter (e.g. "as of") is an exact
// cutoff, not a range — one fixed operator keeps DateBody on its
// single-date UI instead of the range picker.
return { key: f.key, label: f.label, type: "date", operators: ["before"] };
case "select":
return { key: f.key, label: f.label, type: "enum", multiple: false, options: f.options ?? [] };
case "multiselect":
return { key: f.key, label: f.label, type: "enum", multiple: true, options: f.options ?? [] };
default:
return { key: f.key, label: f.label, type: "text" };
}
});
}
function SortableHeader({ label, column }: { label: string; column: Column<Record<string, unknown>, unknown> }) {
const sorted = column.getIsSorted();
const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
@@ -40,6 +74,8 @@ interface ReportViewProps {
* arrow) with export/refresh as its actions, instead of inline above the
* table. Off by default for embedded sections. */
pageHeader?: boolean;
/** Opens on the chart instead of the table — for dashboard tiles. */
defaultView?: "table" | "chart";
}
/**
@@ -47,15 +83,29 @@ interface ReportViewProps {
* filters, KPI strip, sortable/paginated table or chart, xlsx/pdf export.
* Adding a report never touches this file.
*/
export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProps) {
export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: ReportViewProps) {
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
const def = catalog?.find((r) => r.key === reportKey);
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 20 });
const [sorting, setSorting] = useState<SortingState>([]);
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
const [view, setView] = useState<"table" | "chart">("table");
const [view, setView] = useState<"table" | "chart">(defaultView ?? "table");
// FilterBar's own state — reads/writes the URL directly, same as
// BookingRequestsPage, so a drilled-into or shared report URL opens
// already filtered.
const reportFilterDefs = useMemo(() => toFilterDefs(def?.filters ?? []), [def?.filters]);
const controls = useFilters(reportFilterDefs);
// useFilters also tracks its own page/pageSize — unused here, this report
// view paginates itself (and overrides pageSize for the chart view below).
const filterParams = useMemo(() => {
const rest = { ...controls.params };
delete rest.page;
delete rest.pageSize;
return rest;
}, [controls.params]);
// Filters + sort as the user currently has them — independent of the view
// toggle's paging, so export always matches what's on screen either way.
@@ -64,10 +114,18 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
return {
sortBy: sort?.id,
sortOrder: sort ? (sort.desc ? "DESC" as const : "ASC" as const) : undefined,
...debouncedFilters,
...filterParams,
...(def?.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
};
}, [def, sorting, debouncedFilters, idKeyValue]);
}, [def, sorting, filterParams, idKeyValue]);
// A filter change should land back on page 1, same as every other
// FilterBar page — but pagination here is local (not URL-driven via
// controls.tableProps), so it needs an explicit reset.
useEffect(() => {
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterParams]);
const runParams: ReportRunParams | undefined = useMemo(() => {
if (!def) return undefined;
@@ -87,6 +145,30 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
enabled: Boolean(runParams),
});
/**
* Row click carries this row's values into the target report as filter
* params — the "summary to transaction level" drill-down. Undefined unless
* the report declares `drill`, which is what leaves the row unclickable.
*/
const handleRowClick = useMemo(() => {
const drill = def?.drill;
if (!drill) return undefined;
return (row: Record<string, unknown>) => {
const params = new URLSearchParams();
for (const [column, filterKey] of Object.entries(drill.carry)) {
const value = row[column];
if (value !== null && value !== undefined && value !== "") {
params.set(filterKey, String(value));
}
}
// Carry the filters already applied, so the drill narrows rather than resets.
for (const [key, value] of Object.entries(appliedParams)) {
if (typeof value === "string" && value && !params.has(key)) params.set(key, value);
}
navigate(`/dashboard/reports/${drill.to}?${params.toString()}`);
};
}, [def?.drill, appliedParams, navigate]);
const total = data?.meta.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
@@ -167,25 +249,23 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<ReportFilters
filters={def.filters}
values={filterValues}
onChange={(v) => {
setFilterValues(v);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
/>
<Group gap="xs">
{chartToggle}
{pageHeader ? null : (
<>
{exportButton}
{refreshButton}
</>
)}
</Group>
</Group>
<FilterBar
defs={reportFilterDefs}
controls={controls}
// Only a handful of reports actually implement the `search`
// param server-side (see toFilterDefs) — showing the box on
// every report would be a dead control on the rest.
showSearch={def.filters.some((f) => f.key === "search")}
searchPlaceholder="Search…"
>
{chartToggle}
{pageHeader ? null : (
<>
{exportButton}
{refreshButton}
</>
)}
</FilterBar>
</Box>
{view === "chart" && def.chart ? (
@@ -195,6 +275,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
<DataTable
columns={columns}
data={data?.items ?? []}
onRowClick={handleRowClick}
status={isLoading ? "loading" : isError ? "error" : "success"}
emptyMessage="No data for the selected filters."
error={isError ? { message: "Failed to load report.", onRetry: () => void refetch() } : undefined}