From fe9ae0118c8bf66e2a5ad5e45715acfd3d552299 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 11 Jul 2026 15:52:21 -0400 Subject: [PATCH] feat(reports): adopt shared FilterPanel on Compare and Budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts on ReportsComparePage (next to the existing CompareReferenceMonthPicker) and BudgetPage (next to the existing YearNavigator), keeping each page's temporal control unchanged. Threads accountIds from useReportsPeriod through useCompare into getCompareMonthOverMonth/getCompareYearOverYear, and through CompareBudgetView into getBudgetVsActualData — closing the gap where that sub-tab silently ignored the filter while the rest of the Compare page respected it. Budget: the issue named getBudgetVsActualData as the target for useBudget, but that hook never calls it (it's exclusive to CompareBudgetView/Dashboard/Cartes) — its only real actuals fetch is getActualTotalsForYear(year - 1), the previous-year reference column. Extended that function with the same optional accountIds pass-through established by #273, rather than wiring in an unrelated call shape. Both hooks fetch their account checkbox list via getAllImportSources, mirroring the useTrends/#275 pattern. Empty selection = no filter, byte-identical to pre-#276 output (regression-tested). Resolves #276 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + docs/architecture.md | 2 +- src/components/reports/CompareBudgetView.tsx | 10 ++-- src/hooks/useBudget.ts | 55 ++++++++++++++------ src/hooks/useCompare.ts | 39 +++++++++++--- src/pages/BudgetPage.tsx | 33 +++++++----- src/pages/ReportsComparePage.tsx | 29 +++++++---- src/services/budgetService.test.ts | 38 +++++++++++++- src/services/budgetService.ts | 5 +- 10 files changed, 158 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 9eb14fa..16016c8 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -10,6 +10,7 @@ - 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). - Rapports → Tendances : le panneau de filtre partagé apparaît désormais sur ce rapport, à côté du sélecteur de période. Cocher une ou plusieurs sources d'import restreint à la fois la vue mensuelle globale et le tableau de résultat par catégorie à ces sources ; ne rien cocher garde l'affichage de toutes les sources, inchangé (#275). +- Rapports → Comparaison et Budget : le panneau de filtre partagé apparaît désormais aussi sur ces deux rapports, à côté de leur contrôle de période existant (la comparaison garde son sélecteur de mois de référence, la grille budget garde son navigateur d'année — inchangés). Cocher une ou plusieurs sources d'import restreint le rapport de comparaison — y compris son onglet budget-vs-réel — et la colonne de référence de l'année précédente de la grille budget à ces sources ; ne rien cocher garde l'affichage de toutes les sources, inchangé (#276). ### Corrigé diff --git a/CHANGELOG.md b/CHANGELOG.md index cb69c37..55d2f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - 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). - Reports → Trends: the shared filter panel now appears on this report, next to the period selector. Checking one or more import sources narrows both the global monthly view and the by-category result table to those sources; leaving none checked keeps showing every source, unchanged (#275). +- Reports → Compare & Budget: the shared filter panel now appears on both reports too, next to their existing period control (the compare report keeps its reference-month picker, the budget grid keeps its year navigator — unchanged). Checking one or more import sources narrows the compare report — including its budget-vs-actual tab — and the budget grid's previous-year reference column to those sources; leaving none checked keeps showing every source, unchanged (#276). ### Fixed diff --git a/docs/architecture.md b/docs/architecture.md index d201453..ec401fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -206,7 +206,7 @@ Chaque hook encapsule la logique d'état via `useReducer` : | `useAdjustments` | Ajustements | | `useBudget` | Budget | | `useDashboard` | Métriques du tableau de bord | -| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `` (#274) ; adopté sur la page Tendances (#275) — reste à adopter sur Comparaison/Budget/Dashboard/Hub (#276) | +| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276) — reste Dashboard/Hub (M2) | | `useHighlights` | Panneau de faits saillants du hub rapports | | `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) | | `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) | diff --git a/src/components/reports/CompareBudgetView.tsx b/src/components/reports/CompareBudgetView.tsx index 1532e10..018c5ec 100644 --- a/src/components/reports/CompareBudgetView.tsx +++ b/src/components/reports/CompareBudgetView.tsx @@ -7,9 +7,13 @@ import type { BudgetVsActualRow } from "../../shared/types"; export interface CompareBudgetViewProps { year: number; month: number; + /** Account (import source) filter — forwarded to getBudgetVsActualData so this + * sub-tab respects the same FilterPanel selection as the rest of the Compare + * page (Issue #276). Empty/omitted = no filter. */ + accountIds?: number[]; } -export default function CompareBudgetView({ year, month }: CompareBudgetViewProps) { +export default function CompareBudgetView({ year, month, accountIds }: CompareBudgetViewProps) { const { t } = useTranslation(); const [rows, setRows] = useState([]); const [error, setError] = useState(null); @@ -17,7 +21,7 @@ export default function CompareBudgetView({ year, month }: CompareBudgetViewProp useEffect(() => { let cancelled = false; setError(null); - getBudgetVsActualData(year, month) + getBudgetVsActualData(year, month, accountIds) .then((data) => { if (!cancelled) setRows(data); }) @@ -27,7 +31,7 @@ export default function CompareBudgetView({ year, month }: CompareBudgetViewProp return () => { cancelled = true; }; - }, [year, month]); + }, [year, month, accountIds]); if (error) { return ( diff --git a/src/hooks/useBudget.ts b/src/hooks/useBudget.ts index bdd0038..a8ffb8f 100644 --- a/src/hooks/useBudget.ts +++ b/src/hooks/useBudget.ts @@ -1,5 +1,5 @@ import { useReducer, useCallback, useEffect, useRef } from "react"; -import type { BudgetYearRow, BudgetTemplate } from "../shared/types"; +import type { BudgetYearRow, BudgetTemplate, ImportSource } from "../shared/types"; import { getAllActiveCategories, getBudgetEntriesForYear, @@ -11,11 +11,14 @@ import { applyTemplate as applyTemplateSvc, deleteTemplate as deleteTemplateSvc, } from "../services/budgetService"; +import { getAllImportSources } from "../services/transactionService"; +import { useReportsPeriod } from "./useReportsPeriod"; interface BudgetState { year: number; rows: BudgetYearRow[]; templates: BudgetTemplate[]; + accounts: ImportSource[]; isLoading: boolean; isSaving: boolean; error: string | null; @@ -26,6 +29,7 @@ type BudgetAction = | { type: "SET_SAVING"; payload: boolean } | { type: "SET_ERROR"; payload: string | null } | { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } } + | { type: "SET_ACCOUNTS"; payload: ImportSource[] } | { type: "SET_YEAR"; payload: number }; function initialState(): BudgetState { @@ -33,6 +37,7 @@ function initialState(): BudgetState { year: new Date().getFullYear(), rows: [], templates: [], + accounts: [], isLoading: false, isSaving: false, error: null, @@ -54,6 +59,8 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState { templates: action.payload.templates, isLoading: false, }; + case "SET_ACCOUNTS": + return { ...state, accounts: action.payload }; case "SET_YEAR": return { ...state, year: action.payload }; default: @@ -64,10 +71,11 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState { const TYPE_ORDER: Record = { expense: 0, income: 1, transfer: 2 }; export function useBudget() { + const { accountIds } = useReportsPeriod(); const [state, dispatch] = useReducer(reducer, undefined, initialState); const fetchIdRef = useRef(0); - const refreshData = useCallback(async (year: number) => { + const refreshData = useCallback(async (year: number, ids: number[]) => { const fetchId = ++fetchIdRef.current; dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_ERROR", payload: null }); @@ -76,7 +84,7 @@ export function useBudget() { const [allCategories, entries, prevYearActuals, templates] = await Promise.all([ getAllActiveCategories(), getBudgetEntriesForYear(year), - getActualTotalsForYear(year - 1), + getActualTotalsForYear(year - 1, ids), getAllTemplates(), ]); @@ -364,8 +372,21 @@ export function useBudget() { }, []); useEffect(() => { - refreshData(state.year); - }, [state.year, refreshData]); + refreshData(state.year, accountIds); + }, [state.year, accountIds, refreshData]); + + // Load the import-source list once, for the FilterPanel's account checkboxes + // (same query as the Trends/Compare source filters — see useTrends.ts). + useEffect(() => { + (async () => { + try { + const accounts = await getAllImportSources(); + dispatch({ type: "SET_ACCOUNTS", payload: accounts }); + } catch (e) { + dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); + } + })(); + }, []); const navigateYear = useCallback((delta: -1 | 1) => { dispatch({ type: "SET_YEAR", payload: state.year + delta }); @@ -376,7 +397,7 @@ export function useBudget() { dispatch({ type: "SET_SAVING", payload: true }); try { await upsertBudgetEntry(categoryId, state.year, month, amount); - await refreshData(state.year); + await refreshData(state.year, accountIds); } catch (e) { dispatch({ type: "SET_ERROR", @@ -386,7 +407,7 @@ export function useBudget() { dispatch({ type: "SET_SAVING", payload: false }); } }, - [state.year, refreshData] + [state.year, accountIds, refreshData] ); const splitEvenly = useCallback( @@ -400,7 +421,7 @@ export function useBudget() { amounts.push(m < remainder ? base + 0.01 : base); } await upsertBudgetEntriesForYear(categoryId, state.year, amounts); - await refreshData(state.year); + await refreshData(state.year, accountIds); } catch (e) { dispatch({ type: "SET_ERROR", @@ -410,7 +431,7 @@ export function useBudget() { dispatch({ type: "SET_SAVING", payload: false }); } }, - [state.year, refreshData] + [state.year, accountIds, refreshData] ); const saveTemplate = useCallback( @@ -423,7 +444,7 @@ export function useBudget() { .filter((r) => !r.is_parent && r.months[0] !== 0) .map((r) => ({ category_id: r.category_id, amount: r.months[0] })); await saveAsTemplateSvc(name, description, entries); - await refreshData(state.year); + await refreshData(state.year, accountIds); } catch (e) { dispatch({ type: "SET_ERROR", @@ -433,7 +454,7 @@ export function useBudget() { dispatch({ type: "SET_SAVING", payload: false }); } }, - [state.rows, state.year, refreshData] + [state.rows, state.year, accountIds, refreshData] ); const applyTemplate = useCallback( @@ -441,7 +462,7 @@ export function useBudget() { dispatch({ type: "SET_SAVING", payload: true }); try { await applyTemplateSvc(templateId, state.year, month); - await refreshData(state.year); + await refreshData(state.year, accountIds); } catch (e) { dispatch({ type: "SET_ERROR", @@ -451,7 +472,7 @@ export function useBudget() { dispatch({ type: "SET_SAVING", payload: false }); } }, - [state.year, refreshData] + [state.year, accountIds, refreshData] ); const applyTemplateAllMonths = useCallback( @@ -461,7 +482,7 @@ export function useBudget() { for (let m = 1; m <= 12; m++) { await applyTemplateSvc(templateId, state.year, m); } - await refreshData(state.year); + await refreshData(state.year, accountIds); } catch (e) { dispatch({ type: "SET_ERROR", @@ -471,7 +492,7 @@ export function useBudget() { dispatch({ type: "SET_SAVING", payload: false }); } }, - [state.year, refreshData] + [state.year, accountIds, refreshData] ); const deleteTemplate = useCallback( @@ -479,7 +500,7 @@ export function useBudget() { dispatch({ type: "SET_SAVING", payload: true }); try { await deleteTemplateSvc(templateId); - await refreshData(state.year); + await refreshData(state.year, accountIds); } catch (e) { dispatch({ type: "SET_ERROR", @@ -489,7 +510,7 @@ export function useBudget() { dispatch({ type: "SET_SAVING", payload: false }); } }, - [state.year, refreshData] + [state.year, accountIds, refreshData] ); return { diff --git a/src/hooks/useCompare.ts b/src/hooks/useCompare.ts index 23e73f7..7e1f14d 100644 --- a/src/hooks/useCompare.ts +++ b/src/hooks/useCompare.ts @@ -1,6 +1,7 @@ import { useReducer, useCallback, useEffect, useRef } from "react"; -import type { CategoryDelta } from "../shared/types"; +import type { CategoryDelta, ImportSource } from "../shared/types"; import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService"; +import { getAllImportSources } from "../services/transactionService"; import { useReportsPeriod } from "./useReportsPeriod"; import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod"; @@ -13,6 +14,7 @@ interface State { year: number; month: number; rows: CategoryDelta[]; + accounts: ImportSource[]; isLoading: boolean; error: string | null; } @@ -23,6 +25,7 @@ type Action = | { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } } | { type: "SET_LOADING"; payload: boolean } | { type: "SET_ROWS"; payload: CategoryDelta[] } + | { type: "SET_ACCOUNTS"; payload: ImportSource[] } | { type: "SET_ERROR"; payload: string }; /** @@ -93,6 +96,7 @@ const initialState: State = { year: defaultRef.year, month: defaultRef.month, rows: [], + accounts: [], isLoading: false, error: null, }; @@ -109,6 +113,8 @@ function reducer(state: State, action: Action): State { return { ...state, isLoading: action.payload }; case "SET_ROWS": return { ...state, rows: action.payload, isLoading: false, error: null }; + case "SET_ACCOUNTS": + return { ...state, accounts: action.payload }; case "SET_ERROR": return { ...state, error: action.payload, isLoading: false }; default: @@ -117,20 +123,26 @@ function reducer(state: State, action: Action): State { } export function useCompare() { - const { from, to } = useReportsPeriod(); + const { from, to, accountIds } = useReportsPeriod(); const [state, dispatch] = useReducer(reducer, initialState); const fetchIdRef = useRef(0); const fetch = useCallback( - async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => { + async ( + mode: CompareMode, + subMode: CompareSubMode, + year: number, + month: number, + ids: number[], + ) => { if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly const id = ++fetchIdRef.current; dispatch({ type: "SET_LOADING", payload: true }); try { const rows = subMode === "mom" - ? await getCompareMonthOverMonth(year, month) - : await getCompareYearOverYear(year, month); + ? await getCompareMonthOverMonth(year, month, ids) + : await getCompareYearOverYear(year, month, ids); if (id !== fetchIdRef.current) return; dispatch({ type: "SET_ROWS", payload: rows }); } catch (e) { @@ -142,8 +154,21 @@ export function useCompare() { ); useEffect(() => { - fetch(state.mode, state.subMode, state.year, state.month); - }, [fetch, state.mode, state.subMode, state.year, state.month]); + fetch(state.mode, state.subMode, state.year, state.month, accountIds); + }, [fetch, state.mode, state.subMode, state.year, state.month, accountIds]); + + // Load the import-source list once, for the FilterPanel's account checkboxes + // (same query as the Trends/Transactions source filters — see useTrends.ts). + useEffect(() => { + (async () => { + try { + const accounts = await getAllImportSources(); + dispatch({ type: "SET_ACCOUNTS", payload: accounts }); + } catch (e) { + dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); + } + })(); + }, []); // Keep the reference month in sync with the URL period when the user navigates // via PeriodSelector — but not on mount. The ref is seeded with the initial diff --git a/src/pages/BudgetPage.tsx b/src/pages/BudgetPage.tsx index 63e0cf1..fbf82ba 100644 --- a/src/pages/BudgetPage.tsx +++ b/src/pages/BudgetPage.tsx @@ -1,12 +1,15 @@ import { useTranslation } from "react-i18next"; import { PageHelp } from "../components/shared/PageHelp"; +import FilterPanel from "../components/reports/FilterPanel"; import { useBudget } from "../hooks/useBudget"; +import { useReportsPeriod } from "../hooks/useReportsPeriod"; import YearNavigator from "../components/budget/YearNavigator"; import BudgetTable from "../components/budget/BudgetTable"; import TemplateActions from "../components/budget/TemplateActions"; export default function BudgetPage() { const { t } = useTranslation(); + const { accountIds, setAccountIds } = useReportsPeriod(); const { state, navigateYear, @@ -18,28 +21,32 @@ export default function BudgetPage() { deleteTemplate, } = useBudget(); - const { year, rows, templates, isLoading, isSaving, error } = state; + const { year, rows, templates, accounts, isLoading, isSaving, error } = state; return (
-
+

{t("budget.title")}

-
- - -
+
+ } + accountIds={accountIds} + onAccountIdsChange={setAccountIds} + accounts={accounts} + /> + {error && (
{error} diff --git a/src/pages/ReportsComparePage.tsx b/src/pages/ReportsComparePage.tsx index fbba1ad..6d0ee68 100644 --- a/src/pages/ReportsComparePage.tsx +++ b/src/pages/ReportsComparePage.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { ArrowLeft } from "lucide-react"; import PeriodSelector from "../components/dashboard/PeriodSelector"; +import FilterPanel from "../components/reports/FilterPanel"; import CompareModeTabs from "../components/reports/CompareModeTabs"; import CompareSubModeToggle from "../components/reports/CompareSubModeToggle"; import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker"; @@ -25,7 +26,8 @@ function formatMonthLabel(year: number, month: number, language: string): string export default function ReportsComparePage() { const { t, i18n } = useTranslation(); - const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); + const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } = + useReportsPeriod(); const { mode, subMode, @@ -35,6 +37,7 @@ export default function ReportsComparePage() { year, month, rows, + accounts, isLoading, error, } = useCompare(); @@ -90,21 +93,25 @@ export default function ReportsComparePage() {
-
-
+ - {showActualControls && ( - - )} -
- {showActualControls && ( + } + accountIds={accountIds} + onAccountIdsChange={setAccountIds} + accounts={accounts} + /> + + {showActualControls && ( +
+ - )} -
+
+ )} {error && (
@@ -113,7 +120,7 @@ export default function ReportsComparePage() { )} {mode === "budget" ? ( - + ) : viewMode === "chart" ? ( { const getDb = vi.fn(); @@ -82,3 +82,39 @@ describe("getBudgetVsActualData — accountIds filter (Issue #273)", () => { expect(budgetEntriesCall[0]).not.toContain("source_id"); }); }); + +// getActualTotalsForYear is the previous-year actuals reference column +// consumed by useBudget (Issue #276) — a thin wrapper over the same +// getActualsByCategoryRange helper, so it gets the same accountIds pass-through. +describe("getActualTotalsForYear — accountIds filter (Issue #276)", () => { + it("without accountIds, the query carries no source_id clause (regression)", async () => { + mockSelect.mockImplementation(() => Promise.resolve([])); + + await getActualTotalsForYear(2025); + + expect(mockSelect).toHaveBeenCalledTimes(1); + const [sql, params] = mockSelect.mock.calls[0]; + expect(sql as string).not.toContain("source_id"); + expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31"]); + }); + + it("an empty accountIds array behaves exactly like no filter (regression)", async () => { + mockSelect.mockImplementation(() => Promise.resolve([])); + + await getActualTotalsForYear(2025, []); + + const [sql, params] = mockSelect.mock.calls[0]; + expect(sql as string).not.toContain("source_id"); + expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31"]); + }); + + it("applies a parameterized accountIds IN clause", async () => { + mockSelect.mockImplementation(() => Promise.resolve([])); + + await getActualTotalsForYear(2025, [4, 9]); + + const [sql, params] = mockSelect.mock.calls[0]; + expect(sql as string).toContain("source_id IN ($3, $4)"); + expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31", 4, 9]); + }); +}); diff --git a/src/services/budgetService.ts b/src/services/budgetService.ts index 8954f6f..ba1520b 100644 --- a/src/services/budgetService.ts +++ b/src/services/budgetService.ts @@ -182,11 +182,12 @@ export async function deleteTemplate(templateId: number): Promise { // --- Actuals helpers --- export async function getActualTotalsForYear( - year: number + year: number, + accountIds?: number[] ): Promise> { const dateFrom = `${year}-01-01`; const dateTo = `${year}-12-31`; - return getActualsByCategoryRange(dateFrom, dateTo); + return getActualsByCategoryRange(dateFrom, dateTo, accountIds); } // --- Budget vs Actual ---