feat: implement aggregated and per-account balance visualization in statistics page with interactive chart options
This commit is contained in:
@@ -119,27 +119,84 @@ export default function StatisticsPage() {
|
|||||||
const avgMonthlyExpenses =
|
const avgMonthlyExpenses =
|
||||||
monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0;
|
monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0;
|
||||||
|
|
||||||
// Balance evolution
|
// Balance evolution - Aggregated
|
||||||
const sortedTransactions = [...transactions].sort(
|
const allTransactionsForBalance = data.transactions.filter(
|
||||||
|
(t) => new Date(t.date) >= startDate
|
||||||
|
);
|
||||||
|
const sortedAllTransactions = [...allTransactionsForBalance].sort(
|
||||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
let runningBalance = 0;
|
let runningBalance = 0;
|
||||||
const balanceByDate = new Map<string, number>();
|
const aggregatedBalanceByDate = new Map<string, number>();
|
||||||
sortedTransactions.forEach((t) => {
|
sortedAllTransactions.forEach((t) => {
|
||||||
runningBalance += t.amount;
|
runningBalance += t.amount;
|
||||||
balanceByDate.set(t.date, runningBalance);
|
aggregatedBalanceByDate.set(t.date, runningBalance);
|
||||||
});
|
});
|
||||||
|
|
||||||
const balanceChartData = Array.from(balanceByDate.entries()).map(
|
const aggregatedBalanceData = Array.from(
|
||||||
([date, balance]) => ({
|
aggregatedBalanceByDate.entries()
|
||||||
|
).map(([date, balance]) => ({
|
||||||
|
date: new Date(date).toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
}),
|
||||||
|
solde: Math.round(balance),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Balance evolution - Per account
|
||||||
|
const accountBalances = new Map<string, Map<string, number>>();
|
||||||
|
data.accounts.forEach((account) => {
|
||||||
|
accountBalances.set(account.id, new Map());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate running balance per account
|
||||||
|
const accountRunningBalances = new Map<string, number>();
|
||||||
|
data.accounts.forEach((account) => {
|
||||||
|
accountRunningBalances.set(account.id, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
sortedAllTransactions.forEach((t) => {
|
||||||
|
const currentBalance = accountRunningBalances.get(t.accountId) || 0;
|
||||||
|
const newBalance = currentBalance + t.amount;
|
||||||
|
accountRunningBalances.set(t.accountId, newBalance);
|
||||||
|
|
||||||
|
const accountDates = accountBalances.get(t.accountId);
|
||||||
|
if (accountDates) {
|
||||||
|
accountDates.set(t.date, newBalance);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge all dates and create data points
|
||||||
|
const allDates = new Set<string>();
|
||||||
|
accountBalances.forEach((dates) => {
|
||||||
|
dates.forEach((_, date) => allDates.add(date));
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedDates = Array.from(allDates).sort();
|
||||||
|
const lastBalances = new Map<string, number>();
|
||||||
|
data.accounts.forEach((account) => {
|
||||||
|
lastBalances.set(account.id, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const perAccountBalanceData = sortedDates.map((date) => {
|
||||||
|
const point: { date: string; [key: string]: string | number } = {
|
||||||
date: new Date(date).toLocaleDateString("fr-FR", {
|
date: new Date(date).toLocaleDateString("fr-FR", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "short",
|
month: "short",
|
||||||
}),
|
}),
|
||||||
solde: Math.round(balance),
|
};
|
||||||
})
|
|
||||||
);
|
data.accounts.forEach((account) => {
|
||||||
|
const accountDates = accountBalances.get(account.id);
|
||||||
|
if (accountDates?.has(date)) {
|
||||||
|
lastBalances.set(account.id, accountDates.get(date)!);
|
||||||
|
}
|
||||||
|
point[account.id] = Math.round(lastBalances.get(account.id) || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return point;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
monthlyChartData,
|
monthlyChartData,
|
||||||
@@ -148,7 +205,8 @@ export default function StatisticsPage() {
|
|||||||
totalIncome,
|
totalIncome,
|
||||||
totalExpenses,
|
totalExpenses,
|
||||||
avgMonthlyExpenses,
|
avgMonthlyExpenses,
|
||||||
balanceChartData,
|
aggregatedBalanceData,
|
||||||
|
perAccountBalanceData,
|
||||||
transactionCount: transactions.length,
|
transactionCount: transactions.length,
|
||||||
};
|
};
|
||||||
}, [data, period, selectedAccount]);
|
}, [data, period, selectedAccount]);
|
||||||
@@ -219,7 +277,9 @@ export default function StatisticsPage() {
|
|||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
/>
|
/>
|
||||||
<BalanceLineChart
|
<BalanceLineChart
|
||||||
data={stats.balanceChartData}
|
aggregatedData={stats.aggregatedBalanceData}
|
||||||
|
perAccountData={stats.perAccountBalanceData}
|
||||||
|
accounts={data.accounts}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
/>
|
/>
|
||||||
<TopExpensesList
|
<TopExpensesList
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Layers, SplitSquareHorizontal } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -9,29 +12,75 @@ import {
|
|||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import type { Account } from "@/lib/types";
|
||||||
|
|
||||||
interface BalanceChartData {
|
interface BalanceChartDataPoint {
|
||||||
date: string;
|
date: string;
|
||||||
solde: number;
|
[key: string]: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BalanceLineChartProps {
|
interface BalanceLineChartProps {
|
||||||
data: BalanceChartData[];
|
aggregatedData: BalanceChartDataPoint[];
|
||||||
|
perAccountData: BalanceChartDataPoint[];
|
||||||
|
accounts: Account[];
|
||||||
formatCurrency: (amount: number) => string;
|
formatCurrency: (amount: number) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACCOUNT_COLORS = [
|
||||||
|
"#6366f1", // indigo
|
||||||
|
"#22c55e", // green
|
||||||
|
"#f59e0b", // amber
|
||||||
|
"#ef4444", // red
|
||||||
|
"#8b5cf6", // violet
|
||||||
|
"#06b6d4", // cyan
|
||||||
|
"#ec4899", // pink
|
||||||
|
"#84cc16", // lime
|
||||||
|
];
|
||||||
|
|
||||||
export function BalanceLineChart({
|
export function BalanceLineChart({
|
||||||
data,
|
aggregatedData,
|
||||||
|
perAccountData,
|
||||||
|
accounts,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
}: BalanceLineChartProps) {
|
}: BalanceLineChartProps) {
|
||||||
|
const [mode, setMode] = useState<"aggregated" | "split">("aggregated");
|
||||||
|
const [hoveredAccount, setHoveredAccount] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const data = mode === "aggregated" ? aggregatedData : perAccountData;
|
||||||
|
const hasData = data.length > 0;
|
||||||
|
|
||||||
|
const getLineOpacity = (accountId: string) => {
|
||||||
|
if (!hoveredAccount) return 1;
|
||||||
|
return hoveredAccount === accountId ? 1 : 0.15;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle>Évolution du solde</CardTitle>
|
<CardTitle>Évolution du solde</CardTitle>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant={mode === "aggregated" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setMode("aggregated")}
|
||||||
|
title="Tous les comptes agrégés"
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={mode === "split" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setMode("split")}
|
||||||
|
title="Par compte"
|
||||||
|
>
|
||||||
|
<SplitSquareHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{data.length > 0 ? (
|
{hasData ? (
|
||||||
<div className="h-[300px]">
|
<div className="h-[300px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={data}>
|
<LineChart data={data}>
|
||||||
@@ -50,13 +99,68 @@ export function BalanceLineChart({
|
|||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Line
|
{mode === "aggregated" ? (
|
||||||
type="monotone"
|
<Line
|
||||||
dataKey="solde"
|
type="monotone"
|
||||||
stroke="#6366f1"
|
dataKey="solde"
|
||||||
strokeWidth={2}
|
name="Solde total"
|
||||||
dot={false}
|
stroke="#6366f1"
|
||||||
/>
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
accounts.map((account, index) => (
|
||||||
|
<Line
|
||||||
|
key={account.id}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={account.id}
|
||||||
|
name={account.name}
|
||||||
|
stroke={ACCOUNT_COLORS[index % ACCOUNT_COLORS.length]}
|
||||||
|
strokeWidth={hoveredAccount === account.id ? 3 : 2}
|
||||||
|
strokeOpacity={getLineOpacity(account.id)}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{mode === "split" && (
|
||||||
|
<Legend
|
||||||
|
content={({ payload }) => (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap justify-center gap-x-4 gap-y-1 mt-2"
|
||||||
|
onMouseLeave={() => setHoveredAccount(null)}
|
||||||
|
>
|
||||||
|
{payload?.map((entry, index) => {
|
||||||
|
const accountId = entry.dataKey as string;
|
||||||
|
const isHovered = hoveredAccount === accountId;
|
||||||
|
const isDimmed =
|
||||||
|
hoveredAccount && hoveredAccount !== accountId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`legend-${index}`}
|
||||||
|
className="flex items-center gap-1.5 text-sm cursor-pointer transition-opacity"
|
||||||
|
style={{
|
||||||
|
opacity: isDimmed ? 0.3 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredAccount(accountId)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: entry.color,
|
||||||
|
transform: isHovered ? "scale(1.2)" : "scale(1)",
|
||||||
|
transition: "transform 0.15s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-foreground">
|
||||||
|
{entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,4 +173,3 @@ export function BalanceLineChart({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user