Merge issue-277 (BVA income-first) into main
# Conflicts: # CHANGELOG.fr.md # CHANGELOG.md
This commit is contained in:
commit
6dbaaa25fe
6 changed files with 356 additions and 163 deletions
|
|
@ -19,6 +19,7 @@
|
||||||
### Modifié
|
### 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 → 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).
|
- 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).
|
||||||
|
|
||||||
## [0.12.0] - 2026-07-05
|
## [0.12.0] - 2026-07-05
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
### Changed
|
### 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 → 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).
|
- 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).
|
||||||
|
|
||||||
## [0.12.0] - 2026-07-05
|
## [0.12.0] - 2026-07-05
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { BudgetVsActualRow } from "../../shared/types";
|
||||||
import { reorderRows } from "../../utils/reorderRows";
|
import { reorderRows } from "../../utils/reorderRows";
|
||||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||||
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
||||||
|
import { type SectionType, type Totals, sumLeaves, pct, computeResults } from "./budgetVsActualResults";
|
||||||
|
|
||||||
const cadFormatter = (value: number) =>
|
const cadFormatter = (value: number) =>
|
||||||
new Intl.NumberFormat("en-CA", {
|
new Intl.NumberFormat("en-CA", {
|
||||||
|
|
@ -68,8 +69,8 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group rows by type for section headers
|
// Group rows into contiguous type sections (the service already type-sorts:
|
||||||
type SectionType = "expense" | "income" | "transfer";
|
// income → expense → transfer, Issue #277).
|
||||||
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
|
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
|
||||||
const typeLabels: Record<SectionType, string> = {
|
const typeLabels: Record<SectionType, string> = {
|
||||||
expense: t("budget.expenses"),
|
expense: t("budget.expenses"),
|
||||||
|
|
@ -91,21 +92,159 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
sections[sections.length - 1].rows.push(row);
|
sections[sections.length - 1].rows.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grand totals (leaf rows only)
|
// Income-statement result lines (Issue #277): income + expense (expense
|
||||||
const leaves = data.filter((r) => !r.is_parent);
|
// already arrives signed negative in BudgetVsActualRow — see
|
||||||
const totals = leaves.reduce(
|
// budgetVsActualResults.ts), then the net after adding transfers. Replaces
|
||||||
(acc, r) => ({
|
// the old flat grand total row.
|
||||||
monthActual: acc.monthActual + r.monthActual,
|
const results = computeResults(data);
|
||||||
monthBudget: acc.monthBudget + r.monthBudget,
|
const nonTransferSections = sections.filter((s) => s.type !== "transfer");
|
||||||
monthVariation: acc.monthVariation + r.monthVariation,
|
const transferSection = sections.find((s) => s.type === "transfer");
|
||||||
ytdActual: acc.ytdActual + r.ytdActual,
|
|
||||||
ytdBudget: acc.ytdBudget + r.ytdBudget,
|
const renderSection = (section: { type: SectionType; label: string; rows: BudgetVsActualRow[] }) => {
|
||||||
ytdVariation: acc.ytdVariation + r.ytdVariation,
|
const sectionTotals = sumLeaves(section.rows);
|
||||||
}),
|
const sectionMonthPct = pct(sectionTotals.monthVariation, sectionTotals.monthBudget);
|
||||||
{ monthActual: 0, monthBudget: 0, monthVariation: 0, ytdActual: 0, ytdBudget: 0, ytdVariation: 0 }
|
const sectionYtdPct = pct(sectionTotals.ytdVariation, sectionTotals.ytdBudget);
|
||||||
);
|
return (
|
||||||
const totalMonthPct = totals.monthBudget !== 0 ? totals.monthVariation / Math.abs(totals.monthBudget) : null;
|
<Fragment key={section.type}>
|
||||||
const totalYtdPct = totals.ytdBudget !== 0 ? totals.ytdVariation / Math.abs(totals.ytdBudget) : null;
|
<tr className="bg-[var(--muted)]">
|
||||||
|
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
|
||||||
|
{section.label}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
|
||||||
|
const isParent = row.is_parent;
|
||||||
|
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
||||||
|
const isTopParent = isParent && depth === 0;
|
||||||
|
const isIntermediateParent = isParent && depth >= 1;
|
||||||
|
const collapsed = isTopParent && groups.isCollapsed(row);
|
||||||
|
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
||||||
|
className={`border-b border-[var(--border)]/50 ${
|
||||||
|
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
|
||||||
|
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className={`py-1.5 sticky left-0 z-10 ${
|
||||||
|
isTopParent
|
||||||
|
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
|
||||||
|
: isIntermediateParent
|
||||||
|
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
||||||
|
: `${paddingClass} bg-[var(--card)]`
|
||||||
|
}`}>
|
||||||
|
{isTopParent ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => groups.toggle(row)}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: row.category_color }}
|
||||||
|
/>
|
||||||
|
{row.category_name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: row.category_color }}
|
||||||
|
/>
|
||||||
|
{row.category_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
||||||
|
{cadFormatter(row.monthActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-1.5">{cadFormatter(row.monthBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
||||||
|
{cadFormatter(row.monthVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
||||||
|
{pctFormatter(row.monthVariationPct)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
||||||
|
{cadFormatter(row.ytdActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-1.5">{cadFormatter(row.ytdBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
||||||
|
{cadFormatter(row.ytdVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
||||||
|
{pctFormatter(row.ytdVariationPct)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
|
||||||
|
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
|
||||||
|
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(sectionTotals.monthActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
||||||
|
{cadFormatter(sectionTotals.monthVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
||||||
|
{pctFormatter(sectionMonthPct)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(sectionTotals.ytdActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
||||||
|
{cadFormatter(sectionTotals.ytdVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
||||||
|
{pctFormatter(sectionYtdPct)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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={`${cell} ${variationColor(tot.monthBudget)}`}>{cadFormatter(tot.monthBudget)}</td>
|
||||||
|
<td className={`${cell} ${variationColor(tot.monthVariation)}`}>
|
||||||
|
{cadFormatter(tot.monthVariation)}
|
||||||
|
</td>
|
||||||
|
<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 hasGroups = groups.groupCount(data) > 0;
|
||||||
const allExpanded = groups.allExpanded(data);
|
const allExpanded = groups.allExpanded(data);
|
||||||
|
|
@ -173,151 +312,15 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sections.map((section) => {
|
{nonTransferSections.map(renderSection)}
|
||||||
const sectionLeaves = section.rows.filter((r) => !r.is_parent);
|
{/* Operating result (income + expense), shown before the transfers
|
||||||
const sectionTotals = sectionLeaves.reduce(
|
section only when transfers exist — otherwise it equals the net
|
||||||
(acc, r) => ({
|
total below and would just be noise (mirrors ComparePeriodTable). */}
|
||||||
monthActual: acc.monthActual + r.monthActual,
|
{results.hasTransfers &&
|
||||||
monthBudget: acc.monthBudget + r.monthBudget,
|
renderResultRow("reports.compare.resultBeforeTransfers", results.resultBefore, false)}
|
||||||
monthVariation: acc.monthVariation + r.monthVariation,
|
{transferSection && renderSection(transferSection)}
|
||||||
ytdActual: acc.ytdActual + r.ytdActual,
|
{/* Bottom line: result after adding transfers. */}
|
||||||
ytdBudget: acc.ytdBudget + r.ytdBudget,
|
{renderResultRow("reports.compare.resultNet", results.resultNet, true)}
|
||||||
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;
|
|
||||||
return (
|
|
||||||
<Fragment key={section.type}>
|
|
||||||
<tr className="bg-[var(--muted)]">
|
|
||||||
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0 bg-[var(--muted)]">
|
|
||||||
{section.label}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{reorderRows(groups.visible(section.rows), subtotalsOnTop).map((row) => {
|
|
||||||
const isParent = row.is_parent;
|
|
||||||
const depth = row.depth ?? (row.parent_id !== null && !row.is_parent ? 1 : 0);
|
|
||||||
const isTopParent = isParent && depth === 0;
|
|
||||||
const isIntermediateParent = isParent && depth >= 1;
|
|
||||||
const collapsed = isTopParent && groups.isCollapsed(row);
|
|
||||||
const paddingClass = depth >= 3 ? "pl-20" : depth === 2 ? "pl-14" : depth === 1 ? "pl-8" : "px-3";
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={`${row.category_id}-${row.is_parent}-${depth}`}
|
|
||||||
className={`border-b border-[var(--border)]/50 ${
|
|
||||||
isTopParent ? "bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))] font-semibold" :
|
|
||||||
isIntermediateParent ? "bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))] font-medium" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<td className={`py-1.5 sticky left-0 z-10 ${
|
|
||||||
isTopParent
|
|
||||||
? "px-3 bg-[color-mix(in_srgb,var(--muted)_30%,var(--card))]"
|
|
||||||
: isIntermediateParent
|
|
||||||
? `${paddingClass} bg-[color-mix(in_srgb,var(--muted)_15%,var(--card))]`
|
|
||||||
: `${paddingClass} bg-[var(--card)]`
|
|
||||||
}`}>
|
|
||||||
{isTopParent ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => groups.toggle(row)}
|
|
||||||
aria-expanded={!collapsed}
|
|
||||||
className="flex items-center gap-2 w-full text-left hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
{collapsed ? (
|
|
||||||
<ChevronRight size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={14} className="shrink-0 text-[var(--muted-foreground)]" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
|
||||||
style={{ backgroundColor: row.category_color }}
|
|
||||||
/>
|
|
||||||
{row.category_name}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
|
||||||
style={{ backgroundColor: row.category_color }}
|
|
||||||
/>
|
|
||||||
{row.category_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
|
||||||
{cadFormatter(row.monthActual)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-1.5">{cadFormatter(row.monthBudget)}</td>
|
|
||||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
|
||||||
{cadFormatter(row.monthVariation)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.monthVariation)}`}>
|
|
||||||
{pctFormatter(row.monthVariationPct)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-1.5 border-l border-[var(--border)]/50`}>
|
|
||||||
{cadFormatter(row.ytdActual)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-1.5">{cadFormatter(row.ytdBudget)}</td>
|
|
||||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
|
||||||
{cadFormatter(row.ytdVariation)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-1.5 ${variationColor(row.ytdVariation)}`}>
|
|
||||||
{pctFormatter(row.ytdVariationPct)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<tr className="border-b border-[var(--border)] bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] font-semibold text-sm">
|
|
||||||
<td className="px-3 py-2.5 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_40%,var(--card))] z-10">{t(typeTotalKeys[section.type])}</td>
|
|
||||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
|
||||||
{cadFormatter(sectionTotals.monthActual)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.monthBudget)}</td>
|
|
||||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
|
||||||
{cadFormatter(sectionTotals.monthVariation)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.monthVariation)}`}>
|
|
||||||
{pctFormatter(sectionMonthPct)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
|
|
||||||
{cadFormatter(sectionTotals.ytdActual)}
|
|
||||||
</td>
|
|
||||||
<td className="text-right px-3 py-2.5">{cadFormatter(sectionTotals.ytdBudget)}</td>
|
|
||||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
|
||||||
{cadFormatter(sectionTotals.ytdVariation)}
|
|
||||||
</td>
|
|
||||||
<td className={`text-right px-3 py-2.5 ${variationColor(sectionTotals.ytdVariation)}`}>
|
|
||||||
{pctFormatter(sectionYtdPct)}
|
|
||||||
</td>
|
|
||||||
</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)}
|
|
||||||
</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>
|
|
||||||
<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>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 };
|
||||||
|
}
|
||||||
|
|
@ -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`
|
* Budget vs actual, by category, for the reference month plus YTD. `accountIds`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue