"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 { getAccountBalance } from "@/lib/account-utils"; 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 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("6months"); const [selectedAccounts, setSelectedAccounts] = useState(["all"]); const [selectedCategories, setSelectedCategories] = useState(["all"]); const [excludeInternalTransfers, setExcludeInternalTransfers] = useState(true); const [customStartDate, setCustomStartDate] = useState(undefined); const [customEndDate, setCustomEndDate] = useState(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(); 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(); 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 - 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(); 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>(); 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(); 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(); accountBalances.forEach((dates) => { dates.forEach((_, date) => allDates.add(date)); }); const sortedDates = Array.from(allDates).sort(); const lastBalances = new Map(); // 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; }); return { monthlyChartData, categoryChartData, topExpenses, totalIncome, totalExpenses, avgMonthlyExpenses, aggregatedBalanceData, perAccountBalanceData, transactionCount: transactions.length, }; }, [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 ; } return (
{period === "custom" && (
{ setCustomStartDate(date); if (date && customEndDate && date > customEndDate) { setCustomEndDate(undefined); } }} locale={fr} />
{ if (date && customStartDate && date < customStartDate) { return; } setCustomEndDate(date); if (date && customStartDate) { setIsCustomDatePickerOpen(false); } }} disabled={(date) => { if (!customStartDate) return true; return date < customStartDate; }} locale={fr} />
{customStartDate && customEndDate && (
)}
)} {internalTransferCategory && (
setExcludeInternalTransfers(checked === true)} />
)}
{ 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} />
); } 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 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 (
{selectedAccs.map((acc) => ( {acc.name} ))} {isUncategorized && ( Non catégorisé )} {selectedCats.map((cat) => ( {cat.name} ))} {hasPeriod && ( {getPeriodLabel(period)} )}
); }