Collapse the three Compare tabs (MoM / YoY / Budget) into two modes. The
new "Actual vs actual" mode exposes an explicit reference-month dropdown
in the header (defaults to the previous month, wraps around January) and
a MoM/YoY sub-toggle. The chart is rewritten to a grouped side-by-side
BarChart with two bars per category (reference period vs comparison
period) so both values are visible at a glance instead of just the
delta. The URL PeriodSelector stays in sync with the reference month.
- useCompare: state splits into { mode: "actual"|"budget", subMode:
"mom"|"yoy", year, month }. Pure helpers previousMonth(),
defaultReferencePeriod(), comparisonMeta() extracted for tests
- CompareModeTabs: 2 modes instead of 3
- New CompareSubModeToggle and CompareReferenceMonthPicker components
- ComparePeriodChart: grouped bars via two <Bar dataKey="..."/> on a
vertical BarChart
- i18n: modeActual / subModeMoM / subModeYoY / referenceMonth (FR+EN),
retire modeMoM / modeYoY
- 9 new vitest cases covering the pure helpers (January wrap-around for
both MoM and YoY, default reference period, month/year arithmetic)
Closes #96
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
93 lines
2.5 KiB
TypeScript
93 lines
2.5 KiB
TypeScript
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>
|
|
);
|
|
}
|