refactor: compare report — Actual-vs-actual mode with reference month picker (#96)

Closes #96
This commit is contained in:
maximus 2026-04-15 23:10:23 +00:00
commit 5fd2108d07
12 changed files with 383 additions and 89 deletions

View file

@ -2,6 +2,9 @@
## [Non publié] ## [Non publié]
### Modifié
- **Rapport Comparables** (`/reports/compare`) : passage de trois onglets (MoM / YoY / Budget) à deux modes (Réel vs réel / Réel vs budget). La vue « Réel vs réel » affiche désormais un sélecteur de mois de référence en en-tête (défaut : mois précédent), un sous-toggle MoM ↔ YoY, et un graphique en barres groupées côte-à-côte (deux barres par catégorie : période de référence vs période comparée). Le `PeriodSelector` d'URL reste synchronisé avec le sélecteur de mois (#96)
## [0.8.0] - 2026-04-14 ## [0.8.0] - 2026-04-14
### Ajouté ### Ajouté

View file

@ -2,6 +2,9 @@
## [Unreleased] ## [Unreleased]
### Changed
- **Compare report** (`/reports/compare`): reduced from three tabs (MoM / YoY / Budget) to two modes (Actual vs. actual / Actual vs. budget). The actual-vs-actual view now has an explicit reference-month dropdown in the header (defaults to the previous month), a MoM ↔ YoY sub-toggle, and a grouped side-by-side bar chart (two bars per category: reference period vs. comparison period). The URL `PeriodSelector` stays in sync with the reference month picker (#96)
## [0.8.0] - 2026-04-14 ## [0.8.0] - 2026-04-14
### Added ### Added

View file

@ -149,7 +149,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) | | `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) |
| `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 MoM / YoY / budget) | | `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories | | `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
| `useDataExport` | Export de données | | `useDataExport` | Export de données |
| `useTheme` | Thème clair/sombre | | `useTheme` | Thème clair/sombre |

View file

@ -10,8 +10,7 @@ export default function CompareModeTabs({ value, onChange }: CompareModeTabsProp
const { t } = useTranslation(); const { t } = useTranslation();
const modes: { id: CompareMode; labelKey: string }[] = [ const modes: { id: CompareMode; labelKey: string }[] = [
{ id: "mom", labelKey: "reports.compare.modeMoM" }, { id: "actual", labelKey: "reports.compare.modeActual" },
{ id: "yoy", labelKey: "reports.compare.modeYoY" },
{ id: "budget", labelKey: "reports.compare.modeBudget" }, { id: "budget", labelKey: "reports.compare.modeBudget" },
]; ];

View file

@ -4,16 +4,17 @@ import {
Bar, Bar,
XAxis, XAxis,
YAxis, YAxis,
Cell,
ReferenceLine,
Tooltip, Tooltip,
Legend,
ResponsiveContainer, ResponsiveContainer,
CartesianGrid,
} from "recharts"; } from "recharts";
import type { CategoryDelta } from "../../shared/types"; import type { CategoryDelta } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
export interface ComparePeriodChartProps { export interface ComparePeriodChartProps {
rows: CategoryDelta[]; rows: CategoryDelta[];
previousLabel: string;
currentLabel: string;
} }
function formatCurrency(amount: number, language: string): string { function formatCurrency(amount: number, language: string): string {
@ -24,7 +25,11 @@ function formatCurrency(amount: number, language: string): string {
}).format(amount); }).format(amount);
} }
export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) { export default function ComparePeriodChart({
rows,
previousLabel,
currentLabel,
}: ComparePeriodChartProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
if (rows.length === 0) { if (rows.length === 0) {
@ -35,31 +40,44 @@ export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) {
); );
} }
const chartData = rows // Sort by current-period amount (largest spending first) so the user's eye
.map((r, i) => ({ // lands on the biggest categories, then reverse so the biggest appears at
// the top of the vertical bar chart.
const chartData = [...rows]
.sort((a, b) => b.currentAmount - a.currentAmount)
.map((r) => ({
name: r.categoryName, name: r.categoryName,
previousAmount: r.previousAmount,
currentAmount: r.currentAmount,
color: r.categoryColor, color: r.categoryColor,
delta: r.deltaAbs, }));
index: i,
})) const previousFill = "var(--muted-foreground)";
.sort((a, b) => a.delta - b.delta); const currentFill = "var(--primary)";
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 32 + 40)}> <ResponsiveContainer width="100%" height={Math.max(280, chartData.length * 44 + 60)}>
<BarChart data={chartData} layout="vertical" margin={{ top: 10, right: 20, bottom: 10, left: 10 }}> <BarChart
<ChartPatternDefs data={chartData}
prefix="compare-delta" layout="vertical"
categories={chartData.map((d) => ({ color: d.color, index: d.index }))} margin={{ top: 10, right: 24, bottom: 10, left: 10 }}
/> barCategoryGap="20%"
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
<XAxis <XAxis
type="number" type="number"
tickFormatter={(v) => formatCurrency(v, i18n.language)} tickFormatter={(v) => formatCurrency(v, i18n.language)}
stroke="var(--muted-foreground)" stroke="var(--muted-foreground)"
fontSize={11} fontSize={11}
/> />
<YAxis type="category" dataKey="name" width={140} stroke="var(--muted-foreground)" fontSize={11} /> <YAxis
<ReferenceLine x={0} stroke="var(--border)" /> type="category"
dataKey="name"
width={140}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<Tooltip <Tooltip
formatter={(value) => formatter={(value) =>
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value) typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
@ -69,15 +87,23 @@ export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) {
border: "1px solid var(--border)", border: "1px solid var(--border)",
borderRadius: "0.5rem", borderRadius: "0.5rem",
}} }}
cursor={{ fill: "var(--muted)", fillOpacity: 0.2 }}
/>
<Legend
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
/>
<Bar
dataKey="previousAmount"
name={previousLabel}
fill={previousFill}
radius={[0, 4, 4, 0]}
/>
<Bar
dataKey="currentAmount"
name={currentLabel}
fill={currentFill}
radius={[0, 4, 4, 0]}
/> />
<Bar dataKey="delta">
{chartData.map((entry) => (
<Cell
key={entry.name}
fill={getPatternFill("compare-delta", entry.index, entry.color)}
/>
))}
</Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

View file

@ -0,0 +1,93 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export interface CompareReferenceMonthPickerProps {
year: number;
month: number;
onChange: (year: number, month: number) => void;
/** Number of recent months to show in the dropdown. Default: 24. */
monthCount?: number;
/** "today" override for tests. */
today?: Date;
}
interface Option {
value: string; // "YYYY-MM"
year: number;
month: number;
label: string;
}
function formatMonth(year: number, month: number, language: string): string {
const date = new Date(year, month - 1, 1);
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "long",
year: "numeric",
}).format(date);
}
export default function CompareReferenceMonthPicker({
year,
month,
onChange,
monthCount = 24,
today = new Date(),
}: CompareReferenceMonthPickerProps) {
const { t, i18n } = useTranslation();
const options = useMemo<Option[]>(() => {
const list: Option[] = [];
let y = today.getFullYear();
let m = today.getMonth() + 1;
for (let i = 0; i < monthCount; i++) {
list.push({
value: `${y}-${String(m).padStart(2, "0")}`,
year: y,
month: m,
label: formatMonth(y, m, i18n.language),
});
m -= 1;
if (m === 0) {
m = 12;
y -= 1;
}
}
return list;
}, [today, monthCount, i18n.language]);
const currentValue = `${year}-${String(month).padStart(2, "0")}`;
const isKnown = options.some((o) => o.value === currentValue);
const displayOptions = isKnown
? options
: [
{
value: currentValue,
year,
month,
label: formatMonth(year, month, i18n.language),
},
...options,
];
return (
<label className="inline-flex items-center gap-2">
<span className="text-sm text-[var(--muted-foreground)]">
{t("reports.compare.referenceMonth")}
</span>
<select
value={currentValue}
onChange={(e) => {
const opt = displayOptions.find((o) => o.value === e.target.value);
if (opt) onChange(opt.year, opt.month);
}}
className="rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] px-3 py-2 text-sm hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
>
{displayOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
);
}

View file

@ -0,0 +1,40 @@
import { useTranslation } from "react-i18next";
import type { CompareSubMode } from "../../hooks/useCompare";
export interface CompareSubModeToggleProps {
value: CompareSubMode;
onChange: (subMode: CompareSubMode) => void;
}
export default function CompareSubModeToggle({ value, onChange }: CompareSubModeToggleProps) {
const { t } = useTranslation();
const items: { id: CompareSubMode; labelKey: string }[] = [
{ id: "mom", labelKey: "reports.compare.subModeMoM" },
{ id: "yoy", labelKey: "reports.compare.subModeYoY" },
];
return (
<div
className="inline-flex rounded-lg border border-[var(--border)] bg-[var(--card)] p-0.5"
role="group"
aria-label={t("reports.compare.subModeAria")}
>
{items.map(({ id, labelKey }) => (
<button
key={id}
type="button"
onClick={() => onChange(id)}
aria-pressed={value === id}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
value === id
? "bg-[var(--primary)] text-white"
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t(labelKey)}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { previousMonth, defaultReferencePeriod, comparisonMeta } from "./useCompare";
describe("useCompare helpers", () => {
describe("previousMonth", () => {
it("goes back one month within the same year", () => {
expect(previousMonth(2026, 3)).toEqual({ year: 2026, month: 2 });
expect(previousMonth(2026, 12)).toEqual({ year: 2026, month: 11 });
});
it("wraps around January to December of previous year", () => {
expect(previousMonth(2026, 1)).toEqual({ year: 2025, month: 12 });
});
});
describe("defaultReferencePeriod", () => {
it("returns the month before the given date", () => {
expect(defaultReferencePeriod(new Date(2026, 3, 15))).toEqual({ year: 2026, month: 3 });
});
it("wraps around when today is in January", () => {
expect(defaultReferencePeriod(new Date(2026, 0, 10))).toEqual({ year: 2025, month: 12 });
});
it("handles the last day of a month", () => {
expect(defaultReferencePeriod(new Date(2026, 6, 31))).toEqual({ year: 2026, month: 6 });
});
});
describe("comparisonMeta", () => {
it("MoM returns the previous month", () => {
expect(comparisonMeta("mom", 2026, 3)).toEqual({ previousYear: 2026, previousMonth: 2 });
});
it("MoM wraps around January", () => {
expect(comparisonMeta("mom", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 12 });
});
it("YoY returns the same month in the previous year", () => {
expect(comparisonMeta("yoy", 2026, 3)).toEqual({ previousYear: 2025, previousMonth: 3 });
});
it("YoY for January stays on January of previous year", () => {
expect(comparisonMeta("yoy", 2026, 1)).toEqual({ previousYear: 2025, previousMonth: 1 });
});
});
});

View file

@ -3,10 +3,12 @@ import type { CategoryDelta } from "../shared/types";
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService"; import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
import { useReportsPeriod } from "./useReportsPeriod"; import { useReportsPeriod } from "./useReportsPeriod";
export type CompareMode = "mom" | "yoy" | "budget"; export type CompareMode = "actual" | "budget";
export type CompareSubMode = "mom" | "yoy";
interface State { interface State {
mode: CompareMode; mode: CompareMode;
subMode: CompareSubMode;
year: number; year: number;
month: number; month: number;
rows: CategoryDelta[]; rows: CategoryDelta[];
@ -16,16 +18,52 @@ interface State {
type Action = type Action =
| { type: "SET_MODE"; payload: CompareMode } | { type: "SET_MODE"; payload: CompareMode }
| { type: "SET_PERIOD"; payload: { year: number; month: number } } | { type: "SET_SUB_MODE"; payload: CompareSubMode }
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_LOADING"; payload: boolean } | { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ROWS"; payload: CategoryDelta[] } | { type: "SET_ROWS"; payload: CategoryDelta[] }
| { type: "SET_ERROR"; payload: string }; | { type: "SET_ERROR"; payload: string };
const today = new Date(); /**
* Wrap-around helper: returns (year, month) shifted back by one month.
* Example: previousMonth(2026, 1) -> { year: 2025, month: 12 }.
*/
export function previousMonth(year: number, month: number): { year: number; month: number } {
if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 };
}
/**
* Default reference period for the Compare report: the month preceding `today`.
* Exported for unit tests.
*/
export function defaultReferencePeriod(today: Date = new Date()): { year: number; month: number } {
return previousMonth(today.getFullYear(), today.getMonth() + 1);
}
/**
* Returns the comparison meta for a given subMode + reference period.
* - MoM: previous month vs current month
* - YoY: same month previous year vs current year
*/
export function comparisonMeta(
subMode: CompareSubMode,
year: number,
month: number,
): { previousYear: number; previousMonth: number } {
if (subMode === "mom") {
const prev = previousMonth(year, month);
return { previousYear: prev.year, previousMonth: prev.month };
}
return { previousYear: year - 1, previousMonth: month };
}
const defaultRef = defaultReferencePeriod();
const initialState: State = { const initialState: State = {
mode: "mom", mode: "actual",
year: today.getFullYear(), subMode: "mom",
month: today.getMonth() + 1, year: defaultRef.year,
month: defaultRef.month,
rows: [], rows: [],
isLoading: false, isLoading: false,
error: null, error: null,
@ -35,7 +73,9 @@ function reducer(state: State, action: Action): State {
switch (action.type) { switch (action.type) {
case "SET_MODE": case "SET_MODE":
return { ...state, mode: action.payload }; return { ...state, mode: action.payload };
case "SET_PERIOD": case "SET_SUB_MODE":
return { ...state, subMode: action.payload };
case "SET_REFERENCE_PERIOD":
return { ...state, year: action.payload.year, month: action.payload.month }; return { ...state, year: action.payload.year, month: action.payload.month };
case "SET_LOADING": case "SET_LOADING":
return { ...state, isLoading: action.payload }; return { ...state, isLoading: action.payload };
@ -53,33 +93,38 @@ export function useCompare() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
const fetch = useCallback(async (mode: CompareMode, year: number, month: number) => { const fetch = useCallback(
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => {
const id = ++fetchIdRef.current; if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
dispatch({ type: "SET_LOADING", payload: true }); const id = ++fetchIdRef.current;
try { dispatch({ type: "SET_LOADING", payload: true });
const rows = try {
mode === "mom" const rows =
? await getCompareMonthOverMonth(year, month) subMode === "mom"
: await getCompareYearOverYear(year); ? await getCompareMonthOverMonth(year, month)
if (id !== fetchIdRef.current) return; : await getCompareYearOverYear(year);
dispatch({ type: "SET_ROWS", payload: rows }); if (id !== fetchIdRef.current) return;
} catch (e) { dispatch({ type: "SET_ROWS", payload: rows });
if (id !== fetchIdRef.current) return; } catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); if (id !== fetchIdRef.current) return;
} dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}, []); }
},
[],
);
useEffect(() => { useEffect(() => {
fetch(state.mode, state.year, state.month); fetch(state.mode, state.subMode, state.year, state.month);
}, [fetch, state.mode, state.year, state.month]); }, [fetch, state.mode, state.subMode, state.year, state.month]);
// When the URL period changes, use the `to` date to infer the target year/month. // When the URL period changes, align the reference month with `to`.
// The explicit dropdown remains the primary selector — this effect only
// keeps the two in sync when the user navigates via PeriodSelector.
useEffect(() => { useEffect(() => {
const [y, m] = to.split("-").map(Number); const [y, m] = to.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return; if (!Number.isFinite(y) || !Number.isFinite(m)) return;
if (y !== state.year || m !== state.month) { if (y !== state.year || m !== state.month) {
dispatch({ type: "SET_PERIOD", payload: { year: y, month: m } }); dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [to]); }, [to]);
@ -88,9 +133,13 @@ export function useCompare() {
dispatch({ type: "SET_MODE", payload: m }); dispatch({ type: "SET_MODE", payload: m });
}, []); }, []);
const setTargetPeriod = useCallback((year: number, month: number) => { const setSubMode = useCallback((s: CompareSubMode) => {
dispatch({ type: "SET_PERIOD", payload: { year, month } }); dispatch({ type: "SET_SUB_MODE", payload: s });
}, []); }, []);
return { ...state, setMode, setTargetPeriod, from, to }; const setReferencePeriod = useCallback((year: number, month: number) => {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
}, []);
return { ...state, setMode, setSubMode, setReferencePeriod, from, to };
} }

View file

@ -395,7 +395,7 @@
"trends": "Trends", "trends": "Trends",
"trendsDescription": "Where you're heading over 12 months", "trendsDescription": "Where you're heading over 12 months",
"compare": "Compare", "compare": "Compare",
"compareDescription": "Month, year, and budget comparisons", "compareDescription": "Compare a reference month against previous month, previous year, or budget",
"categoryZoom": "Category Analysis", "categoryZoom": "Category Analysis",
"categoryZoomDescription": "Zoom in on a single category" "categoryZoomDescription": "Zoom in on a single category"
}, },
@ -404,9 +404,12 @@
"subviewByCategory": "By category" "subviewByCategory": "By category"
}, },
"compare": { "compare": {
"modeMoM": "Month vs previous month", "modeActual": "Actual vs actual",
"modeYoY": "Year vs previous year", "modeBudget": "Actual vs budget",
"modeBudget": "Actual vs budget" "subModeMoM": "Previous month",
"subModeYoY": "Previous year",
"subModeAria": "Comparison period",
"referenceMonth": "Reference month"
}, },
"category": { "category": {
"selectCategory": "Select a category", "selectCategory": "Select a category",

View file

@ -395,7 +395,7 @@
"trends": "Tendances", "trends": "Tendances",
"trendsDescription": "Où vous allez sur 12 mois", "trendsDescription": "Où vous allez sur 12 mois",
"compare": "Comparables", "compare": "Comparables",
"compareDescription": "Comparaisons mois, année et budget", "compareDescription": "Comparer un mois de référence au précédent, à l'année passée ou au budget",
"categoryZoom": "Analyse par catégorie", "categoryZoom": "Analyse par catégorie",
"categoryZoomDescription": "Zoom sur une catégorie" "categoryZoomDescription": "Zoom sur une catégorie"
}, },
@ -404,9 +404,12 @@
"subviewByCategory": "Par catégorie" "subviewByCategory": "Par catégorie"
}, },
"compare": { "compare": {
"modeMoM": "Mois vs mois précédent", "modeActual": "Réel vs réel",
"modeYoY": "Année vs année précédente", "modeBudget": "Réel vs budget",
"modeBudget": "Réel vs budget" "subModeMoM": "Mois précédent",
"subModeYoY": "Année précédente",
"subModeAria": "Période de comparaison",
"referenceMonth": "Mois de référence"
}, },
"category": { "category": {
"selectCategory": "Choisir une catégorie", "selectCategory": "Choisir une catégorie",

View file

@ -4,42 +4,53 @@ 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 CompareModeTabs from "../components/reports/CompareModeTabs"; import CompareModeTabs from "../components/reports/CompareModeTabs";
import CompareSubModeToggle from "../components/reports/CompareSubModeToggle";
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
import ComparePeriodTable from "../components/reports/ComparePeriodTable"; import ComparePeriodTable from "../components/reports/ComparePeriodTable";
import ComparePeriodChart from "../components/reports/ComparePeriodChart"; import ComparePeriodChart from "../components/reports/ComparePeriodChart";
import CompareBudgetView from "../components/reports/CompareBudgetView"; import CompareBudgetView from "../components/reports/CompareBudgetView";
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle"; import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
import { useCompare } from "../hooks/useCompare"; import { useCompare, comparisonMeta } from "../hooks/useCompare";
import { useReportsPeriod } from "../hooks/useReportsPeriod"; import { useReportsPeriod } from "../hooks/useReportsPeriod";
const STORAGE_KEY = "reports-viewmode-compare"; const STORAGE_KEY = "reports-viewmode-compare";
const MONTH_NAMES_EN = [ function formatMonthLabel(year: number, month: number, language: string): string {
"January", "February", "March", "April", "May", "June", const date = new Date(year, month - 1, 1);
"July", "August", "September", "October", "November", "December", return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
]; month: "long",
const MONTH_NAMES_FR = [ year: "numeric",
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin", }).format(date);
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
];
function monthName(month: number, language: string): string {
return (language === "fr" ? MONTH_NAMES_FR : MONTH_NAMES_EN)[month - 1] ?? String(month);
} }
export default function ReportsComparePage() { export default function ReportsComparePage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const { mode, setMode, year, month, rows, isLoading, error } = useCompare(); const {
mode,
subMode,
setMode,
setSubMode,
setReferencePeriod,
year,
month,
rows,
isLoading,
error,
} = useCompare();
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY)); const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
const preserveSearch = typeof window !== "undefined" ? window.location.search : ""; const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const previousLabel = const { previousYear, previousMonth: prevMonth } = comparisonMeta(subMode, year, month);
mode === "mom"
? `${monthName(month === 1 ? 12 : month - 1, i18n.language)} ${month === 1 ? year - 1 : year}`
: `${year - 1}`;
const currentLabel = const currentLabel =
mode === "mom" ? `${monthName(month, i18n.language)} ${year}` : `${year}`; subMode === "mom" ? formatMonthLabel(year, month, i18n.language) : String(year);
const previousLabel =
subMode === "mom"
? formatMonthLabel(previousYear, prevMonth, i18n.language)
: String(previousYear);
const showActualControls = mode === "actual";
return ( return (
<div className={isLoading ? "opacity-60" : ""}> <div className={isLoading ? "opacity-60" : ""}>
@ -54,7 +65,7 @@ export default function ReportsComparePage() {
<h1 className="text-2xl font-bold">{t("reports.hub.compare")}</h1> <h1 className="text-2xl font-bold">{t("reports.hub.compare")}</h1>
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4 flex-wrap">
<PeriodSelector <PeriodSelector
value={period} value={period}
onChange={setPeriod} onChange={setPeriod}
@ -64,10 +75,23 @@ export default function ReportsComparePage() {
/> />
<div className="flex gap-2 items-center flex-wrap"> <div className="flex gap-2 items-center flex-wrap">
<CompareModeTabs value={mode} onChange={setMode} /> <CompareModeTabs value={mode} onChange={setMode} />
{mode !== "budget" && ( </div>
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} /> </div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
<CompareReferenceMonthPicker
year={year}
month={month}
onChange={setReferencePeriod}
/>
{showActualControls && (
<CompareSubModeToggle value={subMode} onChange={setSubMode} />
)} )}
</div> </div>
{showActualControls && (
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
)}
</div> </div>
{error && ( {error && (
@ -79,7 +103,11 @@ export default function ReportsComparePage() {
{mode === "budget" ? ( {mode === "budget" ? (
<CompareBudgetView year={year} month={month} /> <CompareBudgetView year={year} month={month} />
) : viewMode === "chart" ? ( ) : viewMode === "chart" ? (
<ComparePeriodChart rows={rows} /> <ComparePeriodChart
rows={rows}
previousLabel={previousLabel}
currentLabel={currentLabel}
/>
) : ( ) : (
<ComparePeriodTable rows={rows} previousLabel={previousLabel} currentLabel={currentLabel} /> <ComparePeriodTable rows={rows} previousLabel={previousLabel} currentLabel={currentLabel} />
)} )}