Compare commits
No commits in common. "5fd2108d073c8219216d24541ec876083f3098bb" and "54cbdad710c4f4b45eb325d703171c43ab359ca7" have entirely different histories.
5fd2108d07
...
54cbdad710
12 changed files with 89 additions and 383 deletions
|
|
@ -2,9 +2,6 @@
|
||||||
|
|
||||||
## [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é
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@
|
||||||
|
|
||||||
## [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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
| `useDataExport` | Export de données |
|
| `useDataExport` | Export de données |
|
||||||
| `useTheme` | Thème clair/sombre |
|
| `useTheme` | Thème clair/sombre |
|
||||||
|
|
|
||||||
|
|
@ -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,93 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
@ -404,12 +404,9 @@
|
||||||
"subviewByCategory": "By category"
|
"subviewByCategory": "By category"
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
"modeActual": "Actual vs actual",
|
"modeMoM": "Month vs previous month",
|
||||||
"modeBudget": "Actual vs budget",
|
"modeYoY": "Year vs previous year",
|
||||||
"subModeMoM": "Previous month",
|
"modeBudget": "Actual vs budget"
|
||||||
"subModeYoY": "Previous year",
|
|
||||||
"subModeAria": "Comparison period",
|
|
||||||
"referenceMonth": "Reference month"
|
|
||||||
},
|
},
|
||||||
"category": {
|
"category": {
|
||||||
"selectCategory": "Select a category",
|
"selectCategory": "Select a category",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
@ -404,12 +404,9 @@
|
||||||
"subviewByCategory": "Par catégorie"
|
"subviewByCategory": "Par catégorie"
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
"modeActual": "Réel vs réel",
|
"modeMoM": "Mois vs mois précédent",
|
||||||
"modeBudget": "Réel vs budget",
|
"modeYoY": "Année vs année précédente",
|
||||||
"subModeMoM": "Mois précédent",
|
"modeBudget": "Réel vs budget"
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -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