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:
2026-03-19 19:06:43 -04:00
parent 5f5f1111c5
commit 0835b86c1a
3 changed files with 611 additions and 1 deletions

View File

@@ -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 (131)</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;

View File

@@ -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
View 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;