Compare commits
7 commits
093ba83c51
...
a982f9ed9d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a982f9ed9d | ||
|
|
6dbaaa25fe | ||
|
|
e55c3bd250 | ||
|
|
9ee5ad353f | ||
|
|
193ef1d9dd | ||
|
|
14755eb155 | ||
|
|
ab67c68605 |
20 changed files with 1162 additions and 397 deletions
|
|
@ -19,6 +19,9 @@
|
|||
### Modifié
|
||||
|
||||
- Rapports → Tendances : le rapport **s'ouvre désormais sur la vue « Par catégorie » en tableau** par défaut, au lieu du graphique mensuel global. Le tableau par catégorie comporte une section revenus, donc une catégorie de revenu (ex. votre paie) apparaît d'emblée — sans changer de vue au préalable. La vue que vous choisissez vous-même reste mémorisée (#262).
|
||||
- Rapports → Comparaison (réel vs budget) : le tableau budget-vs-réel se lit désormais comme les autres rapports comparables — une **analyse de résultat**. Les catégories sont regroupées Revenus → Dépenses → Transferts (au lieu de Dépenses → Revenus → Transferts), et l'ancienne ligne « Total » plate est remplacée par une ligne **Résultat avant transferts** (affichée lorsque des transferts existent) et une ligne **Résultat net**, toutes deux colorées en vert pour un surplus, en rouge pour un déficit. Les montants de catégorie et de section sont inchangés (#277).
|
||||
- Budget : la grille budget se lit désormais comme une analyse de résultat — les catégories sont regroupées **Revenus → Dépenses → Transferts** (auparavant dépenses en premier), et l'ancienne ligne « Total » brute est remplacée par deux lignes de résultat : **Résultat avant transferts** (revenus − dépenses) et **Résultat net** (après transferts), calculées sur les colonnes année précédente, annuelle et mensuelles. Toutes les catégories restent affichées sur la grille — elle demeure une surface d'édition, sans ligne masquée ni repliable (#278).
|
||||
- Tableau de bord (`/`) : la page d'accueil adopte désormais le modèle du rapport Cartes. Les cartes d'indicateurs (revenus, dépenses, résultat net, taux d'épargne) affichent leur variation vs le mois précédent et vs l'an dernier (sélecteur de mois de référence, par défaut le dernier mois complet) ; les catégories en hausse/en baisse et le respect du budget sont présentés de la même façon que sur Cartes. Une nouvelle tuile **valeur nette** affiche le dernier total du Bilan — une métrique différente de toutes les autres cartes de la page, donc masquée (jamais un « 0 $ » trompeur) tant qu'aucun compte de bilan n'a de snapshot enregistré, et jamais affectée par le filtre de comptes ci-dessous. Le camembert des dépenses est remplacé par des **barres classées** des catégories de dépense les plus importantes, et le filtre de comptes (sources d'import) — introduit sur Tendances/Comparaison/Budget — s'applique désormais aussi aux widgets transactionnels du tableau de bord (#279).
|
||||
|
||||
## [0.12.0] - 2026-07-05
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@
|
|||
### Changed
|
||||
|
||||
- Reports → Trends: the report now **opens on the "By category" table view** by default, instead of the global monthly chart. The by-category table has an income section, so a revenue category (e.g. your pay) shows up straight away — no need to switch views first. Whatever view you pick yourself is still remembered (#262).
|
||||
- Reports → Compare (actual vs budget): the budget-vs-actual table now reads the same way as the other comparable reports — an **income statement**. Categories are grouped Income → Expenses → Transfers (instead of Expenses → Income → Transfers), and the old flat "Total" row is replaced by a **Result before transfers** line (shown when transfers exist) and a **Net result** line, both coloured green for a surplus and red for a deficit. Leaf and section figures are unchanged (#277).
|
||||
- Budget: the budget grid now reads as an income statement — categories are grouped **Income → Expenses → Transfers** (previously expenses first), and the old plain "Total" row is replaced by two result lines: **Result before transfers** (income − expenses) and **Net result** (after transfers), computed across the previous-year, annual, and monthly columns alike. Every category still shows on the grid — it stays an editable surface, with no rows hidden or collapsed (#278).
|
||||
- Dashboard (`/`): the home page now matches the Cartes report's model. KPI cards for income, expenses, net result and savings rate show their change vs the previous month and vs last year (reference-month picker, defaulting to the last complete month); rising/falling categories and budget adherence are shown the same way as on Cartes. A new **net worth** tile shows the Balance sheet's latest total — a different metric from every other card here, so it stays hidden (never a misleading "$0") until at least one balance account has a recorded snapshot, and it is never affected by the account filter below. The expense-only pie chart is replaced by a **ranked bar chart** of top expense categories, and the account (import-source) filter — introduced on Trends/Compare/Budget — now also applies to the Dashboard's own transactional widgets (#279).
|
||||
|
||||
## [0.12.0] - 2026-07-05
|
||||
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
|
|||
| `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) |
|
||||
| `adjustmentService.ts` | Gestion des ajustements |
|
||||
| `budgetService.ts` | Gestion budgétaire |
|
||||
| `dashboardService.ts` | Agrégation données tableau de bord |
|
||||
| `dashboardService.ts` | Agrégation données tableau de bord : `getExpensesByCategory` (barres classées, `accountIds`), `getDashboardSummary` (non consommé depuis #279), `deriveNetWorthTile` (pur — tuile valeur nette du Bilan, réutilise `deriveLandingState`) |
|
||||
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
|
||||
| `dataExportService.ts` | Export de données (chiffré) |
|
||||
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
||||
|
|
@ -205,8 +205,8 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
| `useImportHistory` | Historique des imports |
|
||||
| `useAdjustments` | Ajustements |
|
||||
| `useBudget` | Budget |
|
||||
| `useDashboard` | Métriques du tableau de bord |
|
||||
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276) — reste Dashboard/Hub (M2) |
|
||||
| `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte |
|
||||
| `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). Le Dashboard (`/`) n'utilise pas ce hook — il porte son propre `accountIds` local (`useDashboard`), câblé au même `<FilterPanel>` (#279) |
|
||||
| `useHighlights` | Panneau de faits saillants du hub rapports |
|
||||
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
|
||||
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
|
||||
|
|
@ -350,7 +350,7 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
|
|||
|
||||
| Route | Page | Description |
|
||||
|-------|------|-------------|
|
||||
| `/` | `DashboardPage` | Tableau de bord (résumé, pie chart, budget vs réel, dépenses dans le temps) |
|
||||
| `/` | `DashboardPage` | Tableau de bord (KPIs+deltas, top movers, adhérence budget, tuile valeur nette, barres classées, dépenses dans le temps — modèle Cartes, #279) |
|
||||
| `/import` | `ImportPage` | Assistant d'import CSV |
|
||||
| `/transactions` | `TransactionsPage` | Liste avec filtres |
|
||||
| `/categories` | `CategoriesPage` | Gestion hiérarchique |
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||
import { AlertTriangle, ArrowUpDown } from "lucide-react";
|
||||
import type { BudgetYearRow } from "../../shared/types";
|
||||
import { reorderRows } from "../../utils/reorderRows";
|
||||
import { computeBudgetResults, type BudgetTotals } from "./budgetTableResults";
|
||||
|
||||
const fmt = new Intl.NumberFormat("en-CA", {
|
||||
style: "currency",
|
||||
|
|
@ -127,7 +128,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
grouped[key].push(row);
|
||||
}
|
||||
|
||||
const typeOrder = ["expense", "income", "transfer"] as const;
|
||||
// Income-statement reading order: revenue first, then expenses, then
|
||||
// transfers (Issue #278) — matches the compare/trend reports' ordering.
|
||||
const typeOrder = ["income", "expense", "transfer"] as const;
|
||||
const typeLabelKeys: Record<string, string> = {
|
||||
expense: "budget.expenses",
|
||||
income: "budget.income",
|
||||
|
|
@ -139,19 +142,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
transfer: "budget.totalTransfers",
|
||||
};
|
||||
|
||||
// Column totals with sign convention (only count leaf rows to avoid double-counting parents)
|
||||
const monthTotals: number[] = Array(12).fill(0);
|
||||
let annualTotal = 0;
|
||||
let prevYearTotal = 0;
|
||||
for (const row of rows) {
|
||||
if (row.is_parent) continue; // skip parent subtotals to avoid double-counting
|
||||
const sign = signFor(row.category_type);
|
||||
for (let m = 0; m < 12; m++) {
|
||||
monthTotals[m] += row.months[m] * sign;
|
||||
}
|
||||
annualTotal += row.annual * sign;
|
||||
prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB
|
||||
}
|
||||
// Income-statement roll-up (Issue #278): Résultat avant transferts / net,
|
||||
// computed on the budgeted + previous-year-actual leaves. Mathematically
|
||||
// equivalent to the old plain grand-total (income − expense + transfer over
|
||||
// every leaf), now surfaced as the two labeled result rows below instead of
|
||||
// a single unlabeled "Total".
|
||||
const results = computeBudgetResults(rows);
|
||||
|
||||
const totalCols = 15; // category + prev year + annual + 12 months
|
||||
|
||||
|
|
@ -295,39 +291,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
||||
<button
|
||||
onClick={toggleSubtotals}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<ArrowUpDown size={13} />
|
||||
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||
<table className="w-full text-sm whitespace-nowrap">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
|
||||
{t("budget.category")}
|
||||
</th>
|
||||
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
||||
{t("budget.previousYear")}
|
||||
</th>
|
||||
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
||||
{t("budget.annual")}
|
||||
</th>
|
||||
{MONTH_KEYS.map((key) => (
|
||||
<th key={key} className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[70px]">
|
||||
{t(key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{typeOrder.map((type) => {
|
||||
// One type's section: header, its (reorderable) rows, and a leaf-summed
|
||||
// subtotal row. Extracted so it can be called for income/expense, then
|
||||
// again for transfer once the Résultat rows are interleaved between them.
|
||||
const renderTypeSection = (type: (typeof typeOrder)[number]) => {
|
||||
const group = grouped[type];
|
||||
if (!group || group.length === 0) return null;
|
||||
const sign = signFor(type);
|
||||
|
|
@ -367,18 +334,76 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{/* Totals row */}
|
||||
<tr className="bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]">
|
||||
<td className="py-3 px-3 sticky left-0 bg-[var(--muted)] z-10 text-sm">{t("common.total")}</td>
|
||||
<td className="py-3 px-2 text-right text-sm text-[var(--muted-foreground)]">{formatSigned(prevYearTotal)}</td>
|
||||
<td className="py-3 px-2 text-right text-sm">{formatSigned(annualTotal)}</td>
|
||||
{monthTotals.map((total, mIdx) => (
|
||||
<td key={mIdx} className="py-3 px-2 text-right text-sm">
|
||||
{formatSigned(total)}
|
||||
};
|
||||
|
||||
// A Résultat row (avant-transferts subtotal, or the net bottom line).
|
||||
// `strong` mirrors the previous grand-total row's weight (bold, border-t-2);
|
||||
// the softer variant mirrors the per-type section-subtotal row above.
|
||||
const renderResultRow = (labelKey: string, tot: BudgetTotals, strong: boolean) => {
|
||||
const rowClass = strong
|
||||
? "bg-[var(--muted)] font-bold border-t-2 border-[var(--border)]"
|
||||
: "bg-[var(--muted)]/40 border-b border-[var(--border)]";
|
||||
const stickyBg = strong ? "bg-[var(--muted)]" : "bg-[var(--muted)]/40";
|
||||
const cellWeight = strong ? "" : "font-semibold";
|
||||
const pad = strong ? "py-3" : "py-2.5";
|
||||
return (
|
||||
<tr key={labelKey} className={rowClass}>
|
||||
<td className={`${pad} px-3 sticky left-0 z-10 text-sm ${stickyBg}`}>{t(labelKey)}</td>
|
||||
<td className={`${pad} px-2 text-right text-sm ${cellWeight} text-[var(--muted-foreground)]`}>
|
||||
{formatSigned(tot.previousYearTotal)}
|
||||
</td>
|
||||
<td className={`${pad} px-2 text-right text-sm ${cellWeight}`}>{formatSigned(tot.annual)}</td>
|
||||
{tot.months.map((val, mIdx) => (
|
||||
<td key={mIdx} className={`${pad} px-2 text-right text-sm ${cellWeight}`}>
|
||||
{formatSigned(val)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
|
||||
<button
|
||||
onClick={toggleSubtotals}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<ArrowUpDown size={13} />
|
||||
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||
<table className="w-full text-sm whitespace-nowrap">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-30 min-w-[140px]">
|
||||
{t("budget.category")}
|
||||
</th>
|
||||
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
||||
{t("budget.previousYear")}
|
||||
</th>
|
||||
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
|
||||
{t("budget.annual")}
|
||||
</th>
|
||||
{MONTH_KEYS.map((key) => (
|
||||
<th key={key} className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[70px]">
|
||||
{t(key)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderTypeSection("income")}
|
||||
{renderTypeSection("expense")}
|
||||
{/* Operating result (revenues − expenses), interleaved before the
|
||||
transfers section only when transfers exist — otherwise it
|
||||
equals the net result below and would just be noise. */}
|
||||
{results.hasTransfers &&
|
||||
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
|
||||
{renderTypeSection("transfer")}
|
||||
{/* Bottom line: result after netting transfers. */}
|
||||
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
94
src/components/budget/budgetTableResults.test.ts
Normal file
94
src/components/budget/budgetTableResults.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type { BudgetYearRow } from "../../shared/types";
|
||||
import { computeBudgetResults, sumLeavesForType, signForBudgetType } from "./budgetTableResults";
|
||||
|
||||
// Minimal leaf factory. `months` arrives as the positive-magnitude budgeted
|
||||
// figure BudgetTable itself flips by type via `signFor` — same convention
|
||||
// `sumLeavesForType` re-implements. `annual` always equals `sum(months)`
|
||||
// (useBudget derives it that way; see useBudget.ts's `buildMonths`).
|
||||
function leaf(
|
||||
type: BudgetYearRow["category_type"],
|
||||
months: number[],
|
||||
previousYearTotal = 0,
|
||||
overrides: Partial<BudgetYearRow> = {},
|
||||
): BudgetYearRow {
|
||||
return {
|
||||
category_id: 1,
|
||||
category_name: type,
|
||||
category_color: "#000",
|
||||
category_type: type,
|
||||
parent_id: null,
|
||||
is_parent: false,
|
||||
depth: 0,
|
||||
months,
|
||||
annual: months.reduce((s, v) => s + v, 0),
|
||||
previousYearTotal,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const FLAT12 = (v: number) => Array(12).fill(v) as number[];
|
||||
|
||||
describe("computeBudgetResults — income-statement roll-up (Issue #278)", () => {
|
||||
it("resultBefore = budgeted income − expense; resultNet adds transfers", () => {
|
||||
const r = computeBudgetResults([
|
||||
leaf("income", FLAT12(400)), // annual 4800
|
||||
leaf("expense", FLAT12(300)), // annual 3600, stored positive, flipped to -300/mo
|
||||
leaf("transfer", FLAT12(0)),
|
||||
]);
|
||||
expect(r.resultBefore.months[0]).toBe(100); // 400 − 300
|
||||
expect(r.resultBefore.annual).toBe(1200); // 4800 − 3600
|
||||
expect(r.resultNet.annual).toBe(1200); // balanced (0) transfer → unchanged
|
||||
expect(r.hasTransfers).toBe(true);
|
||||
});
|
||||
|
||||
it("an unbalanced (one-leg) transfer moves resultNet away from resultBefore", () => {
|
||||
const r = computeBudgetResults([
|
||||
leaf("income", FLAT12(1000)),
|
||||
leaf("expense", FLAT12(700)),
|
||||
leaf("transfer", FLAT12(50)), // uncategorized single leg, signed pass-through
|
||||
]);
|
||||
expect(r.resultBefore.annual).toBe(3600); // (1000 − 700) × 12
|
||||
expect(r.resultNet.annual).toBe(4200); // resultBefore + transfer(50×12)
|
||||
});
|
||||
|
||||
it("a deficit yields a negative net result and no transfer line when absent", () => {
|
||||
const r = computeBudgetResults([leaf("income", FLAT12(200)), leaf("expense", FLAT12(250))]);
|
||||
expect(r.resultNet.months[0]).toBe(-50);
|
||||
expect(r.hasTransfers).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores subtotal (is_parent) rows so a group is not double-counted", () => {
|
||||
const parent = leaf("expense", FLAT12(999), 0, { is_parent: true });
|
||||
const r = computeBudgetResults([parent, leaf("expense", FLAT12(100))]);
|
||||
expect(r.expense.annual).toBe(-1200); // only the leaf's 100 × 12 (flipped), not 999
|
||||
});
|
||||
|
||||
it("carries the previous-year actual through unsigned (already signed in the DB)", () => {
|
||||
// Actual totals arrive pre-signed: expense negative, income positive.
|
||||
const r = computeBudgetResults([
|
||||
leaf("income", FLAT12(100), 1150),
|
||||
leaf("expense", FLAT12(60), -650),
|
||||
]);
|
||||
expect(r.resultBefore.previousYearTotal).toBe(500); // 1150 + (−650), no re-sign
|
||||
});
|
||||
|
||||
it("sumLeavesForType filters by type and excludes parents", () => {
|
||||
const t = sumLeavesForType(
|
||||
[
|
||||
leaf("expense", FLAT12(50)),
|
||||
leaf("expense", FLAT12(999), 0, { is_parent: true }),
|
||||
leaf("income", FLAT12(10)),
|
||||
],
|
||||
"expense",
|
||||
);
|
||||
expect(t.annual).toBe(-600); // 50 × 12, flipped negative
|
||||
expect(t.months[0]).toBe(-50);
|
||||
});
|
||||
|
||||
it("signForBudgetType flips expense only", () => {
|
||||
expect(signForBudgetType("expense")).toBe(-1);
|
||||
expect(signForBudgetType("income")).toBe(1);
|
||||
expect(signForBudgetType("transfer")).toBe(1);
|
||||
});
|
||||
});
|
||||
94
src/components/budget/budgetTableResults.ts
Normal file
94
src/components/budget/budgetTableResults.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { BudgetYearRow } from "../../shared/types";
|
||||
|
||||
export type BudgetSectionType = "income" | "expense" | "transfer";
|
||||
|
||||
/**
|
||||
* Sign multiplier applied to budgeted (months/annual) magnitudes. Budgeted
|
||||
* amounts are entered/stored as positive magnitudes regardless of type and
|
||||
* flipped for display by category type — mirrors `BudgetTable`'s `signFor`.
|
||||
*/
|
||||
export function signForBudgetType(type: BudgetSectionType): 1 | -1 {
|
||||
return type === "expense" ? -1 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate of the budget grid's 3 comparable column groups, already signed:
|
||||
* 12 monthly (budgeted) totals, the annual (budgeted) total, and the
|
||||
* previous-year (actual) total.
|
||||
*/
|
||||
export interface BudgetTotals {
|
||||
/** index 0-11 = Jan-Dec, signed budgeted total */
|
||||
months: number[];
|
||||
/** signed budgeted annual total */
|
||||
annual: number;
|
||||
/** signed actual total from the previous year (already signed in the DB) */
|
||||
previousYearTotal: number;
|
||||
}
|
||||
|
||||
function zeroTotals(): BudgetTotals {
|
||||
return { months: Array(12).fill(0) as number[], annual: 0, previousYearTotal: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum every non-subtotal (leaf) row of a single type into one `BudgetTotals`.
|
||||
*
|
||||
* The type's sign is applied to the budgeted figures (`months`/`annual`),
|
||||
* matching how `BudgetTable` displays them. `previousYearTotal` is NOT
|
||||
* re-signed: it comes from `getActualTotalsForYear` (a plain signed
|
||||
* `SUM(amount)` over the transactions table), already negative for expenses
|
||||
* and ~0-net for balanced transfers — the same convention `compareResults.ts`
|
||||
* relies on for its own previous-period figures.
|
||||
*/
|
||||
export function sumLeavesForType(rows: BudgetYearRow[], type: BudgetSectionType): BudgetTotals {
|
||||
const sign = signForBudgetType(type);
|
||||
const totals = zeroTotals();
|
||||
for (const row of rows) {
|
||||
if (row.is_parent || row.category_type !== type) continue;
|
||||
for (let m = 0; m < 12; m++) totals.months[m] += row.months[m] * sign;
|
||||
totals.annual += row.annual * sign;
|
||||
totals.previousYearTotal += row.previousYearTotal;
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** Component-wise sum of two BudgetTotals (both already signed). */
|
||||
function combine(a: BudgetTotals, b: BudgetTotals): BudgetTotals {
|
||||
return {
|
||||
months: a.months.map((v, i) => v + b.months[i]),
|
||||
annual: a.annual + b.annual,
|
||||
previousYearTotal: a.previousYearTotal + b.previousYearTotal,
|
||||
};
|
||||
}
|
||||
|
||||
export interface BudgetResults {
|
||||
income: BudgetTotals;
|
||||
expense: BudgetTotals;
|
||||
transfer: BudgetTotals;
|
||||
/** Budgeted/actual revenues − expenses: the operating result, before transfers. */
|
||||
resultBefore: BudgetTotals;
|
||||
/** resultBefore + transfers: the bottom-line total. */
|
||||
resultNet: BudgetTotals;
|
||||
hasTransfers: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Income-statement roll-up for the budget grid (Issue #278), mirroring
|
||||
* `compareResults.ts`'s shape (see that module's doc comment for the sign
|
||||
* convention this relies on).
|
||||
*
|
||||
* Operates on the flat `BudgetYearRow[]` the grid already renders (produced
|
||||
* by `useBudget`) — LEAVES only (`is_parent === false`), so parent subtotal
|
||||
* rows are never double-counted. All three column groups (previous-year
|
||||
* actual, budgeted annual, budgeted monthly) are folded into the same result
|
||||
* rows so "Résultat avant transferts" / "Résultat net" read consistently
|
||||
* across every column of the grid.
|
||||
*/
|
||||
export function computeBudgetResults(rows: BudgetYearRow[]): BudgetResults {
|
||||
const income = sumLeavesForType(rows, "income");
|
||||
const expense = sumLeavesForType(rows, "expense");
|
||||
const transfer = sumLeavesForType(rows, "transfer");
|
||||
const resultBefore = combine(income, expense);
|
||||
const resultNet = combine(resultBefore, transfer);
|
||||
const hasTransfers = rows.some((r) => !r.is_parent && r.category_type === "transfer");
|
||||
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
|
||||
}
|
||||
62
src/components/dashboard/NetWorthTile.tsx
Normal file
62
src/components/dashboard/NetWorthTile.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// NetWorthTile — Dashboard KPI-row tile showing the Bilan's latest net worth.
|
||||
//
|
||||
// Issue #279. Deliberately NOT `HubNetBalanceTile` (that one is the reports
|
||||
// hub's transaction-P&L net balance — a different metric). This tile mirrors
|
||||
// `BalanceOverviewCard`'s "latest total" figure and reuses its exact i18n
|
||||
// copy (`balance.overview.latestTotal` / `asOf`) so the label is unambiguous
|
||||
// next to the transactional KPI cards and the "import sources" account
|
||||
// filter — same words as the Bilan page itself, never "balance"/"solde".
|
||||
//
|
||||
// Renders nothing when `data.visible` is false (no balance account yet, or
|
||||
// accounts but no snapshot yet) — never a misleading "$0" tile.
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wallet } from "lucide-react";
|
||||
import type { NetWorthTileData } from "../../services/dashboardService";
|
||||
|
||||
export interface NetWorthTileProps {
|
||||
data: NetWorthTileData;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, language: string): string {
|
||||
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||
style: "currency",
|
||||
currency: "CAD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export default function NetWorthTile({ data }: NetWorthTileProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (!data.visible || data.total === null || data.asOfDate === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
|
||||
const formattedDate = new Date(data.asOfDate).toLocaleDateString(dateLocale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/balance"
|
||||
data-kpi="net-worth"
|
||||
className="flex-1 min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3 hover:border-[var(--primary)] transition-colors"
|
||||
>
|
||||
<div className="text-sm text-[var(--muted-foreground)] flex items-center gap-1.5">
|
||||
<Wallet size={14} aria-hidden="true" />
|
||||
<span>{t("balance.overview.latestTotal")}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
|
||||
{formatCurrency(data.total, i18n.language)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)] pt-1 border-t border-[var(--border)]">
|
||||
{t("balance.overview.asOf", { date: formattedDate })}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import type { BudgetVsActualRow } from "../../shared/types";
|
|||
import { reorderRows } from "../../utils/reorderRows";
|
||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
||||
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./budgetVsActualResults";
|
||||
|
||||
const cadFormatter = (value: number) =>
|
||||
new Intl.NumberFormat("en-CA", {
|
||||
|
|
@ -68,8 +69,8 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
);
|
||||
}
|
||||
|
||||
// Group rows by type for section headers
|
||||
type SectionType = "expense" | "income" | "transfer";
|
||||
// Group rows into contiguous type sections (the service already type-sorts:
|
||||
// income → expense → transfer, Issue #277).
|
||||
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
|
||||
const typeLabels: Record<SectionType, string> = {
|
||||
expense: t("budget.expenses"),
|
||||
|
|
@ -91,103 +92,18 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
sections[sections.length - 1].rows.push(row);
|
||||
}
|
||||
|
||||
// Grand totals (leaf rows only)
|
||||
const leaves = data.filter((r) => !r.is_parent);
|
||||
const totals = leaves.reduce(
|
||||
(acc, r) => ({
|
||||
monthActual: acc.monthActual + r.monthActual,
|
||||
monthBudget: acc.monthBudget + r.monthBudget,
|
||||
monthVariation: acc.monthVariation + r.monthVariation,
|
||||
ytdActual: acc.ytdActual + r.ytdActual,
|
||||
ytdBudget: acc.ytdBudget + r.ytdBudget,
|
||||
ytdVariation: acc.ytdVariation + r.ytdVariation,
|
||||
}),
|
||||
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
|
||||
);
|
||||
const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null;
|
||||
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
|
||||
// Income-statement result lines (Issue #277): income + expense (expense
|
||||
// already arrives signed negative in BudgetVsActualRow — see
|
||||
// budgetVsActualResults.ts), then the net after adding transfers. Replaces
|
||||
// the old flat grand total row.
|
||||
const results = computeResults(data);
|
||||
const nonTransferSections = sections.filter((s) => s.type !== "transfer");
|
||||
const transferSection = sections.find((s) => s.type === "transfer");
|
||||
|
||||
const hasGroups = groups.groupCount(data) > 0;
|
||||
const allExpanded = groups.allExpanded(data);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||||
{hasGroups && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleSubtotals}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<ArrowUpDown size={13} />
|
||||
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]">
|
||||
{t("budget.category")}
|
||||
</th>
|
||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{t("reports.bva.monthly")}
|
||||
</th>
|
||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{t("reports.bva.ytd")}
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{t("budget.actual")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("budget.planned")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.dollarVar")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.pctVar")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{t("budget.actual")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("budget.planned")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.dollarVar")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.pctVar")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sections.map((section) => {
|
||||
const sectionLeaves = section.rows.filter((r) => !r.is_parent);
|
||||
const sectionTotals = sectionLeaves.reduce(
|
||||
(acc, r) => ({
|
||||
monthActual: acc.monthActual + r.monthActual,
|
||||
monthBudget: acc.monthBudget + r.monthBudget,
|
||||
monthVariation: acc.monthVariation + r.monthVariation,
|
||||
ytdActual: acc.ytdActual + r.ytdActual,
|
||||
ytdBudget: acc.ytdBudget + r.ytdBudget,
|
||||
ytdVariation: acc.ytdVariation + r.ytdVariation,
|
||||
}),
|
||||
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
|
||||
);
|
||||
const sectionMonthPct = sectionTotals.monthBudget !== 0 ? sectionTotals.monthVariation / Math.abs(sectionTotals.monthBudget) : null;
|
||||
const sectionYtdPct = sectionTotals.ytdBudget !== 0 ? sectionTotals.ytdVariation / Math.abs(sectionTotals.ytdBudget) : null;
|
||||
const renderSection = (section: { type: SectionType; label: string; rows: BudgetVsActualRow[] }) => {
|
||||
const sectionTotals = sumLeaves(section.rows);
|
||||
const sectionMonthPct = pct(sectionTotals.monthVariation, sectionTotals.monthBudget);
|
||||
const sectionYtdPct = pct(sectionTotals.ytdVariation, sectionTotals.ytdBudget);
|
||||
return (
|
||||
<Fragment key={section.type}>
|
||||
<tr className="bg-[var(--muted)]">
|
||||
|
|
@ -293,31 +209,118 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
|||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{/* Grand totals */}
|
||||
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
|
||||
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">{t("common.total")}</td>
|
||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(totals.monthActual)}
|
||||
};
|
||||
|
||||
// A result line (before-transfers subtotal or the net total). Every cell is
|
||||
// coloured by its own sign — a surplus is green, a deficit red — mirroring
|
||||
// ComparePeriodTable.renderResultRow (unlike normal rows, which only colour
|
||||
// the variation/pct columns).
|
||||
const renderResultRow = (labelKey: string, tot: Totals, strong: boolean) => {
|
||||
const rowBg = strong
|
||||
? "bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]"
|
||||
: "bg-[color-mix(in_srgb,var(--muted)_10%,var(--card))]";
|
||||
const rowClass = strong
|
||||
? `border-t-2 border-[var(--border)] font-bold text-sm ${rowBg}`
|
||||
: `border-b border-[var(--border)] font-semibold text-sm ${rowBg}`;
|
||||
const cell = "text-right px-3 py-3";
|
||||
const monthPct = pct(tot.monthVariation, tot.monthBudget);
|
||||
const ytdPct = pct(tot.ytdVariation, tot.ytdBudget);
|
||||
return (
|
||||
<tr key={labelKey} className={rowClass}>
|
||||
<td className={`px-3 py-3 sticky left-0 z-10 ${rowBg}`}>{t(labelKey)}</td>
|
||||
<td className={`${cell} border-l border-[var(--border)]/50 ${variationColor(tot.monthActual)}`}>
|
||||
{cadFormatter(tot.monthActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-3">{cadFormatter(totals.monthBudget)}</td>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
||||
{cadFormatter(totals.monthVariation)}
|
||||
<td className={`${cell} ${variationColor(tot.monthBudget)}`}>{cadFormatter(tot.monthBudget)}</td>
|
||||
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>
|
||||
{cadFormatter(tot.monthVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.monthVariation)}`}>
|
||||
{pctFormatter(totalMonthPct)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
|
||||
{cadFormatter(totals.ytdActual)}
|
||||
</td>
|
||||
<td className="text-right px-3 py-3">{cadFormatter(totals.ytdBudget)}</td>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
||||
{cadFormatter(totals.ytdVariation)}
|
||||
</td>
|
||||
<td className={`text-right px-3 py-3 ${variationColor(totals.ytdVariation)}`}>
|
||||
{pctFormatter(totalYtdPct)}
|
||||
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>{pctFormatter(monthPct)}</td>
|
||||
<td className={`${cell} border-l border-[var(--border)]/50 ${variationColor(tot.ytdActual)}`}>
|
||||
{cadFormatter(tot.ytdActual)}
|
||||
</td>
|
||||
<td className={`${cell} ${variationColor(tot.ytdBudget)}`}>{cadFormatter(tot.ytdBudget)}</td>
|
||||
<td className={`${cell} ${variationColor(tot.ytdVariation)}`}>{cadFormatter(tot.ytdVariation)}</td>
|
||||
<td className={`${cell} ${variationColor(tot.ytdVariation)}`}>{pctFormatter(ytdPct)}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const hasGroups = groups.groupCount(data) > 0;
|
||||
const allExpanded = groups.allExpanded(data);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||||
{hasGroups && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (allExpanded ? groups.collapseAll() : groups.expandAll(data))}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleSubtotals}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<ArrowUpDown size={13} />
|
||||
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]">
|
||||
{t("budget.category")}
|
||||
</th>
|
||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{t("reports.bva.monthly")}
|
||||
</th>
|
||||
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{t("reports.bva.ytd")}
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{t("budget.actual")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("budget.planned")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.dollarVar")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.pctVar")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
|
||||
{t("budget.actual")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("budget.planned")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.dollarVar")}
|
||||
</th>
|
||||
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
|
||||
{t("reports.bva.pctVar")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nonTransferSections.map(renderSection)}
|
||||
{/* Operating result (income + expense), shown before the transfers
|
||||
section only when transfers exist — otherwise it equals the net
|
||||
total below and would just be noise (mirrors ComparePeriodTable). */}
|
||||
{results.hasTransfers &&
|
||||
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
|
||||
{transferSection && renderSection(transferSection)}
|
||||
{/* Bottom line: result after adding transfers. */}
|
||||
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
91
src/components/reports/budgetVsActualResults.test.ts
Normal file
91
src/components/reports/budgetVsActualResults.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type { BudgetVsActualRow } from "../../shared/types";
|
||||
import { computeResults, sumLeaves } from "./budgetVsActualResults";
|
||||
|
||||
// Minimal leaf factory. Amounts arrive from getBudgetVsActualData already in
|
||||
// the per-type sign convention: expense actual/budget are negative (raw,
|
||||
// unsigned SUM(transactions.amount) — expenses are stored negative — and the
|
||||
// user-entered budget negated to match via signFor); income/transfer stay
|
||||
// however they naturally sum (positive for income, signed net for transfer).
|
||||
function leaf(
|
||||
type: "expense" | "income" | "transfer",
|
||||
monthActual: number,
|
||||
monthBudget: number,
|
||||
ytdActual = monthActual,
|
||||
ytdBudget = monthBudget,
|
||||
): BudgetVsActualRow {
|
||||
return {
|
||||
category_id: 1,
|
||||
category_name: type,
|
||||
category_color: "#000",
|
||||
category_type: type,
|
||||
parent_id: null,
|
||||
is_parent: false,
|
||||
monthActual,
|
||||
monthBudget,
|
||||
monthVariation: monthActual - monthBudget,
|
||||
monthVariationPct: monthBudget !== 0 ? (monthActual - monthBudget) / Math.abs(monthBudget) : null,
|
||||
ytdActual,
|
||||
ytdBudget,
|
||||
ytdVariation: ytdActual - ytdBudget,
|
||||
ytdVariationPct: ytdBudget !== 0 ? (ytdActual - ytdBudget) / Math.abs(ytdBudget) : null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeResults — income-statement roll-up (Issue #277)", () => {
|
||||
it("resultBefore = income + expense (expense already signed negative)", () => {
|
||||
const r = computeResults([
|
||||
leaf("income", 3000, 2800),
|
||||
leaf("expense", -1800, -2000), // spent 1800, budgeted to spend 2000
|
||||
leaf("transfer", 0, 0),
|
||||
]);
|
||||
// Operating result: 3000 + (-1800) = 1200 actual; 2800 + (-2000) = 800 budgeted.
|
||||
expect(r.resultBefore.monthActual).toBe(1200);
|
||||
expect(r.resultBefore.monthBudget).toBe(800);
|
||||
expect(r.resultBefore.monthVariation).toBe(400);
|
||||
// Balanced transfer (0) → net equals the operating result.
|
||||
expect(r.resultNet.monthActual).toBe(1200);
|
||||
expect(r.hasTransfers).toBe(true);
|
||||
});
|
||||
|
||||
it("a non-zero transfer moves resultNet away from resultBefore", () => {
|
||||
const r = computeResults([
|
||||
leaf("income", 4000, 4000),
|
||||
leaf("expense", -3000, -3000),
|
||||
leaf("transfer", -200, 0), // a single categorised leg
|
||||
]);
|
||||
expect(r.resultBefore.monthActual).toBe(1000);
|
||||
// Net folds the residual transfer in: 1000 + (−200) = 800.
|
||||
expect(r.resultNet.monthActual).toBe(800);
|
||||
});
|
||||
|
||||
it("a deficit yields a negative net result and no transfer line", () => {
|
||||
const r = computeResults([leaf("income", 2000, 2000), leaf("expense", -2500, -2400)]);
|
||||
expect(r.resultNet.monthActual).toBe(-500);
|
||||
expect(r.hasTransfers).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores subtotal (is_parent) rows so a group is not double-counted", () => {
|
||||
const parent = { ...leaf("expense", -999, -999), is_parent: true };
|
||||
const r = computeResults([parent, leaf("expense", -100, -80)]);
|
||||
expect(r.expense.monthActual).toBe(-100);
|
||||
});
|
||||
|
||||
it("carries the YTD figures through the same arithmetic", () => {
|
||||
const r = computeResults([
|
||||
leaf("income", 100, 100, 1200, 1150),
|
||||
leaf("expense", -60, -60, -700, -650),
|
||||
]);
|
||||
expect(r.resultBefore.ytdActual).toBe(500);
|
||||
expect(r.resultBefore.ytdBudget).toBe(500);
|
||||
});
|
||||
|
||||
it("sumLeaves adds only leaves", () => {
|
||||
const t = sumLeaves([
|
||||
leaf("expense", -100, -50),
|
||||
{ ...leaf("expense", -999, -999), is_parent: true },
|
||||
]);
|
||||
expect(t.monthActual).toBe(-100);
|
||||
expect(t.monthBudget).toBe(-50);
|
||||
});
|
||||
});
|
||||
95
src/components/reports/budgetVsActualResults.ts
Normal file
95
src/components/reports/budgetVsActualResults.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { BudgetVsActualRow } from "../../shared/types";
|
||||
|
||||
export type SectionType = "expense" | "income" | "transfer";
|
||||
|
||||
/** Aggregate of the 6 budget-vs-actual figures across a set of leaf rows. */
|
||||
export interface Totals {
|
||||
monthActual: number;
|
||||
monthBudget: number;
|
||||
monthVariation: number;
|
||||
ytdActual: number;
|
||||
ytdBudget: number;
|
||||
ytdVariation: number;
|
||||
}
|
||||
|
||||
const ZERO: Totals = {
|
||||
monthActual: 0,
|
||||
monthBudget: 0,
|
||||
monthVariation: 0,
|
||||
ytdActual: 0,
|
||||
ytdBudget: 0,
|
||||
ytdVariation: 0,
|
||||
};
|
||||
|
||||
/** Sum every non-subtotal (leaf) row into a single Totals. */
|
||||
export function sumLeaves(rows: BudgetVsActualRow[]): Totals {
|
||||
return rows
|
||||
.filter((r) => !r.is_parent)
|
||||
.reduce<Totals>(
|
||||
(acc, r) => ({
|
||||
monthActual: acc.monthActual + r.monthActual,
|
||||
monthBudget: acc.monthBudget + r.monthBudget,
|
||||
monthVariation: acc.monthVariation + r.monthVariation,
|
||||
ytdActual: acc.ytdActual + r.ytdActual,
|
||||
ytdBudget: acc.ytdBudget + r.ytdBudget,
|
||||
ytdVariation: acc.ytdVariation + r.ytdVariation,
|
||||
}),
|
||||
{ ...ZERO },
|
||||
);
|
||||
}
|
||||
|
||||
/** Variation as a fraction of budget (null when budget is 0 — mirrors the
|
||||
* row-level `*VariationPct` fields computed in `getBudgetVsActualData`). */
|
||||
export function pct(variation: number, budget: number): number | null {
|
||||
return budget !== 0 ? variation / Math.abs(budget) : null;
|
||||
}
|
||||
|
||||
/** Component-wise a + b across the 6 figures. */
|
||||
function add(a: Totals, b: Totals): Totals {
|
||||
return {
|
||||
monthActual: a.monthActual + b.monthActual,
|
||||
monthBudget: a.monthBudget + b.monthBudget,
|
||||
monthVariation: a.monthVariation + b.monthVariation,
|
||||
ytdActual: a.ytdActual + b.ytdActual,
|
||||
ytdBudget: a.ytdBudget + b.ytdBudget,
|
||||
ytdVariation: a.ytdVariation + b.ytdVariation,
|
||||
};
|
||||
}
|
||||
|
||||
export interface BudgetVsActualResults {
|
||||
income: Totals;
|
||||
expense: Totals;
|
||||
transfer: Totals;
|
||||
/** Income + expense: the operating result, before transfers. */
|
||||
resultBefore: Totals;
|
||||
/** resultBefore + transfers: the bottom-line total. */
|
||||
resultNet: Totals;
|
||||
hasTransfers: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Income-statement roll-up for the budget-vs-actual report (Issue #277).
|
||||
*
|
||||
* Mirrors `compareResults.computeResults`, with one key sign difference: BVA
|
||||
* amounts already arrive signed the "accounting" way rather than ABS'd to
|
||||
* positive magnitudes. `getBudgetVsActualData` computes `monthActual`/
|
||||
* `ytdActual` as the raw (unsigned-by-us) `SUM(transactions.amount)` — negative
|
||||
* for expense categories, since expense transactions are stored as negative
|
||||
* amounts throughout this app (see reportService/dashboardService `amount < 0`
|
||||
* conventions) — and negates the user-entered (positive) budget figure to
|
||||
* match via `signFor`. So expense totals here are already ≤ 0, and the
|
||||
* operating result is a straight ADD of income + expense (not a subtraction
|
||||
* like the real-vs-real compare, whose expense figures are ABS'd positive
|
||||
* magnitudes). The net just adds the transfer total on top.
|
||||
*/
|
||||
export function computeResults(rows: BudgetVsActualRow[]): BudgetVsActualResults {
|
||||
const byType = (type: SectionType): Totals =>
|
||||
sumLeaves(rows.filter((r) => r.category_type === type));
|
||||
const income = byType("income");
|
||||
const expense = byType("expense");
|
||||
const transfer = byType("transfer");
|
||||
const resultBefore = add(income, expense);
|
||||
const resultNet = add(resultBefore, transfer);
|
||||
const hasTransfers = rows.some((r) => r.category_type === "transfer" && !r.is_parent);
|
||||
return { income, expense, transfer, resultBefore, resultNet, hasTransfers };
|
||||
}
|
||||
|
|
@ -68,7 +68,10 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
|||
}
|
||||
}
|
||||
|
||||
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
||||
// Income-statement reading order: revenue first, then expenses, then
|
||||
// transfers (Issue #278) — matches the compare/trend reports' ordering
|
||||
// (`COMPARE_TYPE_ORDER` / `OVER_TIME_TYPE_ORDER`, income-first since #253).
|
||||
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
||||
|
||||
export function useBudget() {
|
||||
const { accountIds } = useReportsPeriod();
|
||||
|
|
|
|||
|
|
@ -1,29 +1,63 @@
|
|||
// useDashboard — scoped useReducer hook backing DashboardPage (the `/` home page).
|
||||
//
|
||||
// Converged onto the `/reports/cartes` model (Issue #279, epic #260): the KPI
|
||||
// row, top movers and budget adherence are the exact Cartes widgets, sourced
|
||||
// from `getCartesSnapshot` against a (year, month) reference period. The
|
||||
// Dashboard keeps its OWN two temporal axes, neither of which is
|
||||
// `useReportsPeriod` (M1/I4 deliberately left the Dashboard untouched):
|
||||
// - `period` / `customDateFrom` / `customDateTo` — a flexible date range,
|
||||
// feeding the two range-based transactional widgets (top-expenses bar
|
||||
// chart, category-over-time trend).
|
||||
// - `referenceYear` / `referenceMonth` — a single reference month, feeding
|
||||
// the Cartes snapshot (KPIs + deltas, top movers, budget adherence, the
|
||||
// income-vs-expenses overlay chart). Defaults to the previous complete
|
||||
// month, like Cartes/Compare (`defaultReferencePeriod`).
|
||||
//
|
||||
// The account (import-source) filter (`accountIds`) is Dashboard-local state
|
||||
// (not routed through `useReportsPeriod`), but applies to every transactional
|
||||
// widget above — both temporal axes. It does NOT scope the net-worth tile
|
||||
// (see below): `balance_accounts` is a disjoint concept from `import_sources`.
|
||||
//
|
||||
// The net-worth tile is fetched independently, once, on mount — it is not a
|
||||
// transactional figure, has no relationship to `period`/`referenceMonth`, and
|
||||
// must not be scoped by `accountIds`. Keeping it in its own effect makes that
|
||||
// decoupling explicit in the code, not just in the UI copy.
|
||||
|
||||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||
import type {
|
||||
DashboardPeriod,
|
||||
DashboardSummary,
|
||||
CategoryBreakdownItem,
|
||||
CategoryOverTimeData,
|
||||
BudgetVsActualRow,
|
||||
CartesSnapshot,
|
||||
ImportSource,
|
||||
} from "../shared/types";
|
||||
import {
|
||||
getDashboardSummary,
|
||||
getExpensesByCategory,
|
||||
deriveNetWorthTile,
|
||||
type NetWorthTileData,
|
||||
} from "../services/dashboardService";
|
||||
import { getCategoryOverTime } from "../services/reportService";
|
||||
import { getBudgetVsActualData } from "../services/budgetService";
|
||||
import { getCategoryOverTime, getCartesSnapshot } from "../services/reportService";
|
||||
import { getAllImportSources } from "../services/transactionService";
|
||||
import {
|
||||
getSnapshotTotalsByDate,
|
||||
listSnapshots,
|
||||
listBalanceAccounts,
|
||||
} from "../services/balance.service";
|
||||
import { computeDateRange } from "../utils/dateRange";
|
||||
import { defaultReferencePeriod } from "../utils/referencePeriod";
|
||||
|
||||
interface DashboardState {
|
||||
summary: DashboardSummary;
|
||||
categoryBreakdown: CategoryBreakdownItem[];
|
||||
categoryOverTime: CategoryOverTimeData;
|
||||
budgetVsActual: BudgetVsActualRow[];
|
||||
cartesSnapshot: CartesSnapshot | null;
|
||||
period: DashboardPeriod;
|
||||
budgetYear: number;
|
||||
budgetMonth: number;
|
||||
customDateFrom: string;
|
||||
customDateTo: string;
|
||||
referenceYear: number;
|
||||
referenceMonth: number;
|
||||
accountIds: number[];
|
||||
accounts: ImportSource[];
|
||||
netWorth: NetWorthTileData;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
|
@ -34,30 +68,46 @@ type DashboardAction =
|
|||
| {
|
||||
type: "SET_DATA";
|
||||
payload: {
|
||||
summary: DashboardSummary;
|
||||
categoryBreakdown: CategoryBreakdownItem[];
|
||||
categoryOverTime: CategoryOverTimeData;
|
||||
budgetVsActual: BudgetVsActualRow[];
|
||||
cartesSnapshot: CartesSnapshot;
|
||||
};
|
||||
}
|
||||
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
||||
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
||||
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
|
||||
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
|
||||
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
|
||||
| { type: "SET_ACCOUNT_IDS"; payload: number[] }
|
||||
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
|
||||
| { type: "SET_NET_WORTH"; payload: NetWorthTileData };
|
||||
|
||||
const now = new Date();
|
||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
const yearStartStr = `${now.getFullYear()}-01-01`;
|
||||
const defaultRef = defaultReferencePeriod();
|
||||
|
||||
const EMPTY_CATEGORY_OVER_TIME: CategoryOverTimeData = {
|
||||
categories: [],
|
||||
data: [],
|
||||
colors: {},
|
||||
categoryIds: {},
|
||||
types: {},
|
||||
tree: [],
|
||||
};
|
||||
|
||||
const EMPTY_NET_WORTH: NetWorthTileData = { visible: false, total: null, asOfDate: null };
|
||||
|
||||
const initialState: DashboardState = {
|
||||
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
||||
categoryBreakdown: [],
|
||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
|
||||
budgetVsActual: [],
|
||||
categoryOverTime: EMPTY_CATEGORY_OVER_TIME,
|
||||
cartesSnapshot: null,
|
||||
period: "year",
|
||||
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
||||
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
||||
customDateFrom: yearStartStr,
|
||||
customDateTo: todayStr,
|
||||
referenceYear: defaultRef.year,
|
||||
referenceMonth: defaultRef.month,
|
||||
accountIds: [],
|
||||
accounts: [],
|
||||
netWorth: EMPTY_NET_WORTH,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
|
@ -71,18 +121,28 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
|
|||
case "SET_DATA":
|
||||
return {
|
||||
...state,
|
||||
summary: action.payload.summary,
|
||||
categoryBreakdown: action.payload.categoryBreakdown,
|
||||
categoryOverTime: action.payload.categoryOverTime,
|
||||
budgetVsActual: action.payload.budgetVsActual,
|
||||
cartesSnapshot: action.payload.cartesSnapshot,
|
||||
isLoading: false,
|
||||
};
|
||||
case "SET_PERIOD":
|
||||
return { ...state, period: action.payload };
|
||||
case "SET_BUDGET_MONTH":
|
||||
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
||||
case "SET_CUSTOM_DATES":
|
||||
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
||||
return {
|
||||
...state,
|
||||
period: "custom" as DashboardPeriod,
|
||||
customDateFrom: action.payload.dateFrom,
|
||||
customDateTo: action.payload.dateTo,
|
||||
};
|
||||
case "SET_REFERENCE_PERIOD":
|
||||
return { ...state, referenceYear: action.payload.year, referenceMonth: action.payload.month };
|
||||
case "SET_ACCOUNT_IDS":
|
||||
return { ...state, accountIds: action.payload };
|
||||
case "SET_ACCOUNTS":
|
||||
return { ...state, accounts: action.payload };
|
||||
case "SET_NET_WORTH":
|
||||
return { ...state, netWorth: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
@ -96,8 +156,9 @@ export function useDashboard() {
|
|||
period: DashboardPeriod,
|
||||
customFrom: string | undefined,
|
||||
customTo: string | undefined,
|
||||
bYear: number,
|
||||
bMonth: number,
|
||||
refYear: number,
|
||||
refMonth: number,
|
||||
accountIds: number[],
|
||||
) => {
|
||||
const fetchId = ++fetchIdRef.current;
|
||||
dispatch({ type: "SET_LOADING", payload: true });
|
||||
|
|
@ -105,15 +166,17 @@ export function useDashboard() {
|
|||
|
||||
try {
|
||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([
|
||||
getDashboardSummary(dateFrom, dateTo),
|
||||
getExpensesByCategory(dateFrom, dateTo),
|
||||
getCategoryOverTime(dateFrom, dateTo),
|
||||
getBudgetVsActualData(bYear, bMonth),
|
||||
const [categoryBreakdown, categoryOverTime, cartesSnapshot] = await Promise.all([
|
||||
getExpensesByCategory(dateFrom, dateTo, accountIds),
|
||||
// typeFilter "expense" (Issue #279): this chart is titled "expenses
|
||||
// over time" but previously aggregated every category type — a
|
||||
// revenue category could silently show up in an "expenses" trend.
|
||||
getCategoryOverTime(dateFrom, dateTo, undefined, accountIds, "expense"),
|
||||
getCartesSnapshot(refYear, refMonth, "month", accountIds),
|
||||
]);
|
||||
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } });
|
||||
dispatch({ type: "SET_DATA", payload: { categoryBreakdown, categoryOverTime, cartesSnapshot } });
|
||||
} catch (e) {
|
||||
if (fetchId !== fetchIdRef.current) return;
|
||||
dispatch({
|
||||
|
|
@ -124,8 +187,59 @@ export function useDashboard() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth);
|
||||
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]);
|
||||
fetchData(
|
||||
state.period,
|
||||
state.customDateFrom,
|
||||
state.customDateTo,
|
||||
state.referenceYear,
|
||||
state.referenceMonth,
|
||||
state.accountIds,
|
||||
);
|
||||
}, [
|
||||
state.period,
|
||||
state.customDateFrom,
|
||||
state.customDateTo,
|
||||
state.referenceYear,
|
||||
state.referenceMonth,
|
||||
state.accountIds,
|
||||
fetchData,
|
||||
]);
|
||||
|
||||
// One-time: import sources for the FilterPanel's account checkboxes (same
|
||||
// query as Trends/Compare/Budget — 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) });
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// One-time: net-worth tile (Bilan). Independent of `period`/`referenceMonth`
|
||||
// and NOT scoped by `accountIds` — a different "account" concept
|
||||
// (balance_accounts vs import_sources) and a different metric (net worth vs
|
||||
// transaction P&L). A failure here only hides the tile (best-effort); it
|
||||
// never surfaces as a page-wide error for what is a secondary widget.
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [accounts, snapshots, totals] = await Promise.all([
|
||||
listBalanceAccounts(),
|
||||
listSnapshots(),
|
||||
getSnapshotTotalsByDate(),
|
||||
]);
|
||||
dispatch({
|
||||
type: "SET_NET_WORTH",
|
||||
payload: deriveNetWorthTile(accounts.length, snapshots.length > 0, totals),
|
||||
});
|
||||
} catch {
|
||||
dispatch({ type: "SET_NET_WORTH", payload: EMPTY_NET_WORTH });
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const setPeriod = useCallback((period: DashboardPeriod) => {
|
||||
dispatch({ type: "SET_PERIOD", payload: period });
|
||||
|
|
@ -135,9 +249,13 @@ export function useDashboard() {
|
|||
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
||||
}, []);
|
||||
|
||||
const setBudgetMonth = useCallback((year: number, month: number) => {
|
||||
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
||||
const setReferencePeriod = useCallback((year: number, month: number) => {
|
||||
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
|
||||
}, []);
|
||||
|
||||
return { state, setPeriod, setCustomDates, setBudgetMonth };
|
||||
const setAccountIds = useCallback((accountIds: number[]) => {
|
||||
dispatch({ type: "SET_ACCOUNT_IDS", payload: accountIds });
|
||||
}, []);
|
||||
|
||||
return { state, setPeriod, setCustomDates, setReferencePeriod, setAccountIds };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"balance": "Balance",
|
||||
"kpiSectionTitle": "Key indicators",
|
||||
"income": "Income",
|
||||
"expenses": "Expenses",
|
||||
"net": "Net",
|
||||
|
|
@ -50,10 +50,11 @@
|
|||
"help": {
|
||||
"title": "How to use the Dashboard",
|
||||
"tips": [
|
||||
"Use the period selector (top right) to view different time ranges",
|
||||
"Summary cards show your balance, income, and expenses for the selected period",
|
||||
"The pie chart breaks down your expenses by category",
|
||||
"Recent transactions are listed at the bottom"
|
||||
"The filter bar at the top picks the date range and which import sources to include",
|
||||
"The key indicators (income, expenses, net, savings rate) cover the chosen reference month, with their change vs the previous month and vs last year",
|
||||
"The \"Current net worth\" tile comes from the Balance sheet — it is not filtered by import source",
|
||||
"Rising/falling categories and budget adherence summarize the reference month's changes",
|
||||
"The bar chart ranks your expenses by category; the chart below tracks their trend over time"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -403,8 +404,7 @@
|
|||
"ytd": "Year-to-Date",
|
||||
"dollarVar": "$ Var",
|
||||
"pctVar": "% Var",
|
||||
"noData": "No budget or transaction data for this period.",
|
||||
"titlePrefix": "Budget vs Actual for"
|
||||
"noData": "No budget or transaction data for this period."
|
||||
},
|
||||
"export": "Export",
|
||||
"month": "Month",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"balance": "Solde",
|
||||
"kpiSectionTitle": "Indicateurs clés",
|
||||
"income": "Revenus",
|
||||
"expenses": "Dépenses",
|
||||
"net": "Net",
|
||||
|
|
@ -50,10 +50,11 @@
|
|||
"help": {
|
||||
"title": "Comment utiliser le tableau de bord",
|
||||
"tips": [
|
||||
"Utilisez le sélecteur de période (en haut à droite) pour changer la plage de dates",
|
||||
"Les cartes résumées affichent votre solde, revenus et dépenses pour la période sélectionnée",
|
||||
"Le graphique circulaire détaille vos dépenses par catégorie",
|
||||
"Les transactions récentes sont listées en bas de page"
|
||||
"Le filtre en haut choisit la plage de dates et les sources d'import à inclure",
|
||||
"Les indicateurs clés (revenus, dépenses, net, taux d'épargne) portent sur le mois de référence choisi, avec leur variation vs le mois précédent et vs l'an dernier",
|
||||
"La tuile « Valeur nette actuelle » vient du Bilan — elle n'est pas filtrée par source d'import",
|
||||
"Les catégories en hausse/en baisse et le respect du budget résument les changements du mois de référence",
|
||||
"Le graphique en barres classe vos dépenses par catégorie ; le graphique du bas en suit l'évolution dans le temps"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -403,8 +404,7 @@
|
|||
"ytd": "Cumul annuel",
|
||||
"dollarVar": "$ Écart",
|
||||
"pctVar": "% Écart",
|
||||
"noData": "Aucune donnée de budget ou de transaction pour cette période.",
|
||||
"titlePrefix": "Budget vs Réel pour le mois de"
|
||||
"noData": "Aucune donnée de budget ou de transaction pour cette période."
|
||||
},
|
||||
"export": "Exporter",
|
||||
"month": "Mois",
|
||||
|
|
|
|||
|
|
@ -1,23 +1,40 @@
|
|||
// DashboardPage — home page at `/`.
|
||||
//
|
||||
// Converged onto the `/reports/cartes` model (Issue #279, epic #260): the KPI
|
||||
// row, top movers and budget adherence are the exact Cartes presentational
|
||||
// components, sourced from the same `getCartesSnapshot` reference-month
|
||||
// snapshot. Two Dashboard-specific widgets remain: the top-expense-categories
|
||||
// ranked bar chart (replacing the former expense-only pie) and the net-worth
|
||||
// tile (from the Bilan — a different metric from every transactional widget
|
||||
// here, see NetWorthTile's own header comment).
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
|
||||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
|
||||
import NetWorthTile from "../components/dashboard/NetWorthTile";
|
||||
import FilterPanel from "../components/reports/FilterPanel";
|
||||
import KpiCard from "../components/reports/cards/KpiCard";
|
||||
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
|
||||
import TopMoversList from "../components/reports/cards/TopMoversList";
|
||||
import BudgetAdherenceCard from "../components/reports/cards/BudgetAdherenceCard";
|
||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
||||
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
||||
import type { CategoryBreakdownItem } from "../shared/types";
|
||||
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
|
||||
|
||||
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { state, setPeriod, setCustomDates, setBudgetMonth } = useDashboard();
|
||||
const { summary, categoryBreakdown, categoryOverTime, budgetVsActual, period, isLoading } = state;
|
||||
const {
|
||||
state,
|
||||
setPeriod,
|
||||
setCustomDates,
|
||||
setReferencePeriod,
|
||||
setAccountIds,
|
||||
} = useDashboard();
|
||||
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
|
||||
|
||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||
|
|
@ -37,35 +54,6 @@ export default function DashboardPage() {
|
|||
setDetailModal(item);
|
||||
}, []);
|
||||
|
||||
const balance = summary.totalAmount;
|
||||
const balanceColor =
|
||||
balance > 0
|
||||
? "text-[var(--positive)]"
|
||||
: balance < 0
|
||||
? "text-[var(--negative)]"
|
||||
: "text-[var(--primary)]";
|
||||
|
||||
const cards = [
|
||||
{
|
||||
labelKey: "dashboard.balance",
|
||||
value: fmt.format(balance),
|
||||
icon: Wallet,
|
||||
color: balanceColor,
|
||||
},
|
||||
{
|
||||
labelKey: "dashboard.income",
|
||||
value: fmt.format(summary.incomeTotal),
|
||||
icon: TrendingUp,
|
||||
color: "text-[var(--positive)]",
|
||||
},
|
||||
{
|
||||
labelKey: "dashboard.expenses",
|
||||
value: fmt.format(Math.abs(summary.expenseTotal)),
|
||||
icon: TrendingDown,
|
||||
color: "text-[var(--negative)]",
|
||||
},
|
||||
];
|
||||
|
||||
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
|
||||
|
||||
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
|
||||
|
|
@ -73,11 +61,19 @@ export default function DashboardPage() {
|
|||
return (
|
||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||
<CategoriesV1DiscoveryBanner />
|
||||
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex items-center gap-3 mb-6">
|
||||
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
|
||||
<PageHelp helpKey="dashboard" />
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FilterPanel
|
||||
temporalControl={
|
||||
<PeriodSelector
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
|
|
@ -85,46 +81,21 @@ export default function DashboardPage() {
|
|||
customDateTo={state.customDateTo}
|
||||
onCustomDateChange={setCustomDates}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.labelKey}
|
||||
className="bg-[var(--card)] rounded-xl p-5 border border-[var(--border)] shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
{t(card.labelKey)}
|
||||
</span>
|
||||
<card.icon size={20} className={card.color} />
|
||||
</div>
|
||||
<p className="text-2xl font-semibold">{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="lg:col-span-1">
|
||||
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
|
||||
<CategoryPieChart
|
||||
data={categoryBreakdown}
|
||||
hiddenCategories={hiddenCategories}
|
||||
onToggleHidden={toggleHidden}
|
||||
onShowAll={showAll}
|
||||
onViewDetails={viewDetails}
|
||||
}
|
||||
accountIds={state.accountIds}
|
||||
onAccountIdsChange={setAccountIds}
|
||||
accounts={state.accounts}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-3">
|
||||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2 flex-wrap">
|
||||
{t("reports.bva.titlePrefix")}
|
||||
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<h2 className="text-lg font-semibold">{t("dashboard.kpiSectionTitle")}</h2>
|
||||
<select
|
||||
value={`${state.budgetYear}-${state.budgetMonth}`}
|
||||
value={`${state.referenceYear}-${state.referenceMonth}`}
|
||||
onChange={(e) => {
|
||||
const [y, m] = e.target.value.split("-").map(Number);
|
||||
setBudgetMonth(y, m);
|
||||
setReferencePeriod(y, m);
|
||||
}}
|
||||
className="text-base font-semibold bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||
className="text-sm font-medium bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{monthOptions.map((opt) => (
|
||||
<option key={opt.key} value={opt.value}>
|
||||
|
|
@ -132,9 +103,75 @@ export default function DashboardPage() {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
</h2>
|
||||
<BudgetVsActualTable data={budgetVsActual} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<NetWorthTile data={state.netWorth} />
|
||||
{cartesSnapshot && (
|
||||
<>
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<KpiCard
|
||||
id="income"
|
||||
title={t("reports.cartes.income")}
|
||||
kpi={cartesSnapshot.kpis.income}
|
||||
format="currency"
|
||||
deltaIsBadWhenUp={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<KpiCard
|
||||
id="expenses"
|
||||
title={t("reports.cartes.expenses")}
|
||||
kpi={cartesSnapshot.kpis.expenses}
|
||||
format="currency"
|
||||
deltaIsBadWhenUp={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<KpiCard
|
||||
id="net"
|
||||
title={t("reports.cartes.net")}
|
||||
kpi={cartesSnapshot.kpis.net}
|
||||
format="currency"
|
||||
deltaIsBadWhenUp={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<KpiCard
|
||||
id="savingsRate"
|
||||
title={t("reports.cartes.savingsRate")}
|
||||
kpi={cartesSnapshot.kpis.savingsRate}
|
||||
format="percent"
|
||||
deltaIsBadWhenUp={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cartesSnapshot && (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<IncomeExpenseOverlayChart flow={cartesSnapshot.flow12Months} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-6">
|
||||
<TopMoversList movers={cartesSnapshot.topMoversUp} direction="up" />
|
||||
<TopMoversList movers={cartesSnapshot.topMoversDown} direction="down" />
|
||||
<BudgetAdherenceCard adherence={cartesSnapshot.budgetAdherence} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
|
||||
<CategoryBarChart
|
||||
data={categoryBreakdown}
|
||||
hiddenCategories={hiddenCategories}
|
||||
onToggleHidden={toggleHidden}
|
||||
onShowAll={showAll}
|
||||
onViewDetails={viewDetails}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
|
|
|
|||
|
|
@ -211,7 +211,9 @@ async function getActualsByCategoryRange(
|
|||
);
|
||||
}
|
||||
|
||||
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
||||
// Income-statement reading order: revenue first, then expenses, then
|
||||
// transfers (Issue #277 — matches COMPARE_TYPE_ORDER / OVER_TIME_TYPE_ORDER).
|
||||
const TYPE_ORDER: Record<string, number> = { income: 0, expense: 1, transfer: 2 };
|
||||
|
||||
/**
|
||||
* Budget vs actual, by category, for the reference month plus YTD. `accountIds`
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { getExpensesByCategory } from "./dashboardService";
|
||||
import { getExpensesByCategory, deriveNetWorthTile } from "./dashboardService";
|
||||
import type { SnapshotTotalPoint } from "./balance.service";
|
||||
|
||||
vi.mock("./db", () => {
|
||||
const getDb = vi.fn();
|
||||
|
|
@ -63,3 +64,50 @@ describe("getExpensesByCategory — accountIds filter (Issue #273)", () => {
|
|||
expect(params).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveNetWorthTile (Issue #279)", () => {
|
||||
const totals: SnapshotTotalPoint[] = [
|
||||
{ snapshot_date: "2026-01-31", total: 1000 },
|
||||
{ snapshot_date: "2026-02-28", total: 1234.56 },
|
||||
];
|
||||
|
||||
it("is hidden when there is no balance account at all", () => {
|
||||
expect(deriveNetWorthTile(0, false, totals)).toEqual({
|
||||
visible: false,
|
||||
total: null,
|
||||
asOfDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("is hidden when accounts exist but no snapshot has ever been recorded (never a misleading $0)", () => {
|
||||
expect(deriveNetWorthTile(3, false, [])).toEqual({
|
||||
visible: false,
|
||||
total: null,
|
||||
asOfDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("is hidden when accounts exist but no snapshot has ever been recorded, even if totals is non-empty (defensive)", () => {
|
||||
expect(deriveNetWorthTile(3, false, totals)).toEqual({
|
||||
visible: false,
|
||||
total: null,
|
||||
asOfDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("is visible with the latest (last) point once accounts and a snapshot both exist", () => {
|
||||
expect(deriveNetWorthTile(3, true, totals)).toEqual({
|
||||
visible: true,
|
||||
total: 1234.56,
|
||||
asOfDate: "2026-02-28",
|
||||
});
|
||||
});
|
||||
|
||||
it("stays hidden if the two signals disagree with an empty totals series (defensive guard)", () => {
|
||||
expect(deriveNetWorthTile(3, true, [])).toEqual({
|
||||
visible: false,
|
||||
total: null,
|
||||
asOfDate: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { getDb } from "./db";
|
||||
import { inPlaceholders } from "../utils/sqlFilters";
|
||||
import { deriveLandingState } from "../components/balance/balanceLanding";
|
||||
import type { SnapshotTotalPoint } from "./balance.service";
|
||||
import type {
|
||||
DashboardSummary,
|
||||
CategoryBreakdownItem,
|
||||
|
|
@ -164,3 +166,47 @@ export async function getRecentTransactions(
|
|||
[limit]
|
||||
);
|
||||
}
|
||||
|
||||
// --- Net-worth tile (Issue #279) ---
|
||||
//
|
||||
// The Dashboard's net-worth tile surfaces the Bilan's latest total
|
||||
// (`getSnapshotTotalsByDate`, balance.service.ts:1767) — a DIFFERENT metric
|
||||
// from every other Dashboard widget: it is the latest snapshot of account
|
||||
// balances, not a transaction P&L. It must never be confused with (or scoped
|
||||
// by) the transactional account/import-source filter, and must never render
|
||||
// a misleading "$0" before any snapshot has ever been recorded.
|
||||
|
||||
export interface NetWorthTileData {
|
||||
visible: boolean;
|
||||
total: number | null;
|
||||
asOfDate: string | null;
|
||||
}
|
||||
|
||||
const EMPTY_NET_WORTH_TILE: NetWorthTileData = {
|
||||
visible: false,
|
||||
total: null,
|
||||
asOfDate: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shapes the net-worth tile from the same two signals BalancePage's landing
|
||||
* state already uses (`deriveLandingState`) — reused here rather than
|
||||
* re-inferring "no data" from nulls, per the #279 review caveat. The tile
|
||||
* stays hidden (never a misleading "$0") unless at least one balance account
|
||||
* exists AND at least one snapshot has ever been recorded; `totals` (the
|
||||
* `getSnapshotTotalsByDate` series, ascending by date) is otherwise expected
|
||||
* to carry at least one point, but a defensive check guards a theoretical
|
||||
* mismatch between the two independent queries.
|
||||
*/
|
||||
export function deriveNetWorthTile(
|
||||
accountsCount: number,
|
||||
hasAnySnapshot: boolean,
|
||||
totals: SnapshotTotalPoint[]
|
||||
): NetWorthTileData {
|
||||
if (deriveLandingState(accountsCount, hasAnySnapshot) !== "populated") {
|
||||
return EMPTY_NET_WORTH_TILE;
|
||||
}
|
||||
const latest = totals[totals.length - 1];
|
||||
if (!latest) return EMPTY_NET_WORTH_TILE;
|
||||
return { visible: true, total: latest.total, asOfDate: latest.snapshot_date };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -470,13 +470,14 @@ describe("getCartesSnapshot", () => {
|
|||
expect(worst.overrunPct).toBeCloseTo(75, 5);
|
||||
});
|
||||
|
||||
// --- accountIds filter (Issue #273) ---
|
||||
// --- accountIds filter (Issues #273 / #279) ---
|
||||
//
|
||||
// getCartesSnapshot must forward accountIds to its two sub-reports that
|
||||
// getCartesSnapshot must forward accountIds to EVERY sub-query so the whole
|
||||
// dashboard honours an active account filter: the 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.
|
||||
// for budget adherence) AND the KPI/sparkline/seasonality series
|
||||
// (fetchMonthlyFlows, fetchSeasonality). Omitting it anywhere would let the
|
||||
// dashboard silently mix filtered and unfiltered figures on the same screen.
|
||||
|
||||
it("forwards accountIds to the compare (top movers) sub-report", async () => {
|
||||
mockSelect.mockImplementation(() => Promise.resolve([]));
|
||||
|
|
@ -506,6 +507,32 @@ describe("getCartesSnapshot", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("forwards accountIds to the monthly-flows series (KPIs, sparklines, overlay)", async () => {
|
||||
mockSelect.mockImplementation(() => Promise.resolve([]));
|
||||
|
||||
await getCartesSnapshot(2026, 3, "month", [3, 8]);
|
||||
|
||||
// fetchMonthlyFlows is the only snapshot query selecting a "%Y-%m" month.
|
||||
const flowCall = mockSelect.mock.calls.find(([sql]) =>
|
||||
(sql as string).includes("strftime('%Y-%m', date) AS month"),
|
||||
)!;
|
||||
expect(flowCall[0]).toContain("AND source_id IN ($3, $4)");
|
||||
expect(flowCall[1]).toEqual(expect.arrayContaining([3, 8]));
|
||||
});
|
||||
|
||||
it("forwards accountIds to the seasonality series", async () => {
|
||||
mockSelect.mockImplementation(() => Promise.resolve([]));
|
||||
|
||||
await getCartesSnapshot(2026, 3, "month", [3, 8]);
|
||||
|
||||
// fetchSeasonality is the only snapshot query filtering on a "%m" month.
|
||||
const seasonalityCall = mockSelect.mock.calls.find(([sql]) =>
|
||||
(sql as string).includes("strftime('%m', date) = $1"),
|
||||
)!;
|
||||
expect(seasonalityCall[0]).toContain("AND source_id IN ($4, $5)");
|
||||
expect(seasonalityCall[1]).toEqual(expect.arrayContaining([3, 8]));
|
||||
});
|
||||
|
||||
it("without accountIds, no sub-report SQL carries a source_id clause (regression)", async () => {
|
||||
mockSelect.mockImplementation(() => Promise.resolve([]));
|
||||
|
||||
|
|
|
|||
|
|
@ -1186,18 +1186,25 @@ interface RawMonthFlow {
|
|||
async function fetchMonthlyFlows(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
accountIds?: number[],
|
||||
): Promise<RawMonthFlow[]> {
|
||||
const db = await getDb();
|
||||
const params: unknown[] = [dateFrom, dateTo];
|
||||
const accountPlaceholders = inPlaceholders(accountIds, 3);
|
||||
const accountClause = accountPlaceholders
|
||||
? ` AND source_id IN (${accountPlaceholders})`
|
||||
: "";
|
||||
if (accountPlaceholders) params.push(...accountIds!);
|
||||
return db.select<RawMonthFlow[]>(
|
||||
`SELECT
|
||||
strftime('%Y-%m', date) AS month,
|
||||
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
|
||||
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
|
||||
FROM transactions
|
||||
WHERE date >= $1 AND date <= $2
|
||||
WHERE date >= $1 AND date <= $2${accountClause}
|
||||
GROUP BY month
|
||||
ORDER BY month ASC`,
|
||||
[dateFrom, dateTo],
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1210,9 +1217,16 @@ async function fetchSeasonality(
|
|||
month: number,
|
||||
yearFrom: number,
|
||||
yearTo: number,
|
||||
accountIds?: number[],
|
||||
): Promise<RawSeasonalityRow[]> {
|
||||
const db = await getDb();
|
||||
const mm = String(month).padStart(2, "0");
|
||||
const params: unknown[] = [mm, yearFrom, yearTo];
|
||||
const accountPlaceholders = inPlaceholders(accountIds, 4);
|
||||
const accountClause = accountPlaceholders
|
||||
? ` AND source_id IN (${accountPlaceholders})`
|
||||
: "";
|
||||
if (accountPlaceholders) params.push(...accountIds!);
|
||||
return db.select<RawSeasonalityRow[]>(
|
||||
`SELECT
|
||||
CAST(strftime('%Y', date) AS INTEGER) AS year,
|
||||
|
|
@ -1220,10 +1234,10 @@ async function fetchSeasonality(
|
|||
FROM transactions
|
||||
WHERE strftime('%m', date) = $1
|
||||
AND CAST(strftime('%Y', date) AS INTEGER) >= $2
|
||||
AND CAST(strftime('%Y', date) AS INTEGER) <= $3
|
||||
AND CAST(strftime('%Y', date) AS INTEGER) <= $3${accountClause}
|
||||
GROUP BY year
|
||||
ORDER BY year DESC`,
|
||||
[mm, yearFrom, yearTo],
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1240,12 +1254,12 @@ async function fetchSeasonality(
|
|||
* 4. Budget vs actual for the reference month.
|
||||
* 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.
|
||||
* `accountIds` (Issues #273/#279) is forwarded to every sub-query so the whole
|
||||
* dashboard honours an active account filter: the top-movers
|
||||
* (`getCompareMonthOverMonth`) and budget (`getBudgetVsActualData`) sub-reports,
|
||||
* and the KPI/sparkline/seasonality series (`fetchMonthlyFlows`,
|
||||
* `fetchSeasonality`). Omitting it anywhere would let the dashboard silently mix
|
||||
* filtered and unfiltered figures on the same screen.
|
||||
*/
|
||||
export async function getCartesSnapshot(
|
||||
referenceYear: number,
|
||||
|
|
@ -1261,8 +1275,8 @@ export async function getCartesSnapshot(
|
|||
|
||||
// Seasonality range: previous 2 years for the same calendar month.
|
||||
const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([
|
||||
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1),
|
||||
fetchMonthlyFlows(windowStartIso, refEnd),
|
||||
fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1, accountIds),
|
||||
fetchMonthlyFlows(windowStartIso, refEnd, accountIds),
|
||||
getCompareMonthOverMonth(referenceYear, referenceMonth, accountIds),
|
||||
getBudgetVsActualData(referenceYear, referenceMonth, accountIds),
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue