feat(reports): adopt shared FilterPanel on Compare and Budget #284
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 : 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é
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue