Compare commits
2 Commits
e0597b0dcb
...
9de7d1a467
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9de7d1a467 | ||
|
|
407486a109 |
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -103,11 +103,38 @@ export function BalanceLineChart({
|
|||||||
tick={{ fill: "var(--muted-foreground)" }}
|
tick={{ fill: "var(--muted-foreground)" }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => formatCurrency(value)}
|
content={({ active, payload }) => {
|
||||||
contentStyle={{
|
if (!active || !payload?.length) return null;
|
||||||
backgroundColor: "var(--card)",
|
return (
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 rounded-lg shadow-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--popover)",
|
||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
borderRadius: "8px",
|
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" ? (
|
{mode === "aggregated" ? (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user