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;
|
||||
|
||||
Reference in New Issue
Block a user