From 0835b86c1afb97c165d406d8d58e345f1161ebc0 Mon Sep 17 00:00:00 2001 From: Christian Hood Date: Thu, 19 Mar 2026 19:06:43 -0400 Subject: [PATCH] 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 --- client/src/pages/Bills.jsx | 436 ++++++++++++++++++++++++++++++++++++- server/src/index.js | 2 + server/src/routes/bills.js | 174 +++++++++++++++ 3 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 server/src/routes/bills.js diff --git a/client/src/pages/Bills.jsx b/client/src/pages/Bills.jsx index 3aaad13..f338410 100644 --- a/client/src/pages/Bills.jsx +++ b/client/src/pages/Bills.jsx @@ -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

Bills

Placeholder — coming soon.

; + 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 ( +
+ + +
+

Bills

+ {!showForm && ( + + )} +
+ + {showForm && ( +
+

{editingId ? 'Edit Bill' : 'Add Bill'}

+ {formError &&
{formError}
} +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + + + {CATEGORIES.map((c) => ( + +
+
+
+ + +
+
+
+ )} + + {loading &&

Loading bills…

} + {error &&

Error: {error}

} + + {!loading && !error && bills.length === 0 && ( +

No bills yet. Click "Add Bill" to get started.

+ )} + + {!loading && !error && bills.length > 0 && ( + + + + + + + + + + + + + + {bills.map((bill) => ( + + + + + + + + + + ))} + +
NameAmountDue DayPaycheckCategoryActiveActions
{bill.name}{formatCurrency(bill.amount)}{ordinal(bill.due_day)}Paycheck {bill.assigned_paycheck}{bill.category || 'General'} + handleToggle(bill)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && handleToggle(bill)} + > + {bill.active ? '✅' : '⬜'} + + +
+ + +
+
+ )} +
+ ); } export default Bills; diff --git a/server/src/index.js b/server/src/index.js index cdfd02e..2d58a68 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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'); diff --git a/server/src/routes/bills.js b/server/src/routes/bills.js new file mode 100644 index 0000000..ada2dd5 --- /dev/null +++ b/server/src/routes/bills.js @@ -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;