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",
|
||||
"private": true,
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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,
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<Set<string>>(new Set());
|
||||
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>
|
||||
<PageHelp helpKey="reports" />
|
||||
</div>
|
||||
<PeriodSelector value={state.period} onChange={setPeriod} />
|
||||
{state.tab === "budgetVsActual" ? (
|
||||
<MonthNavigator
|
||||
year={state.budgetYear}
|
||||
month={state.budgetMonth}
|
||||
onNavigate={navigateBudgetMonth}
|
||||
/>
|
||||
) : (
|
||||
<PeriodSelector value={state.period} onChange={setPeriod} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
<div className="flex gap-2 mb-6 flex-wrap">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
|
|
@ -104,6 +114,9 @@ export default function ReportsPage() {
|
|||
onViewDetails={viewDetails}
|
||||
/>
|
||||
)}
|
||||
{state.tab === "budgetVsActual" && (
|
||||
<BudgetVsActualTable data={state.budgetVsActual} />
|
||||
)}
|
||||
|
||||
{detailModal && (
|
||||
<TransactionDetailModal
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
BudgetEntry,
|
||||
BudgetTemplate,
|
||||
BudgetTemplateEntry,
|
||||
BudgetVsActualRow,
|
||||
} from "../shared/types";
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
// --- 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 ---
|
||||
|
||||
export type ReportTab = "trends" | "byCategory" | "overTime";
|
||||
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
|
||||
|
||||
export interface MonthlyTrendItem {
|
||||
month: string; // "2025-01"
|
||||
|
|
@ -294,6 +294,23 @@ export interface CategoryOverTimeData {
|
|||
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 =
|
||||
| "source-list"
|
||||
| "source-config"
|
||||
|
|
|
|||
Loading…
Reference in a new issue