feat(bills): "Recently deleted" restore view for the 30-day window (IMP-UX-01)

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 12:56:45 -05:00
parent e09025430b
commit aace5a4356
5 changed files with 237 additions and 1 deletions

View File

@ -210,6 +210,7 @@ export const api = {
// Bills // Bills
bills: (params = {}) => get(`/bills${queryString(params)}`), bills: (params = {}) => get(`/bills${queryString(params)}`),
allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`), allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`),
deletedBills: () => get('/bills/deleted'),
billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`),
bill: (id) => get(`/bills/${id}`), bill: (id) => get(`/bills/${id}`),
createBill: (data) => post('/bills', data), createBill: (data) => post('/bills', data),

View File

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Recently deleted</DialogTitle>
<DialogDescription>
Deleted bills are kept for 30 days before theyre permanently removed. Restore one to bring it back.
</DialogDescription>
</DialogHeader>
{bills.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<Trash2 className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Nothing to recover</p>
<p className="text-xs text-muted-foreground/70">Bills you delete will appear here for 30 days.</p>
</div>
) : (
<ul className="-mx-2 max-h-[55vh] divide-y divide-border/60 overflow-y-auto">
{bills.map(bill => {
const left = daysLeftLabel(bill.days_left);
const busy = busyId === bill.id;
return (
<li key={bill.id} className="flex items-center gap-3 px-2 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{bill.name}</p>
<p className="truncate text-xs text-muted-foreground">
{formatUSD(bill.expected_amount)}
{bill.category_name ? ` · ${bill.category_name}` : ''}
{left ? <span className="text-muted-foreground/60"> · {left}</span> : null}
</p>
</div>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 shrink-0 gap-1.5"
disabled={busy}
onClick={() => handleRestore(bill)}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
Restore
</Button>
</li>
);
})}
</ul>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -23,6 +23,7 @@ import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
import BillsTableInner from '@/components/BillsTableInner'; import BillsTableInner from '@/components/BillsTableInner';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import RecentlyDeletedBillsDialog from '@/components/RecentlyDeletedBillsDialog';
import { makeBillDraft } from '@/lib/billDrafts'; import { makeBillDraft } from '@/lib/billDrafts';
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule'; import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
@ -601,6 +602,8 @@ export default function BillsPage() {
const [bills, setBills] = useState([]); const [bills, setBills] = useState([]);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [savedTemplates, setSavedTemplates] = useState([]); const [savedTemplates, setSavedTemplates] = useState([]);
const [deletedBills, setDeletedBills] = useState([]);
const [showDeleted, setShowDeleted] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showInactive, setShowInactive] = useState(false); const [showInactive, setShowInactive] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@ -640,14 +643,16 @@ export default function BillsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const [billsRes, catRes, templateRes] = await Promise.all([ const [billsRes, catRes, templateRes, deletedRes] = await Promise.all([
api.allBills(), api.allBills(),
api.categories(), api.categories(),
api.billTemplates(), api.billTemplates(),
api.deletedBills().catch(() => []), // non-critical: never block the page
]); ]);
setBills(billsRes || []); setBills(billsRes || []);
setCategories(catRes || []); setCategories(catRes || []);
setSavedTemplates(templateRes || []); setSavedTemplates(templateRes || []);
setDeletedBills(deletedRes || []);
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(err.message);
} finally { } 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() { async function handleDeleteAlternative() {
if (!deleteTarget) return; if (!deleteTarget) return;
const bill = deleteTarget; const bill = deleteTarget;
@ -940,6 +955,17 @@ export default function BillsPage() {
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
{deletedBills.length > 0 && (
<Button
type="button"
variant="outline"
onClick={() => setShowDeleted(true)}
className="h-9 flex-1 gap-2 px-3 text-sm sm:flex-none"
>
<Trash2 className="h-4 w-4" />
Recently deleted ({deletedBills.length})
</Button>
)}
<Button <Button
onClick={() => setModal({ bill: null })} onClick={() => setModal({ bill: null })}
className="h-9 flex-1 gap-2 px-4 text-sm font-medium sm:flex-none" className="h-9 flex-1 gap-2 px-4 text-sm font-medium sm:flex-none"
@ -1142,6 +1168,13 @@ export default function BillsPage() {
/> />
)} )}
<RecentlyDeletedBillsDialog
open={showDeleted}
onOpenChange={setShowDeleted}
bills={deletedBills}
onRestore={handleRestoreDeleted}
/>
{/* ── Deactivate confirmation (replaces window.confirm) ── */} {/* ── Deactivate confirmation (replaces window.confirm) ── */}
<AlertDialog open={!!deactivate} onOpenChange={open => { if (!open) { setDeactivate(null); setDeactivateReason(''); } }}> <AlertDialog open={!!deactivate} onOpenChange={open => { if (!open) { setDeactivate(null); setDeactivateReason(''); } }}>
<AlertDialogContent> <AlertDialogContent>

View File

@ -49,6 +49,31 @@ router.get('/', (req, res) => {
res.json(bills.map(serializeBill)); res.json(bills.map(serializeBill));
}); });
// ── GET /api/bills/deleted ────────────────────────────────────────────────────
// Soft-deleted bills still inside the 30-day recovery window (before the
// retention GC purges them), newest deletion first. Powers the "Recently
// deleted" restore view. Must be declared before GET /:id.
const BILL_RETENTION_DAYS = 30; // matches pruneSoftDeletedFinancialRecords()
router.get('/deleted', (req, res) => {
const db = getDb();
const rows = db.prepare(`
SELECT b.*, c.name AS category_name,
CAST(julianday(b.deleted_at, '+${BILL_RETENTION_DAYS} days') - julianday('now') AS INTEGER) AS days_left
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.user_id = ?
AND b.deleted_at IS NOT NULL
AND b.deleted_at >= datetime('now', '-${BILL_RETENTION_DAYS} days')
ORDER BY b.deleted_at DESC
`).all(req.user.id);
res.json(rows.map(row => ({
...serializeBill(row),
category_name: row.category_name,
deleted_at: row.deleted_at,
days_left: Math.max(0, row.days_left),
})));
});
// ── PUT /api/bills/reorder ─────────────────────────────────────────────────── // ── PUT /api/bills/reorder ───────────────────────────────────────────────────
router.put('/reorder', (req, res) => { router.put('/reorder', (req, res) => {
const db = getDb(); const db = getDb();

View File

@ -0,0 +1,92 @@
'use strict';
// IMP-UX-01: GET /api/bills/deleted lists soft-deleted bills still inside the
// 30-day recovery window (newest first, with days_left), so the "Recently
// deleted" view can offer a restore beyond the transient undo toast. Bills
// purged past the window (or another user's) must not appear.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const dbPath = path.join(os.tmpdir(), `bill-tracker-bills-deleted-route-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
function createUser(db, suffix) {
return db.prepare(
"INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)",
).run(`deleted-bills-${suffix}`).lastInsertRowid;
}
// daysAgo === null → an active (not deleted) bill; otherwise soft-deleted N days ago.
function insertBill(db, userId, name, daysAgo) {
const id = db.prepare(
'INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, ?, 1, 1000, ?)',
).run(userId, name, daysAgo == null ? 1 : 0).lastInsertRowid;
if (daysAgo != null) {
db.prepare("UPDATE bills SET deleted_at = datetime('now', ?) WHERE id = ?").run(`-${daysAgo} days`, id);
}
return id;
}
function callGetDeleted(userId) {
const router = require('../routes/bills');
const layer = router.stack.find(item => item.route?.path === '/deleted' && item.route.methods.get);
assert.ok(layer, 'GET /deleted route should exist');
const handler = layer.route.stack[0].handle;
return new Promise((resolve, reject) => {
const req = { query: {}, params: {}, user: { id: userId, role: 'user' } };
const res = {
statusCode: 200,
status(code) { this.statusCode = code; return this; },
json(data) { resolve({ status: this.statusCode, data }); },
};
try { handler(req, res); } catch (err) { reject(err); }
});
}
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
test('GET /bills/deleted returns only recoverable soft-deleted bills, newest first', async () => {
const db = getDb();
const userId = createUser(db, 'a');
insertBill(db, userId, 'Active Bill', null); // not deleted
insertBill(db, userId, 'Recently Deleted', 2); // 2 days ago
insertBill(db, userId, 'Older Deleted', 20); // 20 days ago
insertBill(db, userId, 'Purgeable', 40); // past the 30-day window
const { status, data } = await callGetDeleted(userId);
assert.equal(status, 200);
const names = data.map(b => b.name);
assert.deepEqual(names, ['Recently Deleted', 'Older Deleted'], 'only in-window bills, newest first');
assert.ok(!names.includes('Active Bill'), 'active bill excluded');
assert.ok(!names.includes('Purgeable'), 'past-window bill excluded');
const recentRow = data.find(b => b.name === 'Recently Deleted');
assert.ok(recentRow.days_left >= 27 && recentRow.days_left <= 28, `~28 days left, got ${recentRow.days_left}`);
assert.ok(recentRow.deleted_at, 'exposes deleted_at');
assert.equal(recentRow.expected_amount, 10, 'money serialized to dollars (cents/100)');
});
test('GET /bills/deleted isolates by user', async () => {
const db = getDb();
const me = createUser(db, 'me');
const other = createUser(db, 'other');
insertBill(db, me, 'Mine', 1);
insertBill(db, other, 'Theirs', 1);
const { data } = await callGetDeleted(me);
const names = data.map(b => b.name);
assert.ok(names.includes('Mine'));
assert.ok(!names.includes('Theirs'), "never leaks another user's deleted bills");
});