From 5e7c7e6609cf2d1c4673046552c3dfeb584023dd Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Sun, 15 Feb 2026 18:01:10 +0000 Subject: [PATCH] feat: add Budget vs Actual report tab with monthly and YTD comparison New tabular report showing actual vs budgeted amounts per category, with dollar and percentage variations for both the selected month and year-to-date. Includes parent/child hierarchy, type grouping, variation coloring, and month navigation. Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- .../reports/BudgetVsActualTable.tsx | 192 ++++++++++++++++ src/hooks/useReports.ts | 55 ++++- src/i18n/locales/en.json | 8 + src/i18n/locales/fr.json | 8 + src/pages/ReportsPage.tsx | 21 +- src/services/budgetService.ts | 206 ++++++++++++++++++ src/shared/types/index.ts | 19 +- 10 files changed, 500 insertions(+), 15 deletions(-) create mode 100644 src/components/reports/BudgetVsActualTable.tsx diff --git a/package.json b/package.json index a58d68d..ffb5b21 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "simpl_result_scaffold", "private": true, - "version": "0.2.10", + "version": "0.2.11", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8b247f9..1cf1734 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "simpl-result" -version = "0.2.10" +version = "0.2.11" description = "Personal finance management app" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 30eaf08..f6f53ad 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Simpl Résultat", - "version": "0.2.10", + "version": "0.2.11", "identifier": "com.simpl.resultat", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/reports/BudgetVsActualTable.tsx b/src/components/reports/BudgetVsActualTable.tsx new file mode 100644 index 0000000..18b4a68 --- /dev/null +++ b/src/components/reports/BudgetVsActualTable.tsx @@ -0,0 +1,192 @@ +import { Fragment } from "react"; +import { useTranslation } from "react-i18next"; +import type { BudgetVsActualRow } from "../../shared/types"; + +const cadFormatter = (value: number) => + new Intl.NumberFormat("en-CA", { + style: "currency", + currency: "CAD", + maximumFractionDigits: 0, + }).format(value); + +const pctFormatter = (value: number | null) => + value == null ? "—" : `${(value * 100).toFixed(1)}%`; + +function variationColor(value: number): string { + if (value > 0) return "text-[var(--positive)]"; + if (value < 0) return "text-[var(--negative)]"; + return ""; +} + +interface BudgetVsActualTableProps { + data: BudgetVsActualRow[]; +} + +export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps) { + const { t } = useTranslation(); + + if (data.length === 0) { + return ( +
+ {t("reports.bva.noData")} +
+ ); + } + + // Group rows by type for section headers + type SectionType = "expense" | "income" | "transfer"; + const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = []; + const typeLabels: Record = { + expense: t("budget.expenses"), + income: t("budget.income"), + transfer: t("budget.transfers"), + }; + + let currentType: SectionType | null = null; + for (const row of data) { + if (row.category_type !== currentType) { + currentType = row.category_type; + sections.push({ type: currentType, label: typeLabels[currentType], rows: [] }); + } + 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; + + return ( +
+ + + + + + + + + + + + + + + + + + + + {sections.map((section) => ( + + + + + {section.rows.map((row) => { + const isParent = row.is_parent; + const isChild = row.parent_id !== null && !row.is_parent; + return ( + + + + + + + + + + + + ); + })} + + ))} + {/* Grand totals */} + + + + + + + + + + + + +
+ {t("budget.category")} + + {t("reports.bva.monthly")} + + {t("reports.bva.ytd")} +
+ {t("budget.actual")} + + {t("budget.planned")} + + {t("reports.bva.dollarVar")} + + {t("reports.bva.pctVar")} + + {t("budget.actual")} + + {t("budget.planned")} + + {t("reports.bva.dollarVar")} + + {t("reports.bva.pctVar")} +
+ {section.label} +
+ + + {row.category_name} + + + {cadFormatter(row.monthActual)} + {cadFormatter(row.monthBudget)} + {cadFormatter(row.monthVariation)} + + {pctFormatter(row.monthVariationPct)} + + {cadFormatter(row.ytdActual)} + {cadFormatter(row.ytdBudget)} + {cadFormatter(row.ytdVariation)} + + {pctFormatter(row.ytdVariationPct)} +
{t("common.total")} + {cadFormatter(totals.monthActual)} + {cadFormatter(totals.monthBudget)} + {cadFormatter(totals.monthVariation)} + + {pctFormatter(totalMonthPct)} + + {cadFormatter(totals.ytdActual)} + {cadFormatter(totals.ytdBudget)} + {cadFormatter(totals.ytdVariation)} + + {pctFormatter(totalYtdPct)} +
+
+ ); +} diff --git a/src/hooks/useReports.ts b/src/hooks/useReports.ts index 383c453..9598b29 100644 --- a/src/hooks/useReports.ts +++ b/src/hooks/useReports.ts @@ -5,9 +5,11 @@ import type { MonthlyTrendItem, CategoryBreakdownItem, CategoryOverTimeData, + BudgetVsActualRow, } from "../shared/types"; import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService"; import { getExpensesByCategory } from "../services/dashboardService"; +import { getBudgetVsActualData } from "../services/budgetService"; interface ReportsState { tab: ReportTab; @@ -15,6 +17,9 @@ interface ReportsState { monthlyTrends: MonthlyTrendItem[]; categorySpending: CategoryBreakdownItem[]; categoryOverTime: CategoryOverTimeData; + budgetYear: number; + budgetMonth: number; + budgetVsActual: BudgetVsActualRow[]; isLoading: boolean; error: string | null; } @@ -26,7 +31,11 @@ type ReportsAction = | { type: "SET_ERROR"; payload: string | null } | { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] } | { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] } - | { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }; + | { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData } + | { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } } + | { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }; + +const now = new Date(); const initialState: ReportsState = { tab: "trends", @@ -34,6 +43,9 @@ const initialState: ReportsState = { monthlyTrends: [], categorySpending: [], categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} }, + budgetYear: now.getFullYear(), + budgetMonth: now.getMonth() + 1, + budgetVsActual: [], isLoading: false, error: null, }; @@ -54,6 +66,10 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState { return { ...state, categorySpending: action.payload, isLoading: false }; case "SET_CATEGORY_OVER_TIME": return { ...state, categoryOverTime: action.payload, isLoading: false }; + case "SET_BUDGET_MONTH": + return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month }; + case "SET_BUDGET_VS_ACTUAL": + return { ...state, budgetVsActual: action.payload, isLoading: false }; default: return state; } @@ -94,33 +110,45 @@ export function useReports() { const [state, dispatch] = useReducer(reducer, initialState); const fetchIdRef = useRef(0); - const fetchData = useCallback(async (tab: ReportTab, period: DashboardPeriod) => { + const fetchData = useCallback(async ( + tab: ReportTab, + period: DashboardPeriod, + budgetYear: number, + budgetMonth: number, + ) => { const fetchId = ++fetchIdRef.current; dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_ERROR", payload: null }); try { - const { dateFrom, dateTo } = computeDateRange(period); - switch (tab) { case "trends": { + const { dateFrom, dateTo } = computeDateRange(period); const data = await getMonthlyTrends(dateFrom, dateTo); if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_MONTHLY_TRENDS", payload: data }); break; } case "byCategory": { + const { dateFrom, dateTo } = computeDateRange(period); const data = await getExpensesByCategory(dateFrom, dateTo); if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_CATEGORY_SPENDING", payload: data }); break; } case "overTime": { + const { dateFrom, dateTo } = computeDateRange(period); const data = await getCategoryOverTime(dateFrom, dateTo); if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data }); break; } + case "budgetVsActual": { + const data = await getBudgetVsActualData(budgetYear, budgetMonth); + if (fetchId !== fetchIdRef.current) return; + dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data }); + break; + } } } catch (e) { if (fetchId !== fetchIdRef.current) return; @@ -132,8 +160,8 @@ export function useReports() { }, []); useEffect(() => { - fetchData(state.tab, state.period); - }, [state.tab, state.period, fetchData]); + fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth); + }, [state.tab, state.period, state.budgetYear, state.budgetMonth, fetchData]); const setTab = useCallback((tab: ReportTab) => { dispatch({ type: "SET_TAB", payload: tab }); @@ -143,5 +171,18 @@ export function useReports() { dispatch({ type: "SET_PERIOD", payload: period }); }, []); - return { state, setTab, setPeriod }; + const navigateBudgetMonth = useCallback((delta: -1 | 1) => { + let newMonth = state.budgetMonth + delta; + let newYear = state.budgetYear; + if (newMonth < 1) { + newMonth = 12; + newYear -= 1; + } else if (newMonth > 12) { + newMonth = 1; + newYear += 1; + } + dispatch({ type: "SET_BUDGET_MONTH", payload: { year: newYear, month: newMonth } }); + }, [state.budgetYear, state.budgetMonth]); + + return { state, setTab, setPeriod, navigateBudgetMonth }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ac7c634..15b41e4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -331,6 +331,14 @@ "byCategory": "Expenses by Category", "overTime": "Category Over Time", "trends": "Monthly Trends", + "budgetVsActual": "Budget vs Actual", + "bva": { + "monthly": "Monthly", + "ytd": "Year-to-Date", + "dollarVar": "$ Var", + "pctVar": "% Var", + "noData": "No budget or transaction data for this period." + }, "export": "Export", "help": { "title": "How to use Reports", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 16cf917..cefd88c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -331,6 +331,14 @@ "byCategory": "Dépenses par catégorie", "overTime": "Catégories dans le temps", "trends": "Tendances mensuelles", + "budgetVsActual": "Budget vs R\u00e9el", + "bva": { + "monthly": "Mensuel", + "ytd": "Cumul annuel", + "dollarVar": "$ \u00c9cart", + "pctVar": "% \u00c9cart", + "noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode." + }, "export": "Exporter", "help": { "title": "Comment utiliser les Rapports", diff --git a/src/pages/ReportsPage.tsx b/src/pages/ReportsPage.tsx index 6233ec8..2f4eb5f 100644 --- a/src/pages/ReportsPage.tsx +++ b/src/pages/ReportsPage.tsx @@ -4,12 +4,14 @@ import { useReports } from "../hooks/useReports"; import { PageHelp } from "../components/shared/PageHelp"; import type { ReportTab, CategoryBreakdownItem, DashboardPeriod } from "../shared/types"; import PeriodSelector from "../components/dashboard/PeriodSelector"; +import MonthNavigator from "../components/budget/MonthNavigator"; import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart"; import CategoryBarChart from "../components/reports/CategoryBarChart"; import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart"; +import BudgetVsActualTable from "../components/reports/BudgetVsActualTable"; import TransactionDetailModal from "../components/shared/TransactionDetailModal"; -const TABS: ReportTab[] = ["trends", "byCategory", "overTime"]; +const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual"]; function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } { if (period === "all") return {}; @@ -31,7 +33,7 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo? export default function ReportsPage() { const { t } = useTranslation(); - const { state, setTab, setPeriod } = useReports(); + const { state, setTab, setPeriod, navigateBudgetMonth } = useReports(); const [hiddenCategories, setHiddenCategories] = useState>(new Set()); const [detailModal, setDetailModal] = useState(null); @@ -60,10 +62,18 @@ export default function ReportsPage() {

{t("reports.title")}

- + {state.tab === "budgetVsActual" ? ( + + ) : ( + + )} -
+
{TABS.map((tab) => (