feat: sticky category column, month dropdown selector, default to last completed month (#29)

- Add sticky left-0 positioning to all category cells in BudgetVsActualTable
- Replace MonthNavigator arrows with inline title + dropdown month selector
- Default budget month to previous completed month instead of current
- Add i18n keys for new title prefix (FR/EN)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
medic-bot 2026-03-09 14:05:47 -04:00
parent 8742c25945
commit 16c6d02e39
7 changed files with 61 additions and 21 deletions

View file

@ -5,6 +5,11 @@
### Ajouté ### Ajouté
- Tableau de budget : colonne du total de l'année précédente affichée comme première colonne de données pour servir de référence (#16) - Tableau de budget : colonne du total de l'année précédente affichée comme première colonne de données pour servir de référence (#16)
### Modifié
- Budget vs Réel : la colonne des catégories reste désormais fixe lors du défilement horizontal (#29)
- Budget vs Réel : titre changé pour « Budget vs Réel pour le mois de [mois] » avec un menu déroulant pour sélectionner le mois (#29)
- Budget vs Réel : le mois par défaut est maintenant le dernier mois complété au lieu du mois courant (#29)
## [0.6.3] ## [0.6.3]
### Ajouté ### Ajouté

View file

@ -5,6 +5,11 @@
### Added ### Added
- Budget table: previous year total column displayed as first data column for baseline reference (#16) - Budget table: previous year total column displayed as first data column for baseline reference (#16)
### Changed
- Budget vs Actual: category column now stays fixed when scrolling horizontally (#29)
- Budget vs Actual: title changed to "Budget vs Réel pour le mois de [month]" with a dropdown month selector (#29)
- Budget vs Actual: default month is now the last completed month instead of current month (#29)
## [0.6.3] ## [0.6.3]
### Added ### Added

View file

@ -151,7 +151,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="sticky top-0 z-20"> <thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]"> <tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom bg-[var(--card)]"> <th rowSpan={2} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]">
{t("budget.category")} {t("budget.category")}
</th> </th>
<th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"> <th colSpan={4} className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
@ -207,7 +207,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
return ( return (
<Fragment key={section.type}> <Fragment key={section.type}>
<tr className="bg-[var(--muted)]/50"> <tr className="bg-[var(--muted)]/50">
<td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider"> <td colSpan={9} className="px-3 py-1.5 font-semibold text-[var(--muted-foreground)] uppercase text-xs tracking-wider sticky left-0">
{section.label} {section.label}
</td> </td>
</tr> </tr>
@ -224,7 +224,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
isIntermediateParent ? "bg-[var(--muted)]/15 font-medium" : "" isIntermediateParent ? "bg-[var(--muted)]/15 font-medium" : ""
}`} }`}
> >
<td className={`py-1.5 ${isParent && !isIntermediateParent ? "px-3" : paddingClass}`}> <td className={`py-1.5 sticky left-0 z-10 ${isParent && !isIntermediateParent ? "px-3 bg-[var(--muted)]/30" : isIntermediateParent ? "bg-[var(--muted)]/15" : "bg-[var(--card)]"} ${isParent && !isIntermediateParent ? "px-3" : paddingClass}`}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
@ -257,7 +257,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
); );
})} })}
<tr className="border-b border-[var(--border)] bg-[var(--muted)]/40 font-semibold text-sm"> <tr className="border-b border-[var(--border)] bg-[var(--muted)]/40 font-semibold text-sm">
<td className="px-3 py-2.5">{t(typeTotalKeys[section.type])}</td> <td className="px-3 py-2.5 sticky left-0 bg-[var(--muted)]/40 z-10">{t(typeTotalKeys[section.type])}</td>
<td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50"> <td className="text-right px-3 py-2.5 border-l border-[var(--border)]/50">
{cadFormatter(sectionTotals.monthActual)} {cadFormatter(sectionTotals.monthActual)}
</td> </td>
@ -284,7 +284,7 @@ export default function BudgetVsActualTable({ data }: BudgetVsActualTableProps)
})} })}
{/* Grand totals */} {/* Grand totals */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20"> <tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td className="px-3 py-3">{t("common.total")}</td> <td className="px-3 py-3 sticky left-0 bg-[var(--muted)]/20 z-10">{t("common.total")}</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50"> <td className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(totals.monthActual)} {cadFormatter(totals.monthActual)}
</td> </td>

View file

@ -59,8 +59,8 @@ const initialState: ReportsState = {
monthlyTrends: [], monthlyTrends: [],
categorySpending: [], categorySpending: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} }, categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
budgetYear: now.getFullYear(), budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
budgetMonth: now.getMonth() + 1, budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
budgetVsActual: [], budgetVsActual: [],
pivotConfig: { rows: [], columns: [], filters: {}, values: [] }, pivotConfig: { rows: [], columns: [], filters: {}, values: [] },
pivotResult: { rows: [], columnValues: [], dimensionLabels: {} }, pivotResult: { rows: [], columnValues: [], dimensionLabels: {} },
@ -237,6 +237,10 @@ export function useReports() {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year: newYear, month: newMonth } }); dispatch({ type: "SET_BUDGET_MONTH", payload: { year: newYear, month: newMonth } });
}, [state.budgetYear, state.budgetMonth]); }, [state.budgetYear, state.budgetMonth]);
const setBudgetMonth = useCallback((year: number, month: number) => {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
}, []);
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => { const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } }); dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []); }, []);
@ -249,5 +253,5 @@ export function useReports() {
dispatch({ type: "SET_SOURCE_ID", payload: id }); dispatch({ type: "SET_SOURCE_ID", payload: id });
}, []); }, []);
return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig, setSourceId }; return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setBudgetMonth, setPivotConfig, setSourceId };
} }

View file

@ -377,7 +377,8 @@
"ytd": "Year-to-Date", "ytd": "Year-to-Date",
"dollarVar": "$ Var", "dollarVar": "$ Var",
"pctVar": "% Var", "pctVar": "% Var",
"noData": "No budget or transaction data for this period." "noData": "No budget or transaction data for this period.",
"titlePrefix": "Budget vs Actual for"
}, },
"dynamic": "Dynamic Report", "dynamic": "Dynamic Report",
"export": "Export", "export": "Export",

View file

@ -377,7 +377,8 @@
"ytd": "Cumul annuel", "ytd": "Cumul annuel",
"dollarVar": "$ \u00c9cart", "dollarVar": "$ \u00c9cart",
"pctVar": "% \u00c9cart", "pctVar": "% \u00c9cart",
"noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode." "noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode.",
"titlePrefix": "Budget vs Réel pour le mois de"
}, },
"dynamic": "Rapport dynamique", "dynamic": "Rapport dynamique",
"export": "Exporter", "export": "Exporter",

View file

@ -6,7 +6,6 @@ import { PageHelp } from "../components/shared/PageHelp";
import type { ReportTab, CategoryBreakdownItem, DashboardPeriod, ImportSource } from "../shared/types"; import type { ReportTab, CategoryBreakdownItem, DashboardPeriod, ImportSource } from "../shared/types";
import { getAllSources } from "../services/importSourceService"; import { getAllSources } from "../services/importSourceService";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import MonthNavigator from "../components/budget/MonthNavigator";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart"; import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable"; import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
import CategoryBarChart from "../components/reports/CategoryBarChart"; import CategoryBarChart from "../components/reports/CategoryBarChart";
@ -48,8 +47,8 @@ function computeDateRange(
} }
export default function ReportsPage() { export default function ReportsPage() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth, setPivotConfig, setSourceId } = useReports(); const { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setPivotConfig, setSourceId } = useReports();
const [sources, setSources] = useState<ImportSource[]>([]); const [sources, setSources] = useState<ImportSource[]>([]);
useEffect(() => { useEffect(() => {
@ -100,16 +99,41 @@ export default function ReportsPage() {
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}> <div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6"> <div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{state.tab === "budgetVsActual" ? (
<h1 className="text-2xl font-bold flex items-center gap-2 flex-wrap">
{t("reports.bva.titlePrefix")}
<select
value={`${state.budgetYear}-${state.budgetMonth}`}
onChange={(e) => {
const [y, m] = e.target.value.split("-").map(Number);
setBudgetMonth(y, m);
}}
className="text-2xl font-bold bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-1 cursor-pointer hover:bg-[var(--muted)] transition-colors"
>
{(() => {
const now = new Date();
const currentMonth = now.getMonth(); // 0-based
const currentYear = now.getFullYear();
return Array.from({ length: 24 }, (_, i) => {
const d = new Date(currentYear, currentMonth - i, 1);
const y = d.getFullYear();
const m = d.getMonth() + 1;
const label = new Intl.DateTimeFormat(i18n.language, { month: "long", year: "numeric" }).format(d);
return (
<option key={`${y}-${m}`} value={`${y}-${m}`}>
{label.charAt(0).toUpperCase() + label.slice(1)}
</option>
);
});
})()}
</select>
</h1>
) : (
<h1 className="text-2xl font-bold">{t("reports.title")}</h1> <h1 className="text-2xl font-bold">{t("reports.title")}</h1>
)}
<PageHelp helpKey="reports" /> <PageHelp helpKey="reports" />
</div> </div>
{state.tab === "budgetVsActual" ? ( {state.tab !== "budgetVsActual" && (
<MonthNavigator
year={state.budgetYear}
month={state.budgetMonth}
onNavigate={navigateBudgetMonth}
/>
) : (
<PeriodSelector <PeriodSelector
value={state.period} value={state.period}
onChange={setPeriod} onChange={setPeriod}