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 : 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 → 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é

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: 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 → 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

View file

@ -206,7 +206,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useAdjustments` | Ajustements |
| `useBudget` | Budget |
| `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 |
| `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) |

View file

@ -1,6 +1,7 @@
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 { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod";
export type TrendsSubView = "global" | "byCategory";
@ -9,6 +10,7 @@ interface State {
subView: TrendsSubView;
monthlyTrends: MonthlyTrendItem[];
categoryOverTime: CategoryOverTimeData;
accounts: ImportSource[];
isLoading: boolean;
error: string | null;
}
@ -18,12 +20,14 @@ type Action =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_ERROR"; payload: string };
const initialState: State = {
subView: "byCategory",
monthlyTrends: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
accounts: [],
isLoading: false,
error: null,
};
@ -38,6 +42,8 @@ function reducer(state: State, action: Action): State {
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
case "SET_CATEGORY_OVER_TIME":
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
default:
@ -46,32 +52,48 @@ function reducer(state: State, action: Action): State {
}
export function useTrends() {
const { from, to } = useReportsPeriod();
const { from, to, accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(async (subView: TrendsSubView, dateFrom: string, dateTo: string) => {
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
if (subView === "global") {
const data = await getMonthlyTrends(dateFrom, dateTo);
const fetch = useCallback(
async (subView: TrendsSubView, dateFrom: string, dateTo: string, ids: number[]) => {
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
if (subView === "global") {
const data = await getMonthlyTrends(dateFrom, dateTo, ids);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_TRENDS", payload: data });
} else {
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, ids);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
}
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_TRENDS", payload: data });
} else {
const data = await getCategoryOverTime(dateFrom, dateTo);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
}, []);
},
[]
);
useEffect(() => {
fetch(state.subView, from, to);
}, [fetch, state.subView, from, to]);
fetch(state.subView, from, to, accountIds);
}, [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) => {
dispatch({ type: "SET_SUBVIEW", payload: sv });

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import FilterPanel from "../components/reports/FilterPanel";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
@ -21,8 +22,8 @@ const STORAGE_KEY = "reports-viewmode-trends";
export default function ReportsTrendsPage() {
const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends();
const { period, setPeriod, from, to, setCustomDates, accountIds, setAccountIds } = useReportsPeriod();
const { subView, setSubView, monthlyTrends, categoryOverTime, accounts, isLoading, error } = useTrends();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY, "table"));
const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType());
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
@ -55,50 +56,56 @@ export default function ReportsTrendsPage() {
<h1 className="text-2xl font-bold">{t("reports.hub.trends")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
<div className="flex gap-2 items-center flex-wrap">
<div className="inline-flex gap-1">
<button
type="button"
onClick={() => setSubView("global")}
aria-pressed={subView === "global"}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
subView === "global"
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t("reports.trends.subviewGlobal")}
</button>
<button
type="button"
onClick={() => setSubView("byCategory")}
aria-pressed={subView === "byCategory"}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
subView === "byCategory"
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t("reports.trends.subviewByCategory")}
</button>
</div>
{subView === "byCategory" && viewMode === "chart" && (
<TrendsChartTypeToggle
value={chartType}
onChange={setChartType}
storageKey={TRENDS_CHART_TYPE_STORAGE_KEY}
/>
)}
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
<FilterPanel
temporalControl={
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
}
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">
<button
type="button"
onClick={() => setSubView("global")}
aria-pressed={subView === "global"}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
subView === "global"
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t("reports.trends.subviewGlobal")}
</button>
<button
type="button"
onClick={() => setSubView("byCategory")}
aria-pressed={subView === "byCategory"}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
subView === "byCategory"
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t("reports.trends.subviewByCategory")}
</button>
</div>
{subView === "byCategory" && viewMode === "chart" && (
<TrendsChartTypeToggle
value={chartType}
onChange={setChartType}
storageKey={TRENDS_CHART_TYPE_STORAGE_KEY}
/>
)}
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
</div>
{error && (