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
|
||||||
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),
|
||||||
|
|
|
||||||
|
|
@ -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 { 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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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