feat: implement transaction reconciliation feature with keyboard navigation support in transaction table

This commit is contained in:
Julien Froidefond
2025-11-28 11:31:47 +01:00
parent 88937579e2
commit 0e0db122b9
8 changed files with 98 additions and 2 deletions

View File

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

View File

@@ -24,3 +24,4 @@ export async function POST() {
);
}
}

View File

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

View File

@@ -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<number | null>(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({
</tr>
</thead>
<tbody>
{transactions.map((transaction) => {
{transactions.map((transaction, index) => {
const category = getCategory(transaction.categoryId);
const account = getAccount(transaction.accountId);
const isFocused = focusedIndex === index;
return (
<tr
key={transaction.id}
className="border-b border-border last:border-0 hover:bg-muted/50"
ref={(el) => {
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"
)}
>
<td className="p-3">
<Checkbox

View File

@@ -10,3 +10,4 @@ console.log(" - parentId: null");
console.log(" - color: #6366f1");
console.log(" - icon: folder");
console.log("3. Créez les catégories par défaut via l'interface web");

View File

@@ -60,3 +60,4 @@ export const categoryService = {
});
},
};

View File

@@ -75,3 +75,4 @@ export const folderService = {
});
},
};

View File

@@ -91,3 +91,4 @@ export const transactionService = {
});
},
};