feat(reports): shared FilterPanel component (temporal slot + account multi-select)

Adds src/components/reports/FilterPanel.tsx, the shared filter bar for the
report pages. It renders the page's own temporal control (PeriodSelector /
CompareReferenceMonthPicker / YearNavigator / …) as-is via a `temporalControl`
ReactNode prop, plus a checkbox multi-select over `accounts: ImportSource[]`.
No `temporalMode` enum — that design was rejected to avoid mixing state
sources; the panel owns no temporal state at all.

Empty `accountIds` means no filter (all sources). Selection toggling is a
pure, exported `toggleAccountId` helper. Copy says "sources"/"import
sources" everywhere, never "comptes"/"accounts", so the filter reads as
distinct from the Bilan module's own "Compte" vocabulary.

This issue creates the standalone component only; wiring it into report
pages is left to follow-up issues (#275/#276).

Resolves #274
This commit is contained in:
le king fu 2026-07-11 15:31:58 -04:00
parent b40381fb89
commit 70b2cd2e42
6 changed files with 304 additions and 2 deletions

View file

@ -8,6 +8,7 @@
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265). - Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265).
- Rapports : pose les fondations d'un futur filtre de comptes pour les rapports — le hook de période partagé suit désormais aussi une sélection multi-comptes via l'URL (mémorisable comme la plage de dates, et validée contre les URL malformées ou modifiées à la main). Purement interne pour l'instant : aucun contrôle de filtre visible encore, le sélecteur de comptes et le branchement par rapport arrivent dans des issues de suivi (#272). - Rapports : pose les fondations d'un futur filtre de comptes pour les rapports — le hook de période partagé suit désormais aussi une sélection multi-comptes via l'URL (mémorisable comme la plage de dates, et validée contre les URL malformées ou modifiées à la main). Purement interne pour l'instant : aucun contrôle de filtre visible encore, le sélecteur de comptes et le branchement par rapport arrivent dans des issues de suivi (#272).
- Rapports : les sept services de rapports (tendances, catégories dans le temps, comparaison réel-vs-réel, budget-vs-réel, dépenses par catégorie, et le tableau de bord Cartes) acceptent désormais un filtre multi-comptes optionnel, comparé à une liste paramétrée de sources d'import. Plomberie interne seulement — omettre le filtre partout donne des résultats identiques au byte près, et aucune page de rapport n'expose encore de contrôle de filtre ; cela arrive dans des issues de suivi (#273). - Rapports : les sept services de rapports (tendances, catégories dans le temps, comparaison réel-vs-réel, budget-vs-réel, dépenses par catégorie, et le tableau de bord Cartes) acceptent désormais un filtre multi-comptes optionnel, comparé à une liste paramétrée de sources d'import. Plomberie interne seulement — omettre le filtre partout donne des résultats identiques au byte près, et aucune page de rapport n'expose encore de contrôle de filtre ; cela arrive dans des issues de suivi (#273).
- Rapports : pose les fondations de l'interface du filtre de comptes — un panneau de filtre partagé affiche le contrôle de période propre à chaque rapport à côté d'une sélection multiple (cases à cocher) de vos sources d'import. Purement interne pour l'instant : aucune page de rapport n'affiche encore ce panneau, cela arrive dans des issues de suivi (#274).
### Corrigé ### Corrigé

View file

@ -8,6 +8,7 @@
- Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265). - Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265).
- Reports: laid the groundwork for an upcoming account filter across the report pages — the shared period hook now also tracks a URL-backed, multi-account selection (bookmarkable like the date range, and validated against malformed/hand-edited URLs). Internal only for now: no filter control is visible yet, the account picker and per-report wiring land in follow-up issues (#272). - Reports: laid the groundwork for an upcoming account filter across the report pages — the shared period hook now also tracks a URL-backed, multi-account selection (bookmarkable like the date range, and validated against malformed/hand-edited URLs). Internal only for now: no filter control is visible yet, the account picker and per-report wiring land in follow-up issues (#272).
- Reports: all seven report services (trends, by-category-over-time, real-vs-real compare, budget-vs-actual, expenses-by-category, and the Cartes dashboard) now accept an optional multi-account filter, matched against a parameterized list of import sources. Backend plumbing only — leaving every filter out still returns byte-identical results, and no report page exposes a filter control yet; that lands in follow-up issues (#273). - Reports: all seven report services (trends, by-category-over-time, real-vs-real compare, budget-vs-actual, expenses-by-category, and the Cartes dashboard) now accept an optional multi-account filter, matched against a parameterized list of import sources. Backend plumbing only — leaving every filter out still returns byte-identical results, and no report page exposes a filter control yet; that lands in follow-up issues (#273).
- Reports: laid the groundwork for the account filter's UI — a shared filter panel renders each report page's own period control alongside a multi-select (checkbox list) of your import sources. Internal only for now: no report page renders this panel yet, that lands in follow-up issues (#274).
### Fixed ### Fixed

View file

@ -0,0 +1,199 @@
// FilterPanel — unit tests (Issue #274).
//
// NOTE: This project has no @testing-library/react / jsdom configured (see
// PriceFetchControl.test.tsx). FilterPanel, unlike most components with
// their own test file, has NO internal state or hooks besides `useTranslation`
// — mocked below into a plain function — so it can be invoked directly as a
// plain function (bypassing JSX/React.createElement's usual `<FilterPanel />`
// call site) and its return value (a plain React-element object tree, since
// JSX creation never touches the DOM) walked directly. This gives genuine
// coverage of the shipped render + selection-wiring code, not a
// re-implementation of its logic in the test.
import { describe, it, expect, vi } from "vitest";
vi.mock("react-i18next", () => ({
useTranslation: vi.fn(() => ({
t: (key: string) => key,
i18n: { language: "fr" },
})),
}));
vi.mock("lucide-react", () => ({
Inbox: () => null,
}));
import FilterPanel, { toggleAccountId } from "./FilterPanel";
import type { FilterPanelProps } from "./FilterPanel";
import type { ImportSource } from "../../shared/types";
// ---------------------------------------------------------------------------
// Minimal React-element tree walker (no renderer/DOM involved)
// ---------------------------------------------------------------------------
interface ElementNode {
type: unknown;
props: Record<string, unknown> & { children?: unknown };
}
type FlatNode = ElementNode | string | number;
function isElementNode(x: unknown): x is ElementNode {
return typeof x === "object" && x !== null && "props" in x;
}
function flatten(node: unknown): FlatNode[] {
if (Array.isArray(node)) return node.flatMap(flatten);
if (node === null || node === undefined || typeof node === "boolean") return [];
if (typeof node === "string" || typeof node === "number") return [node];
if (isElementNode(node)) return [node, ...flatten(node.props.children)];
return [];
}
function findCheckboxes(root: unknown): ElementNode[] {
return flatten(root).filter(
(n): n is ElementNode => isElementNode(n) && n.props.type === "checkbox",
);
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const SOURCE_CHEQUING: ImportSource = {
id: 1,
name: "Compte chèque Desjardins",
date_format: "YYYY-MM-DD",
delimiter: ",",
encoding: "utf-8",
column_mapping: "{}",
skip_lines: 0,
has_header: true,
created_at: "2026-01-01",
updated_at: "2026-01-01",
};
const SOURCE_VISA: ImportSource = {
...SOURCE_CHEQUING,
id: 2,
name: "Visa Desjardins",
};
function renderPanel(overrides: Partial<FilterPanelProps> = {}) {
const onAccountIdsChange = vi.fn();
const props: FilterPanelProps = {
temporalControl: "TEMPORAL_CONTROL_MARKER",
accountIds: [],
onAccountIdsChange,
accounts: [SOURCE_CHEQUING, SOURCE_VISA],
...overrides,
};
const element = FilterPanel(props);
return { element, onAccountIdsChange };
}
// ---------------------------------------------------------------------------
// toggleAccountId — pure selection logic
// ---------------------------------------------------------------------------
describe("toggleAccountId", () => {
it("adds an absent id to an empty list", () => {
expect(toggleAccountId([], 1)).toEqual([1]);
});
it("appends an absent id after the existing ones", () => {
expect(toggleAccountId([1, 2], 3)).toEqual([1, 2, 3]);
});
it("removes a present id, preserving the order of the remaining ids", () => {
expect(toggleAccountId([1, 2, 3], 2)).toEqual([1, 3]);
});
it("removing the only selected id returns an empty array (= all accounts)", () => {
expect(toggleAccountId([1], 1)).toEqual([]);
});
it("does not mutate the input array", () => {
const input = [1, 2];
toggleAccountId(input, 3);
expect(input).toEqual([1, 2]);
});
});
// ---------------------------------------------------------------------------
// FilterPanel — render
// ---------------------------------------------------------------------------
describe("FilterPanel — render", () => {
it("renders the injected temporalControl as-is", () => {
const { element } = renderPanel();
expect(flatten(element)).toContain("TEMPORAL_CONTROL_MARKER");
});
it("renders one checkbox per account", () => {
const { element } = renderPanel();
expect(findCheckboxes(element)).toHaveLength(2);
});
it("renders each account's name as visible text", () => {
const { element } = renderPanel();
const text = flatten(element);
expect(text).toContain("Compte chèque Desjardins");
expect(text).toContain("Visa Desjardins");
});
it("checks exactly the accounts present in accountIds", () => {
const { element } = renderPanel({ accountIds: [2] });
const checkboxes = findCheckboxes(element);
expect(checkboxes.map((c) => c.props.checked)).toEqual([false, true]);
});
it("checks no checkbox when accountIds is empty (= all accounts, no explicit filter)", () => {
const { element } = renderPanel({ accountIds: [] });
const checkboxes = findCheckboxes(element);
expect(checkboxes.every((c) => c.props.checked === false)).toBe(true);
});
it("renders zero checkboxes when accounts is empty, without crashing", () => {
const { element } = renderPanel({ accounts: [] });
expect(findCheckboxes(element)).toHaveLength(0);
});
it("uses i18n keys for the accounts filter label and hint (never a hardcoded string)", () => {
const { element } = renderPanel();
const text = flatten(element);
expect(text).toContain("reports.filters.accounts.label");
expect(text).toContain("reports.filters.accounts.hint");
});
});
// ---------------------------------------------------------------------------
// FilterPanel — selection wiring
// ---------------------------------------------------------------------------
describe("FilterPanel — selection wiring", () => {
it("checking an unselected account calls onAccountIdsChange with it added", () => {
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
const checkboxes = findCheckboxes(element);
// accounts[1] = SOURCE_VISA (id 2), currently unselected
(checkboxes[1].props.onChange as () => void)();
expect(onAccountIdsChange).toHaveBeenCalledWith([1, 2]);
});
it("unchecking the only selected account calls onAccountIdsChange with an empty array", () => {
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
const checkboxes = findCheckboxes(element);
// accounts[0] = SOURCE_CHEQUING (id 1), currently selected
(checkboxes[0].props.onChange as () => void)();
expect(onAccountIdsChange).toHaveBeenCalledWith([]);
});
it("checking a second account preserves the first (multi-select)", () => {
const { element, onAccountIdsChange } = renderPanel({ accountIds: [] });
const checkboxes = findCheckboxes(element);
(checkboxes[0].props.onChange as () => void)();
(checkboxes[1].props.onChange as () => void)();
expect(onAccountIdsChange).toHaveBeenNthCalledWith(1, [1]);
expect(onAccountIdsChange).toHaveBeenNthCalledWith(2, [2]);
});
});

View file

@ -0,0 +1,93 @@
// FilterPanel — shared filter bar for the report pages (Issue #274, epic #260).
//
// Design: a "slot" component. Each report page owns its own temporal state
// (Compare = a local reducer, Trends/ByCategory = the `sources` query string
// via useReportsPeriod, Budget = a plain year) and renders its own control
// (PeriodSelector / CompareReferenceMonthPicker / YearNavigator / …), passed
// in as-is via `temporalControl`. FilterPanel does NOT own a `temporalMode`
// enum and does not know which control it is rendering — an earlier design
// unifying temporal state behind such an enum was rejected specifically to
// avoid mixing state sources (see spec-plan-rapports-uniformes-suite.md).
// The only state this component actually owns/shares across report pages is
// the account (import source) filter.
//
// `accounts`/`accountIds` refer to `ImportSource` rows (bank statement import
// sources, e.g. "Compte chèque Desjardins") — a disjoint id space from the
// Bilan module's `balance_accounts` ("Compte" in that module's own UI). The
// copy in this component deliberately says "sources" everywhere, never
// "comptes"/"accounts", so the two concepts never read as the same filter.
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Inbox } from "lucide-react";
import type { ImportSource } from "../../shared/types";
export interface FilterPanelProps {
/** The page's own temporal control, rendered as-is. */
temporalControl: ReactNode;
/** Selected import source ids. Empty array = no filter (all sources). */
accountIds: number[];
onAccountIdsChange: (accountIds: number[]) => void;
/** Import sources available to filter on. */
accounts: ImportSource[];
}
/**
* Toggles `id` in/out of `accountIds` (add if absent, remove if present),
* preserving the relative order of the remaining ids. Pure and exported so
* the selection logic is unit-testable without rendering the component.
*/
export function toggleAccountId(accountIds: number[], id: number): number[] {
return accountIds.includes(id)
? accountIds.filter((existing) => existing !== id)
: [...accountIds, id];
}
export default function FilterPanel({
temporalControl,
accountIds,
onAccountIdsChange,
accounts,
}: FilterPanelProps) {
const { t } = useTranslation();
const accountsLabel = t("reports.filters.accounts.label");
return (
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] mb-4 flex flex-wrap items-start gap-4">
<div>{temporalControl}</div>
<div className="flex flex-col gap-1.5">
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-[var(--muted-foreground)]">
<Inbox size={14} />
{accountsLabel}
</span>
<div
className="flex flex-wrap gap-x-4 gap-y-1.5"
role="group"
aria-label={accountsLabel}
>
{accounts.map((account) => (
<label
key={account.id}
className="flex items-center gap-1.5 text-sm cursor-pointer"
>
<input
type="checkbox"
checked={accountIds.includes(account.id)}
onChange={() =>
onAccountIdsChange(toggleAccountId(accountIds, account.id))
}
className="accent-[var(--primary)]"
/>
{account.name}
</label>
))}
</div>
<span className="text-xs text-[var(--muted-foreground)]">
{t("reports.filters.accounts.hint")}
</span>
</div>
</div>
);
}

View file

@ -392,7 +392,11 @@
"search": "Search...", "search": "Search...",
"all": "All", "all": "All",
"none": "None", "none": "None",
"allTypes": "All types" "allTypes": "All types",
"accounts": {
"label": "Import sources",
"hint": "No selection = all sources"
}
}, },
"bva": { "bva": {
"monthly": "Monthly", "monthly": "Monthly",

View file

@ -392,7 +392,11 @@
"search": "Rechercher...", "search": "Rechercher...",
"all": "Toutes", "all": "Toutes",
"none": "Aucune", "none": "Aucune",
"allTypes": "Tous les types" "allTypes": "Tous les types",
"accounts": {
"label": "Sources d'import",
"hint": "Aucune sélection = toutes les sources"
}
}, },
"bva": { "bva": {
"monthly": "Mensuel", "monthly": "Mensuel",