feat: compare report — MoM / YoY / Actual vs Budget (#73) #92
11 changed files with 612 additions and 9 deletions
47
src/components/reports/CompareBudgetView.tsx
Normal file
47
src/components/reports/CompareBudgetView.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import BudgetVsActualTable from "./BudgetVsActualTable";
|
||||||
|
import { getBudgetVsActualData } from "../../services/budgetService";
|
||||||
|
import type { BudgetVsActualRow } from "../../shared/types";
|
||||||
|
|
||||||
|
export interface CompareBudgetViewProps {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompareBudgetView({ year, month }: CompareBudgetViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [rows, setRows] = useState<BudgetVsActualRow[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
getBudgetVsActualData(year, month)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setRows(data);
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [year, month]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4">{error}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
||||||
|
{t("reports.bva.noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BudgetVsActualTable data={rows} />;
|
||||||
|
}
|
||||||
38
src/components/reports/CompareModeTabs.tsx
Normal file
38
src/components/reports/CompareModeTabs.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { CompareMode } from "../../hooks/useCompare";
|
||||||
|
|
||||||
|
export interface CompareModeTabsProps {
|
||||||
|
value: CompareMode;
|
||||||
|
onChange: (mode: CompareMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompareModeTabs({ value, onChange }: CompareModeTabsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const modes: { id: CompareMode; labelKey: string }[] = [
|
||||||
|
{ id: "mom", labelKey: "reports.compare.modeMoM" },
|
||||||
|
{ id: "yoy", labelKey: "reports.compare.modeYoY" },
|
||||||
|
{ id: "budget", labelKey: "reports.compare.modeBudget" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex gap-1" role="tablist">
|
||||||
|
{modes.map(({ id, labelKey }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
onClick={() => onChange(id)}
|
||||||
|
aria-selected={value === id}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
value === id
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/components/reports/ComparePeriodChart.tsx
Normal file
85
src/components/reports/ComparePeriodChart.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Cell,
|
||||||
|
ReferenceLine,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import type { CategoryDelta } from "../../shared/types";
|
||||||
|
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
|
||||||
|
|
||||||
|
export interface ComparePeriodChartProps {
|
||||||
|
rows: CategoryDelta[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount: number, language: string): string {
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComparePeriodChart({ rows }: ComparePeriodChartProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
||||||
|
{t("reports.empty.noData")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = rows
|
||||||
|
.map((r, i) => ({
|
||||||
|
name: r.categoryName,
|
||||||
|
color: r.categoryColor,
|
||||||
|
delta: r.deltaAbs,
|
||||||
|
index: i,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.delta - b.delta);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(240, chartData.length * 32 + 40)}>
|
||||||
|
<BarChart data={chartData} layout="vertical" margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
|
||||||
|
<ChartPatternDefs
|
||||||
|
prefix="compare-delta"
|
||||||
|
categories={chartData.map((d) => ({ color: d.color, index: d.index }))}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tickFormatter={(v) => formatCurrency(v, i18n.language)}
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
fontSize={11}
|
||||||
|
/>
|
||||||
|
<YAxis type="category" dataKey="name" width={140} stroke="var(--muted-foreground)" fontSize={11} />
|
||||||
|
<ReferenceLine x={0} stroke="var(--border)" />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) =>
|
||||||
|
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
|
||||||
|
}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="delta">
|
||||||
|
{chartData.map((entry) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.name}
|
||||||
|
fill={getPatternFill("compare-delta", entry.index, entry.color)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/reports/ComparePeriodTable.tsx
Normal file
118
src/components/reports/ComparePeriodTable.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { CategoryDelta } from "../../shared/types";
|
||||||
|
|
||||||
|
export interface ComparePeriodTableProps {
|
||||||
|
rows: CategoryDelta[];
|
||||||
|
previousLabel: string;
|
||||||
|
currentLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount: number, language: string): string {
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignedCurrency(amount: number, language: string): string {
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
signDisplay: "always",
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(pct: number | null, language: string): string {
|
||||||
|
if (pct === null) return "—";
|
||||||
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "percent",
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
signDisplay: "always",
|
||||||
|
}).format(pct / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComparePeriodTable({
|
||||||
|
rows,
|
||||||
|
previousLabel,
|
||||||
|
currentLabel,
|
||||||
|
}: ComparePeriodTableProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[var(--border)]">
|
||||||
|
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.highlights.category")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
|
||||||
|
{previousLabel}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
|
||||||
|
{currentLabel}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.highlights.variationAbs")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("reports.highlights.variationPct")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
|
||||||
|
{t("reports.empty.noData")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`}
|
||||||
|
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: row.categoryColor }}
|
||||||
|
/>
|
||||||
|
{row.categoryName}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">
|
||||||
|
{formatCurrency(row.previousAmount, i18n.language)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">
|
||||||
|
{formatCurrency(row.currentAmount, i18n.language)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="px-3 py-2 text-right tabular-nums font-medium"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
row.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatSignedCurrency(row.deltaAbs, i18n.language)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="px-3 py-2 text-right tabular-nums"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
row.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatPct(row.deltaPct, i18n.language)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,32 @@
|
||||||
import { useReducer, useCallback } from "react";
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
|
import type { CategoryDelta } from "../shared/types";
|
||||||
|
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
|
||||||
import { useReportsPeriod } from "./useReportsPeriod";
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
export type CompareMode = "mom" | "yoy" | "budget";
|
export type CompareMode = "mom" | "yoy" | "budget";
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
mode: CompareMode;
|
mode: CompareMode;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
rows: CategoryDelta[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: "SET_MODE"; payload: CompareMode }
|
| { type: "SET_MODE"; payload: CompareMode }
|
||||||
|
| { type: "SET_PERIOD"; payload: { year: number; month: number } }
|
||||||
| { type: "SET_LOADING"; payload: boolean }
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
|
| { type: "SET_ROWS"; payload: CategoryDelta[] }
|
||||||
| { type: "SET_ERROR"; payload: string };
|
| { type: "SET_ERROR"; payload: string };
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
const initialState: State = {
|
const initialState: State = {
|
||||||
mode: "mom",
|
mode: "mom",
|
||||||
|
year: today.getFullYear(),
|
||||||
|
month: today.getMonth() + 1,
|
||||||
|
rows: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -24,8 +35,12 @@ 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":
|
||||||
|
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 };
|
||||||
|
case "SET_ROWS":
|
||||||
|
return { ...state, rows: action.payload, isLoading: false, error: null };
|
||||||
case "SET_ERROR":
|
case "SET_ERROR":
|
||||||
return { ...state, error: action.payload, isLoading: false };
|
return { ...state, error: action.payload, isLoading: false };
|
||||||
default:
|
default:
|
||||||
|
|
@ -36,11 +51,46 @@ function reducer(state: State, action: Action): State {
|
||||||
export function useCompare() {
|
export function useCompare() {
|
||||||
const { from, to } = useReportsPeriod();
|
const { from, to } = useReportsPeriod();
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
|
const fetch = useCallback(async (mode: CompareMode, year: number, month: number) => {
|
||||||
|
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
|
||||||
|
const id = ++fetchIdRef.current;
|
||||||
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
try {
|
||||||
|
const rows =
|
||||||
|
mode === "mom"
|
||||||
|
? await getCompareMonthOverMonth(year, month)
|
||||||
|
: await getCompareYearOverYear(year);
|
||||||
|
if (id !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_ROWS", payload: rows });
|
||||||
|
} catch (e) {
|
||||||
|
if (id !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(state.mode, state.year, state.month);
|
||||||
|
}, [fetch, state.mode, state.year, state.month]);
|
||||||
|
|
||||||
|
// When the URL period changes, use the `to` date to infer the target year/month.
|
||||||
|
useEffect(() => {
|
||||||
|
const [y, m] = to.split("-").map(Number);
|
||||||
|
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
|
||||||
|
if (y !== state.year || m !== state.month) {
|
||||||
|
dispatch({ type: "SET_PERIOD", payload: { year: y, month: m } });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [to]);
|
||||||
|
|
||||||
const setMode = useCallback((m: CompareMode) => {
|
const setMode = useCallback((m: CompareMode) => {
|
||||||
dispatch({ type: "SET_MODE", payload: m });
|
dispatch({ type: "SET_MODE", payload: m });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Issue #73 will fetch via reportService.getCompareMonthOverMonth / ...YearOverYear
|
const setTargetPeriod = useCallback((year: number, month: number) => {
|
||||||
return { ...state, setMode, from, to };
|
dispatch({ type: "SET_PERIOD", payload: { year, month } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ...state, setMode, setTargetPeriod, from, to };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,11 @@
|
||||||
"subviewGlobal": "Global flow",
|
"subviewGlobal": "Global flow",
|
||||||
"subviewByCategory": "By category"
|
"subviewByCategory": "By category"
|
||||||
},
|
},
|
||||||
|
"compare": {
|
||||||
|
"modeMoM": "Month vs previous month",
|
||||||
|
"modeYoY": "Year vs previous year",
|
||||||
|
"modeBudget": "Actual vs budget"
|
||||||
|
},
|
||||||
"highlights": {
|
"highlights": {
|
||||||
"balances": "Balances",
|
"balances": "Balances",
|
||||||
"netBalanceCurrent": "This month",
|
"netBalanceCurrent": "This month",
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,11 @@
|
||||||
"subviewGlobal": "Flux global",
|
"subviewGlobal": "Flux global",
|
||||||
"subviewByCategory": "Par catégorie"
|
"subviewByCategory": "Par catégorie"
|
||||||
},
|
},
|
||||||
|
"compare": {
|
||||||
|
"modeMoM": "Mois vs mois précédent",
|
||||||
|
"modeYoY": "Année vs année précédente",
|
||||||
|
"modeBudget": "Réel vs budget"
|
||||||
|
},
|
||||||
"highlights": {
|
"highlights": {
|
||||||
"balances": "Soldes",
|
"balances": "Soldes",
|
||||||
"netBalanceCurrent": "Ce mois-ci",
|
"netBalanceCurrent": "Ce mois-ci",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,88 @@
|
||||||
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
|
import CompareModeTabs from "../components/reports/CompareModeTabs";
|
||||||
|
import ComparePeriodTable from "../components/reports/ComparePeriodTable";
|
||||||
|
import ComparePeriodChart from "../components/reports/ComparePeriodChart";
|
||||||
|
import CompareBudgetView from "../components/reports/CompareBudgetView";
|
||||||
|
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
|
||||||
|
import { useCompare } from "../hooks/useCompare";
|
||||||
|
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "reports-viewmode-compare";
|
||||||
|
|
||||||
|
const MONTH_NAMES_EN = [
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December",
|
||||||
|
];
|
||||||
|
const MONTH_NAMES_FR = [
|
||||||
|
"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 } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
||||||
|
const { mode, setMode, year, month, rows, isLoading, error } = useCompare();
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
|
||||||
|
|
||||||
|
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
||||||
|
|
||||||
|
const previousLabel =
|
||||||
|
mode === "mom"
|
||||||
|
? `${monthName(month === 1 ? 12 : month - 1, i18n.language)} ${month === 1 ? year - 1 : year}`
|
||||||
|
: `${year - 1}`;
|
||||||
|
const currentLabel =
|
||||||
|
mode === "mom" ? `${monthName(month, i18n.language)} ${year}` : `${year}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-[var(--muted-foreground)]">
|
<div className={isLoading ? "opacity-60" : ""}>
|
||||||
<h1 className="text-2xl font-bold mb-4">{t("reports.hub.compare")}</h1>
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<p>{t("common.underConstruction")}</p>
|
<Link
|
||||||
|
to={`/reports${preserveSearch}`}
|
||||||
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
|
||||||
|
aria-label={t("reports.hub.title")}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold">{t("reports.hub.compare")}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
|
||||||
|
<PeriodSelector
|
||||||
|
value={period}
|
||||||
|
onChange={setPeriod}
|
||||||
|
customDateFrom={from}
|
||||||
|
customDateTo={to}
|
||||||
|
onCustomDateChange={setCustomDates}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 items-center flex-wrap">
|
||||||
|
<CompareModeTabs value={mode} onChange={setMode} />
|
||||||
|
{mode !== "budget" && (
|
||||||
|
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "budget" ? (
|
||||||
|
<CompareBudgetView year={year} month={month} />
|
||||||
|
) : viewMode === "chart" ? (
|
||||||
|
<ComparePeriodChart rows={rows} />
|
||||||
|
) : (
|
||||||
|
<ComparePeriodTable rows={rows} previousLabel={previousLabel} currentLabel={currentLabel} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { getCategoryOverTime, getHighlights } from "./reportService";
|
import {
|
||||||
|
getCategoryOverTime,
|
||||||
|
getHighlights,
|
||||||
|
getCompareMonthOverMonth,
|
||||||
|
getCompareYearOverYear,
|
||||||
|
} from "./reportService";
|
||||||
|
|
||||||
// Mock the db module
|
// Mock the db module
|
||||||
vi.mock("./db", () => ({
|
vi.mock("./db", () => ({
|
||||||
|
|
@ -266,3 +271,68 @@ describe("getHighlights", () => {
|
||||||
expect(result.topMovers[0].deltaAbs).toBe(120);
|
expect(result.topMovers[0].deltaAbs).toBe(120);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getCompareMonthOverMonth", () => {
|
||||||
|
it("passes current and previous month boundaries as parameters", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getCompareMonthOverMonth(2026, 4);
|
||||||
|
|
||||||
|
expect(mockSelect).toHaveBeenCalledTimes(1);
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(sql).toContain("$1");
|
||||||
|
expect(sql).toContain("$4");
|
||||||
|
expect(params).toEqual(["2026-04-01", "2026-04-30", "2026-03-01", "2026-03-31"]);
|
||||||
|
expect(sql).not.toContain("'2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps to december of previous year when target month is january", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getCompareMonthOverMonth(2026, 1);
|
||||||
|
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(params).toEqual(["2026-01-01", "2026-01-31", "2025-12-01", "2025-12-31"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts raw rows into CategoryDelta with signed deltas", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
category_id: 1,
|
||||||
|
category_name: "Groceries",
|
||||||
|
category_color: "#10b981",
|
||||||
|
current_total: 500,
|
||||||
|
previous_total: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category_id: 2,
|
||||||
|
category_name: "Restaurants",
|
||||||
|
category_color: "#f97316",
|
||||||
|
current_total: 120,
|
||||||
|
previous_total: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await getCompareMonthOverMonth(2026, 4);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
categoryName: "Groceries",
|
||||||
|
deltaAbs: 100,
|
||||||
|
});
|
||||||
|
expect(result[0].deltaPct).toBeCloseTo(25, 4);
|
||||||
|
expect(result[1].deltaPct).toBeNull(); // previous = 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCompareYearOverYear", () => {
|
||||||
|
it("spans two full calendar years with parameterised boundaries", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await getCompareYearOverYear(2026);
|
||||||
|
|
||||||
|
const params = mockSelect.mock.calls[0][1] as unknown[];
|
||||||
|
expect(params).toEqual(["2026-01-01", "2026-12-31", "2025-01-01", "2025-12-31"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
CategoryOverTimeItem,
|
CategoryOverTimeItem,
|
||||||
HighlightsData,
|
HighlightsData,
|
||||||
HighlightMover,
|
HighlightMover,
|
||||||
|
CategoryDelta,
|
||||||
MonthBalance,
|
MonthBalance,
|
||||||
RecentTransaction,
|
RecentTransaction,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
|
|
@ -340,3 +341,107 @@ export async function getHighlights(
|
||||||
topTransactions: recentRows,
|
topTransactions: recentRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Compare (Issue #73) ---
|
||||||
|
|
||||||
|
interface RawDeltaRow {
|
||||||
|
category_id: number | null;
|
||||||
|
category_name: string;
|
||||||
|
category_color: string;
|
||||||
|
current_total: number | null;
|
||||||
|
previous_total: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsToDeltas(rows: RawDeltaRow[]): CategoryDelta[] {
|
||||||
|
return rows.map((r) => {
|
||||||
|
const current = Number(r.current_total ?? 0);
|
||||||
|
const previous = Number(r.previous_total ?? 0);
|
||||||
|
const deltaAbs = current - previous;
|
||||||
|
const deltaPct = previous === 0 ? null : (deltaAbs / previous) * 100;
|
||||||
|
return {
|
||||||
|
categoryId: r.category_id,
|
||||||
|
categoryName: r.category_name,
|
||||||
|
categoryColor: r.category_color,
|
||||||
|
previousAmount: previous,
|
||||||
|
currentAmount: current,
|
||||||
|
deltaAbs,
|
||||||
|
deltaPct,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthBoundaries(year: number, month: number): { start: string; end: string } {
|
||||||
|
const mm = String(month).padStart(2, "0");
|
||||||
|
const endDate = new Date(Date.UTC(year, month, 0));
|
||||||
|
const dd = String(endDate.getUTCDate()).padStart(2, "0");
|
||||||
|
return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousMonth(year: number, month: number): { year: number; month: number } {
|
||||||
|
if (month === 1) return { year: year - 1, month: 12 };
|
||||||
|
return { year, month: month - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Month-over-month expense delta by category. All SQL parameterised.
|
||||||
|
*/
|
||||||
|
export async function getCompareMonthOverMonth(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
): Promise<CategoryDelta[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const { start: curStart, end: curEnd } = monthBoundaries(year, month);
|
||||||
|
const prev = previousMonth(year, month);
|
||||||
|
const { start: prevStart, end: prevEnd } = monthBoundaries(prev.year, prev.month);
|
||||||
|
|
||||||
|
const rows = await db.select<RawDeltaRow[]>(
|
||||||
|
`SELECT
|
||||||
|
t.category_id,
|
||||||
|
COALESCE(c.name, 'Uncategorized') AS category_name,
|
||||||
|
COALESCE(c.color, '#9ca3af') AS category_color,
|
||||||
|
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS current_total,
|
||||||
|
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS previous_total
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
WHERE t.amount < 0
|
||||||
|
AND (
|
||||||
|
(t.date >= $1 AND t.date <= $2)
|
||||||
|
OR (t.date >= $3 AND t.date <= $4)
|
||||||
|
)
|
||||||
|
GROUP BY t.category_id, category_name, category_color
|
||||||
|
ORDER BY ABS(current_total - previous_total) DESC`,
|
||||||
|
[curStart, curEnd, prevStart, prevEnd],
|
||||||
|
);
|
||||||
|
return rowsToDeltas(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Year-over-year expense delta by category. All SQL parameterised.
|
||||||
|
*/
|
||||||
|
export async function getCompareYearOverYear(year: number): Promise<CategoryDelta[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
const curStart = `${year}-01-01`;
|
||||||
|
const curEnd = `${year}-12-31`;
|
||||||
|
const prevStart = `${year - 1}-01-01`;
|
||||||
|
const prevEnd = `${year - 1}-12-31`;
|
||||||
|
|
||||||
|
const rows = await db.select<RawDeltaRow[]>(
|
||||||
|
`SELECT
|
||||||
|
t.category_id,
|
||||||
|
COALESCE(c.name, 'Uncategorized') AS category_name,
|
||||||
|
COALESCE(c.color, '#9ca3af') AS category_color,
|
||||||
|
COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS current_total,
|
||||||
|
COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS previous_total
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
WHERE t.amount < 0
|
||||||
|
AND (
|
||||||
|
(t.date >= $1 AND t.date <= $2)
|
||||||
|
OR (t.date >= $3 AND t.date <= $4)
|
||||||
|
)
|
||||||
|
GROUP BY t.category_id, category_name, category_color
|
||||||
|
ORDER BY ABS(current_total - previous_total) DESC`,
|
||||||
|
[curStart, curEnd, prevStart, prevEnd],
|
||||||
|
);
|
||||||
|
return rowsToDeltas(rows);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -278,7 +278,7 @@ export interface RecentTransaction {
|
||||||
|
|
||||||
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
|
export type ReportTab = "trends" | "byCategory" | "overTime" | "budgetVsActual";
|
||||||
|
|
||||||
export interface HighlightMover {
|
export interface CategoryDelta {
|
||||||
categoryId: number | null;
|
categoryId: number | null;
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
categoryColor: string;
|
categoryColor: string;
|
||||||
|
|
@ -288,6 +288,9 @@ export interface HighlightMover {
|
||||||
deltaPct: number | null; // null when previous is 0
|
deltaPct: number | null; // null when previous is 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Historical alias — used by the highlights hub. Shape identical to CategoryDelta.
|
||||||
|
export type HighlightMover = CategoryDelta;
|
||||||
|
|
||||||
export interface MonthBalance {
|
export interface MonthBalance {
|
||||||
month: string; // "YYYY-MM"
|
month: string; // "YYYY-MM"
|
||||||
netBalance: number;
|
netBalance: number;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue