feat: add Budget vs Actual report tab with monthly and YTD comparison
Some checks failed
Release / build (windows-latest) (push) Has been cancelled
Some checks failed
Release / build (windows-latest) (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
parent
32dae2b7b2
commit
5e7c7e6609
10 changed files with 500 additions and 15 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.10",
|
"version": "0.2.11",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.2.10"
|
version = "0.2.11"
|
||||||
description = "Personal finance management app"
|
description = "Personal finance management app"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Simpl Résultat",
|
"productName": "Simpl Résultat",
|
||||||
"version": "0.2.10",
|
"version": "0.2.11",
|
||||||
"identifier": "com.simpl.resultat",
|
"identifier": "com.simpl.resultat",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
192
src/components/reports/BudgetVsActualTable.tsx
Normal file
192
src/components/reports/BudgetVsActualTable.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.bva.noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group rows by type for section headers
|
||||||
|
type SectionType = "expense" | "income" | "transfer";
|
||||||
|
const sections: { type: SectionType; label: string; rows: BudgetVsActualRow[] }[] = [];
|
||||||
|
const typeLabels: Record<SectionType, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[var(--border)]">
|
||||||
|
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom">
|
||||||
|
{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)]">
|
||||||
|
{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)]">
|
||||||
|
{t("reports.bva.ytd")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-[var(--border)]">
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
|
||||||
|
{t("budget.actual")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("budget.planned")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.bva.dollarVar")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.bva.pctVar")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
|
||||||
|
{t("budget.actual")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("budget.planned")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.bva.dollarVar")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.bva.pctVar")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<Fragment key={section.type}>
|
||||||
|
<tr className="bg-[var(--muted)]/50">
|
||||||
|
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider">
|
||||||
|
{section.label}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{section.rows.map((row) => {
|
||||||
|
const isParent = row.is_parent;
|
||||||
|
const isChild = row.parent_id !== null && !row.is_parent;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`${row.category_id}-${row.is_parent}`}
|
||||||
|
className={`border-b border-[var(--border)]/50 ${
|
||||||
|
isParent ? "bg-[var(--muted)]/30 font-semibold" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className={`px-3 py-1.5 ${isChild ? "pl-8" : ""}`}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
{/* Grand totals */}
|
||||||
|
<tr className="border-t-2 border-[var(--border)] font-bold bg-[var(--muted)]/20">
|
||||||
|
<td className="px-3 py-2">{t("common.total")}</td>
|
||||||
|
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(totals.monthActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2">{cadFormatter(totals.monthBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
|
||||||
|
{cadFormatter(totals.monthVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-2 ${variationColor(totals.monthVariation)}`}>
|
||||||
|
{pctFormatter(totalMonthPct)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2 border-l border-[var(--border)]/50">
|
||||||
|
{cadFormatter(totals.ytdActual)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right px-3 py-2">{cadFormatter(totals.ytdBudget)}</td>
|
||||||
|
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
|
||||||
|
{cadFormatter(totals.ytdVariation)}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right px-3 py-2 ${variationColor(totals.ytdVariation)}`}>
|
||||||
|
{pctFormatter(totalYtdPct)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,9 +5,11 @@ import type {
|
||||||
MonthlyTrendItem,
|
MonthlyTrendItem,
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
CategoryOverTimeData,
|
CategoryOverTimeData,
|
||||||
|
BudgetVsActualRow,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
||||||
import { getExpensesByCategory } from "../services/dashboardService";
|
import { getExpensesByCategory } from "../services/dashboardService";
|
||||||
|
import { getBudgetVsActualData } from "../services/budgetService";
|
||||||
|
|
||||||
interface ReportsState {
|
interface ReportsState {
|
||||||
tab: ReportTab;
|
tab: ReportTab;
|
||||||
|
|
@ -15,6 +17,9 @@ interface ReportsState {
|
||||||
monthlyTrends: MonthlyTrendItem[];
|
monthlyTrends: MonthlyTrendItem[];
|
||||||
categorySpending: CategoryBreakdownItem[];
|
categorySpending: CategoryBreakdownItem[];
|
||||||
categoryOverTime: CategoryOverTimeData;
|
categoryOverTime: CategoryOverTimeData;
|
||||||
|
budgetYear: number;
|
||||||
|
budgetMonth: number;
|
||||||
|
budgetVsActual: BudgetVsActualRow[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -26,7 +31,11 @@ type ReportsAction =
|
||||||
| { type: "SET_ERROR"; payload: string | null }
|
| { type: "SET_ERROR"; payload: string | null }
|
||||||
| { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] }
|
| { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] }
|
||||||
| { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] }
|
| { 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 = {
|
const initialState: ReportsState = {
|
||||||
tab: "trends",
|
tab: "trends",
|
||||||
|
|
@ -34,6 +43,9 @@ const initialState: ReportsState = {
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
categorySpending: [],
|
categorySpending: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||||
|
budgetYear: now.getFullYear(),
|
||||||
|
budgetMonth: now.getMonth() + 1,
|
||||||
|
budgetVsActual: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -54,6 +66,10 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
||||||
return { ...state, categorySpending: action.payload, isLoading: false };
|
return { ...state, categorySpending: action.payload, isLoading: false };
|
||||||
case "SET_CATEGORY_OVER_TIME":
|
case "SET_CATEGORY_OVER_TIME":
|
||||||
return { ...state, categoryOverTime: action.payload, isLoading: false };
|
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:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
@ -94,33 +110,45 @@ export function useReports() {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
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;
|
const fetchId = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
dispatch({ type: "SET_ERROR", payload: null });
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period);
|
|
||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "trends": {
|
case "trends": {
|
||||||
|
const { dateFrom, dateTo } = computeDateRange(period);
|
||||||
const data = await getMonthlyTrends(dateFrom, dateTo);
|
const data = await getMonthlyTrends(dateFrom, dateTo);
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
|
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "byCategory": {
|
case "byCategory": {
|
||||||
|
const { dateFrom, dateTo } = computeDateRange(period);
|
||||||
const data = await getExpensesByCategory(dateFrom, dateTo);
|
const data = await getExpensesByCategory(dateFrom, dateTo);
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
|
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "overTime": {
|
case "overTime": {
|
||||||
|
const { dateFrom, dateTo } = computeDateRange(period);
|
||||||
const data = await getCategoryOverTime(dateFrom, dateTo);
|
const data = await getCategoryOverTime(dateFrom, dateTo);
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||||
break;
|
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) {
|
} catch (e) {
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
|
@ -132,8 +160,8 @@ export function useReports() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(state.tab, state.period);
|
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth);
|
||||||
}, [state.tab, state.period, fetchData]);
|
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, fetchData]);
|
||||||
|
|
||||||
const setTab = useCallback((tab: ReportTab) => {
|
const setTab = useCallback((tab: ReportTab) => {
|
||||||
dispatch({ type: "SET_TAB", payload: tab });
|
dispatch({ type: "SET_TAB", payload: tab });
|
||||||
|
|
@ -143,5 +171,18 @@ export function useReports() {
|
||||||
dispatch({ type: "SET_PERIOD", payload: period });
|
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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,14 @@
|
||||||
"byCategory": "Expenses by Category",
|
"byCategory": "Expenses by Category",
|
||||||
"overTime": "Category Over Time",
|
"overTime": "Category Over Time",
|
||||||
"trends": "Monthly Trends",
|
"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",
|
"export": "Export",
|
||||||
"help": {
|
"help": {
|
||||||
"title": "How to use Reports",
|
"title": "How to use Reports",
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,14 @@
|
||||||
"byCategory": "Dépenses par catégorie",
|
"byCategory": "Dépenses par catégorie",
|
||||||
"overTime": "Catégories dans le temps",
|
"overTime": "Catégories dans le temps",
|
||||||
"trends": "Tendances mensuelles",
|
"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",
|
"export": "Exporter",
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Comment utiliser les Rapports",
|
"title": "Comment utiliser les Rapports",
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ import { useReports } from "../hooks/useReports";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import type { ReportTab, CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
|
import type { ReportTab, CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
|
import MonthNavigator from "../components/budget/MonthNavigator";
|
||||||
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||||
|
import BudgetVsActualTable from "../components/reports/BudgetVsActualTable";
|
||||||
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
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 } {
|
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
|
||||||
if (period === "all") return {};
|
if (period === "all") return {};
|
||||||
|
|
@ -31,7 +33,7 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, setTab, setPeriod } = useReports();
|
const { state, setTab, setPeriod, navigateBudgetMonth } = useReports();
|
||||||
|
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||||
|
|
@ -60,10 +62,18 @@ export default function ReportsPage() {
|
||||||
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
|
||||||
<PageHelp helpKey="reports" />
|
<PageHelp helpKey="reports" />
|
||||||
</div>
|
</div>
|
||||||
|
{state.tab === "budgetVsActual" ? (
|
||||||
|
<MonthNavigator
|
||||||
|
year={state.budgetYear}
|
||||||
|
month={state.budgetMonth}
|
||||||
|
onNavigate={navigateBudgetMonth}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<PeriodSelector value={state.period} onChange={setPeriod} />
|
<PeriodSelector value={state.period} onChange={setPeriod} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="flex gap-2 mb-6 flex-wrap">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
|
|
@ -104,6 +114,9 @@ export default function ReportsPage() {
|
||||||
onViewDetails={viewDetails}
|
onViewDetails={viewDetails}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{state.tab === "budgetVsActual" && (
|
||||||
|
<BudgetVsActualTable data={state.budgetVsActual} />
|
||||||
|
)}
|
||||||
|
|
||||||
{detailModal && (
|
{detailModal && (
|
||||||
<TransactionDetailModal
|
<TransactionDetailModal
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
BudgetEntry,
|
BudgetEntry,
|
||||||
BudgetTemplate,
|
BudgetTemplate,
|
||||||
BudgetTemplateEntry,
|
BudgetTemplateEntry,
|
||||||
|
BudgetVsActualRow,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
|
|
||||||
function computeMonthDateRange(year: number, month: number) {
|
function computeMonthDateRange(year: number, month: number) {
|
||||||
|
|
@ -176,3 +177,208 @@ export async function deleteTemplate(templateId: number): Promise<void> {
|
||||||
);
|
);
|
||||||
await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]);
|
await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Budget vs Actual ---
|
||||||
|
|
||||||
|
async function getActualsByCategoryRange(
|
||||||
|
dateFrom: string,
|
||||||
|
dateTo: string
|
||||||
|
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
||||||
|
const db = await getDb();
|
||||||
|
return db.select<Array<{ category_id: number | null; actual: number }>>(
|
||||||
|
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual
|
||||||
|
FROM transactions
|
||||||
|
WHERE date BETWEEN $1 AND $2
|
||||||
|
GROUP BY category_id`,
|
||||||
|
[dateFrom, dateTo]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
||||||
|
|
||||||
|
export async function getBudgetVsActualData(
|
||||||
|
year: number,
|
||||||
|
month: number
|
||||||
|
): Promise<BudgetVsActualRow[]> {
|
||||||
|
// Date ranges
|
||||||
|
const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month);
|
||||||
|
const ytdFrom = `${year}-01-01`;
|
||||||
|
const ytdTo = monthTo;
|
||||||
|
|
||||||
|
// Fetch all data in parallel
|
||||||
|
const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([
|
||||||
|
getAllActiveCategories(),
|
||||||
|
getBudgetEntriesForYear(year),
|
||||||
|
getActualsByCategoryRange(monthFrom, monthTo),
|
||||||
|
getActualsByCategoryRange(ytdFrom, ytdTo),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build maps
|
||||||
|
const entryMap = new Map<number, Map<number, number>>();
|
||||||
|
for (const e of yearEntries) {
|
||||||
|
if (!entryMap.has(e.category_id)) entryMap.set(e.category_id, new Map());
|
||||||
|
entryMap.get(e.category_id)!.set(e.month, e.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthActualMap = new Map<number, number>();
|
||||||
|
for (const a of monthActuals) {
|
||||||
|
if (a.category_id != null) monthActualMap.set(a.category_id, a.actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ytdActualMap = new Map<number, number>();
|
||||||
|
for (const a of ytdActuals) {
|
||||||
|
if (a.category_id != null) ytdActualMap.set(a.category_id, a.actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index categories
|
||||||
|
const catById = new Map(allCategories.map((c) => [c.id, c]));
|
||||||
|
const childrenByParent = new Map<number, Category[]>();
|
||||||
|
for (const cat of allCategories) {
|
||||||
|
if (cat.parent_id) {
|
||||||
|
if (!childrenByParent.has(cat.parent_id)) childrenByParent.set(cat.parent_id, []);
|
||||||
|
childrenByParent.get(cat.parent_id)!.push(cat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign multiplier: budget stored positive, expenses displayed negative
|
||||||
|
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
||||||
|
|
||||||
|
// Compute leaf row values
|
||||||
|
function buildLeaf(cat: Category, parentId: number | null): BudgetVsActualRow {
|
||||||
|
const sign = signFor(cat.type);
|
||||||
|
const monthMap = entryMap.get(cat.id);
|
||||||
|
const rawMonthBudget = monthMap?.get(month) ?? 0;
|
||||||
|
const monthBudget = rawMonthBudget * sign;
|
||||||
|
|
||||||
|
let rawYtdBudget = 0;
|
||||||
|
for (let m = 1; m <= month; m++) {
|
||||||
|
rawYtdBudget += monthMap?.get(m) ?? 0;
|
||||||
|
}
|
||||||
|
const ytdBudget = rawYtdBudget * sign;
|
||||||
|
|
||||||
|
const monthActual = monthActualMap.get(cat.id) ?? 0;
|
||||||
|
const ytdActual = ytdActualMap.get(cat.id) ?? 0;
|
||||||
|
|
||||||
|
const monthVariation = monthActual - monthBudget;
|
||||||
|
const ytdVariation = ytdActual - ytdBudget;
|
||||||
|
|
||||||
|
return {
|
||||||
|
category_id: cat.id,
|
||||||
|
category_name: cat.name,
|
||||||
|
category_color: cat.color || "#9ca3af",
|
||||||
|
category_type: cat.type,
|
||||||
|
parent_id: parentId,
|
||||||
|
is_parent: false,
|
||||||
|
monthActual,
|
||||||
|
monthBudget,
|
||||||
|
monthVariation,
|
||||||
|
monthVariationPct: monthBudget !== 0 ? monthVariation / Math.abs(monthBudget) : null,
|
||||||
|
ytdActual,
|
||||||
|
ytdBudget,
|
||||||
|
ytdVariation,
|
||||||
|
ytdVariationPct: ytdBudget !== 0 ? ytdVariation / Math.abs(ytdBudget) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRowAllZero(r: BudgetVsActualRow): boolean {
|
||||||
|
return (
|
||||||
|
r.monthActual === 0 &&
|
||||||
|
r.monthBudget === 0 &&
|
||||||
|
r.ytdActual === 0 &&
|
||||||
|
r.ytdBudget === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: BudgetVsActualRow[] = [];
|
||||||
|
const topLevel = allCategories.filter((c) => !c.parent_id);
|
||||||
|
|
||||||
|
for (const cat of topLevel) {
|
||||||
|
const children = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
|
||||||
|
|
||||||
|
if (children.length === 0 && cat.is_inputable) {
|
||||||
|
// Standalone leaf
|
||||||
|
const leaf = buildLeaf(cat, null);
|
||||||
|
if (!isRowAllZero(leaf)) rows.push(leaf);
|
||||||
|
} else if (children.length > 0) {
|
||||||
|
const childRows: BudgetVsActualRow[] = [];
|
||||||
|
|
||||||
|
// If parent is also inputable, create a "(direct)" child row
|
||||||
|
if (cat.is_inputable) {
|
||||||
|
const direct = buildLeaf(cat, cat.id);
|
||||||
|
direct.category_name = `${cat.name} (direct)`;
|
||||||
|
if (!isRowAllZero(direct)) childRows.push(direct);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
const leaf = buildLeaf(child, cat.id);
|
||||||
|
if (!isRowAllZero(leaf)) childRows.push(leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip parent entirely if all children were filtered out
|
||||||
|
if (childRows.length === 0) continue;
|
||||||
|
|
||||||
|
// Build parent subtotal from kept children
|
||||||
|
const parent: BudgetVsActualRow = {
|
||||||
|
category_id: cat.id,
|
||||||
|
category_name: cat.name,
|
||||||
|
category_color: cat.color || "#9ca3af",
|
||||||
|
category_type: cat.type,
|
||||||
|
parent_id: null,
|
||||||
|
is_parent: true,
|
||||||
|
monthActual: 0,
|
||||||
|
monthBudget: 0,
|
||||||
|
monthVariation: 0,
|
||||||
|
monthVariationPct: null,
|
||||||
|
ytdActual: 0,
|
||||||
|
ytdBudget: 0,
|
||||||
|
ytdVariation: 0,
|
||||||
|
ytdVariationPct: null,
|
||||||
|
};
|
||||||
|
for (const cr of childRows) {
|
||||||
|
parent.monthActual += cr.monthActual;
|
||||||
|
parent.monthBudget += cr.monthBudget;
|
||||||
|
parent.monthVariation += cr.monthVariation;
|
||||||
|
parent.ytdActual += cr.ytdActual;
|
||||||
|
parent.ytdBudget += cr.ytdBudget;
|
||||||
|
parent.ytdVariation += cr.ytdVariation;
|
||||||
|
}
|
||||||
|
parent.monthVariationPct =
|
||||||
|
parent.monthBudget !== 0 ? parent.monthVariation / Math.abs(parent.monthBudget) : null;
|
||||||
|
parent.ytdVariationPct =
|
||||||
|
parent.ytdBudget !== 0 ? parent.ytdVariation / Math.abs(parent.ytdBudget) : null;
|
||||||
|
|
||||||
|
rows.push(parent);
|
||||||
|
|
||||||
|
// Sort children: "(direct)" first, then alphabetical
|
||||||
|
childRows.sort((a, b) => {
|
||||||
|
if (a.category_id === cat.id) return -1;
|
||||||
|
if (b.category_id === cat.id) return 1;
|
||||||
|
return a.category_name.localeCompare(b.category_name);
|
||||||
|
});
|
||||||
|
rows.push(...childRows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by type, then within same type keep parent+children groups together
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
||||||
|
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
||||||
|
if (typeA !== typeB) return typeA - typeB;
|
||||||
|
const groupA = a.is_parent ? a.category_id : (a.parent_id ?? a.category_id);
|
||||||
|
const groupB = b.is_parent ? b.category_id : (b.parent_id ?? b.category_id);
|
||||||
|
if (groupA !== groupB) {
|
||||||
|
const catA = catById.get(groupA);
|
||||||
|
const catB = catById.get(groupB);
|
||||||
|
const orderA = catA?.sort_order ?? 999;
|
||||||
|
const orderB = catB?.sort_order ?? 999;
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
return (catA?.name ?? "").localeCompare(catB?.name ?? "");
|
||||||
|
}
|
||||||
|
if (a.is_parent !== b.is_parent) return a.is_parent ? -1 : 1;
|
||||||
|
if (a.parent_id && a.category_id === a.parent_id) return -1;
|
||||||
|
if (b.parent_id && b.category_id === b.parent_id) return 1;
|
||||||
|
return a.category_name.localeCompare(b.category_name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,7 @@ export interface RecentTransaction {
|
||||||
|
|
||||||
// --- Report Types ---
|
// --- Report Types ---
|
||||||
|
|
||||||
export type ReportTab = "trends" | "byCategory" | "overTime";
|
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
|
||||||
|
|
||||||
export interface MonthlyTrendItem {
|
export interface MonthlyTrendItem {
|
||||||
month: string; // "2025-01"
|
month: string; // "2025-01"
|
||||||
|
|
@ -294,6 +294,23 @@ export interface CategoryOverTimeData {
|
||||||
categoryIds: Record<string, number | null>;
|
categoryIds: Record<string, number | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BudgetVsActualRow {
|
||||||
|
category_id: number;
|
||||||
|
category_name: string;
|
||||||
|
category_color: string;
|
||||||
|
category_type: "expense" | "income" | "transfer";
|
||||||
|
parent_id: number | null;
|
||||||
|
is_parent: boolean;
|
||||||
|
monthActual: number;
|
||||||
|
monthBudget: number;
|
||||||
|
monthVariation: number;
|
||||||
|
monthVariationPct: number | null;
|
||||||
|
ytdActual: number;
|
||||||
|
ytdBudget: number;
|
||||||
|
ytdVariation: number;
|
||||||
|
ytdVariationPct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type ImportWizardStep =
|
export type ImportWizardStep =
|
||||||
| "source-list"
|
| "source-list"
|
||||||
| "source-config"
|
| "source-config"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue