Files
fintrack/app/statistics/page.tsx

1082 lines
36 KiB
TypeScript

"use client";
import { useState, useMemo } from "react";
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import {
StatsSummaryCards,
MonthlyChart,
CategoryPieChart,
BalanceLineChart,
TopExpensesList,
CategoryBarChart,
CategoryTrendChart,
SavingsTrendChart,
IncomeExpenseTrendChart,
YearOverYearChart,
} 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 { Checkbox } from "@/components/ui/checkbox";
import { Filter, X, Wallet, CircleSlash, Calendar } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { useIsMobile } from "@/hooks/use-mobile";
import type { Account, Category } from "@/lib/types";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "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 [excludeInternalTransfers, setExcludeInternalTransfers] =
useState(true);
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
undefined,
);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
undefined,
);
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
// Get start date based on period
const startDate = useMemo(() => {
const now = new Date();
switch (period) {
case "1month":
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
case "3months":
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
case "6months":
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
case "12months":
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
case "custom":
return customStartDate || new Date(0);
default:
return new Date(0);
}
}, [period, customStartDate]);
// Get end date (only for custom period)
const endDate = useMemo(() => {
if (period === "custom" && customEndDate) {
return customEndDate;
}
return undefined;
}, [period, customEndDate]);
// Find "Virement interne" category
const internalTransferCategory = useMemo(() => {
if (!data) return null;
return data.categories.find(
(c) => c.name.toLowerCase() === "virement interne",
);
}, [data]);
// Transactions filtered for account filter (by categories, period - not accounts)
const transactionsForAccountFilter = useMemo(() => {
if (!data) return [];
return data.transactions
.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
if (transactionDate < startDate || transactionDate > endOfDay) {
return false;
}
} else {
// Standard period
if (transactionDate < startDate) {
return false;
}
}
return true;
})
.filter((t) => {
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
return !t.categoryId;
} else {
return t.categoryId && selectedCategories.includes(t.categoryId);
}
}
return true;
})
.filter((t) => {
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
return t.categoryId !== internalTransferCategory.id;
}
return true;
});
}, [
data,
startDate,
endDate,
selectedCategories,
excludeInternalTransfers,
internalTransferCategory,
]);
// Transactions filtered for category filter (by accounts, period - not categories)
const transactionsForCategoryFilter = useMemo(() => {
if (!data) return [];
return data.transactions
.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
if (transactionDate < startDate || transactionDate > endOfDay) {
return false;
}
} else {
// Standard period
if (transactionDate < startDate) {
return false;
}
}
return true;
})
.filter((t) => {
if (!selectedAccounts.includes("all")) {
return selectedAccounts.includes(t.accountId);
}
return true;
})
.filter((t) => {
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
return t.categoryId !== internalTransferCategory.id;
}
return true;
});
}, [
data,
startDate,
endDate,
selectedAccounts,
excludeInternalTransfers,
internalTransferCategory,
]);
const stats = useMemo(() => {
if (!data) return null;
let transactions = data.transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
return transactionDate >= startDate && transactionDate <= endOfDay;
} else {
// Standard period
return transactionDate >= 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),
);
}
}
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
transactions = transactions.filter(
(t) => t.categoryId !== internalTransferCategory.id,
);
}
// 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: "numeric",
}),
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);
// Category breakdown grouped by parent (expenses only)
const categoryTotalsByParent = new Map<string, number>();
transactions
.filter((t) => t.amount < 0)
.forEach((t) => {
const category = data.categories.find((c) => c.id === t.categoryId);
// Use parent category ID if exists, otherwise use the category itself
let groupId: string;
if (!category) {
groupId = "uncategorized";
} else if (category.parentId) {
groupId = category.parentId;
} else {
// Category is a parent itself
groupId = category.id;
}
const current = categoryTotalsByParent.get(groupId) || 0;
categoryTotalsByParent.set(groupId, current + Math.abs(t.amount));
});
const categoryChartDataByParent = Array.from(
categoryTotalsByParent.entries(),
)
.map(([groupId, total]) => {
const category = data.categories.find((c) => c.id === groupId);
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);
// Top expenses - deduplicate by ID and sort by amount (most negative first)
const uniqueTransactions = Array.from(
new Map(transactions.map((t) => [t.id, t])).values(),
);
const topExpenses = uniqueTransactions
.filter((t) => t.amount < 0)
.sort((a, b) => {
// Sort by amount (most negative first)
if (a.amount !== b.amount) {
return a.amount - b.amount;
}
// If same amount, sort by date (most recent first) for stable sorting
return new Date(b.date).getTime() - new Date(a.date).getTime();
})
.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(),
);
// Calculate starting balance: initialBalance + transactions before startDate
let runningBalance = 0;
const accountsToUse = selectedAccounts.includes("all")
? data.accounts
: data.accounts.filter((acc) => selectedAccounts.includes(acc.id));
// Start with initial balances
runningBalance = accountsToUse.reduce(
(sum, acc) => sum + (acc.initialBalance || 0),
0,
);
// Add all transactions before the start date for these accounts
const accountsToUseIds = new Set(accountsToUse.map((a) => a.id));
data.transactions
.filter((t) => {
// Filter by account
if (!accountsToUseIds.has(t.accountId)) return false;
// Filter by category if needed
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
if (t.categoryId) return false;
} else {
if (!t.categoryId || !selectedCategories.includes(t.categoryId))
return false;
}
}
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
if (t.categoryId === internalTransferCategory.id) return false;
}
// Only transactions before startDate
const transactionDate = new Date(t.date);
return transactionDate < startDate;
})
.forEach((t) => {
runningBalance += t.amount;
});
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",
year: "numeric",
}),
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
// Start with initialBalance + transactions before startDate
const accountRunningBalances = new Map<string, number>();
data.accounts.forEach((account) => {
let accountBalance = account.initialBalance || 0;
// Add transactions before startDate for this account
data.transactions
.filter((t) => {
if (t.accountId !== account.id) return false;
// Filter by category if needed
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
if (t.categoryId) return false;
} else {
if (!t.categoryId || !selectedCategories.includes(t.categoryId))
return false;
}
}
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
if (t.categoryId === internalTransferCategory.id) return false;
}
const transactionDate = new Date(t.date);
return transactionDate < startDate;
})
.forEach((t) => {
accountBalance += t.amount;
});
accountRunningBalances.set(account.id, accountBalance);
});
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>();
// Initialize with the starting balance (initialBalance + transactions before startDate)
data.accounts.forEach((account) => {
const startingBalance = accountRunningBalances.get(account.id) || 0;
lastBalances.set(account.id, startingBalance);
});
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",
year: "numeric",
}),
};
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;
});
// Savings trend data
const savingsTrendData = monthlyChartData.map((month) => ({
month: month.month,
savings: month.solde,
cumulative: 0, // Will be calculated if needed
}));
// Category trend data (monthly breakdown by category)
const categoryTrendByMonth = new Map<string, Map<string, number>>();
transactions
.filter((t) => t.amount < 0)
.forEach((t) => {
const monthKey = t.date.substring(0, 7);
const catId = t.categoryId || "uncategorized";
if (!categoryTrendByMonth.has(monthKey)) {
categoryTrendByMonth.set(monthKey, new Map());
}
const monthCategories = categoryTrendByMonth.get(monthKey)!;
const current = monthCategories.get(catId) || 0;
monthCategories.set(catId, current + Math.abs(t.amount));
});
const allCategoryMonths = Array.from(categoryTrendByMonth.keys()).sort();
const categoryTrendData = allCategoryMonths.map((monthKey) => {
const point: { month: string; [key: string]: string | number } = {
month: new Date(monthKey + "-01").toLocaleDateString("fr-FR", {
month: "short",
year: "numeric",
}),
};
const monthCategories = categoryTrendByMonth.get(monthKey)!;
monthCategories.forEach((total, catId) => {
point[catId] = Math.round(total);
});
return point;
});
// Category trend data grouped by parent category
const categoryTrendByMonthByParent = new Map<string, Map<string, number>>();
transactions
.filter((t) => t.amount < 0)
.forEach((t) => {
const monthKey = t.date.substring(0, 7);
const category = data.categories.find((c) => c.id === t.categoryId);
// Use parent category ID if category has a parent, otherwise use the category itself
// If category is null (uncategorized), use "uncategorized"
let groupId: string;
if (!category) {
groupId = "uncategorized";
} else if (category.parentId) {
groupId = category.parentId;
} else {
// Category is a parent itself
groupId = category.id;
}
if (!categoryTrendByMonthByParent.has(monthKey)) {
categoryTrendByMonthByParent.set(monthKey, new Map());
}
const monthCategories = categoryTrendByMonthByParent.get(monthKey)!;
const current = monthCategories.get(groupId) || 0;
monthCategories.set(groupId, current + Math.abs(t.amount));
});
// Use the same months as categoryTrendData
const categoryTrendDataByParent = allCategoryMonths.map((monthKey) => {
const point: { month: string; [key: string]: string | number } = {
month: new Date(monthKey + "-01").toLocaleDateString("fr-FR", {
month: "short",
year: "numeric",
}),
};
const monthCategories = categoryTrendByMonthByParent.get(monthKey);
if (monthCategories) {
monthCategories.forEach((total, groupId) => {
point[groupId] = Math.round(total);
});
}
return point;
});
// Year over year comparison (if we have at least 12 months)
const currentYear = new Date().getFullYear();
const previousYear = currentYear - 1;
const yearOverYearData: Array<{
month: string;
current: number;
previous: number;
label: string;
}> = [];
if (allCategoryMonths.length >= 12) {
const currentYearData = new Map<string, number>();
const previousYearData = new Map<string, number>();
monthlyData.forEach((values, monthKey) => {
const date = new Date(monthKey + "-01");
const monthName = date.toLocaleDateString("fr-FR", { month: "short" });
const year = date.getFullYear();
if (year === currentYear) {
currentYearData.set(monthName, values.expenses);
} else if (year === previousYear) {
previousYearData.set(monthName, values.expenses);
}
});
// Get all months from current year
const months = Array.from(currentYearData.keys());
months.forEach((month) => {
yearOverYearData.push({
month,
current: currentYearData.get(month) || 0,
previous: previousYearData.get(month) || 0,
label: month,
});
});
}
return {
monthlyChartData,
categoryChartData,
categoryChartDataByParent,
topExpenses,
totalIncome,
totalExpenses,
avgMonthlyExpenses,
aggregatedBalanceData,
perAccountBalanceData,
transactionCount: transactions.length,
savingsTrendData,
categoryTrendData,
categoryTrendDataByParent,
yearOverYearData,
};
}, [
data,
startDate,
endDate,
selectedAccounts,
selectedCategories,
excludeInternalTransfers,
internalTransferCategory,
]);
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-4 md:mb-6">
<CardContent className="pt-3 md:pt-4">
<div className="flex flex-wrap gap-2 md:gap-4">
<AccountFilterCombobox
accounts={data.accounts}
folders={data.folders}
value={selectedAccounts}
onChange={setSelectedAccounts}
className="w-full md:w-[280px]"
filteredTransactions={transactionsForAccountFilter}
/>
<CategoryFilterCombobox
categories={data.categories}
value={selectedCategories}
onChange={setSelectedCategories}
className="w-full md:w-[220px]"
filteredTransactions={transactionsForCategoryFilter}
/>
<Select
value={period}
onValueChange={(v) => {
setPeriod(v as Period);
if (v !== "custom") {
setIsCustomDatePickerOpen(false);
} else {
setIsCustomDatePickerOpen(true);
}
}}
>
<SelectTrigger className="w-full md:w-[150px]">
<SelectValue placeholder="Période" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1month">1 mois</SelectItem>
<SelectItem value="3months">3 mois</SelectItem>
<SelectItem value="6months">6 mois</SelectItem>
<SelectItem value="12months">12 mois</SelectItem>
<SelectItem value="custom">Personnalisé</SelectItem>
<SelectItem value="all">Tout</SelectItem>
</SelectContent>
</Select>
{period === "custom" && (
<Popover
open={isCustomDatePickerOpen}
onOpenChange={setIsCustomDatePickerOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full md:w-[280px] justify-start text-left font-normal"
>
<Calendar className="mr-2 h-4 w-4" />
{customStartDate && customEndDate ? (
<>
{format(customStartDate, "PPP", { locale: fr })} -{" "}
{format(customEndDate, "PPP", { locale: fr })}
</>
) : customStartDate ? (
format(customStartDate, "PPP", { locale: fr })
) : (
<span className="text-muted-foreground">
Sélectionner les dates
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Date de début
</label>
<CalendarComponent
mode="single"
selected={customStartDate}
onSelect={(date) => {
setCustomStartDate(date);
if (date && customEndDate && date > customEndDate) {
setCustomEndDate(undefined);
}
}}
locale={fr}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Date de fin</label>
<CalendarComponent
mode="single"
selected={customEndDate}
onSelect={(date) => {
if (
date &&
customStartDate &&
date < customStartDate
) {
return;
}
setCustomEndDate(date);
if (date && customStartDate) {
setIsCustomDatePickerOpen(false);
}
}}
disabled={(date) => {
if (!customStartDate) return true;
return date < customStartDate;
}}
locale={fr}
/>
</div>
{customStartDate && customEndDate && (
<div className="flex gap-2 pt-2 border-t">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => {
setCustomStartDate(undefined);
setCustomEndDate(undefined);
}}
>
Réinitialiser
</Button>
<Button
size="sm"
className="flex-1"
onClick={() => setIsCustomDatePickerOpen(false)}
>
Valider
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
)}
{internalTransferCategory && (
<div className="flex items-center gap-2 px-2 md:px-3 py-1.5 md:py-2 border border-border rounded-md bg-[var(--card)]">
<Checkbox
id="exclude-internal-transfers"
checked={excludeInternalTransfers}
onCheckedChange={(checked) =>
setExcludeInternalTransfers(checked === true)
}
/>
<label
htmlFor="exclude-internal-transfers"
className="text-xs md:text-sm font-medium cursor-pointer select-none"
>
Exclure Virement interne
</label>
</div>
)}
</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");
setCustomStartDate(undefined);
setCustomEndDate(undefined);
}}
accounts={data.accounts}
categories={data.categories}
customStartDate={customStartDate}
customEndDate={customEndDate}
/>
</CardContent>
</Card>
{/* Vue d'ensemble */}
<section className="mb-4 md:mb-8">
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">
Vue d'ensemble
</h2>
<StatsSummaryCards
totalIncome={stats.totalIncome}
totalExpenses={stats.totalExpenses}
avgMonthlyExpenses={stats.avgMonthlyExpenses}
formatCurrency={formatCurrency}
/>
<div className="mt-4 md:mt-6">
<BalanceLineChart
aggregatedData={stats.aggregatedBalanceData}
perAccountData={stats.perAccountBalanceData}
accounts={data.accounts}
formatCurrency={formatCurrency}
/>
</div>
<div className="mt-4 md:mt-6">
<SavingsTrendChart
data={stats.savingsTrendData}
formatCurrency={formatCurrency}
/>
</div>
</section>
{/* Revenus et Dépenses */}
<section className="mb-4 md:mb-8">
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">
Revenus et Dépenses
</h2>
<div className="grid gap-4 md:gap-6 lg:grid-cols-2">
<MonthlyChart
data={stats.monthlyChartData}
formatCurrency={formatCurrency}
/>
<IncomeExpenseTrendChart
data={stats.monthlyChartData.map((m) => ({
month: m.month,
revenus: m.revenus,
depenses: m.depenses,
}))}
formatCurrency={formatCurrency}
/>
</div>
{stats.yearOverYearData.length > 0 && (
<div className="mt-4 md:mt-6">
<YearOverYearChart
data={stats.yearOverYearData}
formatCurrency={formatCurrency}
/>
</div>
)}
</section>
{/* Analyse par Catégorie */}
<section className="mb-4 md:mb-8">
<h2 className="text-lg md:text-2xl font-semibold mb-3 md:mb-4">
Analyse par Catégorie
</h2>
<div className="grid gap-4 md:gap-6">
<CategoryPieChart
data={stats.categoryChartData}
dataByParent={stats.categoryChartDataByParent}
categories={data.categories}
formatCurrency={formatCurrency}
/>
<CategoryBarChart
data={stats.categoryChartData}
formatCurrency={formatCurrency}
/>
</div>
<div className="hidden md:block mt-4 md:mt-6">
<CategoryTrendChart
data={stats.categoryTrendData}
dataByParent={stats.categoryTrendDataByParent}
categories={data.categories}
formatCurrency={formatCurrency}
/>
</div>
<div className="mt-4 md:mt-6">
<TopExpensesList
expenses={stats.topExpenses}
categories={data.categories}
formatCurrency={formatCurrency}
/>
</div>
</section>
</PageLayout>
);
}
function ActiveFilters({
selectedAccounts,
onRemoveAccount,
onClearAccounts,
selectedCategories,
onRemoveCategory,
onClearCategories,
period,
onClearPeriod,
accounts,
categories,
customStartDate,
customEndDate,
}: {
selectedAccounts: string[];
onRemoveAccount: (id: string) => void;
onClearAccounts: () => void;
selectedCategories: string[];
onRemoveCategory: (id: string) => void;
onClearCategories: () => void;
period: Period;
onClearPeriod: () => void;
accounts: Account[];
categories: Category[];
customStartDate?: Date;
customEndDate?: Date;
}) {
const isMobile = useIsMobile();
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 "1month":
return "1 mois";
case "3months":
return "3 mois";
case "6months":
return "6 mois";
case "12months":
return "12 mois";
case "custom":
if (customStartDate && customEndDate) {
return `${format(customStartDate, "d MMM", { locale: fr })} - ${format(customEndDate, "d MMM yyyy", { locale: fr })}`;
}
return "Personnalisé";
default:
return "Tout";
}
};
const clearAll = () => {
onClearAccounts();
onClearCategories();
onClearPeriod();
};
return (
<div className="flex items-center gap-1.5 md:gap-2 mt-2 md:mt-3 pt-2 md:pt-3 border-t border-border flex-wrap">
<Filter className="h-3 w-3 md:h-3.5 md:w-3.5 text-muted-foreground" />
{selectedAccs.map((acc) => (
<Badge
key={acc.id}
variant="secondary"
className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"
>
<Wallet className="h-2.5 w-2.5 md:h-3 md:w-3" />
{acc.name}
<button
onClick={() => onRemoveAccount(acc.id)}
className="ml-0.5 md:ml-1 hover:text-foreground"
>
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
</button>
</Badge>
))}
{isUncategorized && (
<Badge
variant="secondary"
className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"
>
<CircleSlash className="h-2.5 w-2.5 md:h-3 md:w-3" />
Non catégorisé
<button
onClick={onClearCategories}
className="ml-0.5 md:ml-1 hover:text-foreground"
>
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
</button>
</Badge>
)}
{selectedCats.map((cat) => (
<Badge
key={cat.id}
variant="secondary"
className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"
style={{
backgroundColor: `${cat.color}15`,
borderColor: `${cat.color}30`,
}}
>
<CategoryIcon
icon={cat.icon}
color={cat.color}
size={isMobile ? 10 : 12}
/>
{cat.name}
<button
onClick={() => onRemoveCategory(cat.id)}
className="ml-0.5 md:ml-1 hover:text-foreground"
>
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
</button>
</Badge>
))}
{hasPeriod && (
<Badge
variant="secondary"
className="gap-0.5 md:gap-1 text-[10px] md:text-xs font-normal"
>
<Calendar className="h-2.5 w-2.5 md:h-3 md:w-3" />
{getPeriodLabel(period)}
<button
onClick={onClearPeriod}
className="ml-0.5 md:ml-1 hover:text-foreground"
>
<X className="h-2.5 w-2.5 md:h-3 md:w-3" />
</button>
</Badge>
)}
<button
onClick={clearAll}
className="text-[10px] md:text-xs text-muted-foreground hover:text-foreground ml-auto"
>
Effacer tout
</button>
</div>
);
}