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 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 19:12:25 -04:00
parent 9ada36deda
commit 17af71a7c7
2 changed files with 428 additions and 4 deletions

View File

@@ -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 (
<div style={styles.column}>
@@ -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 }) {
)}
</div>
<div style={styles.section}>
<div style={styles.sectionLabel}>Variable Spending</div>
<div style={styles.divider} />
{actualsLoading && <div style={styles.emptyNote}>Loading</div>}
{actualsError && <div style={styles.actualsError}>Error: {actualsError}</div>}
{!actualsLoading && actuals.length === 0 && (
<div style={styles.emptyNote}>(none)</div>
)}
{actuals.map((actual) => (
<div key={actual.id} style={styles.actualRow}>
<div style={styles.actualMain}>
<span style={styles.actualCategory}>
{actual.category_name || <em style={{ color: '#aaa' }}>Uncategorized</em>}
</span>
<span style={styles.actualAmount}>{formatCurrency(actual.amount)}</span>
</div>
<div style={styles.actualMeta}>
{actual.note && <span style={styles.actualNote}>{actual.note}</span>}
<span style={styles.actualDate}>{actual.date}</span>
<button
style={styles.deleteButton}
onClick={() => handleDeleteActual(actual.id)}
title="Remove"
>
&times;
</button>
</div>
</div>
))}
<form onSubmit={handleAddActual} style={styles.actualForm}>
<div style={styles.actualFormRow}>
<select
value={formCategoryId}
onChange={e => setFormCategoryId(e.target.value)}
style={styles.formSelect}
>
<option value=""> Category </option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<input
type="number"
placeholder="Amount"
value={formAmount}
onChange={e => setFormAmount(e.target.value)}
step="0.01"
min="0"
style={styles.formInput}
required
/>
</div>
<div style={styles.actualFormRow}>
<input
type="text"
placeholder="Note (optional)"
value={formNote}
onChange={e => setFormNote(e.target.value)}
style={{ ...styles.formInput, flex: 2 }}
/>
<input
type="date"
value={formDate}
onChange={e => setFormDate(e.target.value)}
style={styles.formInput}
/>
<button
type="submit"
disabled={formSubmitting}
style={styles.addButton}
>
Add
</button>
</div>
{formError && <div style={styles.formError}>{formError}</div>}
</form>
</div>
<div style={styles.remainingRow}>
<span style={styles.remainingLabel}>Remaining:</span>
<span style={{ ...styles.remainingAmount, color: remainingColor }}>
@@ -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() {
<div style={styles.loadingMsg}>Loading...</div>
) : (
<div style={styles.grid}>
<PaycheckColumn paycheck={pc1} onBillPaidToggle={handleBillPaidToggle} />
<PaycheckColumn paycheck={pc2} onBillPaidToggle={handleBillPaidToggle} />
<PaycheckColumn
paycheck={pc1}
onBillPaidToggle={handleBillPaidToggle}
categories={categories}
/>
<PaycheckColumn
paycheck={pc2}
onBillPaidToggle={handleBillPaidToggle}
categories={categories}
/>
</div>
)}
</div>
@@ -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',

View File

@@ -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;