Add modern UI with light/dark mode

- CSS custom properties design system with full light/dark themes
- ThemeContext with localStorage persistence and system preference detection
- Theme toggle button in nav (moon/sun icon)
- Modern Inter font, card-based layout, sticky nav
- All pages restyled with CSS classes instead of inline styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 19:47:34 -04:00
parent 16ba4166f2
commit db541b4147
9 changed files with 1293 additions and 1274 deletions

View File

@@ -17,7 +17,6 @@ function formatCurrency(value) {
}
function formatPayDate(dateStr) {
// dateStr is YYYY-MM-DD
const [year, month, day] = dateStr.split('-').map(Number);
return `${MONTH_NAMES[month - 1]} ${day}, ${year}`;
}
@@ -62,11 +61,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
async function handleAddActual(e) {
e.preventDefault();
if (!formAmount) {
setFormError('Amount is required');
return;
}
if (!formAmount) { setFormError('Amount is required'); return; }
setFormSubmitting(true);
setFormError(null);
try {
@@ -85,9 +80,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
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('');
@@ -114,8 +107,10 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
if (!paycheck) {
return (
<div style={styles.column}>
<p style={{ color: '#888' }}>No data</p>
<div className="paycheck-card">
<div className="paycheck-card__body">
<p className="empty-state">No data</p>
</div>
</div>
);
}
@@ -125,196 +120,180 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
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 (
<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}>
<div className="paycheck-card">
<div className="paycheck-card__header">
<div className="paycheck-card__number">Paycheck {paycheck.paycheck_number}</div>
<div className="paycheck-card__date">{formatPayDate(paycheck.pay_date)}</div>
<div className="paycheck-card__amounts">
<span>Gross: <strong>{formatCurrency(paycheck.gross)}</strong></span>
<span style={{ marginLeft: '1rem' }}>Net: <strong>{formatCurrency(paycheck.net)}</strong></span>
<span>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 className="paycheck-card__body">
{/* Bills */}
<div className="mb-2">
<div className="section-title">Bills</div>
{paycheck.bills.length === 0 ? (
<p className="empty-state">(none)</p>
) : (
paycheck.bills.map((bill) => (
<div key={bill.paycheck_bill_id} className="bill-row" style={{ opacity: bill.paid ? 0.6 : 1 }}>
<input
type="checkbox"
checked={!!bill.paid}
onChange={() => onBillPaidToggle(bill.paycheck_bill_id, !bill.paid)}
className="bill-row__check"
/>
<div className="bill-row__info">
<div className={`bill-row__name${bill.paid ? ' paid' : ''}`}>
<span>{bill.name}</span>
<span className="bill-row__amount">{formatCurrency(bill.effective_amount)}</span>
</div>
<div className="bill-row__meta">
<span>due {ordinal(bill.due_day)}</span>
{bill.category && <span className="badge badge-category">{bill.category}</span>}
</div>
</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, opacity: ote.paid ? 0.6 : 1 }}>
<input
type="checkbox"
checked={!!ote.paid}
onChange={() => onOtePaidToggle(ote.id, !ote.paid)}
style={styles.checkbox}
/>
<span style={ote.paid ? { ...styles.oteName, textDecoration: 'line-through', color: '#999' } : styles.oteName}>
{ote.name}
</span>
<span style={styles.oteAmount}>{formatCurrency(ote.amount)}</span>
<button onClick={() => onOteDelete(ote.id)} style={styles.deleteButton} title="Remove expense">&times;</button>
</div>
))
)}
<div style={styles.oteAddForm}>
<input
type="text"
placeholder="Name"
value={newOteName}
onChange={(e) => setNewOteName(e.target.value)}
style={styles.oteAddInput}
/>
<input
type="number"
placeholder="Amount"
value={newOteAmount}
onChange={(e) => setNewOteAmount(e.target.value)}
min="0"
step="0.01"
style={{ ...styles.oteAddInput, width: '80px' }}
/>
<button
onClick={() => {
if (!newOteName.trim() || !newOteAmount) return;
onOteAdd(paycheck.id, newOteName.trim(), parseFloat(newOteAmount));
setNewOteName('');
setNewOteAmount('');
}}
style={styles.oteAddButton}
>
Add
</button>
))
)}
</div>
</div>
<div style={styles.section}>
<div style={styles.sectionLabel}>Variable Spending</div>
<div style={styles.divider} />
{actualsLoading && <div style={styles.emptyNote}>Loading</div>}
{actualsError && <div style={styles.actualsError}>Error: {actualsError}</div>}
{!actualsLoading && actuals.length === 0 && (
<div style={styles.emptyNote}>(none)</div>
)}
{actuals.map((actual) => (
<div key={actual.id} style={styles.actualRow}>
<div style={styles.actualMain}>
<span style={styles.actualCategory}>
{actual.category_name || <em style={{ color: '#aaa' }}>Uncategorized</em>}
</span>
<span style={styles.actualAmount}>{formatCurrency(actual.amount)}</span>
</div>
<div style={styles.actualMeta}>
{actual.note && <span style={styles.actualNote}>{actual.note}</span>}
<span style={styles.actualDate}>{actual.date}</span>
<button
style={styles.deleteButton}
onClick={() => handleDeleteActual(actual.id)}
title="Remove"
>
&times;
</button>
</div>
</div>
))}
<form onSubmit={handleAddActual} style={styles.actualForm}>
<div style={styles.actualFormRow}>
<select
value={formCategoryId}
onChange={e => setFormCategoryId(e.target.value)}
style={styles.formSelect}
>
<option value=""> Category </option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
{/* One-time expenses */}
<div className="mb-2">
<div className="section-title">One-time Expenses</div>
{paycheck.one_time_expenses.length === 0 ? (
<p className="empty-state">(none)</p>
) : (
paycheck.one_time_expenses.map((ote) => (
<div key={ote.id} className="ote-row" style={{ opacity: ote.paid ? 0.6 : 1 }}>
<input
type="checkbox"
checked={!!ote.paid}
onChange={() => onOtePaidToggle(ote.id, !ote.paid)}
className="ote-row__check"
/>
<span className={`ote-row__name${ote.paid ? ' paid' : ''}`}>{ote.name}</span>
<span className="ote-row__amount">{formatCurrency(ote.amount)}</span>
<button className="btn-icon" onClick={() => onOteDelete(ote.id)} title="Remove">&times;</button>
</div>
))
)}
<div className="inline-add-form">
<input
type="text"
placeholder="Name"
value={newOteName}
onChange={(e) => setNewOteName(e.target.value)}
className="form-input"
/>
<input
type="number"
placeholder="Amount"
value={formAmount}
onChange={e => setFormAmount(e.target.value)}
step="0.01"
value={newOteAmount}
onChange={(e) => setNewOteAmount(e.target.value)}
min="0"
style={styles.formInput}
required
/>
</div>
<div style={styles.actualFormRow}>
<input
type="text"
placeholder="Note (optional)"
value={formNote}
onChange={e => setFormNote(e.target.value)}
style={{ ...styles.formInput, flex: 2 }}
/>
<input
type="date"
value={formDate}
onChange={e => setFormDate(e.target.value)}
style={styles.formInput}
step="0.01"
className="form-input"
style={{ maxWidth: '100px' }}
/>
<button
type="submit"
disabled={formSubmitting}
style={styles.addButton}
className="btn btn-sm btn-primary"
onClick={() => {
if (!newOteName.trim() || !newOteAmount) return;
onOteAdd(paycheck.id, newOteName.trim(), parseFloat(newOteAmount));
setNewOteName('');
setNewOteAmount('');
}}
>
Add
</button>
</div>
{formError && <div style={styles.formError}>{formError}</div>}
</form>
</div>
</div>
<div style={styles.remainingRow}>
<span style={styles.remainingLabel}>Remaining:</span>
<span style={{ ...styles.remainingAmount, color: remainingColor }}>
{formatCurrency(remaining)}
</span>
{/* Variable spending */}
<div className="mb-2">
<div className="section-title">Variable Spending</div>
{actualsLoading && <p className="empty-state">Loading</p>}
{actualsError && <div className="alert alert-error">Error: {actualsError}</div>}
{!actualsLoading && actuals.length === 0 && <p className="empty-state">(none)</p>}
{actuals.map((actual) => (
<div key={actual.id} className="actual-row">
<div className="actual-row__main">
<span className="actual-row__category">
{actual.category_name || <em className="text-faint">Uncategorized</em>}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<span className="actual-row__amount">{formatCurrency(actual.amount)}</span>
<button className="btn-icon" onClick={() => handleDeleteActual(actual.id)} title="Remove">&times;</button>
</div>
</div>
<div className="actual-row__meta">
{actual.note && <span className="actual-row__note">{actual.note}</span>}
<span>{actual.date}</span>
</div>
</div>
))}
<form onSubmit={handleAddActual} className="form-rows">
<div className="form-row">
<select
value={formCategoryId}
onChange={e => setFormCategoryId(e.target.value)}
className="form-select"
>
<option value=""> Category </option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<input
type="number"
placeholder="Amount"
value={formAmount}
onChange={e => setFormAmount(e.target.value)}
step="0.01"
min="0"
className="form-input"
style={{ maxWidth: '110px' }}
required
/>
</div>
<div className="form-row">
<input
type="text"
placeholder="Note (optional)"
value={formNote}
onChange={e => setFormNote(e.target.value)}
className="form-input"
/>
<input
type="date"
value={formDate}
onChange={e => setFormDate(e.target.value)}
className="form-input"
style={{ maxWidth: '140px' }}
/>
<button type="submit" disabled={formSubmitting} className="btn btn-sm btn-primary" style={{ flexShrink: 0 }}>
Add
</button>
</div>
{formError && <div className="form-error">{formError}</div>}
</form>
</div>
{/* Remaining */}
<div className="remaining-row">
<span className="remaining-row__label">Remaining</span>
<span className={`remaining-row__amount ${remaining >= 0 ? 'positive' : 'negative'}`}>
{formatCurrency(remaining)}
</span>
</div>
</div>
</div>
);
@@ -323,19 +302,14 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
function PaycheckView() {
const now = new Date();
const [year, setYear] = useState(now.getFullYear());
const [month, setMonth] = useState(now.getMonth() + 1); // 1-based
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([]);
useEffect(() => {
loadPaychecks(year, month);
}, [year, month]);
useEffect(() => {
loadCategories();
}, []);
useEffect(() => { loadPaychecks(year, month); }, [year, month]);
useEffect(() => { loadCategories(); }, []);
async function loadPaychecks(y, m) {
setLoading(true);
@@ -343,8 +317,7 @@ function PaycheckView() {
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);
setPaychecks(await res.json());
} catch (err) {
setError(err.message);
} finally {
@@ -355,30 +328,17 @@ function PaycheckView() {
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);
}
if (!res.ok) return;
setCategories(await res.json());
} catch { /* silent */ }
}
function prevMonth() {
if (month === 1) {
setYear(y => y - 1);
setMonth(12);
} else {
setMonth(m => m - 1);
}
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);
}
if (month === 12) { setYear(y => y + 1); setMonth(1); } else { setMonth(m => m + 1); }
}
async function handleOtePaidToggle(oteId, paid) {
@@ -421,7 +381,6 @@ function PaycheckView() {
}
async function handleBillPaidToggle(paycheckBillId, paid) {
// Optimistic update
setPaychecks(prev =>
prev.map(pc => ({
...pc,
@@ -441,7 +400,6 @@ function PaycheckView() {
});
if (!res.ok) throw new Error(`Server error: ${res.status}`);
const updated = await res.json();
// Sync server response
setPaychecks(prev =>
prev.map(pc => ({
...pc,
@@ -453,14 +411,11 @@ function PaycheckView() {
}))
);
} 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
b.paycheck_bill_id === paycheckBillId ? { ...b, paid: !paid } : b
),
}))
);
@@ -472,21 +427,19 @@ function PaycheckView() {
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>
<div className="period-nav">
<button className="btn-nav" onClick={prevMonth}>&#8592;</button>
<span className="period-nav__label">{MONTH_NAMES[month - 1]} {year}</span>
<button className="btn-nav" onClick={nextMonth}>&#8594;</button>
</div>
{error && (
<div style={styles.errorBanner}>Error: {error}</div>
)}
{error && <div className="alert alert-error">Error: {error}</div>}
{loading ? (
<div style={styles.loadingMsg}>Loading...</div>
<p className="text-muted">Loading</p>
) : (
<div style={styles.grid}>
<div className="paycheck-grid">
<PaycheckColumn
paycheck={pc1}
onBillPaidToggle={handleBillPaidToggle}
@@ -509,282 +462,4 @@ function PaycheckView() {
);
}
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',
},
oteAddForm: {
display: 'flex',
gap: '0.4rem',
marginTop: '0.5rem',
alignItems: 'center',
},
oteAddInput: {
padding: '0.2rem 0.4rem',
fontSize: '0.875rem',
border: '1px solid #ccc',
borderRadius: '3px',
flex: 1,
},
oteAddButton: {
padding: '0.2rem 0.6rem',
fontSize: '0.875rem',
cursor: 'pointer',
border: '1px solid #bbb',
borderRadius: '3px',
background: '#f0f0f0',
flexShrink: 0,
},
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;