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) { const [year, month, day] = dateStr.split('-').map(Number); return `${MONTH_NAMES[month - 1]} ${day}, ${year}`; } function todayISO() { return new Date().toISOString().slice(0, 10); } export { ordinal, formatCurrency, formatPayDate }; // ─── PaycheckColumn ─────────────────────────────────────────────────────────── function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave, onBillAmountSave, onFinancingPaidToggle }) { const [newOteName, setNewOteName] = useState(''); const [newOteAmount, setNewOteAmount] = useState(''); 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); // Inline bill amount editing (for variable bills) const [editingBillAmount, setEditingBillAmount] = useState(null); // bill_id const [billAmountDraft, setBillAmountDraft] = useState(''); const [billAmountSaving, setBillAmountSaving] = useState(false); async function saveBillAmount(bill) { setBillAmountSaving(true); try { await onBillAmountSave(bill.paycheck_bill_id, bill.bill_id, paycheck.paycheck_number, parseFloat(billAmountDraft) || 0); setEditingBillAmount(null); } catch (err) { alert(`Failed to save amount: ${err.message}`); } finally { setBillAmountSaving(false); } } // Inline gross/net editing const [editingAmounts, setEditingAmounts] = useState(false); const [editGross, setEditGross] = useState(''); const [editNet, setEditNet] = useState(''); const [amountSaving, setAmountSaving] = useState(false); const [amountError, setAmountError] = useState(null); useEffect(() => { if (!paycheck?.id) { setActuals([]); 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}`); setActuals(await res.json()); } 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 { // Lazy generate if this is a virtual paycheck let paycheckId = paycheck.id; if (!paycheckId) { const generated = await onGenerate(); paycheckId = generated.find(p => p.paycheck_number === paycheck.paycheck_number).id; } const res = await fetch('/api/actuals', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paycheck_id: paycheckId, 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}`); } await loadActuals(paycheckId); 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}`); } } function openAmountEdit() { setEditGross(parseFloat(paycheck.gross) || ''); setEditNet(parseFloat(paycheck.net) || ''); setAmountError(null); setEditingAmounts(true); } async function saveAmounts(e) { e.preventDefault(); setAmountSaving(true); setAmountError(null); try { await onAmountSave(paycheck.paycheck_number, parseFloat(editGross) || 0, parseFloat(editNet) || 0); setEditingAmounts(false); } catch (err) { setAmountError(err.message); } finally { setAmountSaving(false); } } if (!paycheck) { return (

No data

); } const isVirtual = paycheck.id === null; 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 financingTotal = (paycheck.financing || []).reduce((sum, f) => sum + (parseFloat(f.amount) || 0), 0); const remaining = net - billsTotal - otesTotal - actualsTotal - financingTotal; return (
Paycheck {paycheck.paycheck_number} {isVirtual && ( preview )}
{formatPayDate(paycheck.pay_date)}
{editingAmounts ? (
setEditGross(e.target.value)} className="form-input" />
setEditNet(e.target.value)} className="form-input" />
{amountError &&
{amountError}
}
) : (
Gross: {formatCurrency(paycheck.gross)} Net: {formatCurrency(paycheck.net)}
)}
{/* Bills */}
Bills
{paycheck.bills.length === 0 ? (

(none)

) : ( paycheck.bills.map((bill) => (
onBillPaidToggle(bill.paycheck_bill_id, !bill.paid, bill.bill_id, paycheck.paycheck_number)} className="bill-row__check" />
{bill.name} {bill.variable_amount && editingBillAmount === bill.bill_id ? ( setBillAmountDraft(e.target.value)} className="form-input" style={{ width: '90px', padding: '0.2rem 0.35rem', fontSize: '0.8rem' }} autoFocus onKeyDown={e => { if (e.key === 'Enter') saveBillAmount(bill); if (e.key === 'Escape') setEditingBillAmount(null); }} /> ) : ( {bill.variable_amount && !bill.amount_override ? enter amount : formatCurrency(bill.effective_amount)} {bill.variable_amount && !bill.paid && ( )} )}
due {ordinal(bill.due_day)} {bill.variable_amount && variable} {bill.category && {bill.category}}
)) )}
{/* One-time expenses */}
One-time Expenses
{paycheck.one_time_expenses.length === 0 ? (

(none)

) : ( paycheck.one_time_expenses.map((ote) => (
onOtePaidToggle(ote.id, !ote.paid)} className="ote-row__check" /> {ote.name} {formatCurrency(ote.amount)}
)) )}
setNewOteName(e.target.value)} className="form-input" /> setNewOteAmount(e.target.value)} min="0" step="0.01" className="form-input" style={{ maxWidth: '100px' }} />
{/* Variable spending */}
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" className="form-input" style={{ maxWidth: '110px' }} required />
setFormNote(e.target.value)} className="form-input" /> setFormDate(e.target.value)} className="form-input" style={{ maxWidth: '140px' }} />
{formError &&
{formError}
}
{/* Financing */} {paycheck.financing && paycheck.financing.length > 0 && (
Financing
{paycheck.financing.map((fp) => (
onFinancingPaidToggle(fp.financing_payment_id, !fp.paid, fp.plan_id, paycheck.paycheck_number)} className="bill-row__check" disabled={!fp.financing_payment_id} />
{fp.name} {fp.overdue && ( overdue )} {formatCurrency(fp.amount)}
{fp.total_amount != null && ( {formatCurrency(fp.paid_total ?? 0)} / {formatCurrency(fp.total_amount)} paid )} due {new Date(fp.due_date).toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' })}
))}
)} {/* Remaining */}
Remaining = 0 ? 'positive' : 'negative'}`}> {formatCurrency(remaining)}
); } // ─── PaycheckView ───────────────────────────────────────────────────────────── function PaycheckView() { const now = new Date(); const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); const [paychecks, setPaychecks] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [categories, setCategories] = useState([]); const [regenerating, setRegenerating] = useState(false); 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}`); setPaychecks(await res.json()); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function loadCategories() { try { const res = await fetch('/api/expense-categories'); if (!res.ok) return; setCategories(await res.json()); } catch { /* silent */ } } // Generates (or regenerates) paychecks for the current month, updates state, // and returns the new paychecks array. async function generateMonth() { const res = await fetch(`/api/paychecks/generate?year=${year}&month=${month}`, { method: 'POST' }); if (!res.ok) throw new Error(`Server error: ${res.status}`); const data = await res.json(); setPaychecks(data); return data; } async function handleRegenerate() { setRegenerating(true); try { await generateMonth(); } catch (err) { alert(`Failed to regenerate: ${err.message}`); } finally { setRegenerating(false); } } 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); } } // Saves gross/net for a paycheck. If virtual, generates first then patches. async function handleAmountSave(paycheckNumber, gross, net) { let pc = paychecks.find(p => p.paycheck_number === paycheckNumber); if (!pc.id) { const generated = await generateMonth(); pc = generated.find(p => p.paycheck_number === paycheckNumber); } const res = await fetch(`/api/paychecks/${pc.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gross, net }), }); if (!res.ok) { const body = await res.json(); throw new Error(body.error || `Server error: ${res.status}`); } const updated = await res.json(); setPaychecks(prev => prev.map(p => p.paycheck_number === paycheckNumber ? { ...p, id: updated.id, gross: updated.gross, net: updated.net } : p )); } async function handleFinancingPaidToggle(financingPaymentId, paid, planId, paycheckNumber) { let realId = financingPaymentId; // If virtual paycheck, generate first to get real financing_payment_id if (!realId) { const generated = await generateMonth(); const pc = generated.find(p => p.paycheck_number === paycheckNumber); const fp = pc.financing.find(f => f.plan_id === planId); realId = fp?.financing_payment_id; } if (!realId) { alert('Could not find payment record — try refreshing.'); return; } try { const res = await fetch(`/api/financing-payments/${realId}/paid`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paid }), }); if (!res.ok) throw new Error(`Server error: ${res.status}`); const result = await res.json(); if (result.plan_closed) { // Plan paid off — reload to update all state await loadPaychecks(year, month); } else { // Update local financing state setPaychecks(prev => prev.map(pc => ({ ...pc, financing: (pc.financing || []).map(f => f.financing_payment_id === realId ? { ...f, paid: result.paid, paid_at: result.paid_at } : f ), }))); } } catch (err) { alert(`Failed to update payment: ${err.message}`); } } async function handleBillAmountSave(paycheckBillId, billId, paycheckNumber, amount) { let realPaycheckBillId = paycheckBillId; if (!realPaycheckBillId) { const generated = await generateMonth(); const pc = generated.find(p => p.paycheck_number === paycheckNumber); realPaycheckBillId = pc.bills.find(b => b.bill_id === billId).paycheck_bill_id; } const res = await fetch(`/api/paycheck-bills/${realPaycheckBillId}/amount`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount }), }); if (!res.ok) { const body = await res.json(); throw new Error(body.error || `Server error: ${res.status}`); } // Reflect the new override in local state setPaychecks(prev => prev.map(pc => ({ ...pc, bills: pc.bills.map(b => b.paycheck_bill_id === realPaycheckBillId ? { ...b, amount_override: amount, effective_amount: amount } : b ), }))); } async function handleBillPaidToggle(paycheckBillId, paid, billId, paycheckNumber) { let realPaycheckBillId = paycheckBillId; if (!realPaycheckBillId) { // Virtual paycheck — generate first, then find the real paycheck_bill_id const generated = await generateMonth(); const pc = generated.find(p => p.paycheck_number === paycheckNumber); const bill = pc.bills.find(b => b.bill_id === billId); realPaycheckBillId = bill.paycheck_bill_id; } // Optimistic update setPaychecks(prev => prev.map(pc => ({ ...pc, bills: pc.bills.map(b => b.paycheck_bill_id === realPaycheckBillId ? { ...b, paid, paid_at: paid ? new Date().toISOString() : null } : b ), }))); try { const res = await fetch(`/api/paycheck-bills/${realPaycheckBillId}/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(); setPaychecks(prev => prev.map(pc => ({ ...pc, bills: pc.bills.map(b => b.paycheck_bill_id === realPaycheckBillId ? { ...b, paid: updated.paid, paid_at: updated.paid_at } : b ), }))); } catch (err) { // Revert on failure setPaychecks(prev => prev.map(pc => ({ ...pc, bills: pc.bills.map(b => b.paycheck_bill_id === realPaycheckBillId ? { ...b, paid: !paid } : b ), }))); alert(`Failed to update bill: ${err.message}`); } } async function handleOtePaidToggle(oteId, paid) { try { const res = await fetch(`/api/one-time-expenses/${oteId}/paid`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paid }), }); if (!res.ok) throw new Error(`Server error: ${res.status}`); await loadPaychecks(year, month); } catch (err) { alert(`Failed to update expense: ${err.message}`); } } async function handleOteDelete(oteId) { if (!window.confirm('Remove this expense?')) return; try { const res = await fetch(`/api/one-time-expenses/${oteId}`, { method: 'DELETE' }); if (!res.ok) throw new Error(`Server error: ${res.status}`); await loadPaychecks(year, month); } catch (err) { alert(`Failed to delete expense: ${err.message}`); } } async function handleOteAdd(paycheckNumber, name, amount) { let pc = paychecks.find(p => p.paycheck_number === paycheckNumber); let paycheckId = pc?.id; if (!paycheckId) { const generated = await generateMonth(); paycheckId = generated.find(p => p.paycheck_number === paycheckNumber).id; } try { const res = await fetch('/api/one-time-expenses', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paycheck_id: paycheckId, name, amount }), }); if (!res.ok) throw new Error(`Server error: ${res.status}`); await loadPaychecks(year, month); } catch (err) { alert(`Failed to add expense: ${err.message}`); } } const pc1 = paychecks.find(p => p.paycheck_number === 1) || null; const pc2 = paychecks.find(p => p.paycheck_number === 2) || null; const isVirtual = paychecks.length > 0 && paychecks.every(p => p.id === null); return (
{MONTH_NAMES[month - 1]} {year}
{isVirtual && (
Previewing from current settings — no data saved yet for this month.
)} {error &&
Error: {error}
} {loading ? (

Loading…

) : (
)}
); } export default PaycheckView;