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 (

No data

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

Paycheck {paycheck.paycheck_number}

{formatPayDate(paycheck.pay_date)}
Gross: {formatCurrency(paycheck.gross)} Net: {formatCurrency(paycheck.net)}
Bills
{paycheck.bills.length === 0 ? (
(none)
) : ( paycheck.bills.map((bill) => (
onBillPaidToggle(bill.paycheck_bill_id, !bill.paid)} style={styles.checkbox} />
{bill.name} {formatCurrency(bill.effective_amount)}
due {ordinal(bill.due_day)} {bill.category && ( {bill.category} )}
)) )}
One-time expenses
{paycheck.one_time_expenses.length === 0 ? (
(none)
) : ( paycheck.one_time_expenses.map((ote) => (
{ote.name} {formatCurrency(ote.amount)}
)) )}
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: {formatCurrency(remaining)}
); } 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 (
{MONTH_NAMES[month - 1]} {year}
{error && (
Error: {error}
)} {loading ? (
Loading...
) : (
)}
); } 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;