Simpl-Resultat/src/hooks/useBudget.ts
medic-bot 4e70eee0a8 feat: show actual transactions in budget previous year column
Replace planned budget data with actual transaction totals for the
previous year column in the budget table. Add getActualTotalsForYear
helper to budgetService.

Ref #34

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:03:26 -04:00

504 lines
17 KiB
TypeScript

import { useReducer, useCallback, useEffect, useRef } from "react";
import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
import {
getAllActiveCategories,
getBudgetEntriesForYear,
getActualTotalsForYear,
upsertBudgetEntry,
upsertBudgetEntriesForYear,
getAllTemplates,
saveAsTemplate as saveAsTemplateSvc,
applyTemplate as applyTemplateSvc,
deleteTemplate as deleteTemplateSvc,
} from "../services/budgetService";
interface BudgetState {
year: number;
rows: BudgetYearRow[];
templates: BudgetTemplate[];
isLoading: boolean;
isSaving: boolean;
error: string | null;
}
type BudgetAction =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
| { type: "SET_YEAR"; payload: number };
function initialState(): BudgetState {
return {
year: new Date().getFullYear(),
rows: [],
templates: [],
isLoading: false,
isSaving: false,
error: null,
};
}
function reducer(state: BudgetState, action: BudgetAction): BudgetState {
switch (action.type) {
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_SAVING":
return { ...state, isSaving: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false, isSaving: false };
case "SET_DATA":
return {
...state,
rows: action.payload.rows,
templates: action.payload.templates,
isLoading: false,
};
case "SET_YEAR":
return { ...state, year: action.payload };
default:
return state;
}
}
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
export function useBudget() {
const [state, dispatch] = useReducer(reducer, undefined, initialState);
const fetchIdRef = useRef(0);
const refreshData = useCallback(async (year: number) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const [allCategories, entries, prevYearActuals, templates] = await Promise.all([
getAllActiveCategories(),
getBudgetEntriesForYear(year),
getActualTotalsForYear(year - 1),
getAllTemplates(),
]);
if (fetchId !== fetchIdRef.current) return;
// Build a map: categoryId -> month(1-12) -> amount
const entryMap = new Map<number, Map<number, number>>();
for (const e of entries) {
if (!entryMap.has(e.category_id)) entryMap.set(e.category_id, new Map());
entryMap.get(e.category_id)!.set(e.month, e.amount);
}
// Build a map for previous year actuals: categoryId -> annual actual total
const prevYearTotalMap = new Map<number, number>();
for (const a of prevYearActuals) {
if (a.category_id != null) prevYearTotalMap.set(a.category_id, a.actual);
}
// Helper: build months array from entryMap
const buildMonths = (catId: number) => {
const monthMap = entryMap.get(catId);
const months: number[] = [];
let annual = 0;
for (let m = 1; m <= 12; m++) {
const val = monthMap?.get(m) ?? 0;
months.push(val);
annual += val;
}
const previousYearTotal = prevYearTotalMap.get(catId) ?? 0;
return { months, annual, previousYearTotal };
};
// Index categories by id and group children by parent_id
const catById = new Map(allCategories.map((c) => [c.id, c]));
const childrenByParent = new Map<number, typeof allCategories>();
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);
}
}
const rows: BudgetYearRow[] = [];
// Build rows for an intermediate parent (level 1 or 2 with children)
function buildLevel2Group(cat: typeof allCategories[0], grandparentId: number): BudgetYearRow[] {
const grandchildren = (childrenByParent.get(cat.id) || []).filter((c) => c.is_inputable);
if (grandchildren.length === 0 && cat.is_inputable) {
// Leaf at depth 2
const { months, annual, previousYearTotal } = buildMonths(cat.id);
return [{
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: grandparentId,
is_parent: false,
depth: 2,
months,
annual,
previousYearTotal,
}];
}
if (grandchildren.length === 0 && !cat.is_inputable) {
// Also check if it has non-inputable intermediate children with their own children
// This shouldn't happen at depth 3 (max 3 levels), but handle gracefully
return [];
}
const gcRows: BudgetYearRow[] = [];
if (cat.is_inputable) {
const { months, annual, previousYearTotal } = buildMonths(cat.id);
gcRows.push({
category_id: cat.id,
category_name: `${cat.name} (direct)`,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: cat.id,
is_parent: false,
depth: 2,
months,
annual,
previousYearTotal,
});
}
for (const gc of grandchildren) {
const { months, annual, previousYearTotal } = buildMonths(gc.id);
gcRows.push({
category_id: gc.id,
category_name: gc.name,
category_color: gc.color || cat.color || "#9ca3af",
category_type: gc.type,
parent_id: cat.id,
is_parent: false,
depth: 2,
months,
annual,
previousYearTotal,
});
}
if (gcRows.length === 0) return [];
// Build intermediate subtotal
const subMonths = Array(12).fill(0) as number[];
let subAnnual = 0;
let subPrevYear = 0;
for (const cr of gcRows) {
for (let m = 0; m < 12; m++) subMonths[m] += cr.months[m];
subAnnual += cr.annual;
subPrevYear += cr.previousYearTotal;
}
const subtotal: BudgetYearRow = {
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: grandparentId,
is_parent: true,
depth: 1,
months: subMonths,
annual: subAnnual,
previousYearTotal: subPrevYear,
};
gcRows.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);
});
return [subtotal, ...gcRows];
}
// Identify top-level parents and standalone leaves
const topLevel = allCategories.filter((c) => !c.parent_id);
for (const cat of topLevel) {
const children = childrenByParent.get(cat.id) || [];
const inputableChildren = children.filter((c) => c.is_inputable);
const intermediateParents = children.filter((c) => !c.is_inputable && (childrenByParent.get(c.id) || []).length > 0);
if (inputableChildren.length === 0 && intermediateParents.length === 0 && cat.is_inputable) {
// Standalone leaf (no children) — regular editable row
const { months, annual, previousYearTotal } = buildMonths(cat.id);
rows.push({
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: null,
is_parent: false,
depth: 0,
months,
annual,
previousYearTotal,
});
} else if (inputableChildren.length > 0 || intermediateParents.length > 0) {
const allChildRows: BudgetYearRow[] = [];
// If parent is also inputable, create a "(direct)" fake-child row
if (cat.is_inputable) {
const { months, annual, previousYearTotal } = buildMonths(cat.id);
allChildRows.push({
category_id: cat.id,
category_name: `${cat.name} (direct)`,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: cat.id,
is_parent: false,
depth: 1,
months,
annual,
previousYearTotal,
});
}
for (const child of inputableChildren) {
const grandchildren = childrenByParent.get(child.id) || [];
if (grandchildren.length === 0) {
// Simple leaf at depth 1
const { months, annual, previousYearTotal } = buildMonths(child.id);
allChildRows.push({
category_id: child.id,
category_name: child.name,
category_color: child.color || cat.color || "#9ca3af",
category_type: child.type,
parent_id: cat.id,
is_parent: false,
depth: 1,
months,
annual,
previousYearTotal,
});
} else {
// Intermediate parent at depth 1 with grandchildren
allChildRows.push(...buildLevel2Group(child, cat.id));
}
}
// Non-inputable intermediate parents
for (const ip of intermediateParents) {
allChildRows.push(...buildLevel2Group(ip, cat.id));
}
if (allChildRows.length === 0) continue;
// Parent subtotal row: sum of leaf rows only (avoid double-counting)
const leafRows = allChildRows.filter((r) => !r.is_parent);
const parentMonths = Array(12).fill(0) as number[];
let parentAnnual = 0;
let parentPrevYear = 0;
for (const cr of leafRows) {
for (let m = 0; m < 12; m++) parentMonths[m] += cr.months[m];
parentAnnual += cr.annual;
parentPrevYear += cr.previousYearTotal;
}
rows.push({
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
parent_id: null,
is_parent: true,
depth: 0,
months: parentMonths,
annual: parentAnnual,
previousYearTotal: parentPrevYear,
});
// Sort children alphabetically, but keep "(direct)" first
allChildRows.sort((a, b) => {
if (a.category_id === cat.id && !a.is_parent) return -1;
if (b.category_id === cat.id && !b.is_parent) return 1;
return a.category_name.localeCompare(b.category_name);
});
rows.push(...allChildRows);
}
// else: non-inputable parent with no inputable children — skip
}
// Sort by type, then within each type: keep hierarchy groups together
function getTopGroupId(r: BudgetYearRow): number {
if ((r.depth ?? 0) === 0) return r.category_id;
if (r.is_parent && r.parent_id === null) return r.category_id;
let pid = r.parent_id;
while (pid !== null) {
const pCat = catById.get(pid);
if (!pCat || !pCat.parent_id) return pid;
pid = pCat.parent_id;
}
return r.category_id;
}
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 = getTopGroupId(a);
const groupB = getTopGroupId(b);
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 ?? "");
}
// Same group: sort by depth, then parent before children at same depth
if (a.is_parent !== b.is_parent && (a.depth ?? 0) === (b.depth ?? 0)) return a.is_parent ? -1 : 1;
if ((a.depth ?? 0) !== (b.depth ?? 0)) return (a.depth ?? 0) - (b.depth ?? 0);
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);
});
dispatch({ type: "SET_DATA", payload: { rows, templates } });
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, []);
useEffect(() => {
refreshData(state.year);
}, [state.year, refreshData]);
const navigateYear = useCallback((delta: -1 | 1) => {
dispatch({ type: "SET_YEAR", payload: state.year + delta });
}, [state.year]);
const updatePlanned = useCallback(
async (categoryId: number, month: number, amount: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await upsertBudgetEntry(categoryId, state.year, month, amount);
await refreshData(state.year);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, refreshData]
);
const splitEvenly = useCallback(
async (categoryId: number, annualAmount: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
const base = Math.floor((annualAmount / 12) * 100) / 100;
const remainder = Math.round((annualAmount - base * 12) * 100);
const amounts: number[] = [];
for (let m = 0; m < 12; m++) {
amounts.push(m < remainder ? base + 0.01 : base);
}
await upsertBudgetEntriesForYear(categoryId, state.year, amounts);
await refreshData(state.year);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, refreshData]
);
const saveTemplate = useCallback(
async (name: string, description?: string) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
// Save template from January values (template is a single-month snapshot)
// Exclude parent subtotal rows (they're computed, not real entries)
const entries = state.rows
.filter((r) => !r.is_parent && r.months[0] !== 0)
.map((r) => ({ category_id: r.category_id, amount: r.months[0] }));
await saveAsTemplateSvc(name, description, entries);
await refreshData(state.year);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.rows, state.year, refreshData]
);
const applyTemplate = useCallback(
async (templateId: number, month: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await applyTemplateSvc(templateId, state.year, month);
await refreshData(state.year);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, refreshData]
);
const applyTemplateAllMonths = useCallback(
async (templateId: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
for (let m = 1; m <= 12; m++) {
await applyTemplateSvc(templateId, state.year, m);
}
await refreshData(state.year);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, refreshData]
);
const deleteTemplate = useCallback(
async (templateId: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await deleteTemplateSvc(templateId);
await refreshData(state.year);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, refreshData]
);
return {
state,
navigateYear,
updatePlanned,
splitEvenly,
saveTemplate,
applyTemplate,
applyTemplateAllMonths,
deleteTemplate,
};
}