feat(reports): adopt shared FilterPanel on Compare and Budget #284

Closed
maximus wants to merge 1 commit from issue-276-adopt-compare-budget into issue-275-adopt-trends
10 changed files with 158 additions and 55 deletions
Showing only changes of commit fe9ae0118c - Show all commits

View file

@ -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 : 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 : 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 → 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é ### Corrigé

View file

@ -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: 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: 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 → 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 ### Fixed

View file

@ -206,7 +206,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useAdjustments` | Ajustements | | `useAdjustments` | Ajustements |
| `useBudget` | Budget | | `useBudget` | Budget |
| `useDashboard` | Métriques du tableau de bord | | `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 `<FilterPanel>` (#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 `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276) — reste Dashboard/Hub (M2) |
| `useHighlights` | Panneau de faits saillants du hub rapports | | `useHighlights` | Panneau de faits saillants du hub rapports |
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) | | `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) | | `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |

View file

@ -7,9 +7,13 @@ import type { BudgetVsActualRow } from "../../shared/types";
export interface CompareBudgetViewProps { export interface CompareBudgetViewProps {
year: number; year: number;
month: 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 { t } = useTranslation();
const [rows, setRows] = useState<BudgetVsActualRow[]>([]); const [rows, setRows] = useState<BudgetVsActualRow[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -17,7 +21,7 @@ export default function CompareBudgetView({ year, month }: CompareBudgetViewProp
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setError(null); setError(null);
getBudgetVsActualData(year, month) getBudgetVsActualData(year, month, accountIds)
.then((data) => { .then((data) => {
if (!cancelled) setRows(data); if (!cancelled) setRows(data);
}) })
@ -27,7 +31,7 @@ export default function CompareBudgetView({ year, month }: CompareBudgetViewProp
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [year, month]); }, [year, month, accountIds]);
if (error) { if (error) {
return ( return (

View file

@ -1,5 +1,5 @@
import { useReducer, useCallback, useEffect, useRef } from "react"; import { useReducer, useCallback, useEffect, useRef } from "react";
import type { BudgetYearRow, BudgetTemplate } from "../shared/types"; import type { BudgetYearRow, BudgetTemplate, ImportSource } from "../shared/types";
import { import {
getAllActiveCategories, getAllActiveCategories,
getBudgetEntriesForYear, getBudgetEntriesForYear,
@ -11,11 +11,14 @@ import {
applyTemplate as applyTemplateSvc, applyTemplate as applyTemplateSvc,
deleteTemplate as deleteTemplateSvc, deleteTemplate as deleteTemplateSvc,
} from "../services/budgetService"; } from "../services/budgetService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod";
interface BudgetState { interface BudgetState {
year: number; year: number;
rows: BudgetYearRow[]; rows: BudgetYearRow[];
templates: BudgetTemplate[]; templates: BudgetTemplate[];
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
isSaving: boolean; isSaving: boolean;
error: string | null; error: string | null;
@ -26,6 +29,7 @@ type BudgetAction =
| { type: "SET_SAVING"; payload: boolean } | { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null } | { type: "SET_ERROR"; payload: string | null }
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } } | { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_YEAR"; payload: number }; | { type: "SET_YEAR"; payload: number };
function initialState(): BudgetState { function initialState(): BudgetState {
@ -33,6 +37,7 @@ function initialState(): BudgetState {
year: new Date().getFullYear(), year: new Date().getFullYear(),
rows: [], rows: [],
templates: [], templates: [],
accounts: [],
isLoading: false, isLoading: false,
isSaving: false, isSaving: false,
error: null, error: null,
@ -54,6 +59,8 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
templates: action.payload.templates, templates: action.payload.templates,
isLoading: false, isLoading: false,
}; };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_YEAR": case "SET_YEAR":
return { ...state, year: action.payload }; return { ...state, year: action.payload };
default: default:
@ -64,10 +71,11 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 }; const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
export function useBudget() { export function useBudget() {
const { accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, undefined, initialState); const [state, dispatch] = useReducer(reducer, undefined, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const refreshData = useCallback(async (year: number) => { const refreshData = useCallback(async (year: number, ids: number[]) => {
const fetchId = ++fetchIdRef.current; const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null }); dispatch({ type: "SET_ERROR", payload: null });
@ -76,7 +84,7 @@ export function useBudget() {
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([ const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
getAllActiveCategories(), getAllActiveCategories(),
getBudgetEntriesForYear(year), getBudgetEntriesForYear(year),
getActualTotalsForYear(year - 1), getActualTotalsForYear(year - 1, ids),
getAllTemplates(), getAllTemplates(),
]); ]);
@ -364,8 +372,21 @@ export function useBudget() {
}, []); }, []);
useEffect(() => { useEffect(() => {
refreshData(state.year); refreshData(state.year, accountIds);
}, [state.year, refreshData]); }, [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) => { const navigateYear = useCallback((delta: -1 | 1) => {
dispatch({ type: "SET_YEAR", payload: state.year + delta }); dispatch({ type: "SET_YEAR", payload: state.year + delta });
@ -376,7 +397,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await upsertBudgetEntry(categoryId, state.year, month, amount); await upsertBudgetEntry(categoryId, state.year, month, amount);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -386,7 +407,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const splitEvenly = useCallback( const splitEvenly = useCallback(
@ -400,7 +421,7 @@ export function useBudget() {
amounts.push(m < remainder ? base + 0.01 : base); amounts.push(m < remainder ? base + 0.01 : base);
} }
await upsertBudgetEntriesForYear(categoryId, state.year, amounts); await upsertBudgetEntriesForYear(categoryId, state.year, amounts);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -410,7 +431,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const saveTemplate = useCallback( const saveTemplate = useCallback(
@ -423,7 +444,7 @@ export function useBudget() {
.filter((r) => !r.is_parent && r.months[0] !== 0) .filter((r) => !r.is_parent && r.months[0] !== 0)
.map((r) => ({ category_id: r.category_id, amount: r.months[0] })); .map((r) => ({ category_id: r.category_id, amount: r.months[0] }));
await saveAsTemplateSvc(name, description, entries); await saveAsTemplateSvc(name, description, entries);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -433,7 +454,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.rows, state.year, refreshData] [state.rows, state.year, accountIds, refreshData]
); );
const applyTemplate = useCallback( const applyTemplate = useCallback(
@ -441,7 +462,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await applyTemplateSvc(templateId, state.year, month); await applyTemplateSvc(templateId, state.year, month);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -451,7 +472,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const applyTemplateAllMonths = useCallback( const applyTemplateAllMonths = useCallback(
@ -461,7 +482,7 @@ export function useBudget() {
for (let m = 1; m <= 12; m++) { for (let m = 1; m <= 12; m++) {
await applyTemplateSvc(templateId, state.year, m); await applyTemplateSvc(templateId, state.year, m);
} }
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -471,7 +492,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
const deleteTemplate = useCallback( const deleteTemplate = useCallback(
@ -479,7 +500,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_SAVING", payload: true });
try { try {
await deleteTemplateSvc(templateId); await deleteTemplateSvc(templateId);
await refreshData(state.year); await refreshData(state.year, accountIds);
} catch (e) { } catch (e) {
dispatch({ dispatch({
type: "SET_ERROR", type: "SET_ERROR",
@ -489,7 +510,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false }); dispatch({ type: "SET_SAVING", payload: false });
} }
}, },
[state.year, refreshData] [state.year, accountIds, refreshData]
); );
return { return {

View file

@ -1,6 +1,7 @@
import { useReducer, useCallback, useEffect, useRef } from "react"; 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 { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod"; import { useReportsPeriod } from "./useReportsPeriod";
import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod"; import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod";
@ -13,6 +14,7 @@ interface State {
year: number; year: number;
month: number; month: number;
rows: CategoryDelta[]; rows: CategoryDelta[];
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -23,6 +25,7 @@ type Action =
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } } | { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ROWS"; payload: CategoryDelta[] } | { type: "SET_ROWS"; payload: CategoryDelta[] }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_ERROR"; payload: string }; | { type: "SET_ERROR"; payload: string };
/** /**
@ -93,6 +96,7 @@ const initialState: State = {
year: defaultRef.year, year: defaultRef.year,
month: defaultRef.month, month: defaultRef.month,
rows: [], rows: [],
accounts: [],
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -109,6 +113,8 @@ function reducer(state: State, action: Action): State {
return { ...state, isLoading: action.payload }; return { ...state, isLoading: action.payload };
case "SET_ROWS": case "SET_ROWS":
return { ...state, rows: action.payload, isLoading: false, error: null }; return { ...state, rows: action.payload, isLoading: false, error: null };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_ERROR": case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false }; return { ...state, error: action.payload, isLoading: false };
default: default:
@ -117,20 +123,26 @@ function reducer(state: State, action: Action): State {
} }
export function useCompare() { export function useCompare() {
const { from, to } = useReportsPeriod(); const { from, to, accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetch = useCallback( 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 if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
const id = ++fetchIdRef.current; const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
try { try {
const rows = const rows =
subMode === "mom" subMode === "mom"
? await getCompareMonthOverMonth(year, month) ? await getCompareMonthOverMonth(year, month, ids)
: await getCompareYearOverYear(year, month); : await getCompareYearOverYear(year, month, ids);
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ROWS", payload: rows }); dispatch({ type: "SET_ROWS", payload: rows });
} catch (e) { } catch (e) {
@ -142,8 +154,21 @@ export function useCompare() {
); );
useEffect(() => { useEffect(() => {
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]); }, [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 // 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 // via PeriodSelector — but not on mount. The ref is seeded with the initial

View file

@ -1,12 +1,15 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import FilterPanel from "../components/reports/FilterPanel";
import { useBudget } from "../hooks/useBudget"; import { useBudget } from "../hooks/useBudget";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
import YearNavigator from "../components/budget/YearNavigator"; import YearNavigator from "../components/budget/YearNavigator";
import BudgetTable from "../components/budget/BudgetTable"; import BudgetTable from "../components/budget/BudgetTable";
import TemplateActions from "../components/budget/TemplateActions"; import TemplateActions from "../components/budget/TemplateActions";
export default function BudgetPage() { export default function BudgetPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { accountIds, setAccountIds } = useReportsPeriod();
const { const {
state, state,
navigateYear, navigateYear,
@ -18,28 +21,32 @@ export default function BudgetPage() {
deleteTemplate, deleteTemplate,
} = useBudget(); } = useBudget();
const { year, rows, templates, isLoading, isSaving, error } = state; const { year, rows, templates, accounts, isLoading, isSaving, error } = state;
return ( return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}> <div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6"> <div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("budget.title")}</h1> <h1 className="text-2xl font-bold">{t("budget.title")}</h1>
<PageHelp helpKey="budget" /> <PageHelp helpKey="budget" />
</div> </div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3"> <TemplateActions
<TemplateActions templates={templates}
templates={templates} onApply={applyTemplate}
onApply={applyTemplate} onApplyAllMonths={applyTemplateAllMonths}
onApplyAllMonths={applyTemplateAllMonths} onSave={saveTemplate}
onSave={saveTemplate} onDelete={deleteTemplate}
onDelete={deleteTemplate} disabled={isSaving}
disabled={isSaving} />
/>
<YearNavigator year={year} onNavigate={navigateYear} />
</div>
</div> </div>
<FilterPanel
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
accountIds={accountIds}
onAccountIdsChange={setAccountIds}
accounts={accounts}
/>
{error && ( {error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20"> <div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
{error} {error}

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import FilterPanel from "../components/reports/FilterPanel";
import CompareModeTabs from "../components/reports/CompareModeTabs"; import CompareModeTabs from "../components/reports/CompareModeTabs";
import CompareSubModeToggle from "../components/reports/CompareSubModeToggle"; import CompareSubModeToggle from "../components/reports/CompareSubModeToggle";
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker"; import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
@ -25,7 +26,8 @@ function formatMonthLabel(year: number, month: number, language: string): string
export default function ReportsComparePage() { export default function ReportsComparePage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } =
useReportsPeriod();
const { const {
mode, mode,
subMode, subMode,
@ -35,6 +37,7 @@ export default function ReportsComparePage() {
year, year,
month, month,
rows, rows,
accounts,
isLoading, isLoading,
error, error,
} = useCompare(); } = useCompare();
@ -90,21 +93,25 @@ export default function ReportsComparePage() {
</div> </div>
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap"> <FilterPanel
<div className="flex items-center gap-3 flex-wrap"> temporalControl={
<CompareReferenceMonthPicker <CompareReferenceMonthPicker
year={year} year={year}
month={month} month={month}
onChange={setReferencePeriod} onChange={setReferencePeriod}
/> />
{showActualControls && ( }
<CompareSubModeToggle value={subMode} onChange={setSubMode} /> accountIds={accountIds}
)} onAccountIdsChange={setAccountIds}
</div> accounts={accounts}
{showActualControls && ( />
{showActualControls && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
<CompareSubModeToggle value={subMode} onChange={setSubMode} />
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} /> <ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
)} </div>
</div> )}
{error && ( {error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6"> <div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
@ -113,7 +120,7 @@ export default function ReportsComparePage() {
)} )}
{mode === "budget" ? ( {mode === "budget" ? (
<CompareBudgetView year={year} month={month} /> <CompareBudgetView year={year} month={month} accountIds={accountIds} />
) : viewMode === "chart" ? ( ) : viewMode === "chart" ? (
<ComparePeriodChart <ComparePeriodChart
rows={rows} rows={rows}

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { getBudgetVsActualData } from "./budgetService"; import { getBudgetVsActualData, getActualTotalsForYear } from "./budgetService";
vi.mock("./db", () => { vi.mock("./db", () => {
const getDb = vi.fn(); const getDb = vi.fn();
@ -82,3 +82,39 @@ describe("getBudgetVsActualData — accountIds filter (Issue #273)", () => {
expect(budgetEntriesCall[0]).not.toContain("source_id"); 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]);
});
});

View file

@ -182,11 +182,12 @@ export async function deleteTemplate(templateId: number): Promise<void> {
// --- Actuals helpers --- // --- Actuals helpers ---
export async function getActualTotalsForYear( export async function getActualTotalsForYear(
year: number year: number,
accountIds?: number[]
): Promise<Array<{ category_id: number | null; actual: number }>> { ): Promise<Array<{ category_id: number | null; actual: number }>> {
const dateFrom = `${year}-01-01`; const dateFrom = `${year}-01-01`;
const dateTo = `${year}-12-31`; const dateTo = `${year}-12-31`;
return getActualsByCategoryRange(dateFrom, dateTo); return getActualsByCategoryRange(dateFrom, dateTo, accountIds);
} }
// --- Budget vs Actual --- // --- Budget vs Actual ---