Compare commits
1 commit
4c58b8bab8
...
31765e6d17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31765e6d17 |
11 changed files with 87 additions and 286 deletions
|
|
@ -5,9 +5,6 @@
|
||||||
### Ajouté
|
### Ajouté
|
||||||
- **Rapport Cartes** (`/reports/cartes`) : nouveau sous-rapport de type tableau de bord dans le hub Rapports. Combine quatre cartes KPI (Revenus, Dépenses, Solde net, Taux d'épargne) affichant les deltas MoM et YoY simultanément avec une sparkline 13 mois dont le mois de référence est mis en évidence, un graphique overlay revenus vs dépenses sur 12 mois (barres + ligne de solde net), le top 5 des catégories en hausse et en baisse par rapport au mois précédent, une carte d'adhérence au budget (N/M dans la cible plus les 3 pires dépassements avec barres de progression) et une carte de saisonnalité qui compare le mois de référence à la moyenne du même mois sur les deux années précédentes. Toutes les données proviennent d'un seul appel `getCartesSnapshot()` qui exécute ses requêtes en parallèle (#97)
|
- **Rapport Cartes** (`/reports/cartes`) : nouveau sous-rapport de type tableau de bord dans le hub Rapports. Combine quatre cartes KPI (Revenus, Dépenses, Solde net, Taux d'épargne) affichant les deltas MoM et YoY simultanément avec une sparkline 13 mois dont le mois de référence est mis en évidence, un graphique overlay revenus vs dépenses sur 12 mois (barres + ligne de solde net), le top 5 des catégories en hausse et en baisse par rapport au mois précédent, une carte d'adhérence au budget (N/M dans la cible plus les 3 pires dépassements avec barres de progression) et une carte de saisonnalité qui compare le mois de référence à la moyenne du même mois sur les deux années précédentes. Toutes les données proviennent d'un seul appel `getCartesSnapshot()` qui exécute ses requêtes en parallèle (#97)
|
||||||
|
|
||||||
### 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é
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,6 @@
|
||||||
### Added
|
### Added
|
||||||
- **Cartes report** (`/reports/cartes`): new dashboard-style sub-report in the Reports hub. Combines four KPI cards (income, expenses, net balance, savings rate) showing MoM and YoY deltas simultaneously with a 13-month sparkline highlighting the reference month, a 12-month income vs. expenses overlay chart (bars + net balance line), top 5 category increases and top 5 decreases vs. the previous month, a budget-adherence card (N/M on-target plus the three worst overruns with progress bars), and a seasonality card that compares the reference month against the same calendar month from the two previous years. All data comes from a single `getCartesSnapshot()` service call that runs its queries concurrently (#97)
|
- **Cartes report** (`/reports/cartes`): new dashboard-style sub-report in the Reports hub. Combines four KPI cards (income, expenses, net balance, savings rate) showing MoM and YoY deltas simultaneously with a 13-month sparkline highlighting the reference month, a 12-month income vs. expenses overlay chart (bars + net balance line), top 5 category increases and top 5 decreases vs. the previous month, a budget-adherence card (N/M on-target plus the three worst overruns with progress bars), and a seasonality card that compares the reference month against the same calendar month from the two previous years. All data comes from a single `getCartesSnapshot()` service call that runs its queries concurrently (#97)
|
||||||
|
|
||||||
### 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
|
||||||
|
|
|
||||||
|
|
@ -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 `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
|
| `useCompare` | Rapport Comparables (mode MoM / YoY / budget) |
|
||||||
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
|
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
|
||||||
| `useCartes` | Rapport Cartes (snapshot KPI + sparklines + top movers + budget + saisonnalité via `getCartesSnapshot`) |
|
| `useCartes` | Rapport Cartes (snapshot KPI + sparklines + top movers + budget + saisonnalité via `getCartesSnapshot`) |
|
||||||
| `useDataExport` | Export de données |
|
| `useDataExport` | Export de données |
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ 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: "actual", labelKey: "reports.compare.modeActual" },
|
{ id: "mom", labelKey: "reports.compare.modeMoM" },
|
||||||
|
{ id: "yoy", labelKey: "reports.compare.modeYoY" },
|
||||||
{ id: "budget", labelKey: "reports.compare.modeBudget" },
|
{ id: "budget", labelKey: "reports.compare.modeBudget" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,16 @@ 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 {
|
||||||
|
|
@ -25,11 +24,7 @@ function formatCurrency(amount: number, language: string): string {
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ComparePeriodChart({
|
export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) {
|
||||||
rows,
|
|
||||||
previousLabel,
|
|
||||||
currentLabel,
|
|
||||||
}: ComparePeriodChartProps) {
|
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
|
@ -40,44 +35,31 @@ export default function ComparePeriodChart({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by current-period amount (largest spending first) so the user's eye
|
const chartData = rows
|
||||||
// lands on the biggest categories, then reverse so the biggest appears at
|
.map((r, i) => ({
|
||||||
// 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)";
|
}))
|
||||||
const currentFill = "var(--primary)";
|
.sort((a, b) => a.delta - b.delta);
|
||||||
|
|
||||||
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(280, chartData.length * 44 + 60)}>
|
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 32 + 40)}>
|
||||||
<BarChart
|
<BarChart data={chartData} layout="vertical" margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
|
||||||
data={chartData}
|
<ChartPatternDefs
|
||||||
layout="vertical"
|
prefix="compare-delta"
|
||||||
margin={{ top: 10, right: 24, bottom: 10, left: 10 }}
|
categories={chartData.map((d) => ({ color: d.color, index: d.index }))}
|
||||||
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
|
<YAxis type="category" dataKey="name" width={140} stroke="var(--muted-foreground)" fontSize={11} />
|
||||||
type="category"
|
<ReferenceLine x={0} stroke="var(--border)" />
|
||||||
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)
|
||||||
|
|
@ -87,23 +69,15 @@ export default function ComparePeriodChart({
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -3,12 +3,10 @@ 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 = "actual" | "budget";
|
export type CompareMode = "mom" | "yoy" | "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[];
|
||||||
|
|
@ -18,52 +16,16 @@ interface State {
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: "SET_MODE"; payload: CompareMode }
|
| { type: "SET_MODE"; payload: CompareMode }
|
||||||
| { type: "SET_SUB_MODE"; payload: CompareSubMode }
|
| { type: "SET_PERIOD"; payload: { year: number; month: number } }
|
||||||
| { 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: "actual",
|
mode: "mom",
|
||||||
subMode: "mom",
|
year: today.getFullYear(),
|
||||||
year: defaultRef.year,
|
month: today.getMonth() + 1,
|
||||||
month: defaultRef.month,
|
|
||||||
rows: [],
|
rows: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
@ -73,9 +35,7 @@ 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_SUB_MODE":
|
case "SET_PERIOD":
|
||||||
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 };
|
||||||
|
|
@ -93,38 +53,33 @@ 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(
|
const fetch = useCallback(async (mode: CompareMode, year: number, month: number) => {
|
||||||
async (mode: CompareMode, subMode: CompareSubMode, year: number, month: number) => {
|
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
|
||||||
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
|
const id = ++fetchIdRef.current;
|
||||||
const id = ++fetchIdRef.current;
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
try {
|
||||||
try {
|
const rows =
|
||||||
const rows =
|
mode === "mom"
|
||||||
subMode === "mom"
|
? await getCompareMonthOverMonth(year, month)
|
||||||
? await getCompareMonthOverMonth(year, month)
|
: await getCompareYearOverYear(year);
|
||||||
: await getCompareYearOverYear(year);
|
if (id !== fetchIdRef.current) return;
|
||||||
if (id !== fetchIdRef.current) return;
|
dispatch({ type: "SET_ROWS", payload: rows });
|
||||||
dispatch({ type: "SET_ROWS", payload: rows });
|
} catch (e) {
|
||||||
} catch (e) {
|
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.mode, state.subMode, state.year, state.month);
|
fetch(state.mode, state.year, state.month);
|
||||||
}, [fetch, state.mode, state.subMode, state.year, state.month]);
|
}, [fetch, state.mode, state.year, state.month]);
|
||||||
|
|
||||||
// When the URL period changes, align the reference month with `to`.
|
// When the URL period changes, use the `to` date to infer the target year/month.
|
||||||
// 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_REFERENCE_PERIOD", payload: { year: y, month: m } });
|
dispatch({ type: "SET_PERIOD", payload: { year: y, month: m } });
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [to]);
|
}, [to]);
|
||||||
|
|
@ -133,13 +88,9 @@ export function useCompare() {
|
||||||
dispatch({ type: "SET_MODE", payload: m });
|
dispatch({ type: "SET_MODE", payload: m });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setSubMode = useCallback((s: CompareSubMode) => {
|
const setTargetPeriod = useCallback((year: number, month: number) => {
|
||||||
dispatch({ type: "SET_SUB_MODE", payload: s });
|
dispatch({ type: "SET_PERIOD", payload: { year, month } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setReferencePeriod = useCallback((year: number, month: number) => {
|
return { ...state, setMode, setTargetPeriod, from, to };
|
||||||
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { ...state, setMode, setSubMode, setReferencePeriod, from, to };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": "Compare a reference month against previous month, previous year, or budget",
|
"compareDescription": "Month, year, and budget comparisons",
|
||||||
"categoryZoom": "Category Analysis",
|
"categoryZoom": "Category Analysis",
|
||||||
"categoryZoomDescription": "Zoom in on a single category",
|
"categoryZoomDescription": "Zoom in on a single category",
|
||||||
"cartes": "Cards",
|
"cartes": "Cards",
|
||||||
|
|
@ -406,11 +406,9 @@
|
||||||
"subviewByCategory": "By category"
|
"subviewByCategory": "By category"
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
"modeActual": "Actual vs actual",
|
"modeMoM": "Month vs previous month",
|
||||||
|
"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"
|
"referenceMonth": "Reference month"
|
||||||
},
|
},
|
||||||
"cartes": {
|
"cartes": {
|
||||||
|
|
|
||||||
|
|
@ -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": "Comparer un mois de référence au précédent, à l'année passée ou au budget",
|
"compareDescription": "Comparaisons mois, année et budget",
|
||||||
"categoryZoom": "Analyse par catégorie",
|
"categoryZoom": "Analyse par catégorie",
|
||||||
"categoryZoomDescription": "Zoom sur une catégorie",
|
"categoryZoomDescription": "Zoom sur une catégorie",
|
||||||
"cartes": "Cartes",
|
"cartes": "Cartes",
|
||||||
|
|
@ -406,11 +406,9 @@
|
||||||
"subviewByCategory": "Par catégorie"
|
"subviewByCategory": "Par catégorie"
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
"modeActual": "Réel vs réel",
|
"modeMoM": "Mois vs mois précédent",
|
||||||
|
"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"
|
"referenceMonth": "Mois de référence"
|
||||||
},
|
},
|
||||||
"cartes": {
|
"cartes": {
|
||||||
|
|
|
||||||
|
|
@ -4,53 +4,42 @@ 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, comparisonMeta } from "../hooks/useCompare";
|
import { useCompare } 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";
|
||||||
|
|
||||||
function formatMonthLabel(year: number, month: number, language: string): string {
|
const MONTH_NAMES_EN = [
|
||||||
const date = new Date(year, month - 1, 1);
|
"January", "February", "March", "April", "May", "June",
|
||||||
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
"July", "August", "September", "October", "November", "December",
|
||||||
month: "long",
|
];
|
||||||
year: "numeric",
|
const MONTH_NAMES_FR = [
|
||||||
}).format(date);
|
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
|
||||||
|
"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 {
|
const { mode, setMode, year, month, rows, isLoading, error } = useCompare();
|
||||||
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 { previousYear, previousMonth: prevMonth } = comparisonMeta(subMode, year, month);
|
|
||||||
const currentLabel =
|
|
||||||
subMode === "mom" ? formatMonthLabel(year, month, i18n.language) : String(year);
|
|
||||||
const previousLabel =
|
const previousLabel =
|
||||||
subMode === "mom"
|
mode === "mom"
|
||||||
? formatMonthLabel(previousYear, prevMonth, i18n.language)
|
? `${monthName(month === 1 ? 12 : month - 1, i18n.language)} ${month === 1 ? year - 1 : year}`
|
||||||
: String(previousYear);
|
: `${year - 1}`;
|
||||||
|
const currentLabel =
|
||||||
const showActualControls = mode === "actual";
|
mode === "mom" ? `${monthName(month, i18n.language)} ${year}` : `${year}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isLoading ? "opacity-60" : ""}>
|
<div className={isLoading ? "opacity-60" : ""}>
|
||||||
|
|
@ -65,7 +54,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-4 flex-wrap">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
|
||||||
<PeriodSelector
|
<PeriodSelector
|
||||||
value={period}
|
value={period}
|
||||||
onChange={setPeriod}
|
onChange={setPeriod}
|
||||||
|
|
@ -75,23 +64,10 @@ 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} />
|
||||||
</div>
|
{mode !== "budget" && (
|
||||||
</div>
|
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||||
|
|
||||||
<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 && (
|
||||||
|
|
@ -103,11 +79,7 @@ export default function ReportsComparePage() {
|
||||||
{mode === "budget" ? (
|
{mode === "budget" ? (
|
||||||
<CompareBudgetView year={year} month={month} />
|
<CompareBudgetView year={year} month={month} />
|
||||||
) : viewMode === "chart" ? (
|
) : viewMode === "chart" ? (
|
||||||
<ComparePeriodChart
|
<ComparePeriodChart rows={rows} />
|
||||||
rows={rows}
|
|
||||||
previousLabel={previousLabel}
|
|
||||||
currentLabel={currentLabel}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<ComparePeriodTable rows={rows} previousLabel={previousLabel} currentLabel={currentLabel} />
|
<ComparePeriodTable rows={rows} previousLabel={previousLabel} currentLabel={currentLabel} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue