Simpl-Resultat/src/hooks/useBudget.ts
Le-King-Fu 5f5696c29a
Some checks failed
Release / build (windows-latest) (push) Has been cancelled
feat: add Budget and Adjustments pages with full functionality
Budget: monthly data grid with inline-editable planned amounts per
category, actuals from transactions, difference coloring, month
navigation, and save/apply/delete budget templates.

Adjustments: split-panel CRUD for manual adjustment entries.

Both features include FR/EN translations and follow existing
service/hook/component patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 00:58:43 +00:00

251 lines
7.3 KiB
TypeScript

import { useReducer, useCallback, useEffect, useRef } from "react";
import type { BudgetRow, BudgetTemplate } from "../shared/types";
import {
getActiveCategories,
getBudgetEntriesForMonth,
getActualsByCategory,
upsertBudgetEntry,
deleteBudgetEntry,
getAllTemplates,
saveAsTemplate as saveAsTemplateSvc,
applyTemplate as applyTemplateSvc,
deleteTemplate as deleteTemplateSvc,
} from "../services/budgetService";
interface BudgetState {
year: number;
month: number;
rows: BudgetRow[];
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: BudgetRow[]; templates: BudgetTemplate[] } }
| { type: "NAVIGATE_MONTH"; payload: { year: number; month: number } };
function initialState(): BudgetState {
const now = new Date();
return {
year: now.getFullYear(),
month: now.getMonth() + 1,
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 "NAVIGATE_MONTH":
return { ...state, year: action.payload.year, month: action.payload.month };
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, month: number) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const [categories, entries, actuals, templates] = await Promise.all([
getActiveCategories(),
getBudgetEntriesForMonth(year, month),
getActualsByCategory(year, month),
getAllTemplates(),
]);
if (fetchId !== fetchIdRef.current) return;
const entryMap = new Map(entries.map((e) => [e.category_id, e]));
const actualMap = new Map(actuals.map((a) => [a.category_id, a.actual]));
const rows: BudgetRow[] = categories.map((cat) => {
const entry = entryMap.get(cat.id);
const planned = entry?.amount ?? 0;
const actual = actualMap.get(cat.id) ?? 0;
let difference: number;
if (cat.type === "income") {
difference = actual - planned;
} else {
difference = planned - Math.abs(actual);
}
return {
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
planned,
actual,
difference,
notes: entry?.notes,
};
});
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;
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.month);
}, [state.year, state.month, refreshData]);
const navigateMonth = useCallback((delta: -1 | 1) => {
let newMonth = state.month + delta;
let newYear = state.year;
if (newMonth < 1) {
newMonth = 12;
newYear--;
} else if (newMonth > 12) {
newMonth = 1;
newYear++;
}
dispatch({ type: "NAVIGATE_MONTH", payload: { year: newYear, month: newMonth } });
}, [state.year, state.month]);
const updatePlanned = useCallback(
async (categoryId: number, amount: number, notes?: string) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await upsertBudgetEntry(categoryId, state.year, state.month, amount, notes);
await refreshData(state.year, state.month);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, state.month, refreshData]
);
const removePlanned = useCallback(
async (categoryId: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await deleteBudgetEntry(categoryId, state.year, state.month);
await refreshData(state.year, state.month);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, state.month, refreshData]
);
const saveTemplate = useCallback(
async (name: string, description?: string) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
const entries = state.rows
.filter((r) => r.planned !== 0)
.map((r) => ({ category_id: r.category_id, amount: r.planned }));
await saveAsTemplateSvc(name, description, entries);
await refreshData(state.year, state.month);
} 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, state.month, refreshData]
);
const applyTemplate = useCallback(
async (templateId: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await applyTemplateSvc(templateId, state.year, state.month);
await refreshData(state.year, state.month);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, state.month, refreshData]
);
const deleteTemplate = useCallback(
async (templateId: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await deleteTemplateSvc(templateId);
await refreshData(state.year, state.month);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, state.month, refreshData]
);
return {
state,
navigateMonth,
updatePlanned,
removePlanned,
saveTemplate,
applyTemplate,
deleteTemplate,
};
}