From 70b2cd2e42eecebee50ecfa6227002b8b2e46f7a Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 11 Jul 2026 15:31:58 -0400 Subject: [PATCH] feat(reports): shared FilterPanel component (temporal slot + account multi-select) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + src/components/reports/FilterPanel.test.tsx | 199 ++++++++++++++++++++ src/components/reports/FilterPanel.tsx | 93 +++++++++ src/i18n/locales/en.json | 6 +- src/i18n/locales/fr.json | 6 +- 6 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 src/components/reports/FilterPanel.test.tsx create mode 100644 src/components/reports/FilterPanel.tsx diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 59f869f..2fd9af4 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -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 : 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 : 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é diff --git a/CHANGELOG.md b/CHANGELOG.md index 203c3af..4892846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: 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: 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 diff --git a/src/components/reports/FilterPanel.test.tsx b/src/components/reports/FilterPanel.test.tsx new file mode 100644 index 0000000..2cb00bc --- /dev/null +++ b/src/components/reports/FilterPanel.test.tsx @@ -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 `` +// 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 & { 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 = {}) { + 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]); + }); +}); diff --git a/src/components/reports/FilterPanel.tsx b/src/components/reports/FilterPanel.tsx new file mode 100644 index 0000000..fa22938 --- /dev/null +++ b/src/components/reports/FilterPanel.tsx @@ -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 ( +
+
{temporalControl}
+ +
+ + + {accountsLabel} + +
+ {accounts.map((account) => ( + + ))} +
+ + {t("reports.filters.accounts.hint")} + +
+
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 15051fc..0754121 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -392,7 +392,11 @@ "search": "Search...", "all": "All", "none": "None", - "allTypes": "All types" + "allTypes": "All types", + "accounts": { + "label": "Import sources", + "hint": "No selection = all sources" + } }, "bva": { "monthly": "Monthly", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 1a83ca9..6fd7b3b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -392,7 +392,11 @@ "search": "Rechercher...", "all": "Toutes", "none": "Aucune", - "allTypes": "Tous les types" + "allTypes": "Tous les types", + "accounts": { + "label": "Sources d'import", + "hint": "Aucune sélection = toutes les sources" + } }, "bva": { "monthly": "Mensuel", -- 2.45.2