feat(reports): adopt shared FilterPanel on the Trends page #283

Closed
maximus wants to merge 1 commit from issue-275-adopt-trends into issue-274-filter-panel
5 changed files with 97 additions and 66 deletions

View file

@ -9,6 +9,7 @@
- Rapports : pose les fondations d'un futur filtre de comptes pour les rapports — le hook de période partagé suit désormais aussi une sélection multi-comptes via l'URL (mémorisable comme la plage de dates, et validée contre les URL malformées ou modifiées à la main). Purement interne pour l'instant : aucun contrôle de filtre visible encore, le sélecteur de comptes et le branchement par rapport arrivent dans des issues de suivi (#272). - Rapports : pose les fondations d'un futur filtre de comptes pour les rapports — le hook de période partagé suit désormais aussi une sélection multi-comptes via l'URL (mémorisable comme la plage de dates, et validée contre les URL malformées ou modifiées à la main). Purement interne pour l'instant : aucun contrôle de filtre visible encore, le sélecteur de comptes et le branchement par rapport arrivent dans des issues de suivi (#272).
- Rapports : les sept services de rapports (tendances, catégories dans le temps, comparaison réel-vs-réel, budget-vs-réel, dépenses par catégorie, et le tableau de bord Cartes) acceptent désormais un filtre multi-comptes optionnel, comparé à une liste paramétrée de sources d'import. Plomberie interne seulement — omettre le filtre partout donne des résultats identiques au byte près, et aucune page de rapport n'expose encore de contrôle de filtre ; cela arrive dans des issues de suivi (#273). - Rapports : les sept services de rapports (tendances, catégories dans le temps, comparaison réel-vs-réel, budget-vs-réel, dépenses par catégorie, et le tableau de bord Cartes) acceptent désormais un filtre multi-comptes optionnel, comparé à une liste paramétrée de sources d'import. Plomberie interne seulement — omettre le filtre partout donne des résultats identiques au byte près, et aucune page de rapport n'expose encore de contrôle de filtre ; cela arrive dans des issues de suivi (#273).
- Rapports : pose les fondations de l'interface du filtre de comptes — un panneau de filtre partagé affiche le contrôle de période propre à chaque rapport à côté d'une sélection multiple (cases à cocher) de vos sources d'import. Purement interne pour l'instant : aucune page de rapport n'affiche encore ce panneau, cela arrive dans des issues de suivi (#274). - Rapports : pose les fondations de l'interface du filtre de comptes — un panneau de filtre partagé affiche le contrôle de période propre à chaque rapport à côté d'une sélection multiple (cases à cocher) de vos sources d'import. Purement interne pour l'instant : aucune page de rapport n'affiche encore ce panneau, cela arrive dans des issues de suivi (#274).
- Rapports → Tendances : le panneau de filtre partagé apparaît désormais sur ce rapport, à côté du sélecteur de période. Cocher une ou plusieurs sources d'import restreint à la fois la vue mensuelle globale et le tableau de résultat par catégorie à ces sources ; ne rien cocher garde l'affichage de toutes les sources, inchangé (#275).
### Corrigé ### Corrigé

View file

@ -9,6 +9,7 @@
- Reports: laid the groundwork for an upcoming account filter across the report pages — the shared period hook now also tracks a URL-backed, multi-account selection (bookmarkable like the date range, and validated against malformed/hand-edited URLs). Internal only for now: no filter control is visible yet, the account picker and per-report wiring land in follow-up issues (#272). - Reports: laid the groundwork for an upcoming account filter across the report pages — the shared period hook now also tracks a URL-backed, multi-account selection (bookmarkable like the date range, and validated against malformed/hand-edited URLs). Internal only for now: no filter control is visible yet, the account picker and per-report wiring land in follow-up issues (#272).
- Reports: all seven report services (trends, by-category-over-time, real-vs-real compare, budget-vs-actual, expenses-by-category, and the Cartes dashboard) now accept an optional multi-account filter, matched against a parameterized list of import sources. Backend plumbing only — leaving every filter out still returns byte-identical results, and no report page exposes a filter control yet; that lands in follow-up issues (#273). - Reports: all seven report services (trends, by-category-over-time, real-vs-real compare, budget-vs-actual, expenses-by-category, and the Cartes dashboard) now accept an optional multi-account filter, matched against a parameterized list of import sources. Backend plumbing only — leaving every filter out still returns byte-identical results, and no report page exposes a filter control yet; that lands in follow-up issues (#273).
- Reports: laid the groundwork for the account filter's UI — a shared filter panel renders each report page's own period control alongside a multi-select (checkbox list) of your import sources. Internal only for now: no report page renders this panel yet, that lands in follow-up issues (#274). - Reports: laid the groundwork for the account filter's UI — a shared filter panel renders each report page's own period control alongside a multi-select (checkbox list) of your import sources. Internal only for now: no report page renders this panel yet, that lands in follow-up issues (#274).
- Reports → Trends: the shared filter panel now appears on this report, next to the period selector. Checking one or more import sources narrows both the global monthly view and the by-category result table to those sources; leaving none checked keeps showing every source, unchanged (#275).
### Fixed ### Fixed

View file

@ -206,7 +206,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useAdjustments` | Ajustements | | `useAdjustments` | Ajustements |
| `useBudget` | Budget | | `useBudget` | Budget |
| `useDashboard` | Métriques du tableau de bord | | `useDashboard` | Métriques du tableau de bord |
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272, pas encore branché sur les services ni exposé dans une UI) | | `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur la page Tendances (#275) — reste à adopter sur Comparaison/Budget/Dashboard/Hub (#276) |
| `useHighlights` | Panneau de faits saillants du hub rapports | | `useHighlights` | Panneau de faits saillants du hub rapports |
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) | | `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) | | `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |

View file

@ -1,6 +1,7 @@
import { useReducer, useEffect, useRef, useCallback } from "react"; import { useReducer, useEffect, useRef, useCallback } from "react";
import type { MonthlyTrendItem, CategoryOverTimeData } from "../shared/types"; import type { MonthlyTrendItem, CategoryOverTimeData, ImportSource } from "../shared/types";
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService"; import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod"; import { useReportsPeriod } from "./useReportsPeriod";
export type TrendsSubView = "global" | "byCategory"; export type TrendsSubView = "global" | "byCategory";
@ -9,6 +10,7 @@ interface State {
subView: TrendsSubView; subView: TrendsSubView;
monthlyTrends: MonthlyTrendItem[]; monthlyTrends: MonthlyTrendItem[];
categoryOverTime: CategoryOverTimeData; categoryOverTime: CategoryOverTimeData;
accounts: ImportSource[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -18,12 +20,14 @@ type Action =
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] } | { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData } | { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_ERROR"; payload: string }; | { type: "SET_ERROR"; payload: string };
const initialState: State = { const initialState: State = {
subView: "byCategory", subView: "byCategory",
monthlyTrends: [], monthlyTrends: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] }, categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
accounts: [],
isLoading: false, isLoading: false,
error: null, error: null,
}; };
@ -38,6 +42,8 @@ function reducer(state: State, action: Action): State {
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null }; return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
case "SET_CATEGORY_OVER_TIME": case "SET_CATEGORY_OVER_TIME":
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null }; return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_ERROR": case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false }; return { ...state, error: action.payload, isLoading: false };
default: default:
@ -46,20 +52,21 @@ function reducer(state: State, action: Action): State {
} }
export function useTrends() { export function useTrends() {
const { from, to } = useReportsPeriod(); const { from, to, accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetch = useCallback(async (subView: TrendsSubView, dateFrom: string, dateTo: string) => { const fetch = useCallback(
async (subView: TrendsSubView, dateFrom: string, dateTo: string, ids: number[]) => {
const id = ++fetchIdRef.current; const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_LOADING", payload: true });
try { try {
if (subView === "global") { if (subView === "global") {
const data = await getMonthlyTrends(dateFrom, dateTo); const data = await getMonthlyTrends(dateFrom, dateTo, ids);
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_TRENDS", payload: data }); dispatch({ type: "SET_TRENDS", payload: data });
} else { } else {
const data = await getCategoryOverTime(dateFrom, dateTo); const data = await getCategoryOverTime(dateFrom, dateTo, undefined, ids);
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data }); dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
} }
@ -67,11 +74,26 @@ export function useTrends() {
if (id !== fetchIdRef.current) return; if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
} }
}, []); },
[]
);
useEffect(() => { useEffect(() => {
fetch(state.subView, from, to); fetch(state.subView, from, to, accountIds);
}, [fetch, state.subView, from, to]); }, [fetch, state.subView, from, to, accountIds]);
// Load the import-source list once, for the FilterPanel's account checkboxes
// (same query as the Transactions single-source filter — see useTransactions.ts).
useEffect(() => {
(async () => {
try {
const accounts = await getAllImportSources();
dispatch({ type: "SET_ACCOUNTS", payload: accounts });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
})();
}, []);
const setSubView = useCallback((sv: TrendsSubView) => { const setSubView = useCallback((sv: TrendsSubView) => {
dispatch({ type: "SET_SUBVIEW", payload: sv }); dispatch({ type: "SET_SUBVIEW", payload: sv });

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import FilterPanel from "../components/reports/FilterPanel";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart"; import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable"; import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart"; import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
@ -21,8 +22,8 @@ const STORAGE_KEY = "reports-viewmode-trends";
export default function ReportsTrendsPage() { export default function ReportsTrendsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } = useReportsPeriod();
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends(); const { subView, setSubView, monthlyTrends, categoryOverTime, accounts, isLoading, error } = useTrends();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table")); const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table"));
const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType()); const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType());
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set()); const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
@ -55,7 +56,8 @@ export default function ReportsTrendsPage() {
<h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1> <h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1>
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap"> <FilterPanel
temporalControl={
<PeriodSelector <PeriodSelector
value={period} value={period}
onChange={setPeriod} onChange={setPeriod}
@ -63,7 +65,13 @@ export default function ReportsTrendsPage() {
customDateTo={to} customDateTo={to}
onCustomDateChange={setCustomDates} onCustomDateChange={setCustomDates}
/> />
<div className="flex gap-2 items-center flex-wrap"> }
accountIds={accountIds}
onAccountIdsChange={setAccountIds}
accounts={accounts}
/>
<div className="flex gap-2 items-center justify-end flex-wrap mb-6">
<div className="inline-flex gap-1"> <div className="inline-flex gap-1">
<button <button
type="button" type="button"
@ -99,7 +107,6 @@ export default function ReportsTrendsPage() {
)} )}
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} /> <ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div> </div>
</div>
{error && ( {error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6"> <div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">