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}
+
+
+
+ ))}
+
+
+
+
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;