diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx index ef45188..d245055 100644 --- a/app/transactions/page.tsx +++ b/app/transactions/page.tsx @@ -10,7 +10,11 @@ import { } from "@/components/transactions"; import { RuleCreateDialog } from "@/components/rules"; import { OFXImportDialog } from "@/components/import/ofx-import-dialog"; -import { useBankingMetadata, useTransactions } from "@/lib/hooks"; +import { + useBankingMetadata, + useTransactions, + getTransactionsQueryKey, +} from "@/lib/hooks"; import { updateCategory } from "@/lib/store-db"; import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; @@ -60,21 +64,24 @@ 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()); // Get start date based on period const startDate = useMemo(() => { @@ -156,20 +163,6 @@ export default function TransactionsPage() { invalidate: invalidateTransactions, } = useTransactions(transactionParams, !!metadata); - // Reset page when filters change - useEffect(() => { - setPage(0); - }, [ - startDate, - endDate, - selectedAccounts, - selectedCategories, - debouncedSearchQuery, - showReconciled, - sortField, - sortOrder, - ]); - // For filter comboboxes, we'll use empty arrays for now // They can be enhanced later with separate queries if needed const transactionsForAccountFilter: Transaction[] = []; @@ -188,7 +181,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; @@ -199,7 +192,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]); @@ -215,7 +208,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"); @@ -223,7 +216,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) { @@ -241,8 +234,8 @@ export default function TransactionsPage() { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, categoryId: ruleData.categoryId }), - }), - ), + }) + ) ); } @@ -251,7 +244,7 @@ export default function TransactionsPage() { queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); setRuleDialogOpen(false); }, - [metadata, queryClient], + [metadata, queryClient] ); const invalidateAll = useCallback(() => { @@ -282,7 +275,7 @@ export default function TransactionsPage() { if (!transactionsData) return; const transaction = transactionsData.transactions.find( - (t) => t.id === transactionId, + (t) => t.id === transactionId ); if (!transaction) return; @@ -307,7 +300,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; @@ -330,42 +323,49 @@ 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; - const updatedTransaction = { ...transaction, categoryId }; + setUpdatingTransactionIds((prev) => new Set(prev).add(transactionId)); - // Optimistic update: update the cache immediately - queryClient.setQueryData( - ["transactions", transactionParams], - (oldData) => { + try { + const response = await fetch("/api/banking/transactions", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...transaction, categoryId }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Mise à jour directe du cache après succès + const queryKey = getTransactionsQueryKey(transactionParams); + queryClient.setQueryData(queryKey, (oldData) => { if (!oldData) return oldData; + return { ...oldData, transactions: oldData.transactions.map((t) => - t.id === transactionId ? updatedTransaction : t, + t.id === transactionId ? { ...t, categoryId } : t ), }; - }, - ); - - try { - await fetch("/api/banking/transactions", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updatedTransaction), }); - invalidateTransactions(); } catch (error) { console.error("Failed to update transaction:", error); - // Revert optimistic update on error invalidateTransactions(); + } finally { + setUpdatingTransactionIds((prev) => { + const next = new Set(prev); + next.delete(transactionId); + return next; + }); } }; @@ -373,7 +373,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()); @@ -385,8 +385,8 @@ export default function TransactionsPage() { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...t, isReconciled: reconciled }), - }), - ), + }) + ) ); invalidateTransactions(); } catch (error) { @@ -398,25 +398,16 @@ 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); setSelectedTransactions(new Set()); - - // Optimistic update: update the cache immediately - queryClient.setQueryData( - ["transactions", transactionParams], - (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - transactions: oldData.transactions.map((t) => - transactionIds.includes(t.id) ? { ...t, categoryId } : t, - ), - }; - }, - ); + setUpdatingTransactionIds((prev) => { + const next = new Set(prev); + transactionIds.forEach((id) => next.add(id)); + return next; + }); try { await Promise.all( @@ -425,14 +416,30 @@ export default function TransactionsPage() { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...t, categoryId }), - }), - ), + }) + ) ); - invalidateTransactions(); + + // Mise à jour directe du cache après succès + const queryKey = getTransactionsQueryKey(transactionParams); + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + transactions: oldData.transactions.map((t) => + transactionIds.includes(t.id) ? { ...t, categoryId } : t + ), + }; + }); } catch (error) { console.error("Failed to update transactions:", error); - // Revert optimistic update on error invalidateTransactions(); + } finally { + setUpdatingTransactionIds((prev) => { + const next = new Set(prev); + transactionIds.forEach((id) => next.delete(id)); + return next; + }); } }; @@ -442,7 +449,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)) ); } }; @@ -478,7 +485,7 @@ export default function TransactionsPage() { `/api/banking/transactions?id=${transactionId}`, { method: "DELETE", - }, + } ); if (!response.ok) throw new Error("Failed to delete transaction"); invalidateTransactions(); @@ -516,8 +523,8 @@ export default function TransactionsPage() { }} selectedCategories={selectedCategories} onCategoriesChange={(categories) => { - setSelectedCategories(categories); setPage(0); + setSelectedCategories(categories); }} showReconciled={showReconciled} onReconciledChange={(value) => { @@ -581,6 +588,7 @@ export default function TransactionsPage() { onDelete={deleteTransaction} formatCurrency={formatCurrency} formatDate={formatDate} + updatingTransactionIds={updatingTransactionIds} /> {/* Pagination controls */} diff --git a/components/transactions/transaction-table.tsx b/components/transactions/transaction-table.tsx index bf2adf7..e5a1872 100644 --- a/components/transactions/transaction-table.tsx +++ b/components/transactions/transaction-table.tsx @@ -24,6 +24,7 @@ import { ArrowUpDown, Wand2, Trash2, + Loader2, } from "lucide-react"; import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; @@ -50,6 +51,7 @@ interface TransactionTableProps { onDelete: (id: string) => void; formatCurrency: (amount: number) => string; formatDate: (dateStr: string) => string; + updatingTransactionIds?: Set; } const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne @@ -142,6 +144,7 @@ export function TransactionTable({ onDelete, formatCurrency, formatDate, + updatingTransactionIds = new Set(), }: TransactionTableProps) { const [focusedIndex, setFocusedIndex] = useState(null); const parentRef = useRef(null); @@ -161,7 +164,7 @@ export function TransactionTable({ setFocusedIndex(index); onMarkReconciled(transactionId); }, - [onMarkReconciled], + [onMarkReconciled] ); const handleKeyDown = useCallback( @@ -190,7 +193,7 @@ export function TransactionTable({ } } }, - [focusedIndex, transactions, onMarkReconciled, virtualizer], + [focusedIndex, transactions, onMarkReconciled, virtualizer] ); useEffect(() => { @@ -207,7 +210,7 @@ export function TransactionTable({ (accountId: string) => { return accounts.find((a) => a.id === accountId); }, - [accounts], + [accounts] ); const getCategory = useCallback( @@ -215,7 +218,7 @@ export function TransactionTable({ if (!categoryId) return null; return categories.find((c) => c.id === categoryId); }, - [categories], + [categories] ); return ( @@ -266,7 +269,7 @@ export function TransactionTable({ className={cn( "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", + isFocused && "bg-primary/10 ring-1 ring-primary/30" )} >
@@ -294,7 +297,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 ? "+" : ""} @@ -313,8 +316,13 @@ export function TransactionTable({ )}
e.stopPropagation()} - className="flex-1" + className="flex-1 relative" > + {updatingTransactionIds.has(transaction.id) && ( +
+ +
+ )}
@@ -354,7 +365,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); @@ -463,7 +474,7 @@ export function TransactionTable({ className={cn( "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", + isFocused && "bg-primary/10 ring-1 ring-primary/30" )} >
@@ -493,7 +504,15 @@ export function TransactionTable({
{account?.name || "-"}
-
e.stopPropagation()}> +
e.stopPropagation()} + > + {updatingTransactionIds.has(transaction.id) && ( +
+ +
+ )}
= 0 ? "text-emerald-600" - : "text-red-600", + : "text-red-600" )} > {transaction.amount >= 0 ? "+" : ""} @@ -576,7 +596,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/components/ui/category-combobox.tsx b/components/ui/category-combobox.tsx index 7dd6517..53eb9fd 100644 --- a/components/ui/category-combobox.tsx +++ b/components/ui/category-combobox.tsx @@ -30,6 +30,7 @@ interface CategoryComboboxProps { align?: "start" | "center" | "end"; width?: string; buttonWidth?: string; + disabled?: boolean; } export function CategoryCombobox({ @@ -41,6 +42,7 @@ export function CategoryCombobox({ align = "start", width = "w-[300px]", buttonWidth, + disabled = false, }: CategoryComboboxProps) { const [open, setOpen] = useState(false); @@ -71,9 +73,12 @@ export function CategoryCombobox({ // Badge style trigger if (showBadge) { return ( - + -