refactor: improve code formatting and consistency in StatisticsPage and TopExpensesList components; standardize quotation marks and enhance readability across various sections
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m57s
This commit is contained in:
@@ -1,11 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { List, ListOrdered } from "lucide-react";
|
||||
import type { Transaction, Category } from "@/lib/types";
|
||||
|
||||
interface TopExpensesListProps {
|
||||
@@ -15,14 +27,17 @@ interface TopExpensesListProps {
|
||||
}>;
|
||||
categories: Category[];
|
||||
formatCurrency: (amount: number) => string;
|
||||
allTransactions?: Transaction[]; // Toutes les transactions filtrées pour calculer toutes les dépenses
|
||||
}
|
||||
|
||||
export function TopExpensesList({
|
||||
expensesByCategory,
|
||||
categories,
|
||||
formatCurrency,
|
||||
allTransactions = [],
|
||||
}: TopExpensesListProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [showAllExpenses, setShowAllExpenses] = useState(false);
|
||||
|
||||
// Filtrer les catégories qui ont des dépenses
|
||||
const categoriesWithExpenses = expensesByCategory.filter(
|
||||
@@ -37,6 +52,216 @@ export function TopExpensesList({
|
||||
? categoriesWithExpenses[0].categoryId || "uncategorized"
|
||||
: "";
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(() => defaultTabValue);
|
||||
|
||||
// Mettre à jour activeTab quand defaultTabValue change ou si activeTab est invalide
|
||||
useEffect(() => {
|
||||
if (!defaultTabValue) return;
|
||||
|
||||
// Vérifier si activeTab correspond à une catégorie valide
|
||||
const isValidTab = categoriesWithExpenses.some(
|
||||
(group) => (group.categoryId || "uncategorized") === activeTab,
|
||||
);
|
||||
|
||||
// Si activeTab est vide ou invalide, utiliser defaultTabValue
|
||||
if (!activeTab || !isValidTab) {
|
||||
setActiveTab(defaultTabValue);
|
||||
}
|
||||
}, [defaultTabValue, categoriesWithExpenses, activeTab]);
|
||||
|
||||
// Calculer les données du graphique pour la catégorie active
|
||||
const chartData = useMemo(() => {
|
||||
if (!hasExpenses) return [];
|
||||
|
||||
// Utiliser activeTab ou defaultTabValue comme fallback
|
||||
const currentTab = activeTab || defaultTabValue;
|
||||
if (!currentTab) return [];
|
||||
|
||||
const activeCategoryGroup = categoriesWithExpenses.find(
|
||||
(group) => (group.categoryId || "uncategorized") === currentTab,
|
||||
);
|
||||
|
||||
if (!activeCategoryGroup) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Si showAllExpenses est activé et qu'on a toutes les transactions, utiliser toutes les dépenses de la catégorie
|
||||
let expenses: Transaction[];
|
||||
if (showAllExpenses && allTransactions.length > 0) {
|
||||
// Filtrer toutes les transactions pour obtenir toutes les dépenses de cette catégorie parente
|
||||
const categoryId = activeCategoryGroup.categoryId;
|
||||
expenses = allTransactions
|
||||
.filter((t) => t.amount < 0) // Seulement les dépenses
|
||||
.filter((t) => {
|
||||
if (categoryId === null) {
|
||||
return !t.categoryId;
|
||||
}
|
||||
// Vérifier si la transaction appartient à cette catégorie parente ou ses sous-catégories
|
||||
const category = categories.find((c) => c.id === t.categoryId);
|
||||
if (!category) {
|
||||
return false;
|
||||
}
|
||||
const transactionGroupId = category.parentId || category.id;
|
||||
return transactionGroupId === categoryId;
|
||||
});
|
||||
} else {
|
||||
// Utiliser seulement les top 10
|
||||
expenses = activeCategoryGroup.expenses;
|
||||
}
|
||||
|
||||
if (expenses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Grouper les dépenses par période
|
||||
// Si moins de 30 jours, groupe par jour
|
||||
// Si moins de 6 mois, groupe par semaine
|
||||
// Sinon groupe par mois
|
||||
const sortedExpenses = [...expenses].sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||
);
|
||||
|
||||
if (sortedExpenses.length === 0) return [];
|
||||
|
||||
const firstDate = new Date(sortedExpenses[0].date);
|
||||
const lastDate = new Date(
|
||||
sortedExpenses[sortedExpenses.length - 1].date,
|
||||
);
|
||||
const daysDiff =
|
||||
(lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
|
||||
let groupBy: "day" | "week" | "month";
|
||||
if (daysDiff <= 30) {
|
||||
groupBy = "day";
|
||||
} else if (daysDiff <= 180) {
|
||||
groupBy = "week";
|
||||
} else {
|
||||
groupBy = "month";
|
||||
}
|
||||
|
||||
// Fonction helper pour obtenir la clé de période d'une date
|
||||
const getPeriodKey = (date: Date): { key: string; dateKey: string } => {
|
||||
if (groupBy === "day") {
|
||||
return {
|
||||
key: date.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}),
|
||||
dateKey: date.toISOString().substring(0, 10), // YYYY-MM-DD
|
||||
};
|
||||
} else if (groupBy === "week") {
|
||||
// Semaine commençant le lundi
|
||||
const weekStart = new Date(date);
|
||||
const day = weekStart.getDay();
|
||||
const diff = weekStart.getDate() - day + (day === 0 ? -6 : 1);
|
||||
weekStart.setDate(diff);
|
||||
return {
|
||||
key: `Sem. ${weekStart.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
})}`,
|
||||
dateKey: weekStart.toISOString().substring(0, 10), // YYYY-MM-DD
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
key: date.toLocaleDateString("fr-FR", {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}),
|
||||
dateKey: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`, // YYYY-MM
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Grouper les dépenses par période
|
||||
const groupedData = new Map<
|
||||
string,
|
||||
{ total: number; dateKey: string }
|
||||
>();
|
||||
|
||||
sortedExpenses.forEach((expense) => {
|
||||
const date = new Date(expense.date);
|
||||
const { key, dateKey } = getPeriodKey(date);
|
||||
|
||||
const current = groupedData.get(key);
|
||||
if (current) {
|
||||
current.total += Math.abs(expense.amount);
|
||||
} else {
|
||||
groupedData.set(key, {
|
||||
total: Math.abs(expense.amount),
|
||||
dateKey,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Générer toutes les périodes entre la première et la dernière date
|
||||
const allPeriodsMap = new Map<
|
||||
string,
|
||||
{ period: string; montant: number; dateKey: string }
|
||||
>();
|
||||
const currentDate = new Date(firstDate);
|
||||
const endDate = new Date(lastDate);
|
||||
|
||||
// Normaliser les dates selon le type de groupement
|
||||
if (groupBy === "day") {
|
||||
currentDate.setHours(0, 0, 0, 0);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
} else if (groupBy === "week") {
|
||||
// Début de la semaine de la première date
|
||||
const day = currentDate.getDay();
|
||||
const diff = currentDate.getDate() - day + (day === 0 ? -6 : 1);
|
||||
currentDate.setDate(diff);
|
||||
currentDate.setHours(0, 0, 0, 0);
|
||||
// Début de la semaine de la dernière date (pour la boucle)
|
||||
const lastDay = endDate.getDay();
|
||||
const lastDiff = endDate.getDate() - lastDay + (lastDay === 0 ? -6 : 1);
|
||||
endDate.setDate(lastDiff);
|
||||
endDate.setHours(0, 0, 0, 0);
|
||||
} else {
|
||||
// Mois : premier jour du mois
|
||||
currentDate.setDate(1);
|
||||
currentDate.setHours(0, 0, 0, 0);
|
||||
endDate.setDate(1);
|
||||
endDate.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
const { key, dateKey } = getPeriodKey(currentDate);
|
||||
const existingData = groupedData.get(key);
|
||||
|
||||
// Utiliser dateKey comme clé unique pour éviter les doublons
|
||||
if (!allPeriodsMap.has(dateKey)) {
|
||||
allPeriodsMap.set(dateKey, {
|
||||
period: key,
|
||||
montant: existingData ? Math.round(existingData.total) : 0,
|
||||
dateKey,
|
||||
});
|
||||
}
|
||||
|
||||
// Passer à la période suivante
|
||||
if (groupBy === "day") {
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
} else if (groupBy === "week") {
|
||||
currentDate.setDate(currentDate.getDate() + 7);
|
||||
} else {
|
||||
// Mois suivant
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(allPeriodsMap.values()) .sort((a, b) =>
|
||||
a.dateKey.localeCompare(b.dateKey),
|
||||
);
|
||||
}, [
|
||||
hasExpenses,
|
||||
categoriesWithExpenses,
|
||||
activeTab,
|
||||
defaultTabValue,
|
||||
showAllExpenses,
|
||||
allTransactions,
|
||||
categories,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Card className="card-hover">
|
||||
<CardHeader>
|
||||
@@ -46,7 +271,12 @@ export function TopExpensesList({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasExpenses ? (
|
||||
<Tabs defaultValue={defaultTabValue} className="w-full">
|
||||
<Tabs
|
||||
defaultValue={defaultTabValue}
|
||||
value={activeTab || defaultTabValue}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full flex-wrap h-auto p-1 mb-4">
|
||||
{categoriesWithExpenses.map(({ categoryId, expenses }) => {
|
||||
const category = categoryId
|
||||
@@ -149,6 +379,89 @@ export function TopExpensesList({
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
{/* Graphique d'évolution temporelle */}
|
||||
{chartData.length > 0 && (() => {
|
||||
const currentTab = activeTab || defaultTabValue;
|
||||
const activeCategoryGroup = categoriesWithExpenses.find(
|
||||
(group) => (group.categoryId || "uncategorized") === currentTab,
|
||||
);
|
||||
const activeCategory = activeCategoryGroup?.categoryId
|
||||
? categories.find((c) => c.id === activeCategoryGroup.categoryId)
|
||||
: null;
|
||||
const barColor = activeCategory?.color || "var(--destructive)";
|
||||
|
||||
return (
|
||||
<div className="mt-6 pt-6 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold">
|
||||
Évolution des dépenses dans le temps
|
||||
</h3>
|
||||
{allTransactions.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAllExpenses(!showAllExpenses)}
|
||||
className="text-xs"
|
||||
>
|
||||
{showAllExpenses ? (
|
||||
<>
|
||||
<ListOrdered className="w-3 h-3 mr-1.5" />
|
||||
Top 10 seulement
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<List className="w-3 h-3 mr-1.5" />
|
||||
Voir toutes les dépenses
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-[250px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
className="text-xs"
|
||||
interval="preserveStartEnd"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-xs"
|
||||
width={80}
|
||||
tickFormatter={(v) => {
|
||||
if (Math.abs(v) >= 1000) {
|
||||
return `${(v / 1000).toFixed(1)}k€`;
|
||||
}
|
||||
return `${Math.round(v)}€`;
|
||||
}}
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => formatCurrency(value)}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="montant"
|
||||
fill={barColor}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Tabs>
|
||||
) : (
|
||||
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-xs md:text-sm">
|
||||
|
||||
Reference in New Issue
Block a user