Files
budget-app/client/src/pages/PaycheckView.jsx
Christian Hood 17af71a7c7 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>
2026-03-19 19:12:25 -04:00

684 lines
18 KiB
JavaScript

import { useState, useEffect } from 'react';
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
function ordinal(n) {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
function formatCurrency(value) {
const num = parseFloat(value) || 0;
return '$' + num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function formatPayDate(dateStr) {
// dateStr is YYYY-MM-DD
const [year, month, day] = dateStr.split('-').map(Number);
return `${MONTH_NAMES[month - 1]} ${day}, ${year}`;
}
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}>
<p style={{ color: '#888' }}>No data</p>
</div>
);
}
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 actualsTotal = actuals.reduce((sum, a) => sum + (parseFloat(a.amount) || 0), 0);
const remaining = net - billsTotal - otesTotal - actualsTotal;
const remainingColor = remaining >= 0 ? '#2a7a2a' : '#c0392b';
return (
<div style={styles.column}>
<div style={styles.columnHeader}>
<h2 style={styles.paycheckTitle}>Paycheck {paycheck.paycheck_number}</h2>
<div style={styles.payDate}>{formatPayDate(paycheck.pay_date)}</div>
<div style={styles.payAmounts}>
<span>Gross: <strong>{formatCurrency(paycheck.gross)}</strong></span>
<span style={{ marginLeft: '1rem' }}>Net: <strong>{formatCurrency(paycheck.net)}</strong></span>
</div>
</div>
<div style={styles.section}>
<div style={styles.sectionLabel}>Bills</div>
<div style={styles.divider} />
{paycheck.bills.length === 0 ? (
<div style={styles.emptyNote}>(none)</div>
) : (
paycheck.bills.map((bill) => (
<div
key={bill.paycheck_bill_id}
style={{
...styles.billRow,
opacity: bill.paid ? 0.6 : 1,
}}
>
<input
type="checkbox"
checked={!!bill.paid}
onChange={() => onBillPaidToggle(bill.paycheck_bill_id, !bill.paid)}
style={styles.checkbox}
/>
<div style={styles.billDetails}>
<div style={bill.paid ? styles.billNamePaid : styles.billName}>
{bill.name}
<span style={styles.billAmount}>{formatCurrency(bill.effective_amount)}</span>
</div>
<div style={styles.billMeta}>
<span>due {ordinal(bill.due_day)}</span>
{bill.category && (
<span style={styles.category}>{bill.category}</span>
)}
</div>
</div>
</div>
))
)}
</div>
<div style={styles.section}>
<div style={styles.sectionLabel}>One-time expenses</div>
<div style={styles.divider} />
{paycheck.one_time_expenses.length === 0 ? (
<div style={styles.emptyNote}>(none)</div>
) : (
paycheck.one_time_expenses.map((ote) => (
<div key={ote.id} style={styles.oteRow}>
<span style={styles.oteName}>{ote.name}</span>
<span style={styles.oteAmount}>{formatCurrency(ote.amount)}</span>
</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"
>
&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 }}>
{formatCurrency(remaining)}
</span>
</div>
</div>
);
}
function PaycheckView() {
const now = new Date();
const [year, setYear] = useState(now.getFullYear());
const [month, setMonth] = useState(now.getMonth() + 1); // 1-based
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);
try {
const res = await fetch(`/api/paychecks?year=${y}&month=${m}`);
if (!res.ok) throw new Error(`Server error: ${res.status}`);
const data = await res.json();
setPaychecks(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
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);
setMonth(12);
} else {
setMonth(m => m - 1);
}
}
function nextMonth() {
if (month === 12) {
setYear(y => y + 1);
setMonth(1);
} else {
setMonth(m => m + 1);
}
}
async function handleBillPaidToggle(paycheckBillId, paid) {
// Optimistic update
setPaychecks(prev =>
prev.map(pc => ({
...pc,
bills: pc.bills.map(b =>
b.paycheck_bill_id === paycheckBillId
? { ...b, paid, paid_at: paid ? new Date().toISOString() : null }
: b
),
}))
);
try {
const res = await fetch(`/api/paycheck-bills/${paycheckBillId}/paid`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paid }),
});
if (!res.ok) throw new Error(`Server error: ${res.status}`);
const updated = await res.json();
// Sync server response
setPaychecks(prev =>
prev.map(pc => ({
...pc,
bills: pc.bills.map(b =>
b.paycheck_bill_id === paycheckBillId
? { ...b, paid: updated.paid, paid_at: updated.paid_at }
: b
),
}))
);
} catch (err) {
// Revert optimistic update on failure
setPaychecks(prev =>
prev.map(pc => ({
...pc,
bills: pc.bills.map(b =>
b.paycheck_bill_id === paycheckBillId
? { ...b, paid: !paid }
: b
),
}))
);
alert(`Failed to update bill: ${err.message}`);
}
}
const pc1 = paychecks.find(p => p.paycheck_number === 1) || null;
const pc2 = paychecks.find(p => p.paycheck_number === 2) || null;
return (
<div style={styles.container}>
<div style={styles.monthNav}>
<button style={styles.navButton} onClick={prevMonth}>&larr;</button>
<span style={styles.monthLabel}>{MONTH_NAMES[month - 1]} {year}</span>
<button style={styles.navButton} onClick={nextMonth}>&rarr;</button>
</div>
{error && (
<div style={styles.errorBanner}>Error: {error}</div>
)}
{loading ? (
<div style={styles.loadingMsg}>Loading...</div>
) : (
<div style={styles.grid}>
<PaycheckColumn
paycheck={pc1}
onBillPaidToggle={handleBillPaidToggle}
categories={categories}
/>
<PaycheckColumn
paycheck={pc2}
onBillPaidToggle={handleBillPaidToggle}
categories={categories}
/>
</div>
)}
</div>
);
}
const styles = {
container: {
maxWidth: '960px',
margin: '0 auto',
},
monthNav: {
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginBottom: '1.25rem',
},
navButton: {
padding: '0.3rem 0.75rem',
fontSize: '1rem',
cursor: 'pointer',
border: '1px solid #bbb',
borderRadius: '4px',
background: '#f5f5f5',
},
monthLabel: {
fontSize: '1.25rem',
fontWeight: '600',
minWidth: '160px',
textAlign: 'center',
},
errorBanner: {
background: '#fde8e8',
border: '1px solid #f5a0a0',
borderRadius: '4px',
padding: '0.75rem 1rem',
marginBottom: '1rem',
color: '#c0392b',
},
loadingMsg: {
padding: '2rem',
color: '#888',
},
grid: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1.5rem',
alignItems: 'start',
},
column: {
border: '1px solid #ddd',
borderRadius: '6px',
padding: '1rem',
background: '#fafafa',
},
columnHeader: {
marginBottom: '1rem',
paddingBottom: '0.75rem',
borderBottom: '2px solid #eee',
},
paycheckTitle: {
margin: '0 0 0.25rem 0',
fontSize: '1.1rem',
fontWeight: '700',
},
payDate: {
color: '#555',
marginBottom: '0.4rem',
fontSize: '0.95rem',
},
payAmounts: {
fontSize: '0.95rem',
color: '#333',
},
section: {
marginBottom: '1rem',
},
sectionLabel: {
fontWeight: '600',
fontSize: '0.9rem',
color: '#444',
marginBottom: '0.25rem',
},
divider: {
borderTop: '1px solid #ddd',
marginBottom: '0.5rem',
},
emptyNote: {
color: '#aaa',
fontSize: '0.875rem',
fontStyle: 'italic',
paddingLeft: '0.25rem',
},
billRow: {
display: 'flex',
alignItems: 'flex-start',
gap: '0.5rem',
marginBottom: '0.5rem',
},
checkbox: {
marginTop: '3px',
cursor: 'pointer',
flexShrink: 0,
},
billDetails: {
flex: 1,
},
billName: {
display: 'flex',
justifyContent: 'space-between',
fontWeight: '500',
fontSize: '0.95rem',
},
billNamePaid: {
display: 'flex',
justifyContent: 'space-between',
fontWeight: '500',
fontSize: '0.95rem',
textDecoration: 'line-through',
color: '#999',
},
billAmount: {
fontVariantNumeric: 'tabular-nums',
marginLeft: '0.5rem',
},
billMeta: {
fontSize: '0.8rem',
color: '#888',
display: 'flex',
gap: '0.5rem',
marginTop: '1px',
},
category: {
background: '#e8eaf0',
borderRadius: '3px',
padding: '0 4px',
fontSize: '0.75rem',
color: '#666',
},
oteRow: {
display: 'flex',
justifyContent: 'space-between',
fontSize: '0.95rem',
padding: '0.2rem 0',
},
oteName: {
color: '#333',
},
oteAmount: {
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',
alignItems: 'center',
marginTop: '0.5rem',
paddingTop: '0.75rem',
borderTop: '2px solid #ddd',
},
remainingLabel: {
fontWeight: '600',
fontSize: '1rem',
},
remainingAmount: {
fontWeight: '700',
fontSize: '1.1rem',
fontVariantNumeric: 'tabular-nums',
},
};
export default PaycheckView;