Compare commits
2 Commits
e0597b0dcb
...
9de7d1a467
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9de7d1a467 | ||
|
|
407486a109 |
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -103,11 +103,38 @@ export function BalanceLineChart({
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div
|
||||
className="px-3 py-2 rounded-lg shadow-lg"
|
||||
style={{
|
||||
backgroundColor: "var(--popover)",
|
||||
border: "1px solid var(--border)",
|
||||
opacity: 1,
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
{payload.map((entry, index) => (
|
||||
<div
|
||||
key={`tooltip-${index}`}
|
||||
className="flex items-center gap-2"
|
||||
style={{ color: entry.color }}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{entry.name}:
|
||||
</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{formatCurrency(entry.value as number)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{mode === "aggregated" ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user