From 17af71a7c72d1f5d5a133706bff7ac051523f535 Mon Sep 17 00:00:00 2001 From: Christian Hood Date: Thu, 19 Mar 2026 19:12:25 -0400 Subject: [PATCH] Add variable expense actuals logging API for expense categories and actuals with full CRUD. Paycheck view shows actuals section and includes them in remaining balance calculation. Co-Authored-By: Claude Sonnet 4.6 --- client/src/pages/PaycheckView.jsx | 293 +++++++++++++++++++++++++++++- server/src/routes/actuals.js | 139 ++++++++++++++ 2 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 server/src/routes/actuals.js diff --git a/client/src/pages/PaycheckView.jsx b/client/src/pages/PaycheckView.jsx index 924f5de..8011101 100644 --- a/client/src/pages/PaycheckView.jsx +++ b/client/src/pages/PaycheckView.jsx @@ -22,7 +22,94 @@ function formatPayDate(dateStr) { return `${MONTH_NAMES[month - 1]} ${day}, ${year}`; } -function PaycheckColumn({ paycheck, onBillPaidToggle }) { +function todayISO() { + return new Date().toISOString().slice(0, 10); +} + +function PaycheckColumn({ paycheck, onBillPaidToggle, categories }) { + const [actuals, setActuals] = useState([]); + const [actualsLoading, setActualsLoading] = useState(false); + const [actualsError, setActualsError] = useState(null); + + const [formCategoryId, setFormCategoryId] = useState(''); + const [formAmount, setFormAmount] = useState(''); + const [formNote, setFormNote] = useState(''); + const [formDate, setFormDate] = useState(todayISO()); + const [formSubmitting, setFormSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + + useEffect(() => { + if (!paycheck) return; + loadActuals(paycheck.id); + }, [paycheck?.id]); + + async function loadActuals(paycheckId) { + setActualsLoading(true); + setActualsError(null); + try { + const res = await fetch(`/api/actuals?paycheck_id=${paycheckId}`); + if (!res.ok) throw new Error(`Server error: ${res.status}`); + const data = await res.json(); + setActuals(data); + } catch (err) { + setActualsError(err.message); + } finally { + setActualsLoading(false); + } + } + + async function handleAddActual(e) { + e.preventDefault(); + if (!formAmount) { + setFormError('Amount is required'); + return; + } + + setFormSubmitting(true); + setFormError(null); + try { + const res = await fetch('/api/actuals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + paycheck_id: paycheck.id, + category_id: formCategoryId || null, + amount: parseFloat(formAmount), + note: formNote || null, + date: formDate || todayISO(), + }), + }); + if (!res.ok) { + const body = await res.json(); + throw new Error(body.error || `Server error: ${res.status}`); + } + // Refresh the actuals list + await loadActuals(paycheck.id); + // Reset form fields (keep date as today) + setFormCategoryId(''); + setFormAmount(''); + setFormNote(''); + setFormDate(todayISO()); + } catch (err) { + setFormError(err.message); + } finally { + setFormSubmitting(false); + } + } + + async function handleDeleteActual(id) { + try { + const res = await fetch(`/api/actuals/${id}`, { method: 'DELETE' }); + if (!res.ok) { + const body = await res.json(); + throw new Error(body.error || `Server error: ${res.status}`); + } + setActuals(prev => prev.filter(a => a.id !== id)); + } catch (err) { + alert(`Failed to delete actual: ${err.message}`); + } + } + if (!paycheck) { return (
@@ -34,7 +121,8 @@ function PaycheckColumn({ paycheck, onBillPaidToggle }) { const net = parseFloat(paycheck.net) || 0; const billsTotal = paycheck.bills.reduce((sum, b) => sum + (parseFloat(b.effective_amount) || 0), 0); const otesTotal = paycheck.one_time_expenses.reduce((sum, e) => sum + (parseFloat(e.amount) || 0), 0); - const remaining = net - billsTotal - otesTotal; + const actualsTotal = actuals.reduce((sum, a) => sum + (parseFloat(a.amount) || 0), 0); + const remaining = net - billsTotal - otesTotal - actualsTotal; const remainingColor = remaining >= 0 ? '#2a7a2a' : '#c0392b'; return ( @@ -100,6 +188,88 @@ function PaycheckColumn({ paycheck, onBillPaidToggle }) { )}
+
+
Variable Spending
+
+ + {actualsLoading &&
Loading…
} + {actualsError &&
Error: {actualsError}
} + + {!actualsLoading && actuals.length === 0 && ( +
(none)
+ )} + + {actuals.map((actual) => ( +
+
+ + {actual.category_name || Uncategorized} + + {formatCurrency(actual.amount)} +
+
+ {actual.note && {actual.note}} + {actual.date} + +
+
+ ))} + +
+
+ + setFormAmount(e.target.value)} + step="0.01" + min="0" + style={styles.formInput} + required + /> +
+
+ setFormNote(e.target.value)} + style={{ ...styles.formInput, flex: 2 }} + /> + setFormDate(e.target.value)} + style={styles.formInput} + /> + +
+ {formError &&
{formError}
} +
+
+
Remaining: @@ -117,11 +287,16 @@ function PaycheckView() { const [paychecks, setPaychecks] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [categories, setCategories] = useState([]); useEffect(() => { loadPaychecks(year, month); }, [year, month]); + useEffect(() => { + loadCategories(); + }, []); + async function loadPaychecks(y, m) { setLoading(true); setError(null); @@ -137,6 +312,17 @@ function PaycheckView() { } } + async function loadCategories() { + try { + const res = await fetch('/api/expense-categories'); + if (!res.ok) throw new Error(`Server error: ${res.status}`); + const data = await res.json(); + setCategories(data); + } catch (err) { + console.error('Failed to load expense categories:', err.message); + } + } + function prevMonth() { if (month === 1) { setYear(y => y - 1); @@ -222,8 +408,16 @@ function PaycheckView() {
Loading...
) : (
- - + +
)}
@@ -376,6 +570,97 @@ const styles = { fontVariantNumeric: 'tabular-nums', color: '#333', }, + // Actuals styles + actualsError: { + color: '#c0392b', + fontSize: '0.85rem', + marginBottom: '0.4rem', + }, + actualRow: { + marginBottom: '0.5rem', + fontSize: '0.9rem', + }, + actualMain: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'baseline', + }, + actualCategory: { + fontWeight: '500', + color: '#333', + }, + actualAmount: { + fontVariantNumeric: 'tabular-nums', + color: '#333', + marginLeft: '0.5rem', + }, + actualMeta: { + display: 'flex', + gap: '0.5rem', + alignItems: 'center', + fontSize: '0.8rem', + color: '#888', + marginTop: '1px', + }, + actualNote: { + fontStyle: 'italic', + flex: 1, + }, + actualDate: { + whiteSpace: 'nowrap', + }, + deleteButton: { + background: 'none', + border: 'none', + cursor: 'pointer', + color: '#c0392b', + fontSize: '1rem', + lineHeight: '1', + padding: '0 2px', + opacity: 0.7, + }, + actualForm: { + marginTop: '0.75rem', + display: 'flex', + flexDirection: 'column', + gap: '0.4rem', + }, + actualFormRow: { + display: 'flex', + gap: '0.4rem', + flexWrap: 'wrap', + }, + formSelect: { + flex: 1, + minWidth: '100px', + fontSize: '0.85rem', + padding: '0.3rem 0.4rem', + border: '1px solid #ccc', + borderRadius: '4px', + }, + formInput: { + flex: 1, + minWidth: '70px', + fontSize: '0.85rem', + padding: '0.3rem 0.4rem', + border: '1px solid #ccc', + borderRadius: '4px', + }, + addButton: { + padding: '0.3rem 0.75rem', + fontSize: '0.85rem', + cursor: 'pointer', + border: '1px solid #bbb', + borderRadius: '4px', + background: '#e8f0e8', + color: '#2a7a2a', + fontWeight: '600', + whiteSpace: 'nowrap', + }, + formError: { + color: '#c0392b', + fontSize: '0.8rem', + }, remainingRow: { display: 'flex', justifyContent: 'space-between', diff --git a/server/src/routes/actuals.js b/server/src/routes/actuals.js new file mode 100644 index 0000000..fd48191 --- /dev/null +++ b/server/src/routes/actuals.js @@ -0,0 +1,139 @@ +const express = require('express'); +const router = express.Router(); +const { pool } = require('../db'); + +// GET /api/expense-categories — list all categories +router.get('/expense-categories', async (req, res) => { + try { + const result = await pool.query( + 'SELECT id, name FROM expense_categories ORDER BY name' + ); + res.json(result.rows); + } catch (err) { + console.error('GET /api/expense-categories error:', err); + res.status(500).json({ error: 'Failed to fetch expense categories' }); + } +}); + +// POST /api/expense-categories — create a category { name } +router.post('/expense-categories', async (req, res) => { + const { name } = req.body; + if (!name || typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ error: 'name is required' }); + } + + try { + const result = await pool.query( + 'INSERT INTO expense_categories (name) VALUES ($1) RETURNING id, name', + [name.trim()] + ); + res.status(201).json(result.rows[0]); + } catch (err) { + if (err.code === '23505') { + return res.status(409).json({ error: 'Category already exists' }); + } + console.error('POST /api/expense-categories error:', err); + res.status(500).json({ error: 'Failed to create expense category' }); + } +}); + +// GET /api/actuals?paycheck_id= — list actuals for a paycheck, ordered by date desc +router.get('/actuals', async (req, res) => { + const paycheckId = parseInt(req.query.paycheck_id, 10); + if (isNaN(paycheckId)) { + return res.status(400).json({ error: 'paycheck_id query param is required' }); + } + + try { + const result = await pool.query( + `SELECT a.id, a.paycheck_id, a.category_id, a.bill_id, a.amount, a.note, a.date, a.created_at, + ec.name AS category_name + FROM actuals a + LEFT JOIN expense_categories ec ON ec.id = a.category_id + WHERE a.paycheck_id = $1 + ORDER BY a.date DESC, a.created_at DESC`, + [paycheckId] + ); + res.json(result.rows); + } catch (err) { + console.error('GET /api/actuals error:', err); + res.status(500).json({ error: 'Failed to fetch actuals' }); + } +}); + +// POST /api/actuals — log an actual { paycheck_id, category_id, amount, note, date } +router.post('/actuals', async (req, res) => { + const { paycheck_id, category_id, amount, note, date } = req.body; + + if (!paycheck_id) { + return res.status(400).json({ error: 'paycheck_id is required' }); + } + if (amount === undefined || amount === null || amount === '') { + return res.status(400).json({ error: 'amount is required' }); + } + + const parsedPaycheckId = parseInt(paycheck_id, 10); + const parsedAmount = parseFloat(amount); + + if (isNaN(parsedPaycheckId)) { + return res.status(400).json({ error: 'paycheck_id must be a valid integer' }); + } + if (isNaN(parsedAmount)) { + return res.status(400).json({ error: 'amount must be a valid number' }); + } + + const parsedCategoryId = category_id ? parseInt(category_id, 10) : null; + const actualDate = date || new Date().toISOString().slice(0, 10); + + try { + const result = await pool.query( + `INSERT INTO actuals (paycheck_id, category_id, amount, note, date) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, paycheck_id, category_id, amount, note, date, created_at`, + [parsedPaycheckId, parsedCategoryId, parsedAmount, note || null, actualDate] + ); + + // Fetch category name to include in response + const actual = result.rows[0]; + if (actual.category_id) { + const catResult = await pool.query( + 'SELECT name FROM expense_categories WHERE id = $1', + [actual.category_id] + ); + actual.category_name = catResult.rows[0]?.name || null; + } else { + actual.category_name = null; + } + + res.status(201).json(actual); + } catch (err) { + console.error('POST /api/actuals error:', err); + res.status(500).json({ error: 'Failed to log actual' }); + } +}); + +// DELETE /api/actuals/:id — remove an actual entry +router.delete('/actuals/:id', async (req, res) => { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid id' }); + } + + try { + const result = await pool.query( + 'DELETE FROM actuals WHERE id = $1 RETURNING id', + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Actual not found' }); + } + + res.json({ deleted: true, id: result.rows[0].id }); + } catch (err) { + console.error('DELETE /api/actuals/:id error:', err); + res.status(500).json({ error: 'Failed to delete actual' }); + } +}); + +module.exports = router;