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 {
Plus,
Search,
@@ -56,34 +56,70 @@ export default function TransactionList({
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | 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) => {
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({
<CardContent className="p-0">
<DataTable
columns={columns}
data={transactions}
data={visibleTransactions}
pagination
pageSize={25}
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."
onConfirm={handleDelete}
onCancel={() => setDeleteId(null)}
loading={deleting}
/>
)}
</>