From 407486a109b1f046b11fd478012c23d01e55fb20 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Tue, 23 Dec 2025 11:07:15 +0100 Subject: [PATCH] feat: implement top expenses categorization and enhance UI with tabs; display top 10 expenses per top 5 parent categories, improving data organization and user navigation in the statistics page --- app/statistics/page.tsx | 57 ++++-- components/statistics/top-expenses-list.tsx | 182 ++++++++++++++------ 2 files changed, 170 insertions(+), 69 deletions(-) diff --git a/app/statistics/page.tsx b/app/statistics/page.tsx index 5df3931..f05fc67 100644 --- a/app/statistics/page.tsx +++ b/app/statistics/page.tsx @@ -360,22 +360,53 @@ export default function StatisticsPage() { }) .sort((a, b) => b.value - a.value); - // Top expenses - deduplicate by ID and sort by amount (most negative first) + // Top expenses by top parent categories - deduplicate by ID 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(); - }) + const expenses = uniqueTransactions.filter((t) => t.amount < 0); + + // Get top 5 parent categories by total expenses + const topParentCategories = Array.from(categoryTotalsByParent.entries()) + .map(([groupId, total]) => ({ + groupId: groupId === "uncategorized" ? null : groupId, + total, + })) + .sort((a, b) => b.total - a.total) .slice(0, 5); + // Get top 10 expenses per top parent category (from all its subcategories) + const topExpensesByCategory = topParentCategories.map(({ groupId }) => { + const categoryExpenses = expenses + .filter((t) => { + if (groupId === null) { + return !t.categoryId; + } + // Check if transaction belongs to this parent category or its subcategories + const category = data.categories.find((c) => c.id === t.categoryId); + if (!category) { + return false; + } + // Use parent category ID if exists, otherwise use the category itself + const transactionGroupId = category.parentId || category.id; + return transactionGroupId === groupId; + }) + .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, 10); + + return { + categoryId: groupId, + expenses: categoryExpenses, + }; + }); + // Summary const totalIncome = transactions .filter((t) => t.amount >= 0) @@ -653,7 +684,7 @@ export default function StatisticsPage() { monthlyChartData, categoryChartData, categoryChartDataByParent, - topExpenses, + topExpensesByCategory, totalIncome, totalExpenses, avgMonthlyExpenses, @@ -1200,7 +1231,7 @@ export default function StatisticsPage() {
diff --git a/components/statistics/top-expenses-list.tsx b/components/statistics/top-expenses-list.tsx index c921d44..bfd3538 100644 --- a/components/statistics/top-expenses-list.tsx +++ b/components/statistics/top-expenses-list.tsx @@ -4,86 +4,156 @@ 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 { useIsMobile } from "@/hooks/use-mobile"; import type { Transaction, Category } from "@/lib/types"; interface TopExpensesListProps { - expenses: Transaction[]; + expensesByCategory: Array<{ + categoryId: string | null; + expenses: Transaction[]; + }>; categories: Category[]; formatCurrency: (amount: number) => string; } export function TopExpensesList({ - expenses, + expensesByCategory, categories, formatCurrency, }: TopExpensesListProps) { const isMobile = useIsMobile(); + // Filtrer les catégories qui ont des dépenses + const categoriesWithExpenses = expensesByCategory.filter( + (group) => group.expenses.length > 0, + ); + + const hasExpenses = categoriesWithExpenses.length > 0; + + // Déterminer la valeur par défaut du premier onglet + const defaultTabValue = + categoriesWithExpenses.length > 0 + ? categoriesWithExpenses[0].categoryId || "uncategorized" + : ""; + return ( - Top 5 dépenses + + Top 10 dépenses par top 5 catégories parentes + - {expenses.length > 0 ? ( -
- {expenses.map((expense, index) => { - const category = categories.find( - (c) => c.id === expense.categoryId, - ); + {hasExpenses ? ( + + + {categoriesWithExpenses.map(({ categoryId, expenses }) => { + const category = categoryId + ? categories.find((c) => c.id === categoryId) + : null; + const tabValue = categoryId || "uncategorized"; + + return ( + + {category && ( + + )} + + {category?.name || "Non catégorisé"} + + + ); + })} + + {categoriesWithExpenses.map(({ categoryId, expenses }) => { + const category = categoryId + ? categories.find((c) => c.id === categoryId) + : null; + const tabValue = categoryId || "uncategorized"; + return ( -
-
- {index + 1} -
-
-
-

- {expense.description} -

-
- {formatCurrency(expense.amount)} -
-
-
- - {new Date(expense.date).toLocaleDateString("fr-FR")} - - {category && ( - - - - - {category.name} + +
+ {expenses.map((expense, index) => ( +
+
+ {index + 1} +
+
+
+

+ {expense.description} +

+
+ {formatCurrency(expense.amount)} +
+
+
+ + {new Date(expense.date).toLocaleDateString( + "fr-FR", + )} - - - )} -
+ {expense.categoryId && (() => { + const expenseCategory = categories.find( + (c) => c.id === expense.categoryId, + ); + // Afficher seulement si c'est une sous-catégorie (a un parentId) + if (expenseCategory?.parentId) { + return ( + + + + + {expenseCategory.name} + + + + ); + } + return null; + })()} +
+
+
+ ))}
-
+ ); })} -
+
) : (
Pas de dépenses pour cette période