From 0e0db122b93f7255f024803ecb6b3ebe812b27d9 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 28 Nov 2025 11:31:47 +0100 Subject: [PATCH] feat: implement transaction reconciliation feature with keyboard navigation support in transaction table --- README.md | 1 + .../transactions/clear-categories/route.ts | 1 + app/transactions/page.tsx | 27 ++++++++ components/transactions/transaction-table.tsx | 67 ++++++++++++++++++- scripts/init-db.ts | 1 + services/category.service.ts | 1 + services/folder.service.ts | 1 + services/transaction.service.ts | 1 + 8 files changed, 98 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0ee3f6b..cbabd82 100644 --- a/README.md +++ b/README.md @@ -154,3 +154,4 @@ Ce projet est en développement actif. Les suggestions et améliorations sont le --- Développé avec ❤️ en utilisant Next.js et React + diff --git a/app/api/banking/transactions/clear-categories/route.ts b/app/api/banking/transactions/clear-categories/route.ts index 698d93b..29c7e6d 100644 --- a/app/api/banking/transactions/clear-categories/route.ts +++ b/app/api/banking/transactions/clear-categories/route.ts @@ -24,3 +24,4 @@ export async function POST() { ); } } + diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx index 6befa30..bb53eb8 100644 --- a/app/transactions/page.tsx +++ b/app/transactions/page.tsx @@ -146,6 +146,32 @@ export default function TransactionsPage() { } }; + const markReconciled = async (transactionId: string) => { + const transaction = data.transactions.find((t) => t.id === transactionId); + if (!transaction || transaction.isReconciled) return; // Skip if already reconciled + + const updatedTransaction = { + ...transaction, + isReconciled: true, + }; + + const updatedTransactions = data.transactions.map((t) => + t.id === transactionId ? updatedTransaction : t + ); + update({ ...data, transactions: updatedTransactions }); + + try { + await fetch("/api/banking/transactions", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updatedTransaction), + }); + } catch (error) { + console.error("Failed to update transaction:", error); + refresh(); + } + }; + const setCategory = async ( transactionId: string, categoryId: string | null @@ -299,6 +325,7 @@ export default function TransactionsPage() { onToggleSelectAll={toggleSelectAll} onToggleSelectTransaction={toggleSelectTransaction} onToggleReconciled={toggleReconciled} + onMarkReconciled={markReconciled} onSetCategory={setCategory} formatCurrency={formatCurrency} formatDate={formatDate} diff --git a/components/transactions/transaction-table.tsx b/components/transactions/transaction-table.tsx index 435d940..5c00ac6 100644 --- a/components/transactions/transaction-table.tsx +++ b/components/transactions/transaction-table.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useRef, useState, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; @@ -36,6 +37,7 @@ interface TransactionTableProps { onToggleSelectAll: () => void; onToggleSelectTransaction: (id: string) => void; onToggleReconciled: (id: string) => void; + onMarkReconciled: (id: string) => void; onSetCategory: (transactionId: string, categoryId: string | null) => void; formatCurrency: (amount: number) => string; formatDate: (dateStr: string) => string; @@ -52,10 +54,62 @@ export function TransactionTable({ onToggleSelectAll, onToggleSelectTransaction, onToggleReconciled, + onMarkReconciled, onSetCategory, formatCurrency, formatDate, }: TransactionTableProps) { + const [focusedIndex, setFocusedIndex] = useState(null); + const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]); + + const handleRowClick = useCallback( + (index: number, transactionId: string) => { + setFocusedIndex(index); + onMarkReconciled(transactionId); + }, + [onMarkReconciled] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (focusedIndex === null || transactions.length === 0) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + const newIndex = Math.min(focusedIndex + 1, transactions.length - 1); + if (newIndex !== focusedIndex) { + setFocusedIndex(newIndex); + onMarkReconciled(transactions[newIndex].id); + rowRefs.current[newIndex]?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const newIndex = Math.max(focusedIndex - 1, 0); + if (newIndex !== focusedIndex) { + setFocusedIndex(newIndex); + onMarkReconciled(transactions[newIndex].id); + rowRefs.current[newIndex]?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + } + } + }, + [focusedIndex, transactions, onMarkReconciled] + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + // Reset focused index when transactions change + useEffect(() => { + setFocusedIndex(null); + }, [transactions.length]); const getCategory = (categoryId: string | null) => { if (!categoryId) return null; return categories.find((c) => c.id === categoryId); @@ -126,14 +180,23 @@ export function TransactionTable({ - {transactions.map((transaction) => { + {transactions.map((transaction, index) => { const category = getCategory(transaction.categoryId); const account = getAccount(transaction.accountId); + const isFocused = focusedIndex === index; return ( { + rowRefs.current[index] = el; + }} + onClick={() => handleRowClick(index, transaction.id)} + className={cn( + "border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer", + transaction.isReconciled && "bg-emerald-500/5", + isFocused && "bg-primary/10 ring-1 ring-primary/30" + )} >