From 8a9844cf721fe45c1e74404b277a0a7182b2ba45 Mon Sep 17 00:00:00 2001 From: Christian Hood Date: Thu, 19 Mar 2026 19:09:51 -0400 Subject: [PATCH] Add paycheck-centric main view Two-column monthly view showing bills, amounts, paid status, and remaining balance per paycheck. Month navigation included. Also adds PATCH /api/paycheck-bills/:id/paid endpoint. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 4 + client/src/pages/PaycheckView.jsx | 397 +++++++++++++++++++++++++++++- server/src/routes/paychecks.js | 33 +++ 3 files changed, 432 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 08a1bd6..5b53d70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,3 +46,7 @@ cd client && npm install && npm run dev ```bash cd server && npm install && npm run dev ``` + +## Application Structure + +The default route `/` renders the paycheck-centric main view (`client/src/pages/PaycheckView.jsx`). It shows the current month's two paychecks side-by-side with bills, paid status, one-time expenses, and remaining balance. Month navigation (prev/next) fetches data via `GET /api/paychecks?year=&month=`. diff --git a/client/src/pages/PaycheckView.jsx b/client/src/pages/PaycheckView.jsx index 3d4fe99..924f5de 100644 --- a/client/src/pages/PaycheckView.jsx +++ b/client/src/pages/PaycheckView.jsx @@ -1,5 +1,398 @@ -function PaycheckView() { - return

Paycheck View

Placeholder — coming soon.

; +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 PaycheckColumn({ paycheck, onBillPaidToggle }) { + 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 remaining = net - billsTotal - otesTotal; + 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)} +
+ )) + )} +
+ +
+ 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); + + useEffect(() => { + loadPaychecks(year, month); + }, [year, month]); + + 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); + } + } + + 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', + }, + 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; diff --git a/server/src/routes/paychecks.js b/server/src/routes/paychecks.js index 76e801e..12f607d 100644 --- a/server/src/routes/paychecks.js +++ b/server/src/routes/paychecks.js @@ -232,4 +232,37 @@ router.get('/paychecks/months', async (req, res) => { } }); +// PATCH /api/paycheck-bills/:id/paid +router.patch('/paycheck-bills/:id/paid', async (req, res) => { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid id' }); + } + + const { paid } = req.body; + if (typeof paid !== 'boolean') { + return res.status(400).json({ error: 'paid must be a boolean' }); + } + + try { + const result = await pool.query( + `UPDATE paycheck_bills + SET paid = $1, + paid_at = CASE WHEN $1 THEN NOW() ELSE NULL END + WHERE id = $2 + RETURNING id, paid, paid_at`, + [paid, id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'paycheck_bill not found' }); + } + + res.json(result.rows[0]); + } catch (err) { + console.error('PATCH /api/paycheck-bills/:id/paid error:', err); + res.status(500).json({ error: 'Failed to update paid status' }); + } +}); + module.exports = router;