feat: implement reports page with trends, category, and over-time charts
Add three chart tabs sharing a period selector: monthly income/expenses area chart, horizontal category bar chart, and stacked category-over-time bar chart with top 8 + Other grouping. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3506c2c87e
commit
d6000e191f
9 changed files with 594 additions and 10 deletions
65
src/components/reports/CategoryBarChart.tsx
Normal file
65
src/components/reports/CategoryBarChart.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell,
|
||||||
|
} from "recharts";
|
||||||
|
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||||
|
|
||||||
|
const eurFormatter = (value: number) =>
|
||||||
|
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value);
|
||||||
|
|
||||||
|
interface CategoryBarChartProps {
|
||||||
|
data: CategoryBreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryBarChart({ data }: CategoryBarChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
|
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(400, data.length * 40)}>
|
||||||
|
<BarChart data={data} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tickFormatter={(v) => eurFormatter(v)}
|
||||||
|
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||||
|
stroke="var(--border)"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="category_name"
|
||||||
|
width={120}
|
||||||
|
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||||
|
stroke="var(--border)"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number | undefined) => eurFormatter(value ?? 0)}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="total" name={t("dashboard.expenses")} radius={[0, 4, 4, 0]}>
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<Cell key={index} fill={item.category_color} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/reports/CategoryOverTimeChart.tsx
Normal file
77
src/components/reports/CategoryOverTimeChart.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
CartesianGrid,
|
||||||
|
} from "recharts";
|
||||||
|
import type { CategoryOverTimeData } from "../../shared/types";
|
||||||
|
|
||||||
|
const eurFormatter = (value: number) =>
|
||||||
|
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value);
|
||||||
|
|
||||||
|
function formatMonth(month: string): string {
|
||||||
|
const [year, m] = month.split("-");
|
||||||
|
const date = new Date(Number(year), Number(m) - 1);
|
||||||
|
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryOverTimeChartProps {
|
||||||
|
data: CategoryOverTimeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryOverTimeChart({ data }: CategoryOverTimeChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (data.data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
|
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tickFormatter={formatMonth}
|
||||||
|
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||||
|
stroke="var(--border)"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(v) => eurFormatter(v)}
|
||||||
|
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||||
|
stroke="var(--border)"
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number | undefined) => eurFormatter(value ?? 0)}
|
||||||
|
labelFormatter={(label) => formatMonth(String(label))}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
{data.categories.map((name) => (
|
||||||
|
<Bar
|
||||||
|
key={name}
|
||||||
|
dataKey={name}
|
||||||
|
stackId="stack"
|
||||||
|
fill={data.colors[name]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/components/reports/MonthlyTrendsChart.tsx
Normal file
93
src/components/reports/MonthlyTrendsChart.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
CartesianGrid,
|
||||||
|
} from "recharts";
|
||||||
|
import type { MonthlyTrendItem } from "../../shared/types";
|
||||||
|
|
||||||
|
const eurFormatter = (value: number) =>
|
||||||
|
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }).format(value);
|
||||||
|
|
||||||
|
function formatMonth(month: string): string {
|
||||||
|
const [year, m] = month.split("-");
|
||||||
|
const date = new Date(Number(year), Number(m) - 1);
|
||||||
|
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthlyTrendsChartProps {
|
||||||
|
data: MonthlyTrendItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
|
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<AreaChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradientIncome" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--positive)" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="var(--positive)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradientExpenses" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--negative)" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="var(--negative)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tickFormatter={formatMonth}
|
||||||
|
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||||
|
stroke="var(--border)"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(v) => eurFormatter(v)}
|
||||||
|
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||||
|
stroke="var(--border)"
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number | undefined) => eurFormatter(value ?? 0)}
|
||||||
|
labelFormatter={(label) => formatMonth(String(label))}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="income"
|
||||||
|
name={t("dashboard.income")}
|
||||||
|
stroke="var(--positive)"
|
||||||
|
fill="url(#gradientIncome)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="expenses"
|
||||||
|
name={t("dashboard.expenses")}
|
||||||
|
stroke="var(--negative)"
|
||||||
|
fill="url(#gradientExpenses)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/hooks/useReports.ts
Normal file
147
src/hooks/useReports.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
|
import type {
|
||||||
|
ReportTab,
|
||||||
|
DashboardPeriod,
|
||||||
|
MonthlyTrendItem,
|
||||||
|
CategoryBreakdownItem,
|
||||||
|
CategoryOverTimeData,
|
||||||
|
} from "../shared/types";
|
||||||
|
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
||||||
|
import { getExpensesByCategory } from "../services/dashboardService";
|
||||||
|
|
||||||
|
interface ReportsState {
|
||||||
|
tab: ReportTab;
|
||||||
|
period: DashboardPeriod;
|
||||||
|
monthlyTrends: MonthlyTrendItem[];
|
||||||
|
categorySpending: CategoryBreakdownItem[];
|
||||||
|
categoryOverTime: CategoryOverTimeData;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportsAction =
|
||||||
|
| { type: "SET_TAB"; payload: ReportTab }
|
||||||
|
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
||||||
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
|
| { type: "SET_ERROR"; payload: string | null }
|
||||||
|
| { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] }
|
||||||
|
| { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] }
|
||||||
|
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData };
|
||||||
|
|
||||||
|
const initialState: ReportsState = {
|
||||||
|
tab: "trends",
|
||||||
|
period: "6months",
|
||||||
|
monthlyTrends: [],
|
||||||
|
categorySpending: [],
|
||||||
|
categoryOverTime: { categories: [], data: [], colors: {} },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_TAB":
|
||||||
|
return { ...state, tab: action.payload };
|
||||||
|
case "SET_PERIOD":
|
||||||
|
return { ...state, period: action.payload };
|
||||||
|
case "SET_LOADING":
|
||||||
|
return { ...state, isLoading: action.payload };
|
||||||
|
case "SET_ERROR":
|
||||||
|
return { ...state, error: action.payload, isLoading: false };
|
||||||
|
case "SET_MONTHLY_TRENDS":
|
||||||
|
return { ...state, monthlyTrends: action.payload, isLoading: false };
|
||||||
|
case "SET_CATEGORY_SPENDING":
|
||||||
|
return { ...state, categorySpending: action.payload, isLoading: false };
|
||||||
|
case "SET_CATEGORY_OVER_TIME":
|
||||||
|
return { ...state, categoryOverTime: action.payload, isLoading: false };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
|
||||||
|
if (period === "all") return {};
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth();
|
||||||
|
const day = now.getDate();
|
||||||
|
|
||||||
|
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
let from: Date;
|
||||||
|
switch (period) {
|
||||||
|
case "month":
|
||||||
|
from = new Date(year, month, 1);
|
||||||
|
break;
|
||||||
|
case "3months":
|
||||||
|
from = new Date(year, month - 2, 1);
|
||||||
|
break;
|
||||||
|
case "6months":
|
||||||
|
from = new Date(year, month - 5, 1);
|
||||||
|
break;
|
||||||
|
case "12months":
|
||||||
|
from = new Date(year, month - 11, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
return { dateFrom, dateTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReports() {
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async (tab: ReportTab, period: DashboardPeriod) => {
|
||||||
|
const fetchId = ++fetchIdRef.current;
|
||||||
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { dateFrom, dateTo } = computeDateRange(period);
|
||||||
|
|
||||||
|
switch (tab) {
|
||||||
|
case "trends": {
|
||||||
|
const data = await getMonthlyTrends(dateFrom, dateTo);
|
||||||
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "byCategory": {
|
||||||
|
const data = await getExpensesByCategory(dateFrom, dateTo);
|
||||||
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "overTime": {
|
||||||
|
const data = await getCategoryOverTime(dateFrom, dateTo);
|
||||||
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
dispatch({
|
||||||
|
type: "SET_ERROR",
|
||||||
|
payload: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData(state.tab, state.period);
|
||||||
|
}, [state.tab, state.period, fetchData]);
|
||||||
|
|
||||||
|
const setTab = useCallback((tab: ReportTab) => {
|
||||||
|
dispatch({ type: "SET_TAB", payload: tab });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPeriod = useCallback((period: DashboardPeriod) => {
|
||||||
|
dispatch({ type: "SET_PERIOD", payload: period });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, setTab, setPeriod };
|
||||||
|
}
|
||||||
|
|
@ -225,9 +225,9 @@
|
||||||
"reports": {
|
"reports": {
|
||||||
"title": "Reports",
|
"title": "Reports",
|
||||||
"period": "Period",
|
"period": "Period",
|
||||||
"byCategory": "By Category",
|
"byCategory": "Expenses by Category",
|
||||||
"byMonth": "By Month",
|
"overTime": "Category Over Time",
|
||||||
"trends": "Trends",
|
"trends": "Monthly Trends",
|
||||||
"export": "Export"
|
"export": "Export"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
|
|
||||||
|
|
@ -225,9 +225,9 @@
|
||||||
"reports": {
|
"reports": {
|
||||||
"title": "Rapports",
|
"title": "Rapports",
|
||||||
"period": "Période",
|
"period": "Période",
|
||||||
"byCategory": "Par catégorie",
|
"byCategory": "Dépenses par catégorie",
|
||||||
"byMonth": "Par mois",
|
"overTime": "Catégories dans le temps",
|
||||||
"trends": "Tendances",
|
"trends": "Tendances mensuelles",
|
||||||
"export": "Exporter"
|
"export": "Exporter"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,49 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useReports } from "../hooks/useReports";
|
||||||
|
import type { ReportTab } from "../shared/types";
|
||||||
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
|
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||||
|
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||||
|
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||||
|
|
||||||
|
const TABS: ReportTab[] = ["trends", "byCategory", "overTime"];
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { state, setTab, setPeriod } = useReports();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
<h1 className="text-2xl font-bold mb-6">{t("reports.title")}</h1>
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||||
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
|
<h1 className="text-2xl font-bold">{t("reports.title")}</h1>
|
||||||
<p>{t("common.noResults")}</p>
|
<PeriodSelector value={state.period} onChange={setPeriod} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setTab(tab)}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
tab === state.tab
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`reports.${tab}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.error && (
|
||||||
|
<div className="bg-red-100 text-red-700 rounded-xl p-4 mb-6">
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.tab === "trends" && <MonthlyTrendsChart data={state.monthlyTrends} />}
|
||||||
|
{state.tab === "byCategory" && <CategoryBarChart data={state.categorySpending} />}
|
||||||
|
{state.tab === "overTime" && <CategoryOverTimeChart data={state.categoryOverTime} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
146
src/services/reportService.ts
Normal file
146
src/services/reportService.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { getDb } from "./db";
|
||||||
|
import type {
|
||||||
|
MonthlyTrendItem,
|
||||||
|
CategoryBreakdownItem,
|
||||||
|
CategoryOverTimeData,
|
||||||
|
CategoryOverTimeItem,
|
||||||
|
} from "../shared/types";
|
||||||
|
|
||||||
|
export async function getMonthlyTrends(
|
||||||
|
dateFrom?: string,
|
||||||
|
dateTo?: string
|
||||||
|
): Promise<MonthlyTrendItem[]> {
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
const whereClauses: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClauses.push(`date >= $${paramIndex}`);
|
||||||
|
params.push(dateFrom);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
whereClauses.push(`date <= $${paramIndex}`);
|
||||||
|
params.push(dateTo);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSQL =
|
||||||
|
whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
return db.select<MonthlyTrendItem[]>(
|
||||||
|
`SELECT
|
||||||
|
strftime('%Y-%m', date) AS month,
|
||||||
|
COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income,
|
||||||
|
ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses
|
||||||
|
FROM transactions
|
||||||
|
${whereSQL}
|
||||||
|
GROUP BY month
|
||||||
|
ORDER BY month ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCategoryOverTime(
|
||||||
|
dateFrom?: string,
|
||||||
|
dateTo?: string,
|
||||||
|
topN: number = 8
|
||||||
|
): Promise<CategoryOverTimeData> {
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
const whereClauses: string[] = ["t.amount < 0"];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
whereClauses.push(`t.date >= $${paramIndex}`);
|
||||||
|
params.push(dateFrom);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
whereClauses.push(`t.date <= $${paramIndex}`);
|
||||||
|
params.push(dateTo);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
|
||||||
|
|
||||||
|
// Get top N categories by total spend
|
||||||
|
const topCategories = await db.select<CategoryBreakdownItem[]>(
|
||||||
|
`SELECT
|
||||||
|
t.category_id,
|
||||||
|
COALESCE(c.name, 'Uncategorized') AS category_name,
|
||||||
|
COALESCE(c.color, '#9ca3af') AS category_color,
|
||||||
|
ABS(SUM(t.amount)) AS total
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
${whereSQL}
|
||||||
|
GROUP BY t.category_id
|
||||||
|
ORDER BY total DESC
|
||||||
|
LIMIT $${paramIndex}`,
|
||||||
|
[...params, topN]
|
||||||
|
);
|
||||||
|
|
||||||
|
const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
|
||||||
|
const colors: Record<string, string> = {};
|
||||||
|
for (const cat of topCategories) {
|
||||||
|
colors[cat.category_name] = cat.category_color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get monthly breakdown for all categories
|
||||||
|
const monthlyRows = await db.select<
|
||||||
|
Array<{
|
||||||
|
month: string;
|
||||||
|
category_id: number | null;
|
||||||
|
category_name: string;
|
||||||
|
total: number;
|
||||||
|
}>
|
||||||
|
>(
|
||||||
|
`SELECT
|
||||||
|
strftime('%Y-%m', t.date) AS month,
|
||||||
|
t.category_id,
|
||||||
|
COALESCE(c.name, 'Uncategorized') AS category_name,
|
||||||
|
ABS(SUM(t.amount)) AS total
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
${whereSQL}
|
||||||
|
GROUP BY month, t.category_id
|
||||||
|
ORDER BY month ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build pivot data
|
||||||
|
const monthMap = new Map<string, CategoryOverTimeItem>();
|
||||||
|
let hasOther = false;
|
||||||
|
|
||||||
|
for (const row of monthlyRows) {
|
||||||
|
if (!monthMap.has(row.month)) {
|
||||||
|
monthMap.set(row.month, { month: row.month });
|
||||||
|
}
|
||||||
|
const item = monthMap.get(row.month)!;
|
||||||
|
|
||||||
|
if (topCategoryIds.has(row.category_id)) {
|
||||||
|
item[row.category_name] = ((item[row.category_name] as number) || 0) + row.total;
|
||||||
|
} else {
|
||||||
|
item["Other"] = ((item["Other"] as number) || 0) + row.total;
|
||||||
|
hasOther = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOther) {
|
||||||
|
colors["Other"] = "#9ca3af";
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = topCategories.map((c) => c.category_name);
|
||||||
|
if (hasOther) {
|
||||||
|
categories.push("Other");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories,
|
||||||
|
data: Array.from(monthMap.values()),
|
||||||
|
colors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -233,6 +233,27 @@ export interface RecentTransaction {
|
||||||
category_color: string | null;
|
category_color: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Report Types ---
|
||||||
|
|
||||||
|
export type ReportTab = "trends" | "byCategory" | "overTime";
|
||||||
|
|
||||||
|
export interface MonthlyTrendItem {
|
||||||
|
month: string; // "2025-01"
|
||||||
|
income: number;
|
||||||
|
expenses: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryOverTimeItem {
|
||||||
|
month: string;
|
||||||
|
[categoryName: string]: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryOverTimeData {
|
||||||
|
categories: string[];
|
||||||
|
data: CategoryOverTimeItem[];
|
||||||
|
colors: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
export type ImportWizardStep =
|
export type ImportWizardStep =
|
||||||
| "source-list"
|
| "source-list"
|
||||||
| "source-config"
|
| "source-config"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue