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}
}
+
+
+ )}
+
+ {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 && (
+
+
+
+ | Name |
+ Amount |
+ Due Day |
+ Paycheck |
+ Category |
+ Active |
+ Actions |
+
+
+
+ {bills.map((bill) => (
+
+ | {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;