From ba4d112cb8bf9134b64bbb33a1fb4d7c6e1486e8 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Mon, 8 Dec 2025 09:50:32 +0100 Subject: [PATCH] feat: add duplicate transaction detection and display in transactions page, enhancing user experience with visual indicators for duplicates --- app/api/banking/duplicates/ids/route.ts | 20 +++++ app/transactions/page.tsx | 63 ++++++++------- .../transactions/transaction-filters.tsx | 32 +++++++- components/transactions/transaction-table.tsx | 77 +++++++++++++++---- lib/hooks.ts | 21 ++++- services/transaction.service.ts | 65 ++++++++++++++-- 6 files changed, 223 insertions(+), 55 deletions(-) create mode 100644 app/api/banking/duplicates/ids/route.ts diff --git a/app/api/banking/duplicates/ids/route.ts b/app/api/banking/duplicates/ids/route.ts new file mode 100644 index 0000000..9052ab6 --- /dev/null +++ b/app/api/banking/duplicates/ids/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 GET() { + const authError = await requireAuth(); + if (authError) return authError; + + try { + const duplicateIds = await transactionService.getDuplicateIds(); + return NextResponse.json({ duplicateIds: Array.from(duplicateIds) }); + } catch (error) { + console.error("Error finding duplicate IDs:", error); + return NextResponse.json( + { error: "Failed to find duplicate IDs" }, + { status: 500 }, + ); + } +} + diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx index bcbbe9e..d9bc8c8 100644 --- a/app/transactions/page.tsx +++ b/app/transactions/page.tsx @@ -15,6 +15,7 @@ import { useBankingMetadata, useTransactions, getTransactionsQueryKey, + useDuplicateIds, } from "@/lib/hooks"; import { updateCategory } from "@/lib/store-db"; import { useQueryClient } from "@tanstack/react-query"; @@ -65,24 +66,25 @@ export default function TransactionsPage() { const [showReconciled, setShowReconciled] = useState("all"); const [period, setPeriod] = useState("all"); const [customStartDate, setCustomStartDate] = useState( - undefined, + undefined ); const [customEndDate, setCustomEndDate] = useState( - undefined, + undefined ); const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false); const [sortField, setSortField] = useState("date"); const [sortOrder, setSortOrder] = useState("desc"); const [selectedTransactions, setSelectedTransactions] = useState>( - new Set(), + new Set() ); const [ruleDialogOpen, setRuleDialogOpen] = useState(false); const [ruleTransaction, setRuleTransaction] = useState( - null, + null ); const [updatingTransactionIds, setUpdatingTransactionIds] = useState< Set >(new Set()); + const [showDuplicates, setShowDuplicates] = useState(false); // Get start date based on period const startDate = useMemo(() => { @@ -164,6 +166,9 @@ export default function TransactionsPage() { invalidate: invalidateTransactions, } = useTransactions(transactionParams, !!metadata); + // Fetch duplicate IDs + const { data: duplicateIds = new Set() } = useDuplicateIds(); + // For filter comboboxes, we'll use empty arrays for now // They can be enhanced later with separate queries if needed const transactionsForAccountFilter: Transaction[] = []; @@ -182,7 +187,7 @@ export default function TransactionsPage() { // Use transactions from current page to find similar ones const normalizedDesc = normalizeDescription(ruleTransaction.description); const similarTransactions = transactionsData.transactions.filter( - (t) => normalizeDescription(t.description) === normalizedDesc, + (t) => normalizeDescription(t.description) === normalizedDesc ); if (similarTransactions.length === 0) return null; @@ -193,7 +198,7 @@ export default function TransactionsPage() { transactions: similarTransactions, totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0), suggestedKeyword: suggestKeyword( - similarTransactions.map((t) => t.description), + similarTransactions.map((t) => t.description) ), }; }, [ruleTransaction, transactionsData]); @@ -209,7 +214,7 @@ export default function TransactionsPage() { // 1. Add keyword to category const category = metadata.categories.find( - (c: { id: string }) => c.id === ruleData.categoryId, + (c: { id: string }) => c.id === ruleData.categoryId ); if (!category) { throw new Error("Category not found"); @@ -217,7 +222,7 @@ export default function TransactionsPage() { // Check if keyword already exists const keywordExists = category.keywords.some( - (k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(), + (k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase() ); if (!keywordExists) { @@ -235,8 +240,8 @@ export default function TransactionsPage() { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, categoryId: ruleData.categoryId }), - }), - ), + }) + ) ); } @@ -245,7 +250,7 @@ export default function TransactionsPage() { queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); setRuleDialogOpen(false); }, - [metadata, queryClient], + [metadata, queryClient] ); const invalidateAll = useCallback(() => { @@ -272,7 +277,7 @@ export default function TransactionsPage() { if (!transactionsData) return; const transaction = transactionsData.transactions.find( - (t) => t.id === transactionId, + (t) => t.id === transactionId ); if (!transaction) return; @@ -297,7 +302,7 @@ export default function TransactionsPage() { if (!transactionsData) return; const transaction = transactionsData.transactions.find( - (t) => t.id === transactionId, + (t) => t.id === transactionId ); if (!transaction || transaction.isReconciled) return; @@ -320,12 +325,12 @@ export default function TransactionsPage() { const setCategory = async ( transactionId: string, - categoryId: string | null, + categoryId: string | null ) => { if (!transactionsData) return; const transaction = transactionsData.transactions.find( - (t) => t.id === transactionId, + (t) => t.id === transactionId ); if (!transaction) return; @@ -350,7 +355,7 @@ export default function TransactionsPage() { return { ...oldData, transactions: oldData.transactions.map((t) => - t.id === transactionId ? { ...t, categoryId } : t, + t.id === transactionId ? { ...t, categoryId } : t ), }; }); @@ -370,7 +375,7 @@ export default function TransactionsPage() { if (!transactionsData) return; const transactionsToUpdate = transactionsData.transactions.filter((t) => - selectedTransactions.has(t.id), + selectedTransactions.has(t.id) ); setSelectedTransactions(new Set()); @@ -382,8 +387,8 @@ export default function TransactionsPage() { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...t, isReconciled: reconciled }), - }), - ), + }) + ) ); invalidateTransactions(); } catch (error) { @@ -395,7 +400,7 @@ export default function TransactionsPage() { if (!transactionsData) return; const transactionsToUpdate = transactionsData.transactions.filter((t) => - selectedTransactions.has(t.id), + selectedTransactions.has(t.id) ); const transactionIds = transactionsToUpdate.map((t) => t.id); @@ -413,8 +418,8 @@ export default function TransactionsPage() { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...t, categoryId }), - }), - ), + }) + ) ); // Mise à jour directe du cache après succès @@ -424,7 +429,7 @@ export default function TransactionsPage() { return { ...oldData, transactions: oldData.transactions.map((t) => - transactionIds.includes(t.id) ? { ...t, categoryId } : t, + transactionIds.includes(t.id) ? { ...t, categoryId } : t ), }; }); @@ -446,7 +451,7 @@ export default function TransactionsPage() { setSelectedTransactions(new Set()); } else { setSelectedTransactions( - new Set(transactionsData.transactions.map((t) => t.id)), + new Set(transactionsData.transactions.map((t) => t.id)) ); } }; @@ -488,7 +493,7 @@ export default function TransactionsPage() { return { ...oldData, transactions: oldData.transactions.filter( - (t) => t.id !== transactionId, + (t) => t.id !== transactionId ), total: oldData.total - 1, }; @@ -499,13 +504,13 @@ export default function TransactionsPage() { `/api/banking/transactions?id=${transactionId}`, { method: "DELETE", - }, + } ); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( - errorData.error || `Failed to delete transaction: ${response.status}`, + errorData.error || `Failed to delete transaction: ${response.status}` ); } @@ -593,6 +598,8 @@ export default function TransactionsPage() { }} isCustomDatePickerOpen={isCustomDatePickerOpen} onCustomDatePickerOpenChange={setIsCustomDatePickerOpen} + showDuplicates={showDuplicates} + onShowDuplicatesChange={setShowDuplicates} accounts={metadata.accounts} folders={metadata.folders} categories={metadata.categories} @@ -631,6 +638,8 @@ export default function TransactionsPage() { formatCurrency={formatCurrency} formatDate={formatDate} updatingTransactionIds={updatingTransactionIds} + duplicateIds={duplicateIds} + highlightDuplicates={showDuplicates} /> {/* Pagination controls */} diff --git a/components/transactions/transaction-filters.tsx b/components/transactions/transaction-filters.tsx index 2797790..b8163fc 100644 --- a/components/transactions/transaction-filters.tsx +++ b/components/transactions/transaction-filters.tsx @@ -28,10 +28,12 @@ import { SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; -import { Search, X, Filter, Wallet, Calendar } from "lucide-react"; +import { Search, X, Filter, Wallet, Calendar, Copy } from "lucide-react"; import { format } from "date-fns"; import { fr } from "date-fns/locale"; import { useIsMobile } from "@/hooks/use-mobile"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; import type { Account, Category, Folder, Transaction } from "@/lib/types"; type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all"; @@ -53,6 +55,8 @@ interface TransactionFiltersProps { onCustomEndDateChange: (date: Date | undefined) => void; isCustomDatePickerOpen: boolean; onCustomDatePickerOpenChange: (open: boolean) => void; + showDuplicates: boolean; + onShowDuplicatesChange: (show: boolean) => void; accounts: Account[]; folders: Folder[]; categories: Category[]; @@ -77,6 +81,8 @@ export function TransactionFilters({ onCustomEndDateChange, isCustomDatePickerOpen, onCustomDatePickerOpenChange, + showDuplicates, + onShowDuplicatesChange, accounts, folders, categories, @@ -153,6 +159,23 @@ export function TransactionFilters({ +
+ + onShowDuplicatesChange(checked === true) + } + /> + +
+ {period === "custom" && ( { const newCategories = selectedCategories.filter((c) => c !== id); onCategoriesChange( - newCategories.length > 0 ? newCategories : ["all"], + newCategories.length > 0 ? newCategories : ["all"] ); }} onClearCategories={() => onCategoriesChange(["all"])} @@ -282,7 +305,8 @@ export function TransactionFilters({ (!selectedAccounts.includes("all") ? selectedAccounts.length : 0) + (!selectedCategories.includes("all") ? selectedCategories.length : 0) + (showReconciled !== "all" ? 1 : 0) + - (period !== "all" ? 1 : 0); + (period !== "all" ? 1 : 0) + + (showDuplicates ? 1 : 0); return ( <> @@ -367,7 +391,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"); diff --git a/components/transactions/transaction-table.tsx b/components/transactions/transaction-table.tsx index 0fdb1de..90e87d8 100644 --- a/components/transactions/transaction-table.tsx +++ b/components/transactions/transaction-table.tsx @@ -25,6 +25,7 @@ import { Wand2, Trash2, Loader2, + AlertTriangle, } from "lucide-react"; import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; @@ -52,6 +53,8 @@ interface TransactionTableProps { formatCurrency: (amount: number) => string; formatDate: (dateStr: string) => string; updatingTransactionIds?: Set; + duplicateIds?: Set; + highlightDuplicates?: boolean; } const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne @@ -145,6 +148,8 @@ export function TransactionTable({ formatCurrency, formatDate, updatingTransactionIds = new Set(), + duplicateIds = new Set(), + highlightDuplicates = false, }: TransactionTableProps) { const [focusedIndex, setFocusedIndex] = useState(null); const parentRef = useRef(null); @@ -164,7 +169,7 @@ export function TransactionTable({ setFocusedIndex(index); onMarkReconciled(transactionId); }, - [onMarkReconciled], + [onMarkReconciled] ); const handleKeyDown = useCallback( @@ -193,7 +198,7 @@ export function TransactionTable({ } } }, - [focusedIndex, transactions, onMarkReconciled, virtualizer], + [focusedIndex, transactions, onMarkReconciled, virtualizer] ); useEffect(() => { @@ -210,7 +215,7 @@ export function TransactionTable({ (accountId: string) => { return accounts.find((a) => a.id === accountId); }, - [accounts], + [accounts] ); const getCategory = useCallback( @@ -218,7 +223,7 @@ export function TransactionTable({ if (!categoryId) return null; return categories.find((c) => c.id === categoryId); }, - [categories], + [categories] ); return ( @@ -247,6 +252,8 @@ export function TransactionTable({ const account = getAccount(transaction.accountId); const _category = getCategory(transaction.categoryId); const isFocused = focusedIndex === virtualRow.index; + const isDuplicate = + highlightDuplicates && duplicateIds.has(transaction.id); return (
{ // Désactiver le pointage au clic sur mobile @@ -270,6 +281,7 @@ export function TransactionTable({ "p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border", transaction.isReconciled && "bg-emerald-500/5", isFocused && "bg-primary/10 ring-1 ring-primary/30", + isDuplicate && "shadow-sm" )} >
@@ -282,9 +294,23 @@ export function TransactionTable({ onClick={(e) => e.stopPropagation()} />
-

- {transaction.description} -

+
+

+ {transaction.description} +

+ {isDuplicate && ( + + + + + +

+ Transaction en double (même somme et date) +

+
+
+ )} +
{transaction.memo && (

{transaction.memo} @@ -297,7 +323,7 @@ export function TransactionTable({ "font-semibold tabular-nums text-sm md:text-base shrink-0", transaction.amount >= 0 ? "text-emerald-600" - : "text-red-600", + : "text-red-600" )} > {transaction.amount >= 0 ? "+" : ""} @@ -332,7 +358,7 @@ export function TransactionTable({ showBadge align="start" disabled={updatingTransactionIds.has( - transaction.id, + transaction.id )} />

@@ -365,7 +391,7 @@ export function TransactionTable({ e.stopPropagation(); if ( confirm( - `Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`, + `Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}` ) ) { onDelete(transaction.id); @@ -455,6 +481,8 @@ export function TransactionTable({ const transaction = transactions[virtualRow.index]; const account = getAccount(transaction.accountId); const isFocused = focusedIndex === virtualRow.index; + const isDuplicate = + highlightDuplicates && duplicateIds.has(transaction.id); return (
handleRowClick(virtualRow.index, transaction.id) @@ -475,6 +507,7 @@ export function TransactionTable({ "grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer", transaction.isReconciled && "bg-emerald-500/5", isFocused && "bg-primary/10 ring-1 ring-primary/30", + isDuplicate && "shadow-sm" )} >
@@ -492,9 +525,23 @@ export function TransactionTable({ className="p-3 min-w-0 overflow-hidden" onClick={(e) => e.stopPropagation()} > -

- {transaction.description} -

+
+

+ {transaction.description} +

+ {isDuplicate && ( + + + + + +

+ Transaction en double (même somme et date) +

+
+
+ )} +
{transaction.memo && ( = 0 ? "text-emerald-600" - : "text-red-600", + : "text-red-600" )} > {transaction.amount >= 0 ? "+" : ""} @@ -596,7 +643,7 @@ export function TransactionTable({ e.stopPropagation(); if ( confirm( - `Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`, + `Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}` ) ) { onDelete(transaction.id); diff --git a/lib/hooks.ts b/lib/hooks.ts index 68a4702..0b4ea69 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -83,7 +83,7 @@ export function useLocalStorage(key: string, initialValue: T) { // Helper function to serialize transaction params into a query key export function getTransactionsQueryKey( - params: TransactionsPaginatedParams = {}, + params: TransactionsPaginatedParams = {} ): (string | number)[] { const key: (string | number)[] = ["transactions"]; if (params.limit) key.push(`limit:${params.limit}`); @@ -106,7 +106,7 @@ export function getTransactionsQueryKey( export function useTransactions( params: TransactionsPaginatedParams = {}, - enabled = true, + enabled = true ) { const queryClient = useQueryClient(); @@ -134,7 +134,7 @@ export function useTransactions( if (params.isReconciled !== undefined && params.isReconciled !== "all") { searchParams.set( "isReconciled", - params.isReconciled === true ? "true" : "false", + params.isReconciled === true ? "true" : "false" ); } if (params.sortField) searchParams.set("sortField", params.sortField); @@ -205,3 +205,18 @@ export function useAccountsWithStats() { staleTime: 60 * 1000, // 1 minute }); } + +export function useDuplicateIds() { + return useQuery({ + queryKey: ["duplicate-ids"], + queryFn: async (): Promise> => { + const response = await fetch("/api/banking/duplicates/ids"); + if (!response.ok) { + throw new Error("Failed to fetch duplicate IDs"); + } + const data = await response.json(); + return new Set(data.duplicateIds || []); + }, + staleTime: 30 * 1000, // 30 seconds + }); +} diff --git a/services/transaction.service.ts b/services/transaction.service.ts index 5a6170e..0c5e13a 100644 --- a/services/transaction.service.ts +++ b/services/transaction.service.ts @@ -41,14 +41,14 @@ export const transactionService = { // Create sets for fast lookup const existingFitIdSet = new Set( - existingByFitId.map((t) => `${t.accountId}-${t.fitId}`), + existingByFitId.map((t) => `${t.accountId}-${t.fitId}`) ); // Create set for duplicates by amount + date + description const existingCriteriaSet = new Set( allExistingTransactions.map( - (t) => `${t.accountId}-${t.date}-${t.amount}-${t.description}`, - ), + (t) => `${t.accountId}-${t.date}-${t.amount}-${t.description}` + ) ); // Filter out duplicates based on fitId OR (amount + date + description) @@ -85,7 +85,7 @@ export const transactionService = { async update( id: string, - data: Partial>, + data: Partial> ): Promise { const updated = await prisma.transaction.update({ where: { id }, @@ -123,14 +123,67 @@ export const transactionService = { await prisma.transaction.delete({ where: { id }, }); - } catch (error: any) { - if (error.code === "P2025") { + } catch (error: unknown) { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "P2025" + ) { throw new Error(`Transaction with id ${id} not found`); } throw error; } }, + async getDuplicateIds(): Promise> { + // Get all transactions grouped by account + const allTransactions = await prisma.transaction.findMany({ + orderBy: [ + { accountId: "asc" }, + { date: "asc" }, + { createdAt: "asc" }, // Oldest first + ], + select: { + id: true, + accountId: true, + date: true, + amount: true, + }, + }); + + // Group by account for efficient processing + const transactionsByAccount = new Map(); + for (const transaction of allTransactions) { + if (!transactionsByAccount.has(transaction.accountId)) { + transactionsByAccount.set(transaction.accountId, []); + } + transactionsByAccount.get(transaction.accountId)!.push(transaction); + } + + const duplicateIds = new Set(); + const seenKeys = new Map(); // key -> first transaction ID + + // For each account, find duplicates by amount + date only + for (const [accountId, transactions] of transactionsByAccount.entries()) { + for (const transaction of transactions) { + const key = `${accountId}-${transaction.date}-${transaction.amount}`; + + if (seenKeys.has(key)) { + // This is a duplicate - mark both the first and this one + const firstId = seenKeys.get(key)!; + duplicateIds.add(firstId); + duplicateIds.add(transaction.id); + } else { + // First occurrence + seenKeys.set(key, transaction.id); + } + } + } + + return duplicateIds; + }, + async deduplicate(): Promise<{ deletedCount: number; duplicatesFound: number;