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 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 19:09:51 -04:00
parent afe3895210
commit 8a9844cf72
3 changed files with 432 additions and 2 deletions

View File

@@ -46,3 +46,7 @@ cd client && npm install && npm run dev
```bash ```bash
cd server && npm install && npm run dev 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=`.

View File

@@ -1,5 +1,398 @@
function PaycheckView() { import { useState, useEffect } from 'react';
return <div><h1>Paycheck View</h1><p>Placeholder coming soon.</p></div>;
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 (
<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 remaining = net - billsTotal - otesTotal;
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.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);
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 (
<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} />
<PaycheckColumn paycheck={pc2} onBillPaidToggle={handleBillPaidToggle} />
</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',
},
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; export default PaycheckView;

View File

@@ -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; module.exports = router;