- Add sticky left-0 positioning to all category cells in BudgetVsActualTable - Replace MonthNavigator arrows with inline title + dropdown month selector - Default budget month to previous completed month instead of current - Add i18n keys for new title prefix (FR/EN) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
257 lines
9.3 KiB
TypeScript
257 lines
9.3 KiB
TypeScript
import { useReducer, useCallback, useEffect, useRef } from "react";
|
|
import type {
|
|
ReportTab,
|
|
DashboardPeriod,
|
|
MonthlyTrendItem,
|
|
CategoryBreakdownItem,
|
|
CategoryOverTimeData,
|
|
BudgetVsActualRow,
|
|
PivotConfig,
|
|
PivotResult,
|
|
} from "../shared/types";
|
|
import { getMonthlyTrends, getCategoryOverTime, getDynamicReportData } from "../services/reportService";
|
|
import { getExpensesByCategory } from "../services/dashboardService";
|
|
import { getBudgetVsActualData } from "../services/budgetService";
|
|
|
|
interface ReportsState {
|
|
tab: ReportTab;
|
|
period: DashboardPeriod;
|
|
customDateFrom: string;
|
|
customDateTo: string;
|
|
sourceId: number | null;
|
|
monthlyTrends: MonthlyTrendItem[];
|
|
categorySpending: CategoryBreakdownItem[];
|
|
categoryOverTime: CategoryOverTimeData;
|
|
budgetYear: number;
|
|
budgetMonth: number;
|
|
budgetVsActual: BudgetVsActualRow[];
|
|
pivotConfig: PivotConfig;
|
|
pivotResult: PivotResult;
|
|
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[] }
|
|
| { type: "SET_PIVOT_CONFIG"; payload: PivotConfig }
|
|
| { type: "SET_PIVOT_RESULT"; payload: PivotResult }
|
|
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
|
|
| { type: "SET_SOURCE_ID"; payload: number | null };
|
|
|
|
const now = new Date();
|
|
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
const monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
|
|
|
const initialState: ReportsState = {
|
|
tab: "trends",
|
|
period: "6months",
|
|
customDateFrom: monthStartStr,
|
|
customDateTo: todayStr,
|
|
sourceId: null,
|
|
monthlyTrends: [],
|
|
categorySpending: [],
|
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
|
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
|
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
|
budgetVsActual: [],
|
|
pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
|
|
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
|
|
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 };
|
|
case "SET_PIVOT_CONFIG":
|
|
return { ...state, pivotConfig: action.payload };
|
|
case "SET_PIVOT_RESULT":
|
|
return { ...state, pivotResult: action.payload, isLoading: false };
|
|
case "SET_CUSTOM_DATES":
|
|
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
|
case "SET_SOURCE_ID":
|
|
return { ...state, sourceId: action.payload };
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
function computeDateRange(
|
|
period: DashboardPeriod,
|
|
customDateFrom?: string,
|
|
customDateTo?: string,
|
|
): { dateFrom?: string; dateTo?: string } {
|
|
if (period === "all") return {};
|
|
if (period === "custom" && customDateFrom && customDateTo) {
|
|
return { dateFrom: customDateFrom, dateTo: customDateTo };
|
|
}
|
|
|
|
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 "year":
|
|
from = new Date(year, 0, 1);
|
|
break;
|
|
case "12months":
|
|
from = new Date(year, month - 11, 1);
|
|
break;
|
|
default:
|
|
from = new Date(year, month, 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,
|
|
customFrom?: string,
|
|
customTo?: string,
|
|
pivotCfg?: PivotConfig,
|
|
srcId?: number | null,
|
|
) => {
|
|
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, customFrom, customTo);
|
|
const data = await getMonthlyTrends(dateFrom, dateTo, srcId ?? undefined);
|
|
if (fetchId !== fetchIdRef.current) return;
|
|
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
|
|
break;
|
|
}
|
|
case "byCategory": {
|
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
|
const data = await getExpensesByCategory(dateFrom, dateTo, srcId ?? undefined);
|
|
if (fetchId !== fetchIdRef.current) return;
|
|
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
|
|
break;
|
|
}
|
|
case "overTime": {
|
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
|
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined);
|
|
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;
|
|
}
|
|
case "dynamic": {
|
|
if (!pivotCfg || (pivotCfg.rows.length === 0 && pivotCfg.columns.length === 0) || pivotCfg.values.length === 0) {
|
|
dispatch({ type: "SET_PIVOT_RESULT", payload: { rows: [], columnValues: [], dimensionLabels: {} } });
|
|
break;
|
|
}
|
|
const data = await getDynamicReportData(pivotCfg);
|
|
if (fetchId !== fetchIdRef.current) return;
|
|
dispatch({ type: "SET_PIVOT_RESULT", 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.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId);
|
|
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.pivotConfig, state.sourceId, 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]);
|
|
|
|
const setBudgetMonth = useCallback((year: number, month: number) => {
|
|
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
|
}, []);
|
|
|
|
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
|
|
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
|
}, []);
|
|
|
|
const setPivotConfig = useCallback((config: PivotConfig) => {
|
|
dispatch({ type: "SET_PIVOT_CONFIG", payload: config });
|
|
}, []);
|
|
|
|
const setSourceId = useCallback((id: number | null) => {
|
|
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
|
}, []);
|
|
|
|
return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setBudgetMonth, setPivotConfig, setSourceId };
|
|
}
|