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:
parent
e09025430b
commit
aace5a4356
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 they’re 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
|||
)}
|
||||
</SelectContent>
|
||||
</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
|
||||
onClick={() => setModal({ bill: null })}
|
||||
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) ── */}
|
||||
<AlertDialog open={!!deactivate} onOpenChange={open => { if (!open) { setDeactivate(null); setDeactivateReason(''); } }}>
|
||||
<AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,31 @@ router.get('/', (req, res) => {
|
|||
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 ───────────────────────────────────────────────────
|
||||
router.put('/reorder', (req, res) => {
|
||||
const db = getDb();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
Loading…
Reference in New Issue