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() {
|
||||
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;
|
||||
|
||||
@@ -4,6 +4,7 @@ const cors = require('cors');
|
||||
const path = require('path');
|
||||
const healthRouter = require('./routes/health');
|
||||
const configRouter = require('./routes/config');
|
||||
const billsRouter = require('./routes/bills');
|
||||
const db = require('./db');
|
||||
|
||||
const app = express();
|
||||
@@ -15,6 +16,7 @@ app.use(express.json());
|
||||
// API routes
|
||||
app.use('/api', healthRouter);
|
||||
app.use('/api', configRouter);
|
||||
app.use('/api', billsRouter);
|
||||
|
||||
// Serve static client files in production
|
||||
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