Simpl-Resultat/src/hooks/useReports.ts
Le-King-Fu 5e7c7e6609
Some checks failed
Release / build (windows-latest) (push) Has been cancelled
feat: add Budget vs Actual report tab with monthly and YTD comparison
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>
2026-02-15 18:01:10 +00:00

188 lines
6.2 KiB
TypeScript

import { useReducer, useCallback, useEffect, useRef } from "react";
import type {
ReportTab,
DashboardPeriod,
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;
period: DashboardPeriod;
monthlyTrends: MonthlyTrendItem[];
categorySpending: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData;
budgetYear: number;
budgetMonth: number;
budgetVsActual: BudgetVsActualRow[];
isLoading: boolean;
error: string | null;
}
type ReportsAction =
| { type: "SET_TAB"; payload: ReportTab }
| { type: "SET_PERIOD"; payload: DashboardPeriod }
| { type: "SET_LOADING"; payload: boolean }
| { 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_BUDGET_MONTH"; payload: { year: number; month: number } }
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] };
const now = new Date();
const initialState: ReportsState = {
tab: "trends",
period: "6months",
monthlyTrends: [],
categorySpending: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
budgetYear: now.getFullYear(),
budgetMonth: now.getMonth() + 1,
budgetVsActual: [],
isLoading: false,
error: null,
};
function reducer(state: ReportsState, action: ReportsAction): ReportsState {
switch (action.type) {
case "SET_TAB":
return { ...state, tab: action.payload };
case "SET_PERIOD":
return { ...state, period: action.payload };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
case "SET_MONTHLY_TRENDS":
return { ...state, monthlyTrends: action.payload, isLoading: false };
case "SET_CATEGORY_SPENDING":
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;
}
}
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month":
from = new Date(year, month, 1);
break;
case "3months":
from = new Date(year, month - 2, 1);
break;
case "6months":
from = new Date(year, month - 5, 1);
break;
case "12months":
from = new Date(year, month - 11, 1);
break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
export function useReports() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
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 {
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;
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, []);
useEffect(() => {
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 });
}, []);
const setPeriod = useCallback((period: DashboardPeriod) => {
dispatch({ type: "SET_PERIOD", payload: period });
}, []);
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 };
}