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) => (