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

This commit is contained in:
Julien Froidefond
2025-12-23 11:07:15 +01:00
parent e0597b0dcb
commit 407486a109
2 changed files with 170 additions and 69 deletions

View File

@@ -360,12 +360,37 @@ export default function StatisticsPage() {
}) })
.sort((a, b) => b.value - a.value); .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( const uniqueTransactions = Array.from(
new Map(transactions.map((t) => [t.id, t])).values(), new Map(transactions.map((t) => [t.id, t])).values(),
); );
const topExpenses = uniqueTransactions const expenses = uniqueTransactions.filter((t) => t.amount < 0);
.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((a, b) => {
// Sort by amount (most negative first) // Sort by amount (most negative first)
if (a.amount !== b.amount) { if (a.amount !== b.amount) {
@@ -374,7 +399,13 @@ export default function StatisticsPage() {
// If same amount, sort by date (most recent first) for stable sorting // If same amount, sort by date (most recent first) for stable sorting
return new Date(b.date).getTime() - new Date(a.date).getTime(); return new Date(b.date).getTime() - new Date(a.date).getTime();
}) })
.slice(0, 5); .slice(0, 10);
return {
categoryId: groupId,
expenses: categoryExpenses,
};
});
// Summary // Summary
const totalIncome = transactions const totalIncome = transactions
@@ -653,7 +684,7 @@ export default function StatisticsPage() {
monthlyChartData, monthlyChartData,
categoryChartData, categoryChartData,
categoryChartDataByParent, categoryChartDataByParent,
topExpenses, topExpensesByCategory,
totalIncome, totalIncome,
totalExpenses, totalExpenses,
avgMonthlyExpenses, avgMonthlyExpenses,
@@ -1200,7 +1231,7 @@ export default function StatisticsPage() {
</div> </div>
<div className="mt-4 md:mt-6"> <div className="mt-4 md:mt-6">
<TopExpensesList <TopExpensesList
expenses={stats.topExpenses} expensesByCategory={stats.topExpensesByCategory}
categories={data.categories} categories={data.categories}
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
/> />

View File

@@ -4,40 +4,96 @@ import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CategoryIcon } from "@/components/ui/category-icon"; import { CategoryIcon } from "@/components/ui/category-icon";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/components/ui/tabs";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import type { Transaction, Category } from "@/lib/types"; import type { Transaction, Category } from "@/lib/types";
interface TopExpensesListProps { interface TopExpensesListProps {
expensesByCategory: Array<{
categoryId: string | null;
expenses: Transaction[]; expenses: Transaction[];
}>;
categories: Category[]; categories: Category[];
formatCurrency: (amount: number) => string; formatCurrency: (amount: number) => string;
} }
export function TopExpensesList({ export function TopExpensesList({
expenses, expensesByCategory,
categories, categories,
formatCurrency, formatCurrency,
}: TopExpensesListProps) { }: TopExpensesListProps) {
const isMobile = useIsMobile(); 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 ( return (
<Card className="card-hover"> <Card className="card-hover">
<CardHeader> <CardHeader>
<CardTitle className="text-sm md:text-base">Top 5 dépenses</CardTitle> <CardTitle className="text-sm md:text-base">
Top 10 dépenses par top 5 catégories parentes
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{expenses.length > 0 ? ( {hasExpenses ? (
<div className="space-y-3 md:space-y-4"> <Tabs defaultValue={defaultTabValue} className="w-full">
{expenses.map((expense, index) => { <TabsList className="w-full flex-wrap h-auto p-1 mb-4">
const category = categories.find( {categoriesWithExpenses.map(({ categoryId, expenses }) => {
(c) => c.id === expense.categoryId, const category = categoryId
); ? categories.find((c) => c.id === categoryId)
: null;
const tabValue = categoryId || "uncategorized";
return ( return (
<TabsTrigger
key={tabValue}
value={tabValue}
className="flex items-center gap-1.5 md:gap-2 text-xs md:text-sm"
>
{category && (
<CategoryIcon
icon={category.icon}
color={category.color}
size={isMobile ? 12 : 14}
/>
)}
<span className="truncate max-w-[100px] md:max-w-none">
{category?.name || "Non catégorisé"}
</span>
</TabsTrigger>
);
})}
</TabsList>
{categoriesWithExpenses.map(({ categoryId, expenses }) => {
const category = categoryId
? categories.find((c) => c.id === categoryId)
: null;
const tabValue = categoryId || "uncategorized";
return (
<TabsContent key={tabValue} value={tabValue}>
<div className="space-y-2 md:space-y-3">
{expenses.map((expense, index) => (
<div <div
key={expense.id} key={expense.id}
className="flex items-start gap-2 md:gap-3" className="flex items-start gap-2 md:gap-3"
> >
<div className="w-6 h-6 md:w-8 md:h-8 rounded-full bg-muted flex items-center justify-center text-xs md:text-sm font-semibold shrink-0"> <div className="w-5 h-5 md:w-6 md:h-6 rounded-full bg-muted flex items-center justify-center text-[10px] md:text-xs font-semibold shrink-0">
{index + 1} {index + 1}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -51,39 +107,53 @@ export function TopExpensesList({
</div> </div>
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap"> <div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
<span className="text-[10px] md:text-xs text-muted-foreground"> <span className="text-[10px] md:text-xs text-muted-foreground">
{new Date(expense.date).toLocaleDateString("fr-FR")} {new Date(expense.date).toLocaleDateString(
"fr-FR",
)}
</span> </span>
{category && ( {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 (
<Link <Link
href={`/transactions?categoryIds=${category.id}`} href={`/transactions?categoryIds=${expenseCategory.id}`}
className="inline-block" className="inline-block"
> >
<Badge <Badge
variant="secondary" variant="secondary"
className="text-[10px] md:text-xs px-1.5 md:px-2 py-0.5 inline-flex items-center gap-1 shrink-0 hover:opacity-80 transition-opacity cursor-pointer" className="text-[10px] md:text-xs px-1.5 md:px-2 py-0.5 inline-flex items-center gap-1 shrink-0 hover:opacity-80 transition-opacity cursor-pointer"
style={{ style={{
backgroundColor: `${category.color}20`, backgroundColor: `${expenseCategory.color}20`,
color: category.color, color: expenseCategory.color,
borderColor: `${category.color}30`, borderColor: `${expenseCategory.color}30`,
}} }}
> >
<CategoryIcon <CategoryIcon
icon={category.icon} icon={expenseCategory.icon}
color={category.color} color={expenseCategory.color}
size={isMobile ? 8 : 10} size={isMobile ? 8 : 10}
/> />
<span className="truncate max-w-[120px] md:max-w-none"> <span className="truncate max-w-[100px] md:max-w-none">
{category.name} {expenseCategory.name}
</span> </span>
</Badge> </Badge>
</Link> </Link>
)} );
}
return null;
})()}
</div> </div>
</div> </div>
</div> </div>
))}
</div>
</TabsContent>
); );
})} })}
</div> </Tabs>
) : ( ) : (
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-xs md:text-sm"> <div className="h-[200px] flex items-center justify-center text-muted-foreground text-xs md:text-sm">
Pas de dépenses pour cette période Pas de dépenses pour cette période