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 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-10 18:03:55 -06:00
parent 571428f5ac
commit 629d1181ae

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'; import { useMemo, useRef, useState } from 'react';
import { import {
Plus, Plus,
Search, Search,
@@ -56,34 +56,70 @@ export default function TransactionList({
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null); const [editing, setEditing] = useState<Transaction | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null); const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false); const [pendingDeletes, setPendingDeletes] = useState<Set<number>>(new Set());
const deleteTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const handleEdit = (tx: Transaction) => { const handleEdit = (tx: Transaction) => {
setEditing(tx); setEditing(tx);
setModalOpen(true); setModalOpen(true);
}; };
const handleDelete = async () => { const commitDelete = async (id: number) => {
if (deleteId === null) return; deleteTimers.current.delete(id);
setDeleting(true);
try { try {
await api.delete(`/transactions/${deleteId}`); await api.delete(`/transactions/${id}`);
toast.success('Transacción eliminada');
setDeleteId(null);
onRefresh(); onRefresh();
} catch { } catch {
toast.error('No se pudo eliminar la transacción'); toast.error('No se pudo eliminar la transacción');
} finally { setPendingDeletes((prev) => {
setDeleting(false); 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( const columns = useMemo(
() => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }), () => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }),
[showCategory, showSourceIcon, 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 ( return (
<> <>
@@ -195,7 +231,7 @@ export default function TransactionList({
<CardContent className="p-0"> <CardContent className="p-0">
<DataTable <DataTable
columns={columns} columns={columns}
data={transactions} data={visibleTransactions}
pagination pagination
pageSize={25} pageSize={25}
initialSorting={[{ id: 'date', desc: true }]} initialSorting={[{ id: 'date', desc: true }]}
@@ -220,7 +256,6 @@ export default function TransactionList({
message="This transaction will be permanently deleted. This action cannot be undone." message="This transaction will be permanently deleted. This action cannot be undone."
onConfirm={handleDelete} onConfirm={handleDelete}
onCancel={() => setDeleteId(null)} onCancel={() => setDeleteId(null)}
loading={deleting}
/> />
)} )}
</> </>