From aace5a4356c2fd2aa06d55187edd448a80fab757 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 12:56:45 -0500 Subject: [PATCH] feat(bills): "Recently deleted" restore view for the 30-day window (IMP-UX-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bills soft-delete and are retained 30 days, but the only way back was the transient "Undo" toast — dismiss it and a bill deleted an hour ago was unrecoverable from the UI (even though the API and retention kept it). - GET /api/bills/deleted lists soft-deleted bills still inside the recovery window, newest first, with days_left (declared before /:id). User-scoped. - BillsPage shows a "Recently deleted (N)" button when any exist, opening a dialog to restore each one; restoring refreshes the active list too. - The list fetch is non-blocking (never blanks the page); restore is try/catch + toast; dialog has empty and per-row busy states. Tests: tests/billsDeletedRoute.test.js (window filter, ordering, days_left, money serialization, user isolation). Server 116 pass; client 46; build clean. Co-Authored-By: Claude Opus 4.8 --- client/api.js | 1 + .../components/RecentlyDeletedBillsDialog.jsx | 85 +++++++++++++++++ client/pages/BillsPage.jsx | 35 ++++++- routes/bills.js | 25 +++++ tests/billsDeletedRoute.test.js | 92 +++++++++++++++++++ 5 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 client/components/RecentlyDeletedBillsDialog.jsx create mode 100644 tests/billsDeletedRoute.test.js diff --git a/client/api.js b/client/api.js index 091eb17..3ff5e0b 100644 --- a/client/api.js +++ b/client/api.js @@ -210,6 +210,7 @@ export const api = { // Bills bills: (params = {}) => get(`/bills${queryString(params)}`), allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`), + deletedBills: () => get('/bills/deleted'), billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), bill: (id) => get(`/bills/${id}`), createBill: (data) => post('/bills', data), diff --git a/client/components/RecentlyDeletedBillsDialog.jsx b/client/components/RecentlyDeletedBillsDialog.jsx new file mode 100644 index 0000000..991342d --- /dev/null +++ b/client/components/RecentlyDeletedBillsDialog.jsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { RotateCcw, Trash2, Loader2 } from 'lucide-react'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { formatUSD } from '@/lib/money'; + +function daysLeftLabel(days) { + if (days == null) return null; + if (days <= 0) return 'purges today'; + if (days === 1) return '1 day left'; + return `${days} days left`; +} + +/** + * Lists bills that were soft-deleted within the 30-day recovery window and lets + * the user restore them — a durable path beyond the transient "Undo" toast. + * + * Presentational: the parent owns the list (`bills`) and the async `onRestore`, + * so restoring refreshes the page's active bills too. + */ +export default function RecentlyDeletedBillsDialog({ open, onOpenChange, bills = [], onRestore }) { + const [busyId, setBusyId] = useState(null); + + async function handleRestore(bill) { + setBusyId(bill.id); + try { + await onRestore(bill); + } finally { + setBusyId(null); + } + } + + return ( + + + + Recently deleted + + Deleted bills are kept for 30 days before they’re permanently removed. Restore one to bring it back. + + + + {bills.length === 0 ? ( +
+ +

Nothing to recover

+

Bills you delete will appear here for 30 days.

+
+ ) : ( +
    + {bills.map(bill => { + const left = daysLeftLabel(bill.days_left); + const busy = busyId === bill.id; + return ( +
  • +
    +

    {bill.name}

    +

    + {formatUSD(bill.expected_amount)} + {bill.category_name ? ` · ${bill.category_name}` : ''} + {left ? · {left} : null} +

    +
    + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/client/pages/BillsPage.jsx b/client/pages/BillsPage.jsx index 51f3fcb..917a2d4 100644 --- a/client/pages/BillsPage.jsx +++ b/client/pages/BillsPage.jsx @@ -23,6 +23,7 @@ import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; import BillsTableInner from '@/components/BillsTableInner'; import BillModal from '@/components/BillModal'; +import RecentlyDeletedBillsDialog from '@/components/RecentlyDeletedBillsDialog'; import { makeBillDraft } from '@/lib/billDrafts'; import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule'; @@ -601,6 +602,8 @@ export default function BillsPage() { const [bills, setBills] = useState([]); const [categories, setCategories] = useState([]); const [savedTemplates, setSavedTemplates] = useState([]); + const [deletedBills, setDeletedBills] = useState([]); + const [showDeleted, setShowDeleted] = useState(false); const [loading, setLoading] = useState(true); const [showInactive, setShowInactive] = useState(false); const [search, setSearch] = useState(''); @@ -640,14 +643,16 @@ export default function BillsPage() { const load = useCallback(async () => { try { - const [billsRes, catRes, templateRes] = await Promise.all([ + const [billsRes, catRes, templateRes, deletedRes] = await Promise.all([ api.allBills(), api.categories(), api.billTemplates(), + api.deletedBills().catch(() => []), // non-critical: never block the page ]); setBills(billsRes || []); setCategories(catRes || []); setSavedTemplates(templateRes || []); + setDeletedBills(deletedRes || []); } catch (err) { toast.error(err.message); } finally { @@ -783,6 +788,16 @@ export default function BillsPage() { } } + async function handleRestoreDeleted(bill) { + try { + await api.restoreBill(bill.id); + toast.success(`"${bill.name}" restored`); + await load(); + } catch (err) { + toast.error(err.message || 'Failed to restore bill'); + } + } + async function handleDeleteAlternative() { if (!deleteTarget) return; const bill = deleteTarget; @@ -940,6 +955,17 @@ export default function BillsPage() { )} + {deletedBills.length > 0 && ( + + )}