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