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