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.

+
+ ) : ( + + )} +
+
+ ); +} 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 && ( + + )}