Compare commits
6 commits
a8e3775b8b
...
fe9ae0118c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe9ae0118c | ||
|
|
30682004d3 | ||
|
|
70b2cd2e42 | ||
|
|
b40381fb89 | ||
|
|
4a93c60ea7 | ||
|
|
65be6c7482 |
26 changed files with 1201 additions and 174 deletions
|
|
@ -6,6 +6,11 @@
|
||||||
|
|
||||||
- Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254).
|
- Rapports → Comparaison : les deux rapports comparables hiérarchiques (réel vs réel et réel vs budget) permettent désormais de **replier ou déplier les sous-catégories de chaque catégorie parente**. Un chevron sur chaque catégorie de premier niveau masque son détail tout en gardant sa ligne de sous-total visible, et un bouton « Tout déplier / Tout replier » les bascule ensemble. Les groupes s'ouvrent **repliés** pour un aperçu compact au niveau des sous-totaux ; ce que vous dépliez est mémorisé par rapport (#254).
|
||||||
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265).
|
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265).
|
||||||
|
- Rapports : pose les fondations d'un futur filtre de comptes pour les rapports — le hook de période partagé suit désormais aussi une sélection multi-comptes via l'URL (mémorisable comme la plage de dates, et validée contre les URL malformées ou modifiées à la main). Purement interne pour l'instant : aucun contrôle de filtre visible encore, le sélecteur de comptes et le branchement par rapport arrivent dans des issues de suivi (#272).
|
||||||
|
- Rapports : les sept services de rapports (tendances, catégories dans le temps, comparaison réel-vs-réel, budget-vs-réel, dépenses par catégorie, et le tableau de bord Cartes) acceptent désormais un filtre multi-comptes optionnel, comparé à une liste paramétrée de sources d'import. Plomberie interne seulement — omettre le filtre partout donne des résultats identiques au byte près, et aucune page de rapport n'expose encore de contrôle de filtre ; cela arrive dans des issues de suivi (#273).
|
||||||
|
- Rapports : pose les fondations de l'interface du filtre de comptes — un panneau de filtre partagé affiche le contrôle de période propre à chaque rapport à côté d'une sélection multiple (cases à cocher) de vos sources d'import. Purement interne pour l'instant : aucune page de rapport n'affiche encore ce panneau, cela arrive dans des issues de suivi (#274).
|
||||||
|
- 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é
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@
|
||||||
|
|
||||||
- Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254).
|
- Reports → Compare: the two hierarchical comparable reports (real-vs-real and real-vs-budget) now let you **collapse or expand each parent category's sub-categories**. A chevron on every top-level category folds its breakdown away while keeping the category's subtotal row in view, and an "Expand all / Collapse all" button toggles them together. Groups start **collapsed** so the report opens on a compact, subtotal-level overview; whatever you expand is remembered per report (#254).
|
||||||
- Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265).
|
- Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265).
|
||||||
|
- Reports: laid the groundwork for an upcoming account filter across the report pages — the shared period hook now also tracks a URL-backed, multi-account selection (bookmarkable like the date range, and validated against malformed/hand-edited URLs). Internal only for now: no filter control is visible yet, the account picker and per-report wiring land in follow-up issues (#272).
|
||||||
|
- Reports: all seven report services (trends, by-category-over-time, real-vs-real compare, budget-vs-actual, expenses-by-category, and the Cartes dashboard) now accept an optional multi-account filter, matched against a parameterized list of import sources. Backend plumbing only — leaving every filter out still returns byte-identical results, and no report page exposes a filter control yet; that lands in follow-up issues (#273).
|
||||||
|
- Reports: laid the groundwork for the account filter's UI — a shared filter panel renders each report page's own period control alongside a multi-select (checkbox list) of your import sources. Internal only for now: no report page renders this panel yet, that lands in follow-up issues (#274).
|
||||||
|
- 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) |
|
| `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 (
|
||||||
|
|
|
||||||
199
src/components/reports/FilterPanel.test.tsx
Normal file
199
src/components/reports/FilterPanel.test.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
// FilterPanel — unit tests (Issue #274).
|
||||||
|
//
|
||||||
|
// NOTE: This project has no @testing-library/react / jsdom configured (see
|
||||||
|
// PriceFetchControl.test.tsx). FilterPanel, unlike most components with
|
||||||
|
// their own test file, has NO internal state or hooks besides `useTranslation`
|
||||||
|
// — mocked below into a plain function — so it can be invoked directly as a
|
||||||
|
// plain function (bypassing JSX/React.createElement's usual `<FilterPanel />`
|
||||||
|
// call site) and its return value (a plain React-element object tree, since
|
||||||
|
// JSX creation never touches the DOM) walked directly. This gives genuine
|
||||||
|
// coverage of the shipped render + selection-wiring code, not a
|
||||||
|
// re-implementation of its logic in the test.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: vi.fn(() => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
i18n: { language: "fr" },
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react", () => ({
|
||||||
|
Inbox: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import FilterPanel, { toggleAccountId } from "./FilterPanel";
|
||||||
|
import type { FilterPanelProps } from "./FilterPanel";
|
||||||
|
import type { ImportSource } from "../../shared/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Minimal React-element tree walker (no renderer/DOM involved)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ElementNode {
|
||||||
|
type: unknown;
|
||||||
|
props: Record<string, unknown> & { children?: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlatNode = ElementNode | string | number;
|
||||||
|
|
||||||
|
function isElementNode(x: unknown): x is ElementNode {
|
||||||
|
return typeof x === "object" && x !== null && "props" in x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flatten(node: unknown): FlatNode[] {
|
||||||
|
if (Array.isArray(node)) return node.flatMap(flatten);
|
||||||
|
if (node === null || node === undefined || typeof node === "boolean") return [];
|
||||||
|
if (typeof node === "string" || typeof node === "number") return [node];
|
||||||
|
if (isElementNode(node)) return [node, ...flatten(node.props.children)];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCheckboxes(root: unknown): ElementNode[] {
|
||||||
|
return flatten(root).filter(
|
||||||
|
(n): n is ElementNode => isElementNode(n) && n.props.type === "checkbox",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SOURCE_CHEQUING: ImportSource = {
|
||||||
|
id: 1,
|
||||||
|
name: "Compte chèque Desjardins",
|
||||||
|
date_format: "YYYY-MM-DD",
|
||||||
|
delimiter: ",",
|
||||||
|
encoding: "utf-8",
|
||||||
|
column_mapping: "{}",
|
||||||
|
skip_lines: 0,
|
||||||
|
has_header: true,
|
||||||
|
created_at: "2026-01-01",
|
||||||
|
updated_at: "2026-01-01",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_VISA: ImportSource = {
|
||||||
|
...SOURCE_CHEQUING,
|
||||||
|
id: 2,
|
||||||
|
name: "Visa Desjardins",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderPanel(overrides: Partial<FilterPanelProps> = {}) {
|
||||||
|
const onAccountIdsChange = vi.fn();
|
||||||
|
const props: FilterPanelProps = {
|
||||||
|
temporalControl: "TEMPORAL_CONTROL_MARKER",
|
||||||
|
accountIds: [],
|
||||||
|
onAccountIdsChange,
|
||||||
|
accounts: [SOURCE_CHEQUING, SOURCE_VISA],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const element = FilterPanel(props);
|
||||||
|
return { element, onAccountIdsChange };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// toggleAccountId — pure selection logic
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("toggleAccountId", () => {
|
||||||
|
it("adds an absent id to an empty list", () => {
|
||||||
|
expect(toggleAccountId([], 1)).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends an absent id after the existing ones", () => {
|
||||||
|
expect(toggleAccountId([1, 2], 3)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a present id, preserving the order of the remaining ids", () => {
|
||||||
|
expect(toggleAccountId([1, 2, 3], 2)).toEqual([1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removing the only selected id returns an empty array (= all accounts)", () => {
|
||||||
|
expect(toggleAccountId([1], 1)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the input array", () => {
|
||||||
|
const input = [1, 2];
|
||||||
|
toggleAccountId(input, 3);
|
||||||
|
expect(input).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FilterPanel — render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("FilterPanel — render", () => {
|
||||||
|
it("renders the injected temporalControl as-is", () => {
|
||||||
|
const { element } = renderPanel();
|
||||||
|
expect(flatten(element)).toContain("TEMPORAL_CONTROL_MARKER");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders one checkbox per account", () => {
|
||||||
|
const { element } = renderPanel();
|
||||||
|
expect(findCheckboxes(element)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders each account's name as visible text", () => {
|
||||||
|
const { element } = renderPanel();
|
||||||
|
const text = flatten(element);
|
||||||
|
expect(text).toContain("Compte chèque Desjardins");
|
||||||
|
expect(text).toContain("Visa Desjardins");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks exactly the accounts present in accountIds", () => {
|
||||||
|
const { element } = renderPanel({ accountIds: [2] });
|
||||||
|
const checkboxes = findCheckboxes(element);
|
||||||
|
expect(checkboxes.map((c) => c.props.checked)).toEqual([false, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks no checkbox when accountIds is empty (= all accounts, no explicit filter)", () => {
|
||||||
|
const { element } = renderPanel({ accountIds: [] });
|
||||||
|
const checkboxes = findCheckboxes(element);
|
||||||
|
expect(checkboxes.every((c) => c.props.checked === false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders zero checkboxes when accounts is empty, without crashing", () => {
|
||||||
|
const { element } = renderPanel({ accounts: [] });
|
||||||
|
expect(findCheckboxes(element)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses i18n keys for the accounts filter label and hint (never a hardcoded string)", () => {
|
||||||
|
const { element } = renderPanel();
|
||||||
|
const text = flatten(element);
|
||||||
|
expect(text).toContain("reports.filters.accounts.label");
|
||||||
|
expect(text).toContain("reports.filters.accounts.hint");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FilterPanel — selection wiring
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("FilterPanel — selection wiring", () => {
|
||||||
|
it("checking an unselected account calls onAccountIdsChange with it added", () => {
|
||||||
|
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
|
||||||
|
const checkboxes = findCheckboxes(element);
|
||||||
|
// accounts[1] = SOURCE_VISA (id 2), currently unselected
|
||||||
|
(checkboxes[1].props.onChange as () => void)();
|
||||||
|
expect(onAccountIdsChange).toHaveBeenCalledWith([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unchecking the only selected account calls onAccountIdsChange with an empty array", () => {
|
||||||
|
const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
|
||||||
|
const checkboxes = findCheckboxes(element);
|
||||||
|
// accounts[0] = SOURCE_CHEQUING (id 1), currently selected
|
||||||
|
(checkboxes[0].props.onChange as () => void)();
|
||||||
|
expect(onAccountIdsChange).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checking a second account preserves the first (multi-select)", () => {
|
||||||
|
const { element, onAccountIdsChange } = renderPanel({ accountIds: [] });
|
||||||
|
const checkboxes = findCheckboxes(element);
|
||||||
|
(checkboxes[0].props.onChange as () => void)();
|
||||||
|
(checkboxes[1].props.onChange as () => void)();
|
||||||
|
expect(onAccountIdsChange).toHaveBeenNthCalledWith(1, [1]);
|
||||||
|
expect(onAccountIdsChange).toHaveBeenNthCalledWith(2, [2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
93
src/components/reports/FilterPanel.tsx
Normal file
93
src/components/reports/FilterPanel.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// FilterPanel — shared filter bar for the report pages (Issue #274, epic #260).
|
||||||
|
//
|
||||||
|
// Design: a "slot" component. Each report page owns its own temporal state
|
||||||
|
// (Compare = a local reducer, Trends/ByCategory = the `sources` query string
|
||||||
|
// via useReportsPeriod, Budget = a plain year) and renders its own control
|
||||||
|
// (PeriodSelector / CompareReferenceMonthPicker / YearNavigator / …), passed
|
||||||
|
// in as-is via `temporalControl`. FilterPanel does NOT own a `temporalMode`
|
||||||
|
// enum and does not know which control it is rendering — an earlier design
|
||||||
|
// unifying temporal state behind such an enum was rejected specifically to
|
||||||
|
// avoid mixing state sources (see spec-plan-rapports-uniformes-suite.md).
|
||||||
|
// The only state this component actually owns/shares across report pages is
|
||||||
|
// the account (import source) filter.
|
||||||
|
//
|
||||||
|
// `accounts`/`accountIds` refer to `ImportSource` rows (bank statement import
|
||||||
|
// sources, e.g. "Compte chèque Desjardins") — a disjoint id space from the
|
||||||
|
// Bilan module's `balance_accounts` ("Compte" in that module's own UI). The
|
||||||
|
// copy in this component deliberately says "sources" everywhere, never
|
||||||
|
// "comptes"/"accounts", so the two concepts never read as the same filter.
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Inbox } from "lucide-react";
|
||||||
|
import type { ImportSource } from "../../shared/types";
|
||||||
|
|
||||||
|
export interface FilterPanelProps {
|
||||||
|
/** The page's own temporal control, rendered as-is. */
|
||||||
|
temporalControl: ReactNode;
|
||||||
|
/** Selected import source ids. Empty array = no filter (all sources). */
|
||||||
|
accountIds: number[];
|
||||||
|
onAccountIdsChange: (accountIds: number[]) => void;
|
||||||
|
/** Import sources available to filter on. */
|
||||||
|
accounts: ImportSource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles `id` in/out of `accountIds` (add if absent, remove if present),
|
||||||
|
* preserving the relative order of the remaining ids. Pure and exported so
|
||||||
|
* the selection logic is unit-testable without rendering the component.
|
||||||
|
*/
|
||||||
|
export function toggleAccountId(accountIds: number[], id: number): number[] {
|
||||||
|
return accountIds.includes(id)
|
||||||
|
? accountIds.filter((existing) => existing !== id)
|
||||||
|
: [...accountIds, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilterPanel({
|
||||||
|
temporalControl,
|
||||||
|
accountIds,
|
||||||
|
onAccountIdsChange,
|
||||||
|
accounts,
|
||||||
|
}: FilterPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const accountsLabel = t("reports.filters.accounts.label");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] mb-4 flex flex-wrap items-start gap-4">
|
||||||
|
<div>{temporalControl}</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-[var(--muted-foreground)]">
|
||||||
|
<Inbox size={14} />
|
||||||
|
{accountsLabel}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap gap-x-4 gap-y-1.5"
|
||||||
|
role="group"
|
||||||
|
aria-label={accountsLabel}
|
||||||
|
>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<label
|
||||||
|
key={account.id}
|
||||||
|
className="flex items-center gap-1.5 text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={accountIds.includes(account.id)}
|
||||||
|
onChange={() =>
|
||||||
|
onAccountIdsChange(toggleAccountId(accountIds, account.id))
|
||||||
|
}
|
||||||
|
className="accent-[var(--primary)]"
|
||||||
|
/>
|
||||||
|
{account.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.filters.accounts.hint")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,5 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { resolveReportsPeriod } from "./useReportsPeriod";
|
import { resolveReportsPeriod, parseAccountIds, serializeAccountIds } from "./useReportsPeriod";
|
||||||
|
|
||||||
describe("resolveReportsPeriod", () => {
|
describe("resolveReportsPeriod", () => {
|
||||||
const fixedToday = new Date("2026-04-14T12:00:00Z");
|
const fixedToday = new Date("2026-04-14T12:00:00Z");
|
||||||
|
|
@ -51,3 +51,76 @@ describe("resolveReportsPeriod", () => {
|
||||||
expect(result.to).toBe("2026-12-31");
|
expect(result.to).toBe("2026-12-31");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseAccountIds", () => {
|
||||||
|
it("defaults to an empty array (no filter) when the sources param is absent", () => {
|
||||||
|
expect(parseAccountIds(null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to an empty array for an empty string", () => {
|
||||||
|
expect(parseAccountIds("")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a comma-separated list of ids", () => {
|
||||||
|
expect(parseAccountIds("3,7,12")).toEqual([3, 7, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace around tokens", () => {
|
||||||
|
expect(parseAccountIds(" 3 , 7 ,12")).toEqual([3, 7, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a single id", () => {
|
||||||
|
expect(parseAccountIds("42")).toEqual([42]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops non-numeric tokens but keeps the valid ones (rejects non-numeric)", () => {
|
||||||
|
expect(parseAccountIds("3,abc,7")).toEqual([3, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops decimal tokens (not a finite integer)", () => {
|
||||||
|
expect(parseAccountIds("3,4.5,7")).toEqual([3, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops empty tokens produced by stray/trailing commas", () => {
|
||||||
|
expect(parseAccountIds("3,,7,")).toEqual([3, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops exponential/hex-looking tokens that Number() would otherwise coerce", () => {
|
||||||
|
expect(parseAccountIds("3,1e3,0x10,7")).toEqual([3, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops unsafe-integer tokens (too large to be a reliable id)", () => {
|
||||||
|
expect(parseAccountIds("3,99999999999999999999,7")).toEqual([3, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array when every token is invalid", () => {
|
||||||
|
expect(parseAccountIds("abc,def")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serializeAccountIds", () => {
|
||||||
|
it("joins ids into a comma-separated string", () => {
|
||||||
|
expect(serializeAccountIds([3, 7, 12])).toBe("3,7,12");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes a single id", () => {
|
||||||
|
expect(serializeAccountIds([42])).toBe("42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an empty array so the param is removed (no filter)", () => {
|
||||||
|
expect(serializeAccountIds([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid entries (e.g. NaN) before serializing", () => {
|
||||||
|
expect(serializeAccountIds([3, NaN, 7])).toBe("3,7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when every entry is invalid", () => {
|
||||||
|
expect(serializeAccountIds([NaN, Infinity])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips through parseAccountIds", () => {
|
||||||
|
const ids = [1, 2, 3];
|
||||||
|
expect(parseAccountIds(serializeAccountIds(ids))).toEqual(ids);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,43 @@ function currentYearRange(today: Date = new Date()): { from: string; to: string
|
||||||
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches a bare (optionally negative) integer token, e.g. "3" or "-3", but
|
||||||
|
// not "3.5", "abc", "" or "1e3" — deliberately stricter than `Number(...)` so
|
||||||
|
// that hex/exponential-notation strings from a hand-edited URL don't sneak
|
||||||
|
// through as valid ids.
|
||||||
|
const INTEGER_TOKEN = /^-?\d+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure parser for the `sources` query param (comma-separated account/import
|
||||||
|
* source ids), exposed for the same testability reason as
|
||||||
|
* `resolveReportsPeriod`. Invalid tokens (non-numeric, decimal, empty from a
|
||||||
|
* stray comma, or too large to be a safe integer) are dropped individually
|
||||||
|
* rather than invalidating the whole list — a single corrupted token in a
|
||||||
|
* bookmarked/hand-edited URL should not silently discard the rest of an
|
||||||
|
* otherwise valid selection. Missing param or no valid token → `[]`, which
|
||||||
|
* means "no filter" (all accounts).
|
||||||
|
*/
|
||||||
|
export function parseAccountIds(raw: string | null): number[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
return raw
|
||||||
|
.split(",")
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter((token) => INTEGER_TOKEN.test(token))
|
||||||
|
.map(Number)
|
||||||
|
.filter((n) => Number.isSafeInteger(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse of `parseAccountIds`: serializes account ids back to the `sources`
|
||||||
|
* query param value, or `null` when the param should be removed from the URL
|
||||||
|
* entirely (empty/all-invalid selection = no filter — mirrors how `from`/`to`
|
||||||
|
* are omitted for a non-custom period).
|
||||||
|
*/
|
||||||
|
export function serializeAccountIds(accountIds: number[]): string | null {
|
||||||
|
const valid = accountIds.filter((n) => Number.isSafeInteger(n));
|
||||||
|
return valid.length > 0 ? valid.join(",") : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure resolver used by the hook and unit tests. Exposed to keep the core
|
* Pure resolver used by the hook and unit tests. Exposed to keep the core
|
||||||
* logic hookless and testable without rendering a router.
|
* logic hookless and testable without rendering a router.
|
||||||
|
|
@ -57,15 +94,19 @@ export interface UseReportsPeriodResult {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
period: DashboardPeriod;
|
period: DashboardPeriod;
|
||||||
|
accountIds: number[];
|
||||||
setPeriod: (period: DashboardPeriod) => void;
|
setPeriod: (period: DashboardPeriod) => void;
|
||||||
setCustomDates: (from: string, to: string) => void;
|
setCustomDates: (from: string, to: string) => void;
|
||||||
|
setAccountIds: (accountIds: number[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads/writes the active reporting period via the URL query string so it is
|
* Reads/writes the active reporting period — and, additively, the account
|
||||||
* bookmarkable and shared across the four report sub-routes.
|
* (import source) filter — via the URL query string so both are bookmarkable
|
||||||
|
* and shared across the report sub-routes.
|
||||||
*
|
*
|
||||||
* Defaults to the current civil year (Jan 1 → Dec 31).
|
* Defaults to the current civil year (Jan 1 → Dec 31) with no account filter
|
||||||
|
* (`accountIds: []`, meaning all accounts).
|
||||||
*/
|
*/
|
||||||
export function useReportsPeriod(): UseReportsPeriodResult {
|
export function useReportsPeriod(): UseReportsPeriodResult {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -73,12 +114,15 @@ export function useReportsPeriod(): UseReportsPeriodResult {
|
||||||
const rawPeriod = searchParams.get("period");
|
const rawPeriod = searchParams.get("period");
|
||||||
const rawFrom = searchParams.get("from");
|
const rawFrom = searchParams.get("from");
|
||||||
const rawTo = searchParams.get("to");
|
const rawTo = searchParams.get("to");
|
||||||
|
const rawSources = searchParams.get("sources");
|
||||||
|
|
||||||
const { from, to, period } = useMemo(
|
const { from, to, period } = useMemo(
|
||||||
() => resolveReportsPeriod(rawFrom, rawTo, rawPeriod),
|
() => resolveReportsPeriod(rawFrom, rawTo, rawPeriod),
|
||||||
[rawPeriod, rawFrom, rawTo],
|
[rawPeriod, rawFrom, rawTo],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const accountIds = useMemo(() => parseAccountIds(rawSources), [rawSources]);
|
||||||
|
|
||||||
const setPeriod = useCallback(
|
const setPeriod = useCallback(
|
||||||
(next: DashboardPeriod) => {
|
(next: DashboardPeriod) => {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
|
|
@ -115,5 +159,24 @@ export function useReportsPeriod(): UseReportsPeriodResult {
|
||||||
[setSearchParams],
|
[setSearchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { from, to, period, setPeriod, setCustomDates };
|
const setAccountIds = useCallback(
|
||||||
|
(nextAccountIds: number[]) => {
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const params = new URLSearchParams(prev);
|
||||||
|
const serialized = serializeAccountIds(nextAccountIds);
|
||||||
|
if (serialized === null) {
|
||||||
|
params.delete("sources");
|
||||||
|
} else {
|
||||||
|
params.set("sources", serialized);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { from, to, period, accountIds, setPeriod, setCustomDates, setAccountIds };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useReducer, useEffect, useRef, useCallback } from "react";
|
import { useReducer, useEffect, useRef, useCallback } from "react";
|
||||||
import type { MonthlyTrendItem, CategoryOverTimeData } from "../shared/types";
|
import type { MonthlyTrendItem, CategoryOverTimeData, ImportSource } from "../shared/types";
|
||||||
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
||||||
|
import { getAllImportSources } from "../services/transactionService";
|
||||||
import { useReportsPeriod } from "./useReportsPeriod";
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
export type TrendsSubView = "global" | "byCategory";
|
export type TrendsSubView = "global" | "byCategory";
|
||||||
|
|
@ -9,6 +10,7 @@ interface State {
|
||||||
subView: TrendsSubView;
|
subView: TrendsSubView;
|
||||||
monthlyTrends: MonthlyTrendItem[];
|
monthlyTrends: MonthlyTrendItem[];
|
||||||
categoryOverTime: CategoryOverTimeData;
|
categoryOverTime: CategoryOverTimeData;
|
||||||
|
accounts: ImportSource[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -18,12 +20,14 @@ type Action =
|
||||||
| { type: "SET_LOADING"; payload: boolean }
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
|
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
|
||||||
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
||||||
|
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
|
||||||
| { type: "SET_ERROR"; payload: string };
|
| { type: "SET_ERROR"; payload: string };
|
||||||
|
|
||||||
const initialState: State = {
|
const initialState: State = {
|
||||||
subView: "byCategory",
|
subView: "byCategory",
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
|
||||||
|
accounts: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -38,6 +42,8 @@ function reducer(state: State, action: Action): State {
|
||||||
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
|
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
|
||||||
case "SET_CATEGORY_OVER_TIME":
|
case "SET_CATEGORY_OVER_TIME":
|
||||||
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
|
return { ...state, categoryOverTime: 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:
|
||||||
|
|
@ -46,32 +52,48 @@ function reducer(state: State, action: Action): State {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTrends() {
|
export function useTrends() {
|
||||||
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(async (subView: TrendsSubView, dateFrom: string, dateTo: string) => {
|
const fetch = useCallback(
|
||||||
const id = ++fetchIdRef.current;
|
async (subView: TrendsSubView, dateFrom: string, dateTo: string, ids: number[]) => {
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
const id = ++fetchIdRef.current;
|
||||||
try {
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
if (subView === "global") {
|
try {
|
||||||
const data = await getMonthlyTrends(dateFrom, dateTo);
|
if (subView === "global") {
|
||||||
|
const data = await getMonthlyTrends(dateFrom, dateTo, ids);
|
||||||
|
if (id !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_TRENDS", payload: data });
|
||||||
|
} else {
|
||||||
|
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, ids);
|
||||||
|
if (id !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
if (id !== fetchIdRef.current) return;
|
if (id !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_TRENDS", payload: data });
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||||
} else {
|
|
||||||
const data = await getCategoryOverTime(dateFrom, dateTo);
|
|
||||||
if (id !== fetchIdRef.current) return;
|
|
||||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
},
|
||||||
if (id !== fetchIdRef.current) return;
|
[]
|
||||||
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
);
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(state.subView, from, to);
|
fetch(state.subView, from, to, accountIds);
|
||||||
}, [fetch, state.subView, from, to]);
|
}, [fetch, state.subView, from, to, accountIds]);
|
||||||
|
|
||||||
|
// Load the import-source list once, for the FilterPanel's account checkboxes
|
||||||
|
// (same query as the Transactions single-source filter — see useTransactions.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 setSubView = useCallback((sv: TrendsSubView) => {
|
const setSubView = useCallback((sv: TrendsSubView) => {
|
||||||
dispatch({ type: "SET_SUBVIEW", payload: sv });
|
dispatch({ type: "SET_SUBVIEW", payload: sv });
|
||||||
|
|
|
||||||
|
|
@ -392,7 +392,11 @@
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"allTypes": "All types"
|
"allTypes": "All types",
|
||||||
|
"accounts": {
|
||||||
|
"label": "Import sources",
|
||||||
|
"hint": "No selection = all sources"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"bva": {
|
"bva": {
|
||||||
"monthly": "Monthly",
|
"monthly": "Monthly",
|
||||||
|
|
|
||||||
|
|
@ -392,7 +392,11 @@
|
||||||
"search": "Rechercher...",
|
"search": "Rechercher...",
|
||||||
"all": "Toutes",
|
"all": "Toutes",
|
||||||
"none": "Aucune",
|
"none": "Aucune",
|
||||||
"allTypes": "Tous les types"
|
"allTypes": "Tous les types",
|
||||||
|
"accounts": {
|
||||||
|
"label": "Sources d'import",
|
||||||
|
"hint": "Aucune sélection = toutes les sources"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"bva": {
|
"bva": {
|
||||||
"monthly": "Mensuel",
|
"monthly": "Mensuel",
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||||
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
||||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||||
|
|
@ -21,8 +22,8 @@ const STORAGE_KEY = "reports-viewmode-trends";
|
||||||
|
|
||||||
export default function ReportsTrendsPage() {
|
export default function ReportsTrendsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } = useReportsPeriod();
|
||||||
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends();
|
const { subView, setSubView, monthlyTrends, categoryOverTime, accounts, isLoading, error } = useTrends();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table"));
|
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table"));
|
||||||
const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType());
|
const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType());
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
|
|
@ -55,50 +56,56 @@ export default function ReportsTrendsPage() {
|
||||||
<h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1>
|
<h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
|
<FilterPanel
|
||||||
<PeriodSelector
|
temporalControl={
|
||||||
value={period}
|
<PeriodSelector
|
||||||
onChange={setPeriod}
|
value={period}
|
||||||
customDateFrom={from}
|
onChange={setPeriod}
|
||||||
customDateTo={to}
|
customDateFrom={from}
|
||||||
onCustomDateChange={setCustomDates}
|
customDateTo={to}
|
||||||
/>
|
onCustomDateChange={setCustomDates}
|
||||||
<div className="flex gap-2 items-center flex-wrap">
|
/>
|
||||||
<div className="inline-flex gap-1">
|
}
|
||||||
<button
|
accountIds={accountIds}
|
||||||
type="button"
|
onAccountIdsChange={setAccountIds}
|
||||||
onClick={() => setSubView("global")}
|
accounts={accounts}
|
||||||
aria-pressed={subView === "global"}
|
/>
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
subView === "global"
|
<div className="flex gap-2 items-center justify-end flex-wrap mb-6">
|
||||||
? "bg-[var(--primary)] text-white"
|
<div className="inline-flex gap-1">
|
||||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
<button
|
||||||
}`}
|
type="button"
|
||||||
>
|
onClick={() => setSubView("global")}
|
||||||
{t("reports.trends.subviewGlobal")}
|
aria-pressed={subView === "global"}
|
||||||
</button>
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
<button
|
subView === "global"
|
||||||
type="button"
|
? "bg-[var(--primary)] text-white"
|
||||||
onClick={() => setSubView("byCategory")}
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
aria-pressed={subView === "byCategory"}
|
}`}
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
>
|
||||||
subView === "byCategory"
|
{t("reports.trends.subviewGlobal")}
|
||||||
? "bg-[var(--primary)] text-white"
|
</button>
|
||||||
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
<button
|
||||||
}`}
|
type="button"
|
||||||
>
|
onClick={() => setSubView("byCategory")}
|
||||||
{t("reports.trends.subviewByCategory")}
|
aria-pressed={subView === "byCategory"}
|
||||||
</button>
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
</div>
|
subView === "byCategory"
|
||||||
{subView === "byCategory" && viewMode === "chart" && (
|
? "bg-[var(--primary)] text-white"
|
||||||
<TrendsChartTypeToggle
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
value={chartType}
|
}`}
|
||||||
onChange={setChartType}
|
>
|
||||||
storageKey={TRENDS_CHART_TYPE_STORAGE_KEY}
|
{t("reports.trends.subviewByCategory")}
|
||||||
/>
|
</button>
|
||||||
)}
|
|
||||||
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
|
||||||
</div>
|
</div>
|
||||||
|
{subView === "byCategory" && viewMode === "chart" && (
|
||||||
|
<TrendsChartTypeToggle
|
||||||
|
value={chartType}
|
||||||
|
onChange={setChartType}
|
||||||
|
storageKey={TRENDS_CHART_TYPE_STORAGE_KEY}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
120
src/services/budgetService.test.ts
Normal file
120
src/services/budgetService.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { getBudgetVsActualData, getActualTotalsForYear } from "./budgetService";
|
||||||
|
|
||||||
|
vi.mock("./db", () => {
|
||||||
|
const getDb = vi.fn();
|
||||||
|
return { getDb };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { getDb } from "./db";
|
||||||
|
|
||||||
|
const mockSelect = vi.fn();
|
||||||
|
const mockDb = { select: mockSelect };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
||||||
|
mockSelect.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
// getBudgetVsActualData fans out to 4 parallel selects (getAllActiveCategories,
|
||||||
|
// getBudgetEntriesForYear, and getActualsByCategoryRange for the month + YTD
|
||||||
|
// windows). Only the two `getActualsByCategoryRange` calls query `transactions`
|
||||||
|
// and are eligible for the accountIds filter — budgets are not tied to an
|
||||||
|
// account, so the categories/entries queries must never carry the clause.
|
||||||
|
describe("getBudgetVsActualData — accountIds filter (Issue #273)", () => {
|
||||||
|
it("without accountIds, the actuals queries carry no source_id clause (regression)", async () => {
|
||||||
|
mockSelect.mockImplementation(() => Promise.resolve([]));
|
||||||
|
|
||||||
|
await getBudgetVsActualData(2026, 3);
|
||||||
|
|
||||||
|
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
|
||||||
|
(sql as string).includes("FROM transactions"),
|
||||||
|
);
|
||||||
|
expect(actualsCalls.length).toBe(2); // month + YTD
|
||||||
|
for (const [sql, params] of actualsCalls) {
|
||||||
|
expect(sql as string).not.toContain("source_id");
|
||||||
|
expect(params as unknown[]).toHaveLength(2); // dateFrom, dateTo only
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
|
||||||
|
mockSelect.mockImplementation(() => Promise.resolve([]));
|
||||||
|
|
||||||
|
await getBudgetVsActualData(2026, 3, []);
|
||||||
|
|
||||||
|
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
|
||||||
|
(sql as string).includes("FROM transactions"),
|
||||||
|
);
|
||||||
|
for (const [sql, params] of actualsCalls) {
|
||||||
|
expect(sql as string).not.toContain("source_id");
|
||||||
|
expect(params as unknown[]).toHaveLength(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a parameterized accountIds IN clause to both the month and YTD actuals queries", async () => {
|
||||||
|
mockSelect.mockImplementation(() => Promise.resolve([]));
|
||||||
|
|
||||||
|
await getBudgetVsActualData(2026, 3, [4, 9]);
|
||||||
|
|
||||||
|
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
|
||||||
|
(sql as string).includes("FROM transactions"),
|
||||||
|
);
|
||||||
|
expect(actualsCalls.length).toBe(2);
|
||||||
|
for (const [sql, params] of actualsCalls) {
|
||||||
|
expect(sql as string).toContain("source_id IN ($3, $4)");
|
||||||
|
expect(sql as string).not.toContain("4,9");
|
||||||
|
expect(params as unknown[]).toEqual(expect.arrayContaining([4, 9]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never adds the filter to the categories/budget-entries queries (a budget is not tied to an account)", async () => {
|
||||||
|
mockSelect.mockImplementation(() => Promise.resolve([]));
|
||||||
|
|
||||||
|
await getBudgetVsActualData(2026, 3, [4]);
|
||||||
|
|
||||||
|
const categoriesCall = mockSelect.mock.calls.find(([sql]) =>
|
||||||
|
(sql as string).includes("FROM categories"),
|
||||||
|
)!;
|
||||||
|
const budgetEntriesCall = mockSelect.mock.calls.find(([sql]) =>
|
||||||
|
(sql as string).includes("FROM budget_entries"),
|
||||||
|
)!;
|
||||||
|
expect(categoriesCall[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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import { inPlaceholders } from "../utils/sqlFilters";
|
||||||
import type {
|
import type {
|
||||||
Category,
|
Category,
|
||||||
BudgetEntry,
|
BudgetEntry,
|
||||||
|
|
@ -181,34 +182,47 @@ 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 ---
|
||||||
|
|
||||||
async function getActualsByCategoryRange(
|
async function getActualsByCategoryRange(
|
||||||
dateFrom: string,
|
dateFrom: string,
|
||||||
dateTo: string
|
dateTo: string,
|
||||||
|
accountIds?: number[]
|
||||||
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
const params: unknown[] = [dateFrom, dateTo];
|
||||||
|
const accountPlaceholders = inPlaceholders(accountIds, params.length + 1);
|
||||||
|
const accountFilter = accountPlaceholders ? ` AND source_id IN (${accountPlaceholders})` : "";
|
||||||
|
if (accountPlaceholders) params.push(...accountIds!);
|
||||||
return db.select<Array<{ category_id: number | null; actual: number }>>(
|
return db.select<Array<{ category_id: number | null; actual: number }>>(
|
||||||
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual
|
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual
|
||||||
FROM transactions
|
FROM transactions
|
||||||
WHERE date BETWEEN $1 AND $2
|
WHERE date BETWEEN $1 AND $2${accountFilter}
|
||||||
GROUP BY category_id`,
|
GROUP BY category_id`,
|
||||||
[dateFrom, dateTo]
|
params
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Budget vs actual, by category, for the reference month plus YTD. `accountIds`
|
||||||
|
* (Issue #273) optionally scopes both the month and YTD actuals to a subset of
|
||||||
|
* import sources (`transactions.source_id`) — budgeted amounts are unaffected
|
||||||
|
* by the filter (a budget is not tied to any account).
|
||||||
|
*/
|
||||||
export async function getBudgetVsActualData(
|
export async function getBudgetVsActualData(
|
||||||
year: number,
|
year: number,
|
||||||
month: number
|
month: number,
|
||||||
|
accountIds?: number[]
|
||||||
): Promise<BudgetVsActualRow[]> {
|
): Promise<BudgetVsActualRow[]> {
|
||||||
// Date ranges
|
// Date ranges
|
||||||
const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month);
|
const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month);
|
||||||
|
|
@ -219,8 +233,8 @@ export async function getBudgetVsActualData(
|
||||||
const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([
|
const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([
|
||||||
getAllActiveCategories(),
|
getAllActiveCategories(),
|
||||||
getBudgetEntriesForYear(year),
|
getBudgetEntriesForYear(year),
|
||||||
getActualsByCategoryRange(monthFrom, monthTo),
|
getActualsByCategoryRange(monthFrom, monthTo, accountIds),
|
||||||
getActualsByCategoryRange(ytdFrom, ytdTo),
|
getActualsByCategoryRange(ytdFrom, ytdTo, accountIds),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build maps
|
// Build maps
|
||||||
|
|
|
||||||
65
src/services/dashboardService.test.ts
Normal file
65
src/services/dashboardService.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { getExpensesByCategory } from "./dashboardService";
|
||||||
|
|
||||||
|
vi.mock("./db", () => {
|
||||||
|
const getDb = vi.fn();
|
||||||
|
return { getDb };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { getDb } from "./db";
|
||||||
|
|
||||||
|
const mockSelect = vi.fn();
|
||||||
|
const mockDb = { select: mockSelect };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
||||||
|
mockSelect.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getExpensesByCategory — accountIds filter (Issue #273)", () => {
|
||||||
|
it("without accountIds, the query carries no source_id clause (regression)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getExpensesByCategory("2025-01-01", "2025-12-31");
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).not.toContain("source_id");
|
||||||
|
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getExpensesByCategory("2025-01-01", "2025-12-31", []);
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).not.toContain("source_id");
|
||||||
|
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a parameterized accountIds IN clause, one placeholder per id", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getExpensesByCategory("2025-01-01", "2025-12-31", [6, 11]);
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).toContain("t.source_id IN ($3, $4)");
|
||||||
|
expect(sql).not.toContain("6,11");
|
||||||
|
expect(params).toEqual(["2025-01-01", "2025-12-31", 6, 11]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the accountIds filter alone (no dates) alongside the fixed expense-type clause", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getExpensesByCategory(undefined, undefined, [2]);
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).toContain("t.source_id IN ($1)");
|
||||||
|
expect(sql).toContain("COALESCE(c.type, 'expense') = 'expense'");
|
||||||
|
expect(params).toEqual([2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import { inPlaceholders } from "../utils/sqlFilters";
|
||||||
import type {
|
import type {
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
|
|
@ -54,7 +55,7 @@ export async function getDashboardSummary(
|
||||||
export async function getExpensesByCategory(
|
export async function getExpensesByCategory(
|
||||||
dateFrom?: string,
|
dateFrom?: string,
|
||||||
dateTo?: string,
|
dateTo?: string,
|
||||||
sourceId?: number,
|
accountIds?: number[],
|
||||||
): Promise<CategoryBreakdownItem[]> {
|
): Promise<CategoryBreakdownItem[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
|
|
@ -72,10 +73,11 @@ export async function getExpensesByCategory(
|
||||||
params.push(dateTo);
|
params.push(dateTo);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
if (sourceId != null) {
|
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
|
||||||
whereClauses.push(`t.source_id = $${paramIndex}`);
|
if (accountPlaceholders) {
|
||||||
params.push(sourceId);
|
whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
|
||||||
paramIndex++;
|
params.push(...accountIds!);
|
||||||
|
paramIndex += accountIds!.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||||
|
|
|
||||||
|
|
@ -469,4 +469,50 @@ describe("getCartesSnapshot", () => {
|
||||||
expect(worst.overrunAbs).toBe(150);
|
expect(worst.overrunAbs).toBe(150);
|
||||||
expect(worst.overrunPct).toBeCloseTo(75, 5);
|
expect(worst.overrunPct).toBeCloseTo(75, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- accountIds filter (Issue #273) ---
|
||||||
|
//
|
||||||
|
// getCartesSnapshot must forward accountIds to its two sub-reports that
|
||||||
|
// support it (getCompareMonthOverMonth for top movers, getBudgetVsActualData
|
||||||
|
// for budget adherence) — the review caveat that flagged this: omitting it
|
||||||
|
// would let this dashboard silently ignore an active account filter even
|
||||||
|
// though its own building blocks respect it.
|
||||||
|
|
||||||
|
it("forwards accountIds to the compare (top movers) sub-report", async () => {
|
||||||
|
mockSelect.mockImplementation(() => Promise.resolve([]));
|
||||||
|
|
||||||
|
await getCartesSnapshot(2026, 3, "month", [3, 8]);
|
||||||
|
|
||||||
|
const momCall = mockSelect.mock.calls.find(([sql]) =>
|
||||||
|
(sql as string).includes("ORDER BY ABS(month_current_total - month_previous_total) DESC"),
|
||||||
|
)!;
|
||||||
|
expect(momCall[0]).toContain("AND t.source_id IN ($9, $10)");
|
||||||
|
expect(momCall[1]).toEqual(expect.arrayContaining([3, 8]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards accountIds to the budget-vs-actual (budget adherence) sub-report", async () => {
|
||||||
|
mockSelect.mockImplementation(() => Promise.resolve([]));
|
||||||
|
|
||||||
|
await getCartesSnapshot(2026, 3, "month", [3, 8]);
|
||||||
|
|
||||||
|
const actualsCalls = mockSelect.mock.calls.filter(([sql]) =>
|
||||||
|
(sql as string).includes("FROM transactions\n WHERE date BETWEEN"),
|
||||||
|
);
|
||||||
|
// Both the month and YTD actuals queries (getBudgetVsActualData) receive it.
|
||||||
|
expect(actualsCalls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
for (const [sql, params] of actualsCalls) {
|
||||||
|
expect(sql as string).toContain("source_id IN ($3, $4)");
|
||||||
|
expect(params as unknown[]).toEqual(expect.arrayContaining([3, 8]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without accountIds, no sub-report SQL carries a source_id clause (regression)", async () => {
|
||||||
|
mockSelect.mockImplementation(() => Promise.resolve([]));
|
||||||
|
|
||||||
|
await getCartesSnapshot(2026, 3);
|
||||||
|
|
||||||
|
for (const [sql] of mockSelect.mock.calls) {
|
||||||
|
expect(sql as string).not.toContain("source_id");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
getMonthlyTrends,
|
||||||
getCategoryOverTime,
|
getCategoryOverTime,
|
||||||
getHighlights,
|
getHighlights,
|
||||||
getCompareMonthOverMonth,
|
getCompareMonthOverMonth,
|
||||||
|
|
@ -29,6 +30,66 @@ beforeEach(() => {
|
||||||
mockSelect.mockReset();
|
mockSelect.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getMonthlyTrends", () => {
|
||||||
|
it("builds query without WHERE clause when no filters are provided (regression: byte-identical to pre-#273)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getMonthlyTrends();
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).not.toContain("WHERE");
|
||||||
|
expect(params).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies date range filters without an accountIds argument (regression)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getMonthlyTrends("2025-01-01", "2025-12-31");
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).toContain("date >= $1");
|
||||||
|
expect(sql).toContain("date <= $2");
|
||||||
|
expect(sql).not.toContain("source_id");
|
||||||
|
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty accountIds array behaves exactly like no filter", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getMonthlyTrends("2025-01-01", "2025-12-31", []);
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).not.toContain("source_id");
|
||||||
|
expect(params).toEqual(["2025-01-01", "2025-12-31"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a parameterized accountIds IN clause, one placeholder per id", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getMonthlyTrends("2025-01-01", "2025-12-31", [3, 7]);
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).toContain("source_id IN ($3, $4)");
|
||||||
|
expect(sql).not.toContain("3,7");
|
||||||
|
expect(params).toEqual(["2025-01-01", "2025-12-31", 3, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the accountIds filter alone (no dates)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getMonthlyTrends(undefined, undefined, [5]);
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).toContain("WHERE source_id IN ($1)");
|
||||||
|
expect(params).toEqual([5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getCategoryOverTime", () => {
|
describe("getCategoryOverTime", () => {
|
||||||
it("builds query without WHERE clause when no filters are provided", async () => {
|
it("builds query without WHERE clause when no filters are provided", async () => {
|
||||||
// First call: top categories, second call: monthly breakdown
|
// First call: top categories, second call: monthly breakdown
|
||||||
|
|
@ -87,32 +148,60 @@ describe("getCategoryOverTime", () => {
|
||||||
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
|
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies sourceId filter", async () => {
|
it("applies accountIds filter (single id)", async () => {
|
||||||
mockSelect
|
mockSelect
|
||||||
.mockResolvedValueOnce([]) // topCategories
|
.mockResolvedValueOnce([]) // topCategories
|
||||||
.mockResolvedValueOnce([]); // monthlyRows
|
.mockResolvedValueOnce([]); // monthlyRows
|
||||||
|
|
||||||
await getCategoryOverTime(undefined, undefined, 50, 3);
|
await getCategoryOverTime(undefined, undefined, 50, [3]);
|
||||||
|
|
||||||
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
||||||
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
expect(topCatSQL).toContain("t.source_id");
|
expect(topCatSQL).toContain("t.source_id IN ($1)");
|
||||||
expect(topCatParams[0]).toBe(3);
|
expect(topCatParams[0]).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("combines typeFilter, date range, and sourceId", async () => {
|
it("applies accountIds filter with one bound placeholder per id (never joined into the SQL string)", async () => {
|
||||||
mockSelect
|
mockSelect
|
||||||
.mockResolvedValueOnce([]) // topCategories
|
.mockResolvedValueOnce([]) // topCategories
|
||||||
.mockResolvedValueOnce([]); // monthlyRows
|
.mockResolvedValueOnce([]); // monthlyRows
|
||||||
|
|
||||||
await getCategoryOverTime("2025-01-01", "2025-06-30", 10, 2, "expense");
|
await getCategoryOverTime(undefined, undefined, 50, [3, 7, 12]);
|
||||||
|
|
||||||
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(topCatSQL).toContain("t.source_id IN ($1, $2, $3)");
|
||||||
|
expect(topCatSQL).not.toContain("3,7,12");
|
||||||
|
// topN ($4) follows the 3 account placeholders.
|
||||||
|
expect(topCatParams).toEqual([3, 7, 12, 50]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty accountIds array behaves exactly like no filter (no clause, no orphan param)", async () => {
|
||||||
|
mockSelect
|
||||||
|
.mockResolvedValueOnce([]) // topCategories
|
||||||
|
.mockResolvedValueOnce([]); // monthlyRows
|
||||||
|
|
||||||
|
await getCategoryOverTime("2025-01-01", "2025-12-31", 50, []);
|
||||||
|
|
||||||
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(topCatSQL).not.toContain("source_id");
|
||||||
|
expect(topCatParams).toEqual(["2025-01-01", "2025-12-31", 50]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines typeFilter, date range, and accountIds", async () => {
|
||||||
|
mockSelect
|
||||||
|
.mockResolvedValueOnce([]) // topCategories
|
||||||
|
.mockResolvedValueOnce([]); // monthlyRows
|
||||||
|
|
||||||
|
await getCategoryOverTime("2025-01-01", "2025-06-30", 10, [2], "expense");
|
||||||
|
|
||||||
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
const topCatSQL = mockSelect.mock.calls[0][0] as string;
|
||||||
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
const topCatParams = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
|
expect(topCatSQL).toContain("COALESCE(c.type, 'expense') = $1");
|
||||||
expect(topCatSQL).toContain("t.date >= $2");
|
expect(topCatSQL).toContain("t.date >= $2");
|
||||||
expect(topCatSQL).toContain("t.date <= $3");
|
expect(topCatSQL).toContain("t.date <= $3");
|
||||||
expect(topCatSQL).toContain("t.source_id = $4");
|
expect(topCatSQL).toContain("t.source_id IN ($4)");
|
||||||
expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
|
expect(topCatParams).toEqual(["expense", "2025-01-01", "2025-06-30", 2, 10]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -492,6 +581,46 @@ describe("getCompareMonthOverMonth", () => {
|
||||||
expect(result[0].deltaAbs).toBe(50);
|
expect(result[0].deltaAbs).toBe(50);
|
||||||
expect(result[0].cumulativeDeltaAbs).toBe(80);
|
expect(result[0].cumulativeDeltaAbs).toBe(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getCompareMonthOverMonth(2026, 4);
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).not.toContain("source_id");
|
||||||
|
expect(params).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty accountIds array behaves exactly like no filter (regression)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getCompareMonthOverMonth(2026, 4, []);
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).not.toContain("source_id");
|
||||||
|
expect(params).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getCompareMonthOverMonth(2026, 4, [3, 7]);
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).toContain("AND t.source_id IN ($9, $10)");
|
||||||
|
expect(sql).not.toContain("3,7");
|
||||||
|
expect(params).toEqual([
|
||||||
|
"2026-04-01", "2026-04-30",
|
||||||
|
"2026-03-01", "2026-03-31",
|
||||||
|
"2026-01-01", "2026-04-30",
|
||||||
|
"2026-01-01", "2026-03-31",
|
||||||
|
3, 7,
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getCompareYearOverYear", () => {
|
describe("getCompareYearOverYear", () => {
|
||||||
|
|
@ -549,6 +678,34 @@ describe("getCompareYearOverYear", () => {
|
||||||
expect(result[0].deltaPct).toBeCloseTo(50, 4);
|
expect(result[0].deltaPct).toBeCloseTo(50, 4);
|
||||||
expect(result[0].cumulativeDeltaPct).toBeCloseTo((900 / 1600) * 100, 4);
|
expect(result[0].cumulativeDeltaPct).toBeCloseTo((900 / 1600) * 100, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("without accountIds, the delta SQL/params stay byte-identical to pre-#273 (regression)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getCompareYearOverYear(2026, 4);
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).not.toContain("source_id");
|
||||||
|
expect(params).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a parameterized accountIds IN clause starting at $9 (after the 8 date bounds)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getCompareYearOverYear(2026, 4, [9]);
|
||||||
|
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).toContain("AND t.source_id IN ($9)");
|
||||||
|
expect(params).toEqual([
|
||||||
|
"2026-04-01", "2026-04-30",
|
||||||
|
"2025-04-01", "2025-04-30",
|
||||||
|
"2026-01-01", "2026-04-30",
|
||||||
|
"2025-01-01", "2025-04-30",
|
||||||
|
9,
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("real-vs-real compare — transfer netting (Issue #243)", () => {
|
describe("real-vs-real compare — transfer netting (Issue #243)", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { getBudgetVsActualData } from "./budgetService";
|
import { getBudgetVsActualData } from "./budgetService";
|
||||||
|
import { inPlaceholders } from "../utils/sqlFilters";
|
||||||
import type {
|
import type {
|
||||||
MonthlyTrendItem,
|
MonthlyTrendItem,
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
|
|
@ -29,7 +30,7 @@ import type {
|
||||||
export async function getMonthlyTrends(
|
export async function getMonthlyTrends(
|
||||||
dateFrom?: string,
|
dateFrom?: string,
|
||||||
dateTo?: string,
|
dateTo?: string,
|
||||||
sourceId?: number,
|
accountIds?: number[],
|
||||||
): Promise<MonthlyTrendItem[]> {
|
): Promise<MonthlyTrendItem[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
|
|
@ -47,10 +48,11 @@ export async function getMonthlyTrends(
|
||||||
params.push(dateTo);
|
params.push(dateTo);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
if (sourceId != null) {
|
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
|
||||||
whereClauses.push(`source_id = $${paramIndex}`);
|
if (accountPlaceholders) {
|
||||||
params.push(sourceId);
|
whereClauses.push(`source_id IN (${accountPlaceholders})`);
|
||||||
paramIndex++;
|
params.push(...accountIds!);
|
||||||
|
paramIndex += accountIds!.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereSQL =
|
const whereSQL =
|
||||||
|
|
@ -73,7 +75,7 @@ export async function getCategoryOverTime(
|
||||||
dateFrom?: string,
|
dateFrom?: string,
|
||||||
dateTo?: string,
|
dateTo?: string,
|
||||||
topN: number = 50,
|
topN: number = 50,
|
||||||
sourceId?: number,
|
accountIds?: number[],
|
||||||
typeFilter?: "expense" | "income" | "transfer",
|
typeFilter?: "expense" | "income" | "transfer",
|
||||||
): Promise<CategoryOverTimeData> {
|
): Promise<CategoryOverTimeData> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
@ -98,10 +100,11 @@ export async function getCategoryOverTime(
|
||||||
params.push(dateTo);
|
params.push(dateTo);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
if (sourceId != null) {
|
const accountPlaceholders = inPlaceholders(accountIds, paramIndex);
|
||||||
whereClauses.push(`t.source_id = $${paramIndex}`);
|
if (accountPlaceholders) {
|
||||||
params.push(sourceId);
|
whereClauses.push(`t.source_id IN (${accountPlaceholders})`);
|
||||||
paramIndex++;
|
params.push(...accountIds!);
|
||||||
|
paramIndex += accountIds!.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||||
|
|
@ -688,7 +691,7 @@ export function buildLeafDrivenTree<T>(leaves: T[], opts: LeafDrivenTreeOptions<
|
||||||
* Minimal category metadata for building the compare tree. Fetched WITHOUT an
|
* Minimal category metadata for building the compare tree. Fetched WITHOUT an
|
||||||
* `is_active` filter so soft-deleted categories (is_active = 0) that still carry
|
* `is_active` filter so soft-deleted categories (is_active = 0) that still carry
|
||||||
* historic transactions keep their place in the hierarchy — matching the raw
|
* historic transactions keep their place in the hierarchy — matching the raw
|
||||||
* LEFT JOIN behavior of COMPARE_DELTA_SQL (no regression on the leaves shown).
|
* LEFT JOIN behavior of compareDeltaSql() (no regression on the leaves shown).
|
||||||
*/
|
*/
|
||||||
type CompareCatMeta = TreeCatMeta;
|
type CompareCatMeta = TreeCatMeta;
|
||||||
|
|
||||||
|
|
@ -699,7 +702,7 @@ const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM cat
|
||||||
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
const COMPARE_TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns the flat per-category deltas returned by COMPARE_DELTA_SQL into a
|
* Turns the flat per-category deltas returned by compareDeltaSql() into a
|
||||||
* parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
|
* parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of
|
||||||
* getBudgetVsActualData.
|
* getBudgetVsActualData.
|
||||||
*
|
*
|
||||||
|
|
@ -883,8 +886,16 @@ function previousMonth(year: number, month: number): { year: number; month: numb
|
||||||
* rows still contribute only their outflows, byte-identical to before. The two
|
* rows still contribute only their outflows, byte-identical to before. The two
|
||||||
* result lines (before/after transfers) are computed in ComparePeriodTable from
|
* result lines (before/after transfers) are computed in ComparePeriodTable from
|
||||||
* the per-type subtotals, not here. Uncategorized rows (c.type NULL) → 'expense'.
|
* the per-type subtotals, not here. Uncategorized rows (c.type NULL) → 'expense'.
|
||||||
|
*
|
||||||
|
* `accountIds` (Issue #273) adds an optional `t.source_id IN (...)` clause —
|
||||||
|
* one bound placeholder per id, starting at $9 (the 8 date bounds occupy
|
||||||
|
* $1-$8). Omitted/empty → no clause at all, so the query text and results stay
|
||||||
|
* byte-identical to the pre-#273 behavior.
|
||||||
*/
|
*/
|
||||||
const COMPARE_DELTA_SQL = `SELECT
|
function compareDeltaSql(accountIds?: number[]): string {
|
||||||
|
const accountPlaceholders = inPlaceholders(accountIds, 9);
|
||||||
|
const accountFilter = accountPlaceholders ? ` AND t.source_id IN (${accountPlaceholders})` : "";
|
||||||
|
return `SELECT
|
||||||
t.category_id,
|
t.category_id,
|
||||||
COALESCE(c.name, 'Uncategorized') AS category_name,
|
COALESCE(c.name, 'Uncategorized') AS category_name,
|
||||||
COALESCE(c.color, '#9ca3af') AS category_color,
|
COALESCE(c.color, '#9ca3af') AS category_color,
|
||||||
|
|
@ -900,20 +911,23 @@ const COMPARE_DELTA_SQL = `SELECT
|
||||||
OR (t.date >= $3 AND t.date <= $4)
|
OR (t.date >= $3 AND t.date <= $4)
|
||||||
OR (t.date >= $5 AND t.date <= $6)
|
OR (t.date >= $5 AND t.date <= $6)
|
||||||
OR (t.date >= $7 AND t.date <= $8)
|
OR (t.date >= $7 AND t.date <= $8)
|
||||||
)
|
)${accountFilter}
|
||||||
GROUP BY t.category_id, category_name, category_color
|
GROUP BY t.category_id, category_name, category_color
|
||||||
ORDER BY ABS(month_current_total - month_previous_total) DESC`;
|
ORDER BY ABS(month_current_total - month_previous_total) DESC`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Month-over-month expense delta by category. Returns both a monthly view
|
* Month-over-month expense delta by category. Returns both a monthly view
|
||||||
* (reference month vs immediately-previous month) and a cumulative YTD view
|
* (reference month vs immediately-previous month) and a cumulative YTD view
|
||||||
* (Jan→refMonth of refYear vs Jan→prevMonth of refYear — i.e. "cumulative
|
* (Jan→refMonth of refYear vs Jan→prevMonth of refYear — i.e. "cumulative
|
||||||
* progress through end of last month" vs "cumulative progress through end of
|
* progress through end of last month" vs "cumulative progress through end of
|
||||||
* this month"). All SQL parameterised.
|
* this month"). All SQL parameterised. `accountIds` (Issue #273) optionally
|
||||||
|
* scopes the whole report to a subset of import sources (`transactions.source_id`).
|
||||||
*/
|
*/
|
||||||
export async function getCompareMonthOverMonth(
|
export async function getCompareMonthOverMonth(
|
||||||
year: number,
|
year: number,
|
||||||
month: number,
|
month: number,
|
||||||
|
accountIds?: number[],
|
||||||
): Promise<CategoryDelta[]> {
|
): Promise<CategoryDelta[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const { start: curStart, end: curEnd } = monthBoundaries(year, month);
|
const { start: curStart, end: curEnd } = monthBoundaries(year, month);
|
||||||
|
|
@ -931,16 +945,19 @@ export async function getCompareMonthOverMonth(
|
||||||
const cumPreviousStart = `${prev.year}-01-01`;
|
const cumPreviousStart = `${prev.year}-01-01`;
|
||||||
const cumPreviousEnd = prevEnd;
|
const cumPreviousEnd = prevEnd;
|
||||||
|
|
||||||
|
const deltaParams: unknown[] = [
|
||||||
|
curStart, curEnd,
|
||||||
|
prevStart, prevEnd,
|
||||||
|
cumCurrentStart, cumCurrentEnd,
|
||||||
|
cumPreviousStart, cumPreviousEnd,
|
||||||
|
];
|
||||||
|
if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
|
||||||
|
|
||||||
// Delta select stays first so its params/SQL remain `mock.calls[0]`; the
|
// Delta select stays first so its params/SQL remain `mock.calls[0]`; the
|
||||||
// category metadata (for the hierarchy) is fetched alongside. `?? []` guards
|
// category metadata (for the hierarchy) is fetched alongside. `?? []` guards
|
||||||
// under-specified mocks — db.select never returns undefined in production.
|
// under-specified mocks — db.select never returns undefined in production.
|
||||||
const [rows, cats] = await Promise.all([
|
const [rows, cats] = await Promise.all([
|
||||||
db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
|
db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
|
||||||
curStart, curEnd,
|
|
||||||
prevStart, prevEnd,
|
|
||||||
cumCurrentStart, cumCurrentEnd,
|
|
||||||
cumPreviousStart, cumPreviousEnd,
|
|
||||||
]),
|
|
||||||
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
|
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
|
||||||
]);
|
]);
|
||||||
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
|
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
|
||||||
|
|
@ -952,11 +969,13 @@ export async function getCompareMonthOverMonth(
|
||||||
* view (Jan→refMonth of refYear vs Jan→refMonth of refYear - 1). Uses the
|
* view (Jan→refMonth of refYear vs Jan→refMonth of refYear - 1). Uses the
|
||||||
* reference year's December as the "current month" when no explicit
|
* reference year's December as the "current month" when no explicit
|
||||||
* reference month is provided; callers typically pass the user's chosen
|
* reference month is provided; callers typically pass the user's chosen
|
||||||
* reference month. All SQL parameterised.
|
* reference month. All SQL parameterised. `accountIds` (Issue #273) optionally
|
||||||
|
* scopes the whole report to a subset of import sources (`transactions.source_id`).
|
||||||
*/
|
*/
|
||||||
export async function getCompareYearOverYear(
|
export async function getCompareYearOverYear(
|
||||||
year: number,
|
year: number,
|
||||||
month: number = 12,
|
month: number = 12,
|
||||||
|
accountIds?: number[],
|
||||||
): Promise<CategoryDelta[]> {
|
): Promise<CategoryDelta[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month);
|
const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month);
|
||||||
|
|
@ -967,14 +986,17 @@ export async function getCompareYearOverYear(
|
||||||
const cumPreviousStart = `${year - 1}-01-01`;
|
const cumPreviousStart = `${year - 1}-01-01`;
|
||||||
const cumPreviousEnd = prevMonthEnd;
|
const cumPreviousEnd = prevMonthEnd;
|
||||||
|
|
||||||
|
const deltaParams: unknown[] = [
|
||||||
|
curMonthStart, curMonthEnd,
|
||||||
|
prevMonthStart, prevMonthEnd,
|
||||||
|
cumCurrentStart, cumCurrentEnd,
|
||||||
|
cumPreviousStart, cumPreviousEnd,
|
||||||
|
];
|
||||||
|
if (accountIds && accountIds.length > 0) deltaParams.push(...accountIds);
|
||||||
|
|
||||||
// See getCompareMonthOverMonth: delta select first, categories alongside.
|
// See getCompareMonthOverMonth: delta select first, categories alongside.
|
||||||
const [rows, cats] = await Promise.all([
|
const [rows, cats] = await Promise.all([
|
||||||
db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
|
db.select<RawDeltaRow[]>(compareDeltaSql(accountIds), deltaParams),
|
||||||
curMonthStart, curMonthEnd,
|
|
||||||
prevMonthStart, prevMonthEnd,
|
|
||||||
cumCurrentStart, cumCurrentEnd,
|
|
||||||
cumPreviousStart, cumPreviousEnd,
|
|
||||||
]),
|
|
||||||
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
|
db.select<CompareCatMeta[]>(COMPARE_CATEGORIES_SQL),
|
||||||
]);
|
]);
|
||||||
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
|
return buildCompareTree(rowsToDeltas(rows), cats ?? []);
|
||||||
|
|
@ -1217,11 +1239,19 @@ async function fetchSeasonality(
|
||||||
* via the monthly series instead of re-querying.
|
* via the monthly series instead of re-querying.
|
||||||
* 4. Budget vs actual for the reference month.
|
* 4. Budget vs actual for the reference month.
|
||||||
* 5. Seasonality: same calendar month across the two prior years.
|
* 5. Seasonality: same calendar month across the two prior years.
|
||||||
|
*
|
||||||
|
* `accountIds` (Issue #273) is forwarded to the two sub-reports that already
|
||||||
|
* support it (`getCompareMonthOverMonth` for top movers, `getBudgetVsActualData`
|
||||||
|
* for budget adherence) — omitting it here would let this dashboard silently
|
||||||
|
* ignore an active account filter even though its building blocks respect it.
|
||||||
|
* The KPI/sparkline/seasonality series (`fetchMonthlyFlows`, `fetchSeasonality`)
|
||||||
|
* do not take an account filter yet; that is out of scope for this issue.
|
||||||
*/
|
*/
|
||||||
export async function getCartesSnapshot(
|
export async function getCartesSnapshot(
|
||||||
referenceYear: number,
|
referenceYear: number,
|
||||||
referenceMonth: number,
|
referenceMonth: number,
|
||||||
mode: CartesKpiPeriodMode = "month",
|
mode: CartesKpiPeriodMode = "month",
|
||||||
|
accountIds?: number[],
|
||||||
): Promise<CartesSnapshot> {
|
): Promise<CartesSnapshot> {
|
||||||
// Date window: 25 months back from the reference to cover YoY + a 13-month
|
// Date window: 25 months back from the reference to cover YoY + a 13-month
|
||||||
// sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24.
|
// sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24.
|
||||||
|
|
@ -1233,8 +1263,8 @@ export async function getCartesSnapshot(
|
||||||
const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
|
const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
|
||||||
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1),
|
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1),
|
||||||
fetchMonthlyFlows(windowStartIso, refEnd),
|
fetchMonthlyFlows(windowStartIso, refEnd),
|
||||||
getCompareMonthOverMonth(referenceYear, referenceMonth),
|
getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds),
|
||||||
getBudgetVsActualData(referenceYear, referenceMonth),
|
getBudgetVsActualData(referenceYear, referenceMonth, accountIds),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Index the flow rows by month for O(1) lookup, then fill missing months
|
// Index the flow rows by month for O(1) lookup, then fill missing months
|
||||||
|
|
@ -1388,7 +1418,7 @@ export async function getCartesSnapshot(
|
||||||
// Top movers: biggest MoM increases / decreases. `momRows` now carries the
|
// Top movers: biggest MoM increases / decreases. `momRows` now carries the
|
||||||
// compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a
|
// compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a
|
||||||
// parent group can't double-count against its own leaves. Keep only EXPENSE
|
// parent group can't double-count against its own leaves. Keep only EXPENSE
|
||||||
// leaves: since Issue #253 broadened COMPARE_DELTA_SQL to surface income (and
|
// leaves: since Issue #253 broadened compareDeltaSql() to surface income (and
|
||||||
// net transfers), momRows now also carries revenue rows — and this card is a
|
// net transfers), momRows now also carries revenue rows — and this card is a
|
||||||
// spending view whose colours read "up = red" (more spending is bad), so a
|
// spending view whose colours read "up = red" (more spending is bad), so a
|
||||||
// salary rise must not appear under "biggest increases" in red. Mirror the
|
// salary rise must not appear under "biggest increases" in red. Mirror the
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,17 @@ export interface RecentTransaction {
|
||||||
|
|
||||||
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
|
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared filter state for the report pages: the active period plus the
|
||||||
|
* account (import source) subset. `accountIds` is a list of `ImportSource.id`
|
||||||
|
* — not to be confused with the Bilan module's `balance_accounts` ids, a
|
||||||
|
* disjoint id space. An empty list means "no filter" (all accounts).
|
||||||
|
*/
|
||||||
|
export interface ReportFilters {
|
||||||
|
period: DashboardPeriod;
|
||||||
|
accountIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CategoryDelta {
|
export interface CategoryDelta {
|
||||||
categoryId: number | null;
|
categoryId: number | null;
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
|
|
|
||||||
27
src/utils/sqlFilters.test.ts
Normal file
27
src/utils/sqlFilters.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { inPlaceholders } from "./sqlFilters";
|
||||||
|
|
||||||
|
describe("inPlaceholders", () => {
|
||||||
|
it("returns null for undefined ids (no filter)", () => {
|
||||||
|
expect(inPlaceholders(undefined, 1)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an empty array (no filter)", () => {
|
||||||
|
expect(inPlaceholders([], 1)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a single placeholder for one id", () => {
|
||||||
|
expect(inPlaceholders([7], 1)).toBe("$1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds one placeholder per id, starting at startIndex", () => {
|
||||||
|
expect(inPlaceholders([3, 7, 12], 4)).toBe("$4, $5, $6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never joins the raw id values into the string (CWE-89 guard)", () => {
|
||||||
|
const result = inPlaceholders([3, 7], 1);
|
||||||
|
expect(result).not.toContain("3");
|
||||||
|
expect(result).not.toContain("7");
|
||||||
|
expect(result).toBe("$1, $2");
|
||||||
|
});
|
||||||
|
});
|
||||||
16
src/utils/sqlFilters.ts
Normal file
16
src/utils/sqlFilters.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* Shared helper for building a parameterized SQL `IN (...)` clause — one
|
||||||
|
* bound placeholder per id, never a joined/interpolated value list (CWE-89).
|
||||||
|
* First introduced for the report services' optional account
|
||||||
|
* (`transactions.source_id`) filter (Issue #273); reused by every service
|
||||||
|
* that accepts an `accountIds?: number[]` filter so the placeholder
|
||||||
|
* bookkeeping (start index, one `$N` per id) lives in exactly one place.
|
||||||
|
*
|
||||||
|
* Returns `null` when `ids` is empty/undefined so callers can skip adding the
|
||||||
|
* clause entirely — that is the "no filter" case, and the query must stay
|
||||||
|
* byte-identical to how it read before the filter existed.
|
||||||
|
*/
|
||||||
|
export function inPlaceholders(ids: number[] | undefined, startIndex: number): string | null {
|
||||||
|
if (!ids || ids.length === 0) return null;
|
||||||
|
return ids.map((_, i) => `$${startIndex + i}`).join(", ");
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue