diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md
index 59f869f..2fd9af4 100644
--- a/CHANGELOG.fr.md
+++ b/CHANGELOG.fr.md
@@ -8,6 +8,7 @@
- Rapports → Tendances → par catégorie (vue tableau) : le tableau d'analyse de résultat est désormais **hiérarchique et repliable**, à l'image du rapport de comparaison. Les catégories parentes apparaissent en groupes indentés au-dessus de leurs sous-catégories, chacune avec son propre sous-total, et un chevron (plus un bouton « Tout déplier / Tout replier ») replie un groupe jusqu'à ce seul sous-total. Les groupes s'ouvrent **repliés**, et vos choix sont mémorisés séparément des tableaux comparables. La ligne **Résultat avant transferts** se place maintenant entre les sections de dépenses et la section des transferts au lieu du bas, et replier un groupe ne déplace aucun sous-total ni résultat — les chiffres sont toujours calculés sur l'ensemble des données (#265).
- 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).
### Corrigé
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 203c3af..4892846 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
- Reports → Trends → by category (table view): the income-statement table is now **hierarchical and collapsible**, matching the compare report. Parent categories appear as indented groups above their sub-categories, each with its own subtotal, and a chevron (plus an "Expand all / Collapse all" button) folds a group down to just that subtotal. Groups start **collapsed**, and your choices are remembered separately from the comparable tables. The **Result before transfers** line now sits between the expense sections and the transfers section instead of at the bottom, and folding a group never moves any subtotal or result — the figures are always computed from the full data (#265).
- 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).
### Fixed
diff --git a/src/components/reports/FilterPanel.test.tsx b/src/components/reports/FilterPanel.test.tsx
new file mode 100644
index 0000000..2cb00bc
--- /dev/null
+++ b/src/components/reports/FilterPanel.test.tsx
@@ -0,0 +1,199 @@
+// FilterPanel — unit tests (Issue #274).
+//
+// NOTE: This project has no @testing-library/react / jsdom configured (see
+// PriceFetchControl.test.tsx). FilterPanel, unlike most components with
+// their own test file, has NO internal state or hooks besides `useTranslation`
+// — mocked below into a plain function — so it can be invoked directly as a
+// plain function (bypassing JSX/React.createElement's usual ``
+// call site) and its return value (a plain React-element object tree, since
+// JSX creation never touches the DOM) walked directly. This gives genuine
+// coverage of the shipped render + selection-wiring code, not a
+// re-implementation of its logic in the test.
+
+import { describe, it, expect, vi } from "vitest";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: vi.fn(() => ({
+ t: (key: string) => key,
+ i18n: { language: "fr" },
+ })),
+}));
+
+vi.mock("lucide-react", () => ({
+ Inbox: () => null,
+}));
+
+import FilterPanel, { toggleAccountId } from "./FilterPanel";
+import type { FilterPanelProps } from "./FilterPanel";
+import type { ImportSource } from "../../shared/types";
+
+// ---------------------------------------------------------------------------
+// Minimal React-element tree walker (no renderer/DOM involved)
+// ---------------------------------------------------------------------------
+
+interface ElementNode {
+ type: unknown;
+ props: Record & { children?: unknown };
+}
+
+type FlatNode = ElementNode | string | number;
+
+function isElementNode(x: unknown): x is ElementNode {
+ return typeof x === "object" && x !== null && "props" in x;
+}
+
+function flatten(node: unknown): FlatNode[] {
+ if (Array.isArray(node)) return node.flatMap(flatten);
+ if (node === null || node === undefined || typeof node === "boolean") return [];
+ if (typeof node === "string" || typeof node === "number") return [node];
+ if (isElementNode(node)) return [node, ...flatten(node.props.children)];
+ return [];
+}
+
+function findCheckboxes(root: unknown): ElementNode[] {
+ return flatten(root).filter(
+ (n): n is ElementNode => isElementNode(n) && n.props.type === "checkbox",
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const SOURCE_CHEQUING: ImportSource = {
+ id: 1,
+ name: "Compte chèque Desjardins",
+ date_format: "YYYY-MM-DD",
+ delimiter: ",",
+ encoding: "utf-8",
+ column_mapping: "{}",
+ skip_lines: 0,
+ has_header: true,
+ created_at: "2026-01-01",
+ updated_at: "2026-01-01",
+};
+
+const SOURCE_VISA: ImportSource = {
+ ...SOURCE_CHEQUING,
+ id: 2,
+ name: "Visa Desjardins",
+};
+
+function renderPanel(overrides: Partial = {}) {
+ const onAccountIdsChange = vi.fn();
+ const props: FilterPanelProps = {
+ temporalControl: "TEMPORAL_CONTROL_MARKER",
+ accountIds: [],
+ onAccountIdsChange,
+ accounts: [SOURCE_CHEQUING, SOURCE_VISA],
+ ...overrides,
+ };
+ const element = FilterPanel(props);
+ return { element, onAccountIdsChange };
+}
+
+// ---------------------------------------------------------------------------
+// toggleAccountId — pure selection logic
+// ---------------------------------------------------------------------------
+
+describe("toggleAccountId", () => {
+ it("adds an absent id to an empty list", () => {
+ expect(toggleAccountId([], 1)).toEqual([1]);
+ });
+
+ it("appends an absent id after the existing ones", () => {
+ expect(toggleAccountId([1, 2], 3)).toEqual([1, 2, 3]);
+ });
+
+ it("removes a present id, preserving the order of the remaining ids", () => {
+ expect(toggleAccountId([1, 2, 3], 2)).toEqual([1, 3]);
+ });
+
+ it("removing the only selected id returns an empty array (= all accounts)", () => {
+ expect(toggleAccountId([1], 1)).toEqual([]);
+ });
+
+ it("does not mutate the input array", () => {
+ const input = [1, 2];
+ toggleAccountId(input, 3);
+ expect(input).toEqual([1, 2]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// FilterPanel — render
+// ---------------------------------------------------------------------------
+
+describe("FilterPanel — render", () => {
+ it("renders the injected temporalControl as-is", () => {
+ const { element } = renderPanel();
+ expect(flatten(element)).toContain("TEMPORAL_CONTROL_MARKER");
+ });
+
+ it("renders one checkbox per account", () => {
+ const { element } = renderPanel();
+ expect(findCheckboxes(element)).toHaveLength(2);
+ });
+
+ it("renders each account's name as visible text", () => {
+ const { element } = renderPanel();
+ const text = flatten(element);
+ expect(text).toContain("Compte chèque Desjardins");
+ expect(text).toContain("Visa Desjardins");
+ });
+
+ it("checks exactly the accounts present in accountIds", () => {
+ const { element } = renderPanel({ accountIds: [2] });
+ const checkboxes = findCheckboxes(element);
+ expect(checkboxes.map((c) => c.props.checked)).toEqual([false, true]);
+ });
+
+ it("checks no checkbox when accountIds is empty (= all accounts, no explicit filter)", () => {
+ const { element } = renderPanel({ accountIds: [] });
+ const checkboxes = findCheckboxes(element);
+ expect(checkboxes.every((c) => c.props.checked === false)).toBe(true);
+ });
+
+ it("renders zero checkboxes when accounts is empty, without crashing", () => {
+ const { element } = renderPanel({ accounts: [] });
+ expect(findCheckboxes(element)).toHaveLength(0);
+ });
+
+ it("uses i18n keys for the accounts filter label and hint (never a hardcoded string)", () => {
+ const { element } = renderPanel();
+ const text = flatten(element);
+ expect(text).toContain("reports.filters.accounts.label");
+ expect(text).toContain("reports.filters.accounts.hint");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// FilterPanel — selection wiring
+// ---------------------------------------------------------------------------
+
+describe("FilterPanel — selection wiring", () => {
+ it("checking an unselected account calls onAccountIdsChange with it added", () => {
+ const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
+ const checkboxes = findCheckboxes(element);
+ // accounts[1] = SOURCE_VISA (id 2), currently unselected
+ (checkboxes[1].props.onChange as () => void)();
+ expect(onAccountIdsChange).toHaveBeenCalledWith([1, 2]);
+ });
+
+ it("unchecking the only selected account calls onAccountIdsChange with an empty array", () => {
+ const { element, onAccountIdsChange } = renderPanel({ accountIds: [1] });
+ const checkboxes = findCheckboxes(element);
+ // accounts[0] = SOURCE_CHEQUING (id 1), currently selected
+ (checkboxes[0].props.onChange as () => void)();
+ expect(onAccountIdsChange).toHaveBeenCalledWith([]);
+ });
+
+ it("checking a second account preserves the first (multi-select)", () => {
+ const { element, onAccountIdsChange } = renderPanel({ accountIds: [] });
+ const checkboxes = findCheckboxes(element);
+ (checkboxes[0].props.onChange as () => void)();
+ (checkboxes[1].props.onChange as () => void)();
+ expect(onAccountIdsChange).toHaveBeenNthCalledWith(1, [1]);
+ expect(onAccountIdsChange).toHaveBeenNthCalledWith(2, [2]);
+ });
+});
diff --git a/src/components/reports/FilterPanel.tsx b/src/components/reports/FilterPanel.tsx
new file mode 100644
index 0000000..fa22938
--- /dev/null
+++ b/src/components/reports/FilterPanel.tsx
@@ -0,0 +1,93 @@
+// FilterPanel — shared filter bar for the report pages (Issue #274, epic #260).
+//
+// Design: a "slot" component. Each report page owns its own temporal state
+// (Compare = a local reducer, Trends/ByCategory = the `sources` query string
+// via useReportsPeriod, Budget = a plain year) and renders its own control
+// (PeriodSelector / CompareReferenceMonthPicker / YearNavigator / …), passed
+// in as-is via `temporalControl`. FilterPanel does NOT own a `temporalMode`
+// enum and does not know which control it is rendering — an earlier design
+// unifying temporal state behind such an enum was rejected specifically to
+// avoid mixing state sources (see spec-plan-rapports-uniformes-suite.md).
+// The only state this component actually owns/shares across report pages is
+// the account (import source) filter.
+//
+// `accounts`/`accountIds` refer to `ImportSource` rows (bank statement import
+// sources, e.g. "Compte chèque Desjardins") — a disjoint id space from the
+// Bilan module's `balance_accounts` ("Compte" in that module's own UI). The
+// copy in this component deliberately says "sources" everywhere, never
+// "comptes"/"accounts", so the two concepts never read as the same filter.
+
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { Inbox } from "lucide-react";
+import type { ImportSource } from "../../shared/types";
+
+export interface FilterPanelProps {
+ /** The page's own temporal control, rendered as-is. */
+ temporalControl: ReactNode;
+ /** Selected import source ids. Empty array = no filter (all sources). */
+ accountIds: number[];
+ onAccountIdsChange: (accountIds: number[]) => void;
+ /** Import sources available to filter on. */
+ accounts: ImportSource[];
+}
+
+/**
+ * Toggles `id` in/out of `accountIds` (add if absent, remove if present),
+ * preserving the relative order of the remaining ids. Pure and exported so
+ * the selection logic is unit-testable without rendering the component.
+ */
+export function toggleAccountId(accountIds: number[], id: number): number[] {
+ return accountIds.includes(id)
+ ? accountIds.filter((existing) => existing !== id)
+ : [...accountIds, id];
+}
+
+export default function FilterPanel({
+ temporalControl,
+ accountIds,
+ onAccountIdsChange,
+ accounts,
+}: FilterPanelProps) {
+ const { t } = useTranslation();
+
+ const accountsLabel = t("reports.filters.accounts.label");
+
+ return (
+