feat: enhance statistics page with multi-account and category filtering, and add active filters display for improved user experience
This commit is contained in:
@@ -17,13 +17,21 @@ import {
|
||||
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 [selectedAccount, setSelectedAccount] = useState<string>("all");
|
||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!data) return null;
|
||||
@@ -49,12 +57,24 @@ export default function StatisticsPage() {
|
||||
(t) => new Date(t.date) >= startDate
|
||||
);
|
||||
|
||||
if (selectedAccount !== "all") {
|
||||
// Filter by accounts
|
||||
if (!selectedAccounts.includes("all")) {
|
||||
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
|
||||
const monthlyData = new Map<string, { income: number; expenses: number }>();
|
||||
transactions.forEach((t) => {
|
||||
@@ -119,17 +139,14 @@ export default function StatisticsPage() {
|
||||
const avgMonthlyExpenses =
|
||||
monthlyData.size > 0 ? totalExpenses / monthlyData.size : 0;
|
||||
|
||||
// Balance evolution - Aggregated
|
||||
const allTransactionsForBalance = data.transactions.filter(
|
||||
(t) => new Date(t.date) >= startDate
|
||||
);
|
||||
const sortedAllTransactions = [...allTransactionsForBalance].sort(
|
||||
// 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>();
|
||||
sortedAllTransactions.forEach((t) => {
|
||||
sortedFilteredTransactions.forEach((t) => {
|
||||
runningBalance += t.amount;
|
||||
aggregatedBalanceByDate.set(t.date, runningBalance);
|
||||
});
|
||||
@@ -144,7 +161,7 @@ export default function StatisticsPage() {
|
||||
solde: Math.round(balance),
|
||||
}));
|
||||
|
||||
// Balance evolution - Per account
|
||||
// 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());
|
||||
@@ -156,7 +173,7 @@ export default function StatisticsPage() {
|
||||
accountRunningBalances.set(account.id, 0);
|
||||
});
|
||||
|
||||
sortedAllTransactions.forEach((t) => {
|
||||
sortedFilteredTransactions.forEach((t) => {
|
||||
const currentBalance = accountRunningBalances.get(t.accountId) || 0;
|
||||
const newBalance = currentBalance + t.amount;
|
||||
accountRunningBalances.set(t.accountId, newBalance);
|
||||
@@ -209,7 +226,7 @@ export default function StatisticsPage() {
|
||||
perAccountBalanceData,
|
||||
transactionCount: transactions.length,
|
||||
};
|
||||
}, [data, period, selectedAccount]);
|
||||
}, [data, period, selectedAccounts, selectedCategories]);
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
@@ -227,21 +244,26 @@ export default function StatisticsPage() {
|
||||
<PageHeader
|
||||
title="Statistiques"
|
||||
description="Analysez vos dépenses et revenus"
|
||||
actions={
|
||||
<>
|
||||
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Compte" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les comptes</SelectItem>
|
||||
{data.accounts.map((account) => (
|
||||
<SelectItem key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
|
||||
<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)}
|
||||
@@ -256,9 +278,28 @@ export default function StatisticsPage() {
|
||||
<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}
|
||||
@@ -291,3 +332,125 @@ export default function StatisticsPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user