Adds a segmented Monthly/YTD toggle next to the reference-month picker that flips the four KPI cards (income, expenses, net, savings rate) between the reference-month value (unchanged default) and a Year-to-Date cumulative view. In YTD mode, the "current" value sums January to the reference month of the reference year; MoM delta compares it to Jan to (refMonth - 1) of the same year (null in January, since no prior YTD window exists); YoY delta compares it to Jan to refMonth of the previous year; savings rate is recomputed from YTD income and expenses, and stays null when YTD income is zero. The 13-month sparkline, top movers, seasonality and budget adherence cards remain monthly regardless of the toggle (by design). The savings-rate tooltip is now dynamic and mirrors the active mode. The mode is persisted in localStorage under `reports-cartes-period-mode`. Also adds a dedicated Cartes section to `docs/guide-utilisateur.md` covering the four KPI formulas, the Monthly/YTD toggle and its effect on deltas, the sparkline, top movers, seasonality, budget adherence and the savings-rate edge case. Mirrored in the in-app `docs.reports` i18n tree (features/steps/ tips extended) for both FR and EN. No SQL migration: YTD sums are computed from the already-fetched `flowByMonth` map, so no extra round trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
3.5 KiB
TypeScript
110 lines
3.5 KiB
TypeScript
import { useReducer, useCallback, useEffect, useRef } from "react";
|
|
import type { CartesSnapshot, CartesKpiPeriodMode } from "../shared/types";
|
|
import { getCartesSnapshot } from "../services/reportService";
|
|
import { defaultReferencePeriod } from "../utils/referencePeriod";
|
|
|
|
export const CARTES_PERIOD_MODE_STORAGE_KEY = "reports-cartes-period-mode";
|
|
|
|
interface State {
|
|
year: number;
|
|
month: number;
|
|
mode: CartesKpiPeriodMode;
|
|
snapshot: CartesSnapshot | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
type Action =
|
|
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
|
|
| { type: "SET_MODE"; payload: CartesKpiPeriodMode }
|
|
| { type: "SET_LOADING"; payload: boolean }
|
|
| { type: "SET_SNAPSHOT"; payload: CartesSnapshot }
|
|
| { type: "SET_ERROR"; payload: string };
|
|
|
|
/**
|
|
* Re-exported so older imports keep working. New code should import
|
|
* `defaultReferencePeriod` from `../utils/referencePeriod`.
|
|
*/
|
|
export const defaultCartesReferencePeriod = defaultReferencePeriod;
|
|
|
|
/**
|
|
* Read the persisted period mode from localStorage. Unrecognised values fall
|
|
* back to "month" so a corrupted/legacy key never breaks the page.
|
|
*/
|
|
export function readCartesPeriodMode(
|
|
storageKey: string = CARTES_PERIOD_MODE_STORAGE_KEY,
|
|
fallback: CartesKpiPeriodMode = "month",
|
|
): CartesKpiPeriodMode {
|
|
if (typeof localStorage === "undefined") return fallback;
|
|
const saved = localStorage.getItem(storageKey);
|
|
return saved === "month" || saved === "ytd" ? saved : fallback;
|
|
}
|
|
|
|
const defaultRef = defaultReferencePeriod();
|
|
const initialState: State = {
|
|
year: defaultRef.year,
|
|
month: defaultRef.month,
|
|
mode: "month",
|
|
snapshot: null,
|
|
isLoading: false,
|
|
error: null,
|
|
};
|
|
|
|
function reducer(state: State, action: Action): State {
|
|
switch (action.type) {
|
|
case "SET_REFERENCE_PERIOD":
|
|
return { ...state, year: action.payload.year, month: action.payload.month };
|
|
case "SET_MODE":
|
|
return { ...state, mode: action.payload };
|
|
case "SET_LOADING":
|
|
return { ...state, isLoading: action.payload };
|
|
case "SET_SNAPSHOT":
|
|
return { ...state, snapshot: action.payload, isLoading: false, error: null };
|
|
case "SET_ERROR":
|
|
return { ...state, error: action.payload, isLoading: false };
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
export function useCartes() {
|
|
const [state, dispatch] = useReducer(reducer, initialState, (init) => ({
|
|
...init,
|
|
mode: readCartesPeriodMode(),
|
|
}));
|
|
const fetchIdRef = useRef(0);
|
|
|
|
const fetch = useCallback(async (year: number, month: number, mode: CartesKpiPeriodMode) => {
|
|
const id = ++fetchIdRef.current;
|
|
dispatch({ type: "SET_LOADING", payload: true });
|
|
try {
|
|
const snapshot = await getCartesSnapshot(year, month, mode);
|
|
if (id !== fetchIdRef.current) return;
|
|
dispatch({ type: "SET_SNAPSHOT", payload: snapshot });
|
|
} catch (e) {
|
|
if (id !== fetchIdRef.current) return;
|
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetch(state.year, state.month, state.mode);
|
|
}, [fetch, state.year, state.month, state.mode]);
|
|
|
|
const setReferencePeriod = useCallback((year: number, month: number) => {
|
|
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
|
|
}, []);
|
|
|
|
const setMode = useCallback((mode: CartesKpiPeriodMode) => {
|
|
if (typeof localStorage !== "undefined") {
|
|
localStorage.setItem(CARTES_PERIOD_MODE_STORAGE_KEY, mode);
|
|
}
|
|
dispatch({ type: "SET_MODE", payload: mode });
|
|
}, []);
|
|
|
|
return {
|
|
...state,
|
|
setReferencePeriod,
|
|
setMode,
|
|
};
|
|
}
|