From f295e86fc29dfdc5d8bf2060d5374b3e766326fe Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Tue, 23 Dec 2025 11:49:31 +0100 Subject: [PATCH] refactor: improve code formatting and consistency in StatisticsPage and TopExpensesList components; standardize quotation marks and enhance readability across various sections --- app/statistics/page.tsx | 42 +-- components/statistics/top-expenses-list.tsx | 315 +++++++++++++++++++- 2 files changed, 336 insertions(+), 21 deletions(-) diff --git a/app/statistics/page.tsx b/app/statistics/page.tsx index 362c82b..e87c6b4 100644 --- a/app/statistics/page.tsx +++ b/app/statistics/page.tsx @@ -59,15 +59,15 @@ export default function StatisticsPage() { // Persister les filtres dans le localStorage const [period, setPeriod] = useLocalStorage( "statistics-period", - "6months", + "6months" ); const [selectedAccounts, setSelectedAccounts] = useLocalStorage( "statistics-selected-accounts", - ["all"], + ["all"] ); const [selectedCategories, setSelectedCategories] = useLocalStorage( "statistics-selected-categories", - ["all"], + ["all"] ); const [excludeInternalTransfers, setExcludeInternalTransfers] = useLocalStorage("statistics-exclude-internal-transfers", true); @@ -83,11 +83,11 @@ export default function StatisticsPage() { // Convertir les ISO strings en Date const customStartDate = useMemo( () => (customStartDateISO ? new Date(customStartDateISO) : undefined), - [customStartDateISO], + [customStartDateISO] ); const customEndDate = useMemo( () => (customEndDateISO ? new Date(customEndDateISO) : undefined), - [customEndDateISO], + [customEndDateISO] ); // Fonctions pour mettre à jour les dates avec persistance @@ -145,7 +145,7 @@ export default function StatisticsPage() { const internalTransferCategory = useMemo(() => { if (!data) return null; return data.categories.find( - (c) => c.name.toLowerCase() === "virement interne", + (c) => c.name.toLowerCase() === "virement interne" ); }, [data]); @@ -261,7 +261,7 @@ export default function StatisticsPage() { // Filter by accounts if (!selectedAccounts.includes("all")) { transactions = transactions.filter((t) => - selectedAccounts.includes(t.accountId), + selectedAccounts.includes(t.accountId) ); } @@ -271,7 +271,7 @@ export default function StatisticsPage() { transactions = transactions.filter((t) => !t.categoryId); } else { transactions = transactions.filter( - (t) => t.categoryId && selectedCategories.includes(t.categoryId), + (t) => t.categoryId && selectedCategories.includes(t.categoryId) ); } } @@ -279,7 +279,7 @@ export default function StatisticsPage() { // Exclude "Virement interne" category if checkbox is checked if (excludeInternalTransfers && internalTransferCategory) { transactions = transactions.filter( - (t) => t.categoryId !== internalTransferCategory.id, + (t) => t.categoryId !== internalTransferCategory.id ); } @@ -352,7 +352,7 @@ export default function StatisticsPage() { }); const categoryChartDataByParent = Array.from( - categoryTotalsByParent.entries(), + categoryTotalsByParent.entries() ) .map(([groupId, total]) => { const category = data.categories.find((c) => c.id === groupId); @@ -368,7 +368,7 @@ export default function StatisticsPage() { // Top expenses by top parent categories - deduplicate by ID const uniqueTransactions = Array.from( - new Map(transactions.map((t) => [t.id, t])).values(), + new Map(transactions.map((t) => [t.id, t])).values() ); const expenses = uniqueTransactions.filter((t) => t.amount < 0); @@ -425,7 +425,7 @@ export default function StatisticsPage() { // Balance evolution - Aggregated (using filtered transactions) const sortedFilteredTransactions = [...transactions].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() ); // Calculate starting balance: initialBalance + transactions before startDate @@ -437,7 +437,7 @@ export default function StatisticsPage() { // Start with initial balances runningBalance = accountsToUse.reduce( (sum, acc) => sum + (acc.initialBalance || 0), - 0, + 0 ); // Add all transactions before the start date for these accounts @@ -474,7 +474,7 @@ export default function StatisticsPage() { }); const aggregatedBalanceData = Array.from( - aggregatedBalanceByDate.entries(), + aggregatedBalanceByDate.entries() ).map(([date, balance]) => ({ date: new Date(date).toLocaleDateString("fr-FR", { day: "2-digit", @@ -697,6 +697,7 @@ export default function StatisticsPage() { aggregatedBalanceData, perAccountBalanceData, transactionCount: transactions.length, + transactions, // Toutes les transactions filtrées pour le graphique savingsTrendData, categoryTrendData, categoryTrendDataByParent, @@ -931,7 +932,7 @@ export default function StatisticsPage() { onRemoveAccount={(id) => { const newAccounts = selectedAccounts.filter((a) => a !== id); setSelectedAccounts( - newAccounts.length > 0 ? newAccounts : ["all"], + newAccounts.length > 0 ? newAccounts : ["all"] ); }} onClearAccounts={() => setSelectedAccounts(["all"])} @@ -939,7 +940,7 @@ export default function StatisticsPage() { onRemoveCategory={(id) => { const newCategories = selectedCategories.filter((c) => c !== id); setSelectedCategories( - newCategories.length > 0 ? newCategories : ["all"], + newCategories.length > 0 ? newCategories : ["all"] ); }} onClearCategories={() => setSelectedCategories(["all"])} @@ -1129,17 +1130,17 @@ export default function StatisticsPage() { onRemoveAccount={(id) => { const newAccounts = selectedAccounts.filter((a) => a !== id); setSelectedAccounts( - newAccounts.length > 0 ? newAccounts : ["all"], + newAccounts.length > 0 ? newAccounts : ["all"] ); }} onClearAccounts={() => setSelectedAccounts(["all"])} selectedCategories={selectedCategories} onRemoveCategory={(id) => { const newCategories = selectedCategories.filter( - (c) => c !== id, + (c) => c !== id ); setSelectedCategories( - newCategories.length > 0 ? newCategories : ["all"], + newCategories.length > 0 ? newCategories : ["all"] ); }} onClearCategories={() => setSelectedCategories(["all"])} @@ -1244,6 +1245,7 @@ export default function StatisticsPage() { expensesByCategory={stats.topExpensesByCategory} categories={data.categories} formatCurrency={formatCurrency} + allTransactions={stats.transactions} /> @@ -1289,7 +1291,7 @@ function ActiveFilters({ const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id)); const selectedCats = categories.filter((c) => - selectedCategories.includes(c.id), + selectedCategories.includes(c.id) ); const isUncategorized = selectedCategories.includes("uncategorized"); diff --git a/components/statistics/top-expenses-list.tsx b/components/statistics/top-expenses-list.tsx index 1b27b75..450f16c 100644 --- a/components/statistics/top-expenses-list.tsx +++ b/components/statistics/top-expenses-list.tsx @@ -1,11 +1,23 @@ "use client"; +import { useState, useMemo, useEffect } from "react"; import Link from "next/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { CategoryIcon } from "@/components/ui/category-icon"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; import { useIsMobile } from "@/hooks/use-mobile"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { List, ListOrdered } from "lucide-react"; import type { Transaction, Category } from "@/lib/types"; interface TopExpensesListProps { @@ -15,14 +27,17 @@ interface TopExpensesListProps { }>; categories: Category[]; formatCurrency: (amount: number) => string; + allTransactions?: Transaction[]; // Toutes les transactions filtrées pour calculer toutes les dépenses } export function TopExpensesList({ expensesByCategory, categories, formatCurrency, + allTransactions = [], }: TopExpensesListProps) { const isMobile = useIsMobile(); + const [showAllExpenses, setShowAllExpenses] = useState(false); // Filtrer les catégories qui ont des dépenses const categoriesWithExpenses = expensesByCategory.filter( @@ -37,6 +52,216 @@ export function TopExpensesList({ ? categoriesWithExpenses[0].categoryId || "uncategorized" : ""; + const [activeTab, setActiveTab] = useState(() => defaultTabValue); + + // Mettre à jour activeTab quand defaultTabValue change ou si activeTab est invalide + useEffect(() => { + if (!defaultTabValue) return; + + // Vérifier si activeTab correspond à une catégorie valide + const isValidTab = categoriesWithExpenses.some( + (group) => (group.categoryId || "uncategorized") === activeTab, + ); + + // Si activeTab est vide ou invalide, utiliser defaultTabValue + if (!activeTab || !isValidTab) { + setActiveTab(defaultTabValue); + } + }, [defaultTabValue, categoriesWithExpenses, activeTab]); + + // Calculer les données du graphique pour la catégorie active + const chartData = useMemo(() => { + if (!hasExpenses) return []; + + // Utiliser activeTab ou defaultTabValue comme fallback + const currentTab = activeTab || defaultTabValue; + if (!currentTab) return []; + + const activeCategoryGroup = categoriesWithExpenses.find( + (group) => (group.categoryId || "uncategorized") === currentTab, + ); + + if (!activeCategoryGroup) { + return []; + } + + // Si showAllExpenses est activé et qu'on a toutes les transactions, utiliser toutes les dépenses de la catégorie + let expenses: Transaction[]; + if (showAllExpenses && allTransactions.length > 0) { + // Filtrer toutes les transactions pour obtenir toutes les dépenses de cette catégorie parente + const categoryId = activeCategoryGroup.categoryId; + expenses = allTransactions + .filter((t) => t.amount < 0) // Seulement les dépenses + .filter((t) => { + if (categoryId === null) { + return !t.categoryId; + } + // Vérifier si la transaction appartient à cette catégorie parente ou ses sous-catégories + const category = categories.find((c) => c.id === t.categoryId); + if (!category) { + return false; + } + const transactionGroupId = category.parentId || category.id; + return transactionGroupId === categoryId; + }); + } else { + // Utiliser seulement les top 10 + expenses = activeCategoryGroup.expenses; + } + + if (expenses.length === 0) { + return []; + } + + // Grouper les dépenses par période + // Si moins de 30 jours, groupe par jour + // Si moins de 6 mois, groupe par semaine + // Sinon groupe par mois + const sortedExpenses = [...expenses].sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), + ); + + if (sortedExpenses.length === 0) return []; + + const firstDate = new Date(sortedExpenses[0].date); + const lastDate = new Date( + sortedExpenses[sortedExpenses.length - 1].date, + ); + const daysDiff = + (lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24); + + let groupBy: "day" | "week" | "month"; + if (daysDiff <= 30) { + groupBy = "day"; + } else if (daysDiff <= 180) { + groupBy = "week"; + } else { + groupBy = "month"; + } + + // Fonction helper pour obtenir la clé de période d'une date + const getPeriodKey = (date: Date): { key: string; dateKey: string } => { + if (groupBy === "day") { + return { + key: date.toLocaleDateString("fr-FR", { + day: "2-digit", + month: "short", + }), + dateKey: date.toISOString().substring(0, 10), // YYYY-MM-DD + }; + } else if (groupBy === "week") { + // Semaine commençant le lundi + const weekStart = new Date(date); + const day = weekStart.getDay(); + const diff = weekStart.getDate() - day + (day === 0 ? -6 : 1); + weekStart.setDate(diff); + return { + key: `Sem. ${weekStart.toLocaleDateString("fr-FR", { + day: "2-digit", + month: "short", + })}`, + dateKey: weekStart.toISOString().substring(0, 10), // YYYY-MM-DD + }; + } else { + return { + key: date.toLocaleDateString("fr-FR", { + month: "short", + year: "numeric", + }), + dateKey: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`, // YYYY-MM + }; + } + }; + + // Grouper les dépenses par période + const groupedData = new Map< + string, + { total: number; dateKey: string } + >(); + + sortedExpenses.forEach((expense) => { + const date = new Date(expense.date); + const { key, dateKey } = getPeriodKey(date); + + const current = groupedData.get(key); + if (current) { + current.total += Math.abs(expense.amount); + } else { + groupedData.set(key, { + total: Math.abs(expense.amount), + dateKey, + }); + } + }); + + // Générer toutes les périodes entre la première et la dernière date + const allPeriodsMap = new Map< + string, + { period: string; montant: number; dateKey: string } + >(); + const currentDate = new Date(firstDate); + const endDate = new Date(lastDate); + + // Normaliser les dates selon le type de groupement + if (groupBy === "day") { + currentDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + } else if (groupBy === "week") { + // Début de la semaine de la première date + const day = currentDate.getDay(); + const diff = currentDate.getDate() - day + (day === 0 ? -6 : 1); + currentDate.setDate(diff); + currentDate.setHours(0, 0, 0, 0); + // Début de la semaine de la dernière date (pour la boucle) + const lastDay = endDate.getDay(); + const lastDiff = endDate.getDate() - lastDay + (lastDay === 0 ? -6 : 1); + endDate.setDate(lastDiff); + endDate.setHours(0, 0, 0, 0); + } else { + // Mois : premier jour du mois + currentDate.setDate(1); + currentDate.setHours(0, 0, 0, 0); + endDate.setDate(1); + endDate.setHours(0, 0, 0, 0); + } + + while (currentDate <= endDate) { + const { key, dateKey } = getPeriodKey(currentDate); + const existingData = groupedData.get(key); + + // Utiliser dateKey comme clé unique pour éviter les doublons + if (!allPeriodsMap.has(dateKey)) { + allPeriodsMap.set(dateKey, { + period: key, + montant: existingData ? Math.round(existingData.total) : 0, + dateKey, + }); + } + + // Passer à la période suivante + if (groupBy === "day") { + currentDate.setDate(currentDate.getDate() + 1); + } else if (groupBy === "week") { + currentDate.setDate(currentDate.getDate() + 7); + } else { + // Mois suivant + currentDate.setMonth(currentDate.getMonth() + 1); + } + } + + return Array.from(allPeriodsMap.values()) .sort((a, b) => + a.dateKey.localeCompare(b.dateKey), + ); + }, [ + hasExpenses, + categoriesWithExpenses, + activeTab, + defaultTabValue, + showAllExpenses, + allTransactions, + categories, + ]); + return ( @@ -46,7 +271,12 @@ export function TopExpensesList({ {hasExpenses ? ( - + {categoriesWithExpenses.map(({ categoryId, expenses }) => { const category = categoryId @@ -149,6 +379,89 @@ export function TopExpensesList({ ); })} + {/* Graphique d'évolution temporelle */} + {chartData.length > 0 && (() => { + const currentTab = activeTab || defaultTabValue; + const activeCategoryGroup = categoriesWithExpenses.find( + (group) => (group.categoryId || "uncategorized") === currentTab, + ); + const activeCategory = activeCategoryGroup?.categoryId + ? categories.find((c) => c.id === activeCategoryGroup.categoryId) + : null; + const barColor = activeCategory?.color || "var(--destructive)"; + + return ( +
+
+

+ Évolution des dépenses dans le temps +

+ {allTransactions.length > 0 && ( + + )} +
+
+ + + + + { + if (Math.abs(v) >= 1000) { + return `${(v / 1000).toFixed(1)}k€`; + } + return `${Math.round(v)}€`; + }} + tick={{ fill: "var(--muted-foreground)" }} + /> + formatCurrency(value)} + contentStyle={{ + backgroundColor: "var(--card)", + border: "1px solid var(--border)", + borderRadius: "8px", + }} + /> + + + +
+
+ ); + })()}
) : (