Add bills CRUD API and management UI
Full REST API for bill definitions with validation. Bills page supports add, edit, toggle active, and delete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,439 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
'Housing',
|
||||||
|
'Utilities',
|
||||||
|
'Subscriptions',
|
||||||
|
'Insurance',
|
||||||
|
'Transportation',
|
||||||
|
'Debt',
|
||||||
|
'General',
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_FORM = {
|
||||||
|
name: '',
|
||||||
|
amount: '',
|
||||||
|
due_day: '',
|
||||||
|
assigned_paycheck: '1',
|
||||||
|
category: 'General',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCurrency(value) {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
if (isNaN(num)) return '$0.00';
|
||||||
|
return num.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinal(n) {
|
||||||
|
const int = parseInt(n, 10);
|
||||||
|
if (isNaN(int)) return n;
|
||||||
|
const suffix = ['th', 'st', 'nd', 'rd'];
|
||||||
|
const v = int % 100;
|
||||||
|
return int + (suffix[(v - 20) % 10] || suffix[v] || suffix[0]);
|
||||||
|
}
|
||||||
|
|
||||||
function Bills() {
|
function Bills() {
|
||||||
return <div><h1>Bills</h1><p>Placeholder — coming soon.</p></div>;
|
const [bills, setBills] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [form, setForm] = useState(EMPTY_FORM);
|
||||||
|
const [formError, setFormError] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
async function loadBills() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/bills');
|
||||||
|
if (!res.ok) throw new Error('Failed to load bills');
|
||||||
|
const data = await res.json();
|
||||||
|
setBills(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBills();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function openAddForm() {
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setFormError(null);
|
||||||
|
setShowForm(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditForm(bill) {
|
||||||
|
setEditingId(bill.id);
|
||||||
|
setForm({
|
||||||
|
name: bill.name,
|
||||||
|
amount: bill.amount,
|
||||||
|
due_day: bill.due_day,
|
||||||
|
assigned_paycheck: String(bill.assigned_paycheck),
|
||||||
|
category: bill.category || 'General',
|
||||||
|
});
|
||||||
|
setFormError(null);
|
||||||
|
setShowForm(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelForm() {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(e) {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setForm((prev) => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
amount: form.amount,
|
||||||
|
due_day: form.due_day,
|
||||||
|
assigned_paycheck: form.assigned_paycheck,
|
||||||
|
category: form.category,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = editingId ? `/api/bills/${editingId}` : '/api/bills';
|
||||||
|
const method = editingId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setFormError(data.error || 'Failed to save bill');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadBills();
|
||||||
|
cancelForm();
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggle(bill) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/bills/${bill.id}/toggle`, { method: 'PATCH' });
|
||||||
|
if (!res.ok) throw new Error('Failed to toggle bill');
|
||||||
|
await loadBills();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(bill) {
|
||||||
|
if (!window.confirm(`Delete bill "${bill.name}"? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/bills/${bill.id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error('Failed to delete bill');
|
||||||
|
await loadBills();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<style>{`
|
||||||
|
.bills-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.bills-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border: 1px solid #888;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #1d4ed8; border-color: #1d4ed8; }
|
||||||
|
.btn-danger {
|
||||||
|
background: #fff;
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: #fef2f2; }
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.form-card {
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
.form-card h2 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex: 1 1 180px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.form-error {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.bills-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.bills-table th,
|
||||||
|
.bills-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.bills-table th {
|
||||||
|
background: #f3f4f6;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.bills-table tr:hover td {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
.inactive-row td {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.active-toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.actions-cell {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="bills-header">
|
||||||
|
<h1>Bills</h1>
|
||||||
|
{!showForm && (
|
||||||
|
<button className="btn btn-primary" onClick={openAddForm}>
|
||||||
|
+ Add Bill
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="form-card">
|
||||||
|
<h2>{editingId ? 'Edit Bill' : 'Add Bill'}</h2>
|
||||||
|
{formError && <div className="form-error">{formError}</div>}
|
||||||
|
<form onSubmit={handleSave} autoComplete="off">
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group" style={{ flex: '2 1 240px' }}>
|
||||||
|
<label htmlFor="name">Name</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
placeholder="e.g. Rent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="amount">Amount ($)</label>
|
||||||
|
<input
|
||||||
|
id="amount"
|
||||||
|
name="amount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={form.amount}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="due_day">Due Day (1–31)</label>
|
||||||
|
<input
|
||||||
|
id="due_day"
|
||||||
|
name="due_day"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="31"
|
||||||
|
value={form.due_day}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="assigned_paycheck">Paycheck</label>
|
||||||
|
<select
|
||||||
|
id="assigned_paycheck"
|
||||||
|
name="assigned_paycheck"
|
||||||
|
value={form.assigned_paycheck}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<option value="1">Paycheck 1</option>
|
||||||
|
<option value="2">Paycheck 2</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ flex: '2 1 200px' }}>
|
||||||
|
<label htmlFor="category">Category</label>
|
||||||
|
<input
|
||||||
|
id="category"
|
||||||
|
name="category"
|
||||||
|
type="text"
|
||||||
|
list="category-list"
|
||||||
|
value={form.category}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="General"
|
||||||
|
/>
|
||||||
|
<datalist id="category-list">
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button className="btn" type="button" onClick={cancelForm}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <p>Loading bills…</p>}
|
||||||
|
{error && <p style={{ color: '#dc2626' }}>Error: {error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && bills.length === 0 && (
|
||||||
|
<p className="empty-state">No bills yet. Click "Add Bill" to get started.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && bills.length > 0 && (
|
||||||
|
<table className="bills-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Due Day</th>
|
||||||
|
<th>Paycheck</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bills.map((bill) => (
|
||||||
|
<tr key={bill.id} className={bill.active ? '' : 'inactive-row'}>
|
||||||
|
<td>{bill.name}</td>
|
||||||
|
<td>{formatCurrency(bill.amount)}</td>
|
||||||
|
<td>{ordinal(bill.due_day)}</td>
|
||||||
|
<td>Paycheck {bill.assigned_paycheck}</td>
|
||||||
|
<td>{bill.category || 'General'}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className="active-toggle"
|
||||||
|
title={bill.active ? 'Click to deactivate' : 'Click to activate'}
|
||||||
|
onClick={() => handleToggle(bill)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleToggle(bill)}
|
||||||
|
>
|
||||||
|
{bill.active ? '✅' : '⬜'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="actions-cell">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => openEditForm(bill)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
onClick={() => handleDelete(bill)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Bills;
|
export default Bills;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const cors = require('cors');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const healthRouter = require('./routes/health');
|
const healthRouter = require('./routes/health');
|
||||||
const configRouter = require('./routes/config');
|
const configRouter = require('./routes/config');
|
||||||
|
const billsRouter = require('./routes/bills');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -15,6 +16,7 @@ app.use(express.json());
|
|||||||
// API routes
|
// API routes
|
||||||
app.use('/api', healthRouter);
|
app.use('/api', healthRouter);
|
||||||
app.use('/api', configRouter);
|
app.use('/api', configRouter);
|
||||||
|
app.use('/api', billsRouter);
|
||||||
|
|
||||||
// Serve static client files in production
|
// Serve static client files in production
|
||||||
const clientDist = path.join(__dirname, '../../client/dist');
|
const clientDist = path.join(__dirname, '../../client/dist');
|
||||||
|
|||||||
174
server/src/routes/bills.js
Normal file
174
server/src/routes/bills.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { pool } = require('../db');
|
||||||
|
|
||||||
|
function validateBillFields(body) {
|
||||||
|
const { name, amount, due_day, assigned_paycheck } = body;
|
||||||
|
if (!name || name.toString().trim() === '') {
|
||||||
|
return 'name is required';
|
||||||
|
}
|
||||||
|
if (amount === undefined || amount === null || amount === '') {
|
||||||
|
return 'amount is required';
|
||||||
|
}
|
||||||
|
if (isNaN(Number(amount))) {
|
||||||
|
return 'amount must be a number';
|
||||||
|
}
|
||||||
|
if (due_day === undefined || due_day === null || due_day === '') {
|
||||||
|
return 'due_day is required';
|
||||||
|
}
|
||||||
|
const dueDayInt = parseInt(due_day, 10);
|
||||||
|
if (isNaN(dueDayInt) || dueDayInt < 1 || dueDayInt > 31) {
|
||||||
|
return 'due_day must be an integer between 1 and 31';
|
||||||
|
}
|
||||||
|
if (assigned_paycheck === undefined || assigned_paycheck === null || assigned_paycheck === '') {
|
||||||
|
return 'assigned_paycheck is required';
|
||||||
|
}
|
||||||
|
const pc = parseInt(assigned_paycheck, 10);
|
||||||
|
if (pc !== 1 && pc !== 2) {
|
||||||
|
return 'assigned_paycheck must be 1 or 2';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/bills — list all bills
|
||||||
|
router.get('/bills', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT * FROM bills ORDER BY assigned_paycheck, name'
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/bills error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch bills' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/bills — create a bill
|
||||||
|
router.post('/bills', async (req, res) => {
|
||||||
|
const validationError = validateBillFields(req.body);
|
||||||
|
if (validationError) {
|
||||||
|
return res.status(400).json({ error: validationError });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
amount,
|
||||||
|
due_day,
|
||||||
|
assigned_paycheck,
|
||||||
|
category = 'General',
|
||||||
|
active = true,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO bills (name, amount, due_day, assigned_paycheck, category, active)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
name.toString().trim(),
|
||||||
|
Number(amount),
|
||||||
|
parseInt(due_day, 10),
|
||||||
|
parseInt(assigned_paycheck, 10),
|
||||||
|
category || 'General',
|
||||||
|
active !== undefined ? active : true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/bills error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create bill' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/bills/:id — get single bill
|
||||||
|
router.get('/bills/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT * FROM bills WHERE id = $1', [req.params.id]);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Bill not found' });
|
||||||
|
}
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/bills/:id error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch bill' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/bills/:id — update bill
|
||||||
|
router.put('/bills/:id', async (req, res) => {
|
||||||
|
const validationError = validateBillFields(req.body);
|
||||||
|
if (validationError) {
|
||||||
|
return res.status(400).json({ error: validationError });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
amount,
|
||||||
|
due_day,
|
||||||
|
assigned_paycheck,
|
||||||
|
category = 'General',
|
||||||
|
active = true,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE bills
|
||||||
|
SET name = $1, amount = $2, due_day = $3, assigned_paycheck = $4,
|
||||||
|
category = $5, active = $6
|
||||||
|
WHERE id = $7
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
name.toString().trim(),
|
||||||
|
Number(amount),
|
||||||
|
parseInt(due_day, 10),
|
||||||
|
parseInt(assigned_paycheck, 10),
|
||||||
|
category || 'General',
|
||||||
|
active !== undefined ? active : true,
|
||||||
|
req.params.id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Bill not found' });
|
||||||
|
}
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PUT /api/bills/:id error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update bill' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/bills/:id — hard delete
|
||||||
|
router.delete('/bills/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'DELETE FROM bills WHERE id = $1 RETURNING id',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Bill not found' });
|
||||||
|
}
|
||||||
|
res.json({ deleted: true, id: result.rows[0].id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DELETE /api/bills/:id error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete bill' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/bills/:id/toggle — toggle active field
|
||||||
|
router.patch('/bills/:id/toggle', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'UPDATE bills SET active = NOT active WHERE id = $1 RETURNING *',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Bill not found' });
|
||||||
|
}
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PATCH /api/bills/:id/toggle error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to toggle bill' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user