diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx
deleted file mode 100644
index e44e97ec8..000000000
--- a/apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx
+++ /dev/null
@@ -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 (
-
- {filters.map((filter) => {
- switch (filter.type) {
- case "daterange":
- return (
-
- set({ [`${filter.key}From`]: fromDate(from), [`${filter.key}To`]: fromDate(to) })
- }
- presets={getDateRangePresets()}
- radius="md"
- size="sm"
- clearable
- w={230}
- />
- );
- case "date":
- return (
- set({ [filter.key]: fromDate(d) })}
- radius="md"
- size="sm"
- clearable
- w={150}
- />
- );
- case "select":
- return (
-
- );
-}
-
-export default ReportFilters;
diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx
index 47f5a194a..2634e882c 100644
--- a/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx
@@ -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, 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([]);
- const [filterValues, setFilterValues] = useState({});
- 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) => {
+ 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
-
- {
- setFilterValues(v);
- setPagination((prev) => ({ ...prev, pageIndex: 0 }));
- }}
- />
-
- {chartToggle}
- {pageHeader ? null : (
- <>
- {exportButton}
- {refreshButton}
- >
- )}
-
-
+ f.key === "search")}
+ searchPlaceholder="Search…"
+ >
+ {chartToggle}
+ {pageHeader ? null : (
+ <>
+ {exportButton}
+ {refreshButton}
+ >
+ )}
+
{view === "chart" && def.chart ? (
@@ -195,6 +275,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProp
void refetch() } : undefined}