feat(reports): adopt shared FilterPanel on Compare and Budget
Mounts <FilterPanel> 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) <noreply@anthropic.com>
This commit is contained in:
parent
30682004d3
commit
fe9ae0118c
10 changed files with 158 additions and 55 deletions
|
|
@ -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é
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `<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 |
|
||||
| `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) |
|
||||
|
|
|
|||
|
|
@ -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<BudgetVsActualRow[]>([]);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
|
|
|
|||
|
|
@ -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<string, number> = { 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,16 +21,15 @@ export default function BudgetPage() {
|
|||
deleteTemplate,
|
||||
} = useBudget();
|
||||
|
||||
const { year, rows, templates, isLoading, isSaving, error } = state;
|
||||
const { year, rows, templates, accounts, isLoading, isSaving, error } = state;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h1 className="text-2xl font-bold">{t("budget.title")}</h1>
|
||||
<PageHelp helpKey="budget" />
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<TemplateActions
|
||||
templates={templates}
|
||||
onApply={applyTemplate}
|
||||
|
|
@ -36,10 +38,15 @@ export default function BudgetPage() {
|
|||
onDelete={deleteTemplate}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<YearNavigator year={year} onNavigate={navigateYear} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterPanel
|
||||
temporalControl={<YearNavigator year={year} onNavigate={navigateYear} />}
|
||||
accountIds={accountIds}
|
||||
onAccountIdsChange={setAccountIds}
|
||||
accounts={accounts}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||
{error}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<FilterPanel
|
||||
temporalControl={
|
||||
<CompareReferenceMonthPicker
|
||||
year={year}
|
||||
month={month}
|
||||
onChange={setReferencePeriod}
|
||||
/>
|
||||
}
|
||||
accountIds={accountIds}
|
||||
onAccountIdsChange={setAccountIds}
|
||||
accounts={accounts}
|
||||
/>
|
||||
|
||||
{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} />
|
||||
)}
|
||||
</div>
|
||||
{showActualControls && (
|
||||
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<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" ? (
|
||||
<CompareBudgetView year={year} month={month} />
|
||||
<CompareBudgetView year={year} month={month} accountIds={accountIds} />
|
||||
) : viewMode === "chart" ? (
|
||||
<ComparePeriodChart
|
||||
rows={rows}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { getBudgetVsActualData } from "./budgetService";
|
||||
import { getBudgetVsActualData, getActualTotalsForYear } from "./budgetService";
|
||||
|
||||
vi.mock("./db", () => {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -182,11 +182,12 @@ export async function deleteTemplate(templateId: number): Promise<void> {
|
|||
// --- Actuals helpers ---
|
||||
|
||||
export async function getActualTotalsForYear(
|
||||
year: number
|
||||
year: number,
|
||||
accountIds?: number[]
|
||||
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
||||
const dateFrom = `${year}-01-01`;
|
||||
const dateTo = `${year}-12-31`;
|
||||
return getActualsByCategoryRange(dateFrom, dateTo);
|
||||
return getActualsByCategoryRange(dateFrom, dateTo, accountIds);
|
||||
}
|
||||
|
||||
// --- Budget vs Actual ---
|
||||
|
|
|
|||
Loading…
Reference in a new issue