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,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() {
</div>
<div className="mt-4 md:mt-6">
<TopExpensesList
expenses={stats.topExpenses}
expensesByCategory={stats.topExpensesByCategory}
categories={data.categories}
formatCurrency={formatCurrency}
/>

View File

@@ -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 (
<Card className="card-hover">
<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>
<CardContent>
{expenses.length > 0 ? (
<div className="space-y-3 md:space-y-4">
{expenses.map((expense, index) => {
const category = categories.find(
(c) => c.id === expense.categoryId,
);
{hasExpenses ? (
<Tabs defaultValue={defaultTabValue} className="w-full">
<TabsList className="w-full flex-wrap h-auto p-1 mb-4">
{categoriesWithExpenses.map(({ categoryId, expenses }) => {
const category = categoryId
? categories.find((c) => c.id === categoryId)
: null;
const tabValue = categoryId || "uncategorized";
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 (
<div
key={expense.id}
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">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<p className="font-medium text-xs md:text-sm truncate flex-1">
{expense.description}
</p>
<div className="text-destructive font-semibold tabular-nums text-xs md:text-sm shrink-0">
{formatCurrency(expense.amount)}
</div>
</div>
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
<span className="text-[10px] md:text-xs text-muted-foreground">
{new Date(expense.date).toLocaleDateString("fr-FR")}
</span>
{category && (
<Link
href={`/transactions?categoryIds=${category.id}`}
className="inline-block"
>
<Badge
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"
style={{
backgroundColor: `${category.color}20`,
color: category.color,
borderColor: `${category.color}30`,
}}
>
<CategoryIcon
icon={category.icon}
color={category.color}
size={isMobile ? 8 : 10}
/>
<span className="truncate max-w-[120px] md:max-w-none">
{category.name}
<TabsContent key={tabValue} value={tabValue}>
<div className="space-y-2 md:space-y-3">
{expenses.map((expense, index) => (
<div
key={expense.id}
className="flex items-start gap-2 md:gap-3"
>
<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}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<p className="font-medium text-xs md:text-sm truncate flex-1">
{expense.description}
</p>
<div className="text-destructive font-semibold tabular-nums text-xs md:text-sm shrink-0">
{formatCurrency(expense.amount)}
</div>
</div>
<div className="flex items-center gap-1.5 md:gap-2 flex-wrap">
<span className="text-[10px] md:text-xs text-muted-foreground">
{new Date(expense.date).toLocaleDateString(
"fr-FR",
)}
</span>
</Badge>
</Link>
)}
</div>
{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
href={`/transactions?categoryIds=${expenseCategory.id}`}
className="inline-block"
>
<Badge
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"
style={{
backgroundColor: `${expenseCategory.color}20`,
color: expenseCategory.color,
borderColor: `${expenseCategory.color}30`,
}}
>
<CategoryIcon
icon={expenseCategory.icon}
color={expenseCategory.color}
size={isMobile ? 8 : 10}
/>
<span className="truncate max-w-[100px] md:max-w-none">
{expenseCategory.name}
</span>
</Badge>
</Link>
);
}
return null;
})()}
</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">
Pas de dépenses pour cette période