feat: enhance statistics page with multi-account and category filtering, and add active filters display for improved user experience

This commit is contained in:
Julien Froidefond
2025-11-29 19:15:00 +01:00
parent c082ab5e63
commit 921ee4a5f0

View File

@@ -17,13 +17,21 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } 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"; type Period = "3months" | "6months" | "12months" | "all";
export default function StatisticsPage() { export default function StatisticsPage() {
const { data, isLoading } = useBankingData(); const { data, isLoading } = useBankingData();
const [period, setPeriod] = useState<Period>("6months"); const [period, setPeriod] = useState<Period>("6months");
const [selectedAccount, setSelectedAccount] = useState<string>("all"); const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
const stats = useMemo(() => { const stats = useMemo(() => {
if (!data) return null; if (!data) return null;
@@ -49,12 +57,24 @@ export default function StatisticsPage() {
(t) => new Date(t.date) >= startDate (t) => new Date(t.date) >= startDate
); );
if (selectedAccount !== "all") { // Filter by accounts
if (!selectedAccounts.includes("all")) {
transactions = transactions.filter( transactions = transactions.filter(
(t) => t.accountId === selectedAccount (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 // Monthly breakdown
const monthlyData = new Map<string, { income: number; expenses: number }>(); const monthlyData = new Map<string, { income: number; expenses: number }>();
transactions.forEach((t) => { transactions.forEach((t) => {
@@ -119,17 +139,14 @@ export default function StatisticsPage() {
const avgMonthlyExpenses = const avgMonthlyExpenses =
monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0; monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0;
// Balance evolution - Aggregated // Balance evolution - Aggregated (using filtered transactions)
const allTransactionsForBalance = data.transactions.filter( const sortedFilteredTransactions = [...transactions].sort(
(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 aggregatedBalanceByDate = new Map<string, number>(); const aggregatedBalanceByDate = new Map<string, number>();
sortedAllTransactions.forEach((t) => { sortedFilteredTransactions.forEach((t) => {
runningBalance += t.amount; runningBalance += t.amount;
aggregatedBalanceByDate.set(t.date, runningBalance); aggregatedBalanceByDate.set(t.date, runningBalance);
}); });
@@ -144,7 +161,7 @@ export default function StatisticsPage() {
solde: Math.round(balance), solde: Math.round(balance),
})); }));
// Balance evolution - Per account // Balance evolution - Per account (using filtered transactions)
const accountBalances = new Map<string, Map<string, number>>(); const accountBalances = new Map<string, Map<string, number>>();
data.accounts.forEach((account) => { data.accounts.forEach((account) => {
accountBalances.set(account.id, new Map()); accountBalances.set(account.id, new Map());
@@ -156,7 +173,7 @@ export default function StatisticsPage() {
accountRunningBalances.set(account.id, 0); accountRunningBalances.set(account.id, 0);
}); });
sortedAllTransactions.forEach((t) => { sortedFilteredTransactions.forEach((t) => {
const currentBalance = accountRunningBalances.get(t.accountId) || 0; const currentBalance = accountRunningBalances.get(t.accountId) || 0;
const newBalance = currentBalance + t.amount; const newBalance = currentBalance + t.amount;
accountRunningBalances.set(t.accountId, newBalance); accountRunningBalances.set(t.accountId, newBalance);
@@ -209,7 +226,7 @@ export default function StatisticsPage() {
perAccountBalanceData, perAccountBalanceData,
transactionCount: transactions.length, transactionCount: transactions.length,
}; };
}, [data, period, selectedAccount]); }, [data, period, selectedAccounts, selectedCategories]);
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
@@ -227,21 +244,26 @@ export default function StatisticsPage() {
<PageHeader <PageHeader
title="Statistiques" title="Statistiques"
description="Analysez vos dépenses et revenus" description="Analysez vos dépenses et revenus"
actions={ />
<>
<Select value={selectedAccount} onValueChange={setSelectedAccount}> <Card className="mb-6">
<SelectTrigger className="w-[180px]"> <CardContent className="pt-4">
<SelectValue placeholder="Compte" /> <div className="flex flex-wrap gap-4">
</SelectTrigger> <AccountFilterCombobox
<SelectContent> accounts={data.accounts}
<SelectItem value="all">Tous les comptes</SelectItem> folders={data.folders}
{data.accounts.map((account) => ( value={selectedAccounts}
<SelectItem key={account.id} value={account.id}> onChange={setSelectedAccounts}
{account.name} className="w-[200px]"
</SelectItem> />
))}
</SelectContent> <CategoryFilterCombobox
</Select> categories={data.categories}
value={selectedCategories}
onChange={setSelectedCategories}
className="w-[220px]"
/>
<Select <Select
value={period} value={period}
onValueChange={(v) => setPeriod(v as Period)} onValueChange={(v) => setPeriod(v as Period)}
@@ -256,9 +278,28 @@ export default function StatisticsPage() {
<SelectItem value="all">Tout</SelectItem> <SelectItem value="all">Tout</SelectItem>
</SelectContent> </SelectContent>
</Select> </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 <StatsSummaryCards
totalIncome={stats.totalIncome} totalIncome={stats.totalIncome}
@@ -291,3 +332,125 @@ export default function StatisticsPage() {
</PageLayout> </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>
);
}