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

This commit is contained in:
Julien Froidefond
2025-12-23 11:49:31 +01:00
parent c57daa9cc8
commit f295e86fc2
2 changed files with 336 additions and 21 deletions

View File

@@ -59,15 +59,15 @@ export default function StatisticsPage() {
// Persister les filtres dans le localStorage
const [period, setPeriod] = useLocalStorage<Period>(
"statistics-period",
"6months",
"6months"
);
const [selectedAccounts, setSelectedAccounts] = useLocalStorage<string[]>(
"statistics-selected-accounts",
["all"],
["all"]
);
const [selectedCategories, setSelectedCategories] = useLocalStorage<string[]>(
"statistics-selected-categories",
["all"],
["all"]
);
const [excludeInternalTransfers, setExcludeInternalTransfers] =
useLocalStorage("statistics-exclude-internal-transfers", true);
@@ -83,11 +83,11 @@ export default function StatisticsPage() {
// Convertir les ISO strings en Date
const customStartDate = useMemo(
() => (customStartDateISO ? new Date(customStartDateISO) : undefined),
[customStartDateISO],
[customStartDateISO]
);
const customEndDate = useMemo(
() => (customEndDateISO ? new Date(customEndDateISO) : undefined),
[customEndDateISO],
[customEndDateISO]
);
// Fonctions pour mettre à jour les dates avec persistance
@@ -145,7 +145,7 @@ export default function StatisticsPage() {
const internalTransferCategory = useMemo(() => {
if (!data) return null;
return data.categories.find(
(c) => c.name.toLowerCase() === "virement interne",
(c) => c.name.toLowerCase() === "virement interne"
);
}, [data]);
@@ -261,7 +261,7 @@ export default function StatisticsPage() {
// Filter by accounts
if (!selectedAccounts.includes("all")) {
transactions = transactions.filter((t) =>
selectedAccounts.includes(t.accountId),
selectedAccounts.includes(t.accountId)
);
}
@@ -271,7 +271,7 @@ export default function StatisticsPage() {
transactions = transactions.filter((t) => !t.categoryId);
} else {
transactions = transactions.filter(
(t) => t.categoryId && selectedCategories.includes(t.categoryId),
(t) => t.categoryId && selectedCategories.includes(t.categoryId)
);
}
}
@@ -279,7 +279,7 @@ export default function StatisticsPage() {
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
transactions = transactions.filter(
(t) => t.categoryId !== internalTransferCategory.id,
(t) => t.categoryId !== internalTransferCategory.id
);
}
@@ -352,7 +352,7 @@ export default function StatisticsPage() {
});
const categoryChartDataByParent = Array.from(
categoryTotalsByParent.entries(),
categoryTotalsByParent.entries()
)
.map(([groupId, total]) => {
const category = data.categories.find((c) => c.id === groupId);
@@ -368,7 +368,7 @@ export default function StatisticsPage() {
// Top expenses by top parent categories - deduplicate by ID
const uniqueTransactions = Array.from(
new Map(transactions.map((t) => [t.id, t])).values(),
new Map(transactions.map((t) => [t.id, t])).values()
);
const expenses = uniqueTransactions.filter((t) => t.amount < 0);
@@ -425,7 +425,7 @@ export default function StatisticsPage() {
// Balance evolution - Aggregated (using filtered transactions)
const sortedFilteredTransactions = [...transactions].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
// Calculate starting balance: initialBalance + transactions before startDate
@@ -437,7 +437,7 @@ export default function StatisticsPage() {
// Start with initial balances
runningBalance = accountsToUse.reduce(
(sum, acc) => sum + (acc.initialBalance || 0),
0,
0
);
// Add all transactions before the start date for these accounts
@@ -474,7 +474,7 @@ export default function StatisticsPage() {
});
const aggregatedBalanceData = Array.from(
aggregatedBalanceByDate.entries(),
aggregatedBalanceByDate.entries()
).map(([date, balance]) => ({
date: new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
@@ -697,6 +697,7 @@ export default function StatisticsPage() {
aggregatedBalanceData,
perAccountBalanceData,
transactionCount: transactions.length,
transactions, // Toutes les transactions filtrées pour le graphique
savingsTrendData,
categoryTrendData,
categoryTrendDataByParent,
@@ -931,7 +932,7 @@ export default function StatisticsPage() {
onRemoveAccount={(id) => {
const newAccounts = selectedAccounts.filter((a) => a !== id);
setSelectedAccounts(
newAccounts.length > 0 ? newAccounts : ["all"],
newAccounts.length > 0 ? newAccounts : ["all"]
);
}}
onClearAccounts={() => setSelectedAccounts(["all"])}
@@ -939,7 +940,7 @@ export default function StatisticsPage() {
onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter((c) => c !== id);
setSelectedCategories(
newCategories.length > 0 ? newCategories : ["all"],
newCategories.length > 0 ? newCategories : ["all"]
);
}}
onClearCategories={() => setSelectedCategories(["all"])}
@@ -1129,17 +1130,17 @@ export default function StatisticsPage() {
onRemoveAccount={(id) => {
const newAccounts = selectedAccounts.filter((a) => a !== id);
setSelectedAccounts(
newAccounts.length > 0 ? newAccounts : ["all"],
newAccounts.length > 0 ? newAccounts : ["all"]
);
}}
onClearAccounts={() => setSelectedAccounts(["all"])}
selectedCategories={selectedCategories}
onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter(
(c) => c !== id,
(c) => c !== id
);
setSelectedCategories(
newCategories.length > 0 ? newCategories : ["all"],
newCategories.length > 0 ? newCategories : ["all"]
);
}}
onClearCategories={() => setSelectedCategories(["all"])}
@@ -1244,6 +1245,7 @@ export default function StatisticsPage() {
expensesByCategory={stats.topExpensesByCategory}
categories={data.categories}
formatCurrency={formatCurrency}
allTransactions={stats.transactions}
/>
</div>
</section>
@@ -1289,7 +1291,7 @@ function ActiveFilters({
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
const selectedCats = categories.filter((c) =>
selectedCategories.includes(c.id),
selectedCategories.includes(c.id)
);
const isUncategorized = selectedCategories.includes("uncategorized");

View File

@@ -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">