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:
@@ -22,7 +22,94 @@ function formatPayDate(dateStr) {
|
|||||||
return `${MONTH_NAMES[month - 1]} ${day}, ${year}`;
|
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) {
|
if (!paycheck) {
|
||||||
return (
|
return (
|
||||||
<div style={styles.column}>
|
<div style={styles.column}>
|
||||||
@@ -34,7 +121,8 @@ function PaycheckColumn({ paycheck, onBillPaidToggle }) {
|
|||||||
const net = parseFloat(paycheck.net) || 0;
|
const net = parseFloat(paycheck.net) || 0;
|
||||||
const billsTotal = paycheck.bills.reduce((sum, b) => sum + (parseFloat(b.effective_amount) || 0), 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 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';
|
const remainingColor = remaining >= 0 ? '#2a7a2a' : '#c0392b';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,6 +188,88 @@ function PaycheckColumn({ paycheck, onBillPaidToggle }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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}>
|
<div style={styles.remainingRow}>
|
||||||
<span style={styles.remainingLabel}>Remaining:</span>
|
<span style={styles.remainingLabel}>Remaining:</span>
|
||||||
<span style={{ ...styles.remainingAmount, color: remainingColor }}>
|
<span style={{ ...styles.remainingAmount, color: remainingColor }}>
|
||||||
@@ -117,11 +287,16 @@ function PaycheckView() {
|
|||||||
const [paychecks, setPaychecks] = useState([]);
|
const [paychecks, setPaychecks] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPaychecks(year, month);
|
loadPaychecks(year, month);
|
||||||
}, [year, month]);
|
}, [year, month]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function loadPaychecks(y, m) {
|
async function loadPaychecks(y, m) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
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() {
|
function prevMonth() {
|
||||||
if (month === 1) {
|
if (month === 1) {
|
||||||
setYear(y => y - 1);
|
setYear(y => y - 1);
|
||||||
@@ -222,8 +408,16 @@ function PaycheckView() {
|
|||||||
<div style={styles.loadingMsg}>Loading...</div>
|
<div style={styles.loadingMsg}>Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={styles.grid}>
|
<div style={styles.grid}>
|
||||||
<PaycheckColumn paycheck={pc1} onBillPaidToggle={handleBillPaidToggle} />
|
<PaycheckColumn
|
||||||
<PaycheckColumn paycheck={pc2} onBillPaidToggle={handleBillPaidToggle} />
|
paycheck={pc1}
|
||||||
|
onBillPaidToggle={handleBillPaidToggle}
|
||||||
|
categories={categories}
|
||||||
|
/>
|
||||||
|
<PaycheckColumn
|
||||||
|
paycheck={pc2}
|
||||||
|
onBillPaidToggle={handleBillPaidToggle}
|
||||||
|
categories={categories}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -376,6 +570,97 @@ const styles = {
|
|||||||
fontVariantNumeric: 'tabular-nums',
|
fontVariantNumeric: 'tabular-nums',
|
||||||
color: '#333',
|
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: {
|
remainingRow: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
|||||||
139
server/src/routes/actuals.js
Normal file
139
server/src/routes/actuals.js
Normal 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;
|
||||||
Reference in New Issue
Block a user