From d5aa00a8857f9e0cdb77cb2c58454f4abaee8639 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 30 Nov 2025 13:02:03 +0100 Subject: [PATCH] feat: add transaction deduplication feature and enhance filtering options in settings and transactions pages --- .../banking/transactions/deduplicate/route.ts | 20 ++ app/settings/page.tsx | 19 ++ app/statistics/page.tsx | 75 +++++++- app/transactions/page.tsx | 132 ++++++++++++- components/settings/danger-zone-card.tsx | 65 ++++++- .../transactions/transaction-filters.tsx | 174 +++++++++++++++++- components/transactions/transaction-table.tsx | 20 ++ scripts/import-csv-to-db.ts | 31 +++- services/transaction.service.ts | 106 ++++++++++- 9 files changed, 616 insertions(+), 26 deletions(-) create mode 100644 app/api/banking/transactions/deduplicate/route.ts diff --git a/app/api/banking/transactions/deduplicate/route.ts b/app/api/banking/transactions/deduplicate/route.ts new file mode 100644 index 0000000..82db1e1 --- /dev/null +++ b/app/api/banking/transactions/deduplicate/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { transactionService } from "@/services/transaction.service"; +import { requireAuth } from "@/lib/auth-utils"; + +export async function POST() { + const authError = await requireAuth(); + if (authError) return authError; + + try { + const result = await transactionService.deduplicate(); + return NextResponse.json(result); + } catch (error) { + console.error("Error deduplicating transactions:", error); + return NextResponse.json( + { error: "Failed to deduplicate transactions" }, + { status: 500 } + ); + } +} + diff --git a/app/settings/page.tsx b/app/settings/page.tsx index d1413a8..131d0e5 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -89,6 +89,24 @@ export default function SettingsPage() { } }; + const deduplicateTransactions = async () => { + try { + const response = await fetch( + "/api/banking/transactions/deduplicate", + { + method: "POST", + } + ); + if (!response.ok) throw new Error("Erreur"); + const result = await response.json(); + refresh(); + return result; + } catch (error) { + console.error(error); + throw error; + } + }; + const categorizedCount = data.transactions.filter((t) => t.categoryId).length; return ( @@ -114,6 +132,7 @@ export default function SettingsPage() { categorizedCount={categorizedCount} onClearCategories={clearAllCategories} onResetData={resetData} + onDeduplicate={deduplicateTransactions} /> diff --git a/app/statistics/page.tsx b/app/statistics/page.tsx index cc9cf7e..f031af8 100644 --- a/app/statistics/page.tsx +++ b/app/statistics/page.tsx @@ -23,6 +23,7 @@ import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { CategoryIcon } from "@/components/ui/category-icon"; +import { Checkbox } from "@/components/ui/checkbox"; import { Filter, X, Wallet, CircleSlash, Calendar } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Calendar as CalendarComponent } from "@/components/ui/calendar"; @@ -38,6 +39,7 @@ export default function StatisticsPage() { const [period, setPeriod] = useState("6months"); const [selectedAccounts, setSelectedAccounts] = useState(["all"]); const [selectedCategories, setSelectedCategories] = useState(["all"]); + const [excludeInternalTransfers, setExcludeInternalTransfers] = useState(true); const [customStartDate, setCustomStartDate] = useState(undefined); const [customEndDate, setCustomEndDate] = useState(undefined); const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false); @@ -69,6 +71,14 @@ export default function StatisticsPage() { return undefined; }, [period, customEndDate]); + // Find "Virement interne" category + const internalTransferCategory = useMemo(() => { + if (!data) return null; + return data.categories.find( + (c) => c.name.toLowerCase() === "virement interne" + ); + }, [data]); + // Transactions filtered for account filter (by categories, period - not accounts) const transactionsForAccountFilter = useMemo(() => { if (!data) return []; @@ -98,8 +108,14 @@ export default function StatisticsPage() { } } return true; + }).filter((t) => { + // Exclude "Virement interne" category if checkbox is checked + if (excludeInternalTransfers && internalTransferCategory) { + return t.categoryId !== internalTransferCategory.id; + } + return true; }); - }, [data, startDate, endDate, selectedCategories]); + }, [data, startDate, endDate, selectedCategories, excludeInternalTransfers, internalTransferCategory]); // Transactions filtered for category filter (by accounts, period - not categories) const transactionsForCategoryFilter = useMemo(() => { @@ -126,8 +142,14 @@ export default function StatisticsPage() { return selectedAccounts.includes(t.accountId); } return true; + }).filter((t) => { + // Exclude "Virement interne" category if checkbox is checked + if (excludeInternalTransfers && internalTransferCategory) { + return t.categoryId !== internalTransferCategory.id; + } + return true; }); - }, [data, startDate, endDate, selectedAccounts]); + }, [data, startDate, endDate, selectedAccounts, excludeInternalTransfers, internalTransferCategory]); const stats = useMemo(() => { if (!data) return null; @@ -163,6 +185,13 @@ export default function StatisticsPage() { } } + // Exclude "Virement interne" category if checkbox is checked + if (excludeInternalTransfers && internalTransferCategory) { + transactions = transactions.filter( + (t) => t.categoryId !== internalTransferCategory.id + ); + } + // Monthly breakdown const monthlyData = new Map(); transactions.forEach((t) => { @@ -211,10 +240,20 @@ export default function StatisticsPage() { .sort((a, b) => b.value - a.value) .slice(0, 8); - // Top expenses - const topExpenses = transactions + // Top expenses - deduplicate by ID and sort by amount (most negative first) + const uniqueTransactions = Array.from( + new Map(transactions.map((t) => [t.id, t])).values() + ); + const topExpenses = uniqueTransactions .filter((t) => t.amount < 0) - .sort((a, b) => a.amount - b.amount) + .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, 5); // Summary @@ -259,6 +298,10 @@ export default function StatisticsPage() { return false; } } + // Exclude "Virement interne" category if checkbox is checked + if (excludeInternalTransfers && internalTransferCategory) { + if (t.categoryId === internalTransferCategory.id) return false; + } // Only transactions before startDate const transactionDate = new Date(t.date); return transactionDate < startDate; @@ -308,6 +351,10 @@ export default function StatisticsPage() { return false; } } + // Exclude "Virement interne" category if checkbox is checked + if (excludeInternalTransfers && internalTransferCategory) { + if (t.categoryId === internalTransferCategory.id) return false; + } const transactionDate = new Date(t.date); return transactionDate < startDate; }) @@ -373,7 +420,7 @@ export default function StatisticsPage() { perAccountBalanceData, transactionCount: transactions.length, }; - }, [data, startDate, endDate, selectedAccounts, selectedCategories]); + }, [data, startDate, endDate, selectedAccounts, selectedCategories, excludeInternalTransfers, internalTransferCategory]); const formatCurrency = (amount: number) => { return new Intl.NumberFormat("fr-FR", { @@ -517,6 +564,22 @@ export default function StatisticsPage() { )} + + {internalTransferCategory && ( +
+ setExcludeInternalTransfers(checked === true)} + /> + +
+ )} (["all"]); const [showReconciled, setShowReconciled] = useState("all"); + const [period, setPeriod] = useState("all"); + const [customStartDate, setCustomStartDate] = useState(undefined); + const [customEndDate, setCustomEndDate] = useState(undefined); + const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false); const [sortField, setSortField] = useState("date"); const [sortOrder, setSortOrder] = useState("desc"); const [selectedTransactions, setSelectedTransactions] = useState>( @@ -46,12 +53,54 @@ export default function TransactionsPage() { const [ruleDialogOpen, setRuleDialogOpen] = useState(false); const [ruleTransaction, setRuleTransaction] = useState(null); - // Transactions filtered for account filter (by categories, search, reconciled - not accounts) + // Get start date based on period + const startDate = useMemo(() => { + const now = new Date(); + switch (period) { + case "1month": + return new Date(now.getFullYear(), now.getMonth() - 1, 1); + case "3months": + return new Date(now.getFullYear(), now.getMonth() - 3, 1); + case "6months": + return new Date(now.getFullYear(), now.getMonth() - 6, 1); + case "12months": + return new Date(now.getFullYear(), now.getMonth() - 12, 1); + case "custom": + return customStartDate || new Date(0); + default: + return new Date(0); + } + }, [period, customStartDate]); + + // Get end date (only for custom period) + const endDate = useMemo(() => { + if (period === "custom" && customEndDate) { + return customEndDate; + } + return undefined; + }, [period, customEndDate]); + + // Transactions filtered for account filter (by categories, search, reconciled, period - not accounts) const transactionsForAccountFilter = useMemo(() => { if (!data) return []; let transactions = [...data.transactions]; + // Filter by period + transactions = transactions.filter((t) => { + const transactionDate = new Date(t.date); + if (endDate) { + // Custom date range + const endOfDay = new Date(endDate); + endOfDay.setHours(23, 59, 59, 999); + return transactionDate >= startDate && transactionDate <= endOfDay; + } else if (period !== "all") { + // Standard period + return transactionDate >= startDate; + } + return true; + }); + if (searchQuery) { const query = searchQuery.toLowerCase(); transactions = transactions.filter( @@ -79,14 +128,29 @@ export default function TransactionsPage() { } return transactions; - }, [data, searchQuery, selectedCategories, showReconciled]); + }, [data, searchQuery, selectedCategories, showReconciled, period, startDate, endDate]); - // Transactions filtered for category filter (by accounts, search, reconciled - not categories) + // Transactions filtered for category filter (by accounts, search, reconciled, period - not categories) const transactionsForCategoryFilter = useMemo(() => { if (!data) return []; let transactions = [...data.transactions]; + // Filter by period + transactions = transactions.filter((t) => { + const transactionDate = new Date(t.date); + if (endDate) { + // Custom date range + const endOfDay = new Date(endDate); + endOfDay.setHours(23, 59, 59, 999); + return transactionDate >= startDate && transactionDate <= endOfDay; + } else if (period !== "all") { + // Standard period + return transactionDate >= startDate; + } + return true; + }); + if (searchQuery) { const query = searchQuery.toLowerCase(); transactions = transactions.filter( @@ -110,13 +174,28 @@ export default function TransactionsPage() { } return transactions; - }, [data, searchQuery, selectedAccounts, showReconciled]); + }, [data, searchQuery, selectedAccounts, showReconciled, period, startDate, endDate]); const filteredTransactions = useMemo(() => { if (!data) return []; let transactions = [...data.transactions]; + // Filter by period + transactions = transactions.filter((t) => { + const transactionDate = new Date(t.date); + if (endDate) { + // Custom date range + const endOfDay = new Date(endDate); + endOfDay.setHours(23, 59, 59, 999); + return transactionDate >= startDate && transactionDate <= endOfDay; + } else if (period !== "all") { + // Standard period + return transactionDate >= startDate; + } + return true; + }); + if (searchQuery) { const query = searchQuery.toLowerCase(); transactions = transactions.filter( @@ -172,6 +251,9 @@ export default function TransactionsPage() { selectedAccounts, selectedCategories, showReconciled, + period, + startDate, + endDate, sortField, sortOrder, ]); @@ -424,6 +506,32 @@ export default function TransactionsPage() { } }; + const deleteTransaction = async (transactionId: string) => { + // Optimistic update + const updatedTransactions = data.transactions.filter( + (t) => t.id !== transactionId + ); + update({ ...data, transactions: updatedTransactions }); + + // Remove from selected if selected + const newSelected = new Set(selectedTransactions); + newSelected.delete(transactionId); + setSelectedTransactions(newSelected); + + try { + const response = await fetch( + `/api/banking/transactions?id=${transactionId}`, + { + method: "DELETE", + } + ); + if (!response.ok) throw new Error("Failed to delete transaction"); + } catch (error) { + console.error("Failed to delete transaction:", error); + refresh(); // Revert on error + } + }; + return ( { + setPeriod(p); + if (p !== "custom") { + setIsCustomDatePickerOpen(false); + } else { + setIsCustomDatePickerOpen(true); + } + }} + customStartDate={customStartDate} + customEndDate={customEndDate} + onCustomStartDateChange={setCustomStartDate} + onCustomEndDateChange={setCustomEndDate} + isCustomDatePickerOpen={isCustomDatePickerOpen} + onCustomDatePickerOpenChange={setIsCustomDatePickerOpen} accounts={data.accounts} folders={data.folders} categories={data.categories} @@ -476,6 +599,7 @@ export default function TransactionsPage() { onMarkReconciled={markReconciled} onSetCategory={setCategory} onCreateRule={handleCreateRule} + onDelete={deleteTransaction} formatCurrency={formatCurrency} formatDate={formatDate} /> diff --git a/components/settings/danger-zone-card.tsx b/components/settings/danger-zone-card.tsx index e4d4066..0629646 100644 --- a/components/settings/danger-zone-card.tsx +++ b/components/settings/danger-zone-card.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, @@ -19,19 +20,39 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import { Trash2, Tags } from "lucide-react"; +import { Trash2, Tags, Copy } from "lucide-react"; interface DangerZoneCardProps { categorizedCount: number; onClearCategories: () => void; onResetData: () => void; + onDeduplicate: () => Promise<{ deletedCount: number; duplicatesFound: number }>; } export function DangerZoneCard({ categorizedCount, onClearCategories, onResetData, + onDeduplicate, }: DangerZoneCardProps) { + const [deduplicating, setDeduplicating] = useState(false); + + const handleDeduplicate = async () => { + setDeduplicating(true); + try { + const result = await onDeduplicate(); + if (result.deletedCount > 0) { + alert(`${result.deletedCount} transaction${result.deletedCount > 1 ? "s" : ""} en double supprimée${result.deletedCount > 1 ? "s" : ""}`); + } else { + alert("Aucun doublon trouvé"); + } + } catch (error) { + console.error(error); + alert("Erreur lors du dédoublonnage"); + } finally { + setDeduplicating(false); + } + }; return ( @@ -44,6 +65,48 @@ export function DangerZoneCard({ + {/* Dédoublonnage */} + + + + + + + + Dédoublonner les transactions ? + + + Cette action va rechercher et supprimer les transactions en double + dans votre base de données. Les critères de dédoublonnage sont : + même compte, même date, même montant et même libellé. La première + transaction trouvée sera conservée, les autres seront supprimées. + + + + Annuler + + {deduplicating ? "Dédoublonnage..." : "Dédoublonner"} + + + + + {/* Supprimer catégories des opérations */} diff --git a/components/transactions/transaction-filters.tsx b/components/transactions/transaction-filters.tsx index e43f7ba..202ffef 100644 --- a/components/transactions/transaction-filters.tsx +++ b/components/transactions/transaction-filters.tsx @@ -13,9 +13,16 @@ import { import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox"; import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox"; import { CategoryIcon } from "@/components/ui/category-icon"; -import { Search, X, Filter, Wallet } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Calendar as CalendarComponent } from "@/components/ui/calendar"; +import { Button } from "@/components/ui/button"; +import { Search, X, Filter, Wallet, Calendar } from "lucide-react"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; import type { Account, Category, Folder, Transaction } from "@/lib/types"; +type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all"; + interface TransactionFiltersProps { searchQuery: string; onSearchChange: (query: string) => void; @@ -25,11 +32,19 @@ interface TransactionFiltersProps { onCategoriesChange: (categories: string[]) => void; showReconciled: string; onReconciledChange: (value: string) => void; + period: Period; + onPeriodChange: (period: Period) => void; + customStartDate?: Date; + customEndDate?: Date; + onCustomStartDateChange: (date: Date | undefined) => void; + onCustomEndDateChange: (date: Date | undefined) => void; + isCustomDatePickerOpen: boolean; + onCustomDatePickerOpenChange: (open: boolean) => void; accounts: Account[]; folders: Folder[]; categories: Category[]; - transactionsForAccountFilter?: Transaction[]; // Filtered by categories, search, reconciled (not accounts) - transactionsForCategoryFilter?: Transaction[]; // Filtered by accounts, search, reconciled (not categories) + transactionsForAccountFilter?: Transaction[]; // Filtered by categories, search, reconciled, period (not accounts) + transactionsForCategoryFilter?: Transaction[]; // Filtered by accounts, search, reconciled, period (not categories) } export function TransactionFilters({ @@ -41,6 +56,14 @@ export function TransactionFilters({ onCategoriesChange, showReconciled, onReconciledChange, + period, + onPeriodChange, + customStartDate, + customEndDate, + onCustomStartDateChange, + onCustomEndDateChange, + isCustomDatePickerOpen, + onCustomDatePickerOpenChange, accounts, folders, categories, @@ -90,6 +113,111 @@ export function TransactionFilters({ Non pointées + + + + {period === "custom" && ( + + + + + +
+
+ + { + onCustomStartDateChange(date); + if (date && customEndDate && date > customEndDate) { + onCustomEndDateChange(undefined); + } + }} + locale={fr} + /> +
+
+ + { + if (date && customStartDate && date < customStartDate) { + return; + } + onCustomEndDateChange(date); + if (date && customStartDate) { + onCustomDatePickerOpenChange(false); + } + }} + disabled={(date) => { + if (!customStartDate) return true; + return date < customStartDate; + }} + locale={fr} + /> +
+ {customStartDate && customEndDate && ( +
+ + +
+ )} +
+
+
+ )} onCategoriesChange(["all"])} showReconciled={showReconciled} onClearReconciled={() => onReconciledChange("all")} + period={period} + onClearPeriod={() => { + onPeriodChange("all"); + onCustomStartDateChange(undefined); + onCustomEndDateChange(undefined); + }} + customStartDate={customStartDate} + customEndDate={customEndDate} accounts={accounts} categories={categories} /> @@ -128,6 +264,10 @@ function ActiveFilters({ onClearCategories, showReconciled, onClearReconciled, + period, + onClearPeriod, + customStartDate, + customEndDate, accounts, categories, }: { @@ -141,6 +281,10 @@ function ActiveFilters({ onClearCategories: () => void; showReconciled: string; onClearReconciled: () => void; + period: Period; + onClearPeriod: () => void; + customStartDate?: Date; + customEndDate?: Date; accounts: Account[]; categories: Category[]; }) { @@ -148,8 +292,9 @@ function ActiveFilters({ const hasAccounts = !selectedAccounts.includes("all"); const hasCategories = !selectedCategories.includes("all"); const hasReconciled = showReconciled !== "all"; + const hasPeriod = period !== "all"; - const hasActiveFilters = hasSearch || hasAccounts || hasCategories || hasReconciled; + const hasActiveFilters = hasSearch || hasAccounts || hasCategories || hasReconciled || hasPeriod; if (!hasActiveFilters) return null; @@ -162,6 +307,7 @@ function ActiveFilters({ onClearAccounts(); onClearCategories(); onClearReconciled(); + onClearPeriod(); }; return ( @@ -229,6 +375,26 @@ function ActiveFilters({ )} + {hasPeriod && ( + + + {period === "custom" && customStartDate && customEndDate + ? `${format(customStartDate, "d MMM", { locale: fr })} - ${format(customEndDate, "d MMM yyyy", { locale: fr })}` + : period === "1month" + ? "1 mois" + : period === "3months" + ? "3 mois" + : period === "6months" + ? "6 mois" + : period === "12months" + ? "12 mois" + : "Période"} + + + )} +