457 lines
14 KiB
TypeScript
457 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
|
import {
|
|
StatsSummaryCards,
|
|
MonthlyChart,
|
|
CategoryPieChart,
|
|
BalanceLineChart,
|
|
TopExpensesList,
|
|
} from "@/components/statistics";
|
|
import { useBankingData } from "@/lib/hooks";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox";
|
|
import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
|
import { Filter, X, Wallet, CircleSlash, Calendar } from "lucide-react";
|
|
import type { Account, Category } from "@/lib/types";
|
|
|
|
type Period = "3months" | "6months" | "12months" | "all";
|
|
|
|
export default function StatisticsPage() {
|
|
const { data, isLoading } = useBankingData();
|
|
const [period, setPeriod] = useState<Period>("6months");
|
|
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
|
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
|
|
|
|
const stats = useMemo(() => {
|
|
if (!data) return null;
|
|
|
|
const now = new Date();
|
|
let startDate: Date;
|
|
|
|
switch (period) {
|
|
case "3months":
|
|
startDate = new Date(now.getFullYear(), now.getMonth() - 3, 1);
|
|
break;
|
|
case "6months":
|
|
startDate = new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
|
break;
|
|
case "12months":
|
|
startDate = new Date(now.getFullYear(), now.getMonth() - 12, 1);
|
|
break;
|
|
default:
|
|
startDate = new Date(0);
|
|
}
|
|
|
|
let transactions = data.transactions.filter(
|
|
(t) => new Date(t.date) >= startDate
|
|
);
|
|
|
|
// Filter by accounts
|
|
if (!selectedAccounts.includes("all")) {
|
|
transactions = transactions.filter(
|
|
(t) => selectedAccounts.includes(t.accountId)
|
|
);
|
|
}
|
|
|
|
// Filter by categories
|
|
if (!selectedCategories.includes("all")) {
|
|
if (selectedCategories.includes("uncategorized")) {
|
|
transactions = transactions.filter((t) => !t.categoryId);
|
|
} else {
|
|
transactions = transactions.filter(
|
|
(t) => t.categoryId && selectedCategories.includes(t.categoryId)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Monthly breakdown
|
|
const monthlyData = new Map<string, { income: number; expenses: number }>();
|
|
transactions.forEach((t) => {
|
|
const monthKey = t.date.substring(0, 7);
|
|
const current = monthlyData.get(monthKey) || { income: 0, expenses: 0 };
|
|
if (t.amount >= 0) {
|
|
current.income += t.amount;
|
|
} else {
|
|
current.expenses += Math.abs(t.amount);
|
|
}
|
|
monthlyData.set(monthKey, current);
|
|
});
|
|
|
|
const monthlyChartData = Array.from(monthlyData.entries())
|
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
.map(([month, values]) => ({
|
|
month: new Date(month + "-01").toLocaleDateString("fr-FR", {
|
|
month: "short",
|
|
year: "2-digit",
|
|
}),
|
|
revenus: Math.round(values.income),
|
|
depenses: Math.round(values.expenses),
|
|
solde: Math.round(values.income - values.expenses),
|
|
}));
|
|
|
|
// Category breakdown (expenses only)
|
|
const categoryTotals = new Map<string, number>();
|
|
transactions
|
|
.filter((t) => t.amount < 0)
|
|
.forEach((t) => {
|
|
const catId = t.categoryId || "uncategorized";
|
|
const current = categoryTotals.get(catId) || 0;
|
|
categoryTotals.set(catId, current + Math.abs(t.amount));
|
|
});
|
|
|
|
const categoryChartData = Array.from(categoryTotals.entries())
|
|
.map(([categoryId, total]) => {
|
|
const category = data.categories.find((c) => c.id === categoryId);
|
|
return {
|
|
name: category?.name || "Non catégorisé",
|
|
value: Math.round(total),
|
|
color: category?.color || "#94a3b8",
|
|
icon: category?.icon || "HelpCircle",
|
|
};
|
|
})
|
|
.sort((a, b) => b.value - a.value)
|
|
.slice(0, 8);
|
|
|
|
// Top expenses
|
|
const topExpenses = transactions
|
|
.filter((t) => t.amount < 0)
|
|
.sort((a, b) => a.amount - b.amount)
|
|
.slice(0, 5);
|
|
|
|
// Summary
|
|
const totalIncome = transactions
|
|
.filter((t) => t.amount >= 0)
|
|
.reduce((sum, t) => sum + t.amount, 0);
|
|
const totalExpenses = transactions
|
|
.filter((t) => t.amount < 0)
|
|
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
|
|
const avgMonthlyExpenses =
|
|
monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0;
|
|
|
|
// Balance evolution - Aggregated (using filtered transactions)
|
|
const sortedFilteredTransactions = [...transactions].sort(
|
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
);
|
|
|
|
let runningBalance = 0;
|
|
const aggregatedBalanceByDate = new Map<string, number>();
|
|
sortedFilteredTransactions.forEach((t) => {
|
|
runningBalance += t.amount;
|
|
aggregatedBalanceByDate.set(t.date, runningBalance);
|
|
});
|
|
|
|
const aggregatedBalanceData = Array.from(
|
|
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 (using filtered transactions)
|
|
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);
|
|
});
|
|
|
|
sortedFilteredTransactions.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", {
|
|
day: "2-digit",
|
|
month: "short",
|
|
}),
|
|
};
|
|
|
|
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 {
|
|
monthlyChartData,
|
|
categoryChartData,
|
|
topExpenses,
|
|
totalIncome,
|
|
totalExpenses,
|
|
avgMonthlyExpenses,
|
|
aggregatedBalanceData,
|
|
perAccountBalanceData,
|
|
transactionCount: transactions.length,
|
|
};
|
|
}, [data, period, selectedAccounts, selectedCategories]);
|
|
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat("fr-FR", {
|
|
style: "currency",
|
|
currency: "EUR",
|
|
}).format(amount);
|
|
};
|
|
|
|
if (isLoading || !data || !stats) {
|
|
return <LoadingState />;
|
|
}
|
|
|
|
return (
|
|
<PageLayout>
|
|
<PageHeader
|
|
title="Statistiques"
|
|
description="Analysez vos dépenses et revenus"
|
|
/>
|
|
|
|
<Card className="mb-6">
|
|
<CardContent className="pt-4">
|
|
<div className="flex flex-wrap gap-4">
|
|
<AccountFilterCombobox
|
|
accounts={data.accounts}
|
|
folders={data.folders}
|
|
value={selectedAccounts}
|
|
onChange={setSelectedAccounts}
|
|
className="w-[200px]"
|
|
/>
|
|
|
|
<CategoryFilterCombobox
|
|
categories={data.categories}
|
|
value={selectedCategories}
|
|
onChange={setSelectedCategories}
|
|
className="w-[220px]"
|
|
/>
|
|
|
|
<Select
|
|
value={period}
|
|
onValueChange={(v) => setPeriod(v as Period)}
|
|
>
|
|
<SelectTrigger className="w-[150px]">
|
|
<SelectValue placeholder="Période" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="3months">3 mois</SelectItem>
|
|
<SelectItem value="6months">6 mois</SelectItem>
|
|
<SelectItem value="12months">12 mois</SelectItem>
|
|
<SelectItem value="all">Tout</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<ActiveFilters
|
|
selectedAccounts={selectedAccounts}
|
|
onRemoveAccount={(id) => {
|
|
const newAccounts = selectedAccounts.filter((a) => a !== id);
|
|
setSelectedAccounts(newAccounts.length > 0 ? newAccounts : ["all"]);
|
|
}}
|
|
onClearAccounts={() => setSelectedAccounts(["all"])}
|
|
selectedCategories={selectedCategories}
|
|
onRemoveCategory={(id) => {
|
|
const newCategories = selectedCategories.filter((c) => c !== id);
|
|
setSelectedCategories(newCategories.length > 0 ? newCategories : ["all"]);
|
|
}}
|
|
onClearCategories={() => setSelectedCategories(["all"])}
|
|
period={period}
|
|
onClearPeriod={() => setPeriod("all")}
|
|
accounts={data.accounts}
|
|
categories={data.categories}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<StatsSummaryCards
|
|
totalIncome={stats.totalIncome}
|
|
totalExpenses={stats.totalExpenses}
|
|
avgMonthlyExpenses={stats.avgMonthlyExpenses}
|
|
formatCurrency={formatCurrency}
|
|
/>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<MonthlyChart
|
|
data={stats.monthlyChartData}
|
|
formatCurrency={formatCurrency}
|
|
/>
|
|
<CategoryPieChart
|
|
data={stats.categoryChartData}
|
|
formatCurrency={formatCurrency}
|
|
/>
|
|
<BalanceLineChart
|
|
aggregatedData={stats.aggregatedBalanceData}
|
|
perAccountData={stats.perAccountBalanceData}
|
|
accounts={data.accounts}
|
|
formatCurrency={formatCurrency}
|
|
/>
|
|
<TopExpensesList
|
|
expenses={stats.topExpenses}
|
|
categories={data.categories}
|
|
formatCurrency={formatCurrency}
|
|
/>
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
function ActiveFilters({
|
|
selectedAccounts,
|
|
onRemoveAccount,
|
|
onClearAccounts,
|
|
selectedCategories,
|
|
onRemoveCategory,
|
|
onClearCategories,
|
|
period,
|
|
onClearPeriod,
|
|
accounts,
|
|
categories,
|
|
}: {
|
|
selectedAccounts: string[];
|
|
onRemoveAccount: (id: string) => void;
|
|
onClearAccounts: () => void;
|
|
selectedCategories: string[];
|
|
onRemoveCategory: (id: string) => void;
|
|
onClearCategories: () => void;
|
|
period: Period;
|
|
onClearPeriod: () => void;
|
|
accounts: Account[];
|
|
categories: Category[];
|
|
}) {
|
|
const hasAccounts = !selectedAccounts.includes("all");
|
|
const hasCategories = !selectedCategories.includes("all");
|
|
const hasPeriod = period !== "all";
|
|
|
|
const hasActiveFilters = hasAccounts || hasCategories || hasPeriod;
|
|
|
|
if (!hasActiveFilters) return null;
|
|
|
|
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
|
|
const selectedCats = categories.filter((c) => selectedCategories.includes(c.id));
|
|
const isUncategorized = selectedCategories.includes("uncategorized");
|
|
|
|
const getPeriodLabel = (p: Period) => {
|
|
switch (p) {
|
|
case "3months":
|
|
return "3 mois";
|
|
case "6months":
|
|
return "6 mois";
|
|
case "12months":
|
|
return "12 mois";
|
|
default:
|
|
return "Tout";
|
|
}
|
|
};
|
|
|
|
const clearAll = () => {
|
|
onClearAccounts();
|
|
onClearCategories();
|
|
onClearPeriod();
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border flex-wrap">
|
|
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
|
|
|
{selectedAccs.map((acc) => (
|
|
<Badge key={acc.id} variant="secondary" className="gap-1 text-xs font-normal">
|
|
<Wallet className="h-3 w-3" />
|
|
{acc.name}
|
|
<button
|
|
onClick={() => onRemoveAccount(acc.id)}
|
|
className="ml-1 hover:text-foreground"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
|
|
{isUncategorized && (
|
|
<Badge variant="secondary" className="gap-1 text-xs font-normal">
|
|
<CircleSlash className="h-3 w-3" />
|
|
Non catégorisé
|
|
<button onClick={onClearCategories} className="ml-1 hover:text-foreground">
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
)}
|
|
|
|
{selectedCats.map((cat) => (
|
|
<Badge
|
|
key={cat.id}
|
|
variant="secondary"
|
|
className="gap-1 text-xs font-normal"
|
|
style={{
|
|
backgroundColor: `${cat.color}15`,
|
|
borderColor: `${cat.color}30`,
|
|
}}
|
|
>
|
|
<CategoryIcon icon={cat.icon} color={cat.color} size={12} />
|
|
{cat.name}
|
|
<button
|
|
onClick={() => onRemoveCategory(cat.id)}
|
|
className="ml-1 hover:text-foreground"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
|
|
{hasPeriod && (
|
|
<Badge variant="secondary" className="gap-1 text-xs font-normal">
|
|
<Calendar className="h-3 w-3" />
|
|
{getPeriodLabel(period)}
|
|
<button onClick={onClearPeriod} className="ml-1 hover:text-foreground">
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
)}
|
|
|
|
<button
|
|
onClick={clearAll}
|
|
className="text-xs text-muted-foreground hover:text-foreground ml-auto"
|
|
>
|
|
Effacer tout
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|