From 629d1181ae08ff90812f2b0fdc4dfab4be98939e Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Wed, 10 Jun 2026 18:03:55 -0600 Subject: [PATCH] Undo grace for transaction deletes (UX-10) After confirming a delete the row disappears immediately but the API call waits 5 seconds behind a Deshacer toast action; undo restores the row without any server round-trip. A failed commit restores the row and reports the error. Co-Authored-By: Claude Fable 5 --- frontend/src/components/TransactionList.tsx | 61 ++++++++++++++++----- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/TransactionList.tsx b/frontend/src/components/TransactionList.tsx index 9c54aab..e073cc3 100644 --- a/frontend/src/components/TransactionList.tsx +++ b/frontend/src/components/TransactionList.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Plus, Search, @@ -56,34 +56,70 @@ export default function TransactionList({ const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleteId, setDeleteId] = useState(null); - const [deleting, setDeleting] = useState(false); + const [pendingDeletes, setPendingDeletes] = useState>(new Set()); + const deleteTimers = useRef>>(new Map()); const handleEdit = (tx: Transaction) => { setEditing(tx); setModalOpen(true); }; - const handleDelete = async () => { - if (deleteId === null) return; - setDeleting(true); + const commitDelete = async (id: number) => { + deleteTimers.current.delete(id); try { - await api.delete(`/transactions/${deleteId}`); - toast.success('Transacción eliminada'); - setDeleteId(null); + await api.delete(`/transactions/${id}`); onRefresh(); } catch { toast.error('No se pudo eliminar la transacción'); - } finally { - setDeleting(false); + setPendingDeletes((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); } }; + // Undo grace: the row disappears immediately, the API call fires after 5s + // unless the user clicks Deshacer (UX-10). + const handleDelete = () => { + if (deleteId === null) return; + const id = deleteId; + setDeleteId(null); + setPendingDeletes((prev) => new Set(prev).add(id)); + const toastId = toast('Transacción eliminada', { + duration: 5000, + action: { + label: 'Deshacer', + onClick: () => { + const timer = deleteTimers.current.get(id); + if (timer) clearTimeout(timer); + deleteTimers.current.delete(id); + setPendingDeletes((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }, + }, + }); + deleteTimers.current.set( + id, + setTimeout(() => { + toast.dismiss(toastId); + void commitDelete(id); + }, 5000), + ); + }; + const columns = useMemo( () => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }), [showCategory, showSourceIcon, onToggleDeferred], ); - const empty = transactions.length === 0 && !loading; + const visibleTransactions = pendingDeletes.size + ? transactions.filter((t) => !pendingDeletes.has(t.id)) + : transactions; + const empty = visibleTransactions.length === 0 && !loading; return ( <> @@ -195,7 +231,7 @@ export default function TransactionList({ setDeleteId(null)} - loading={deleting} /> )}