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

@@ -13,9 +13,9 @@ function fmt(value) {
return num < 0 ? `-$${abs}` : `$${abs}`;
}
function surplusStyle(value) {
if (value == null || isNaN(Number(value))) return {};
return { color: Number(value) >= 0 ? '#2a9d2a' : '#cc2222', fontWeight: 500 };
function surplusClass(value) {
if (value == null || isNaN(Number(value))) return '';
return Number(value) >= 0 ? 'text-success' : 'text-danger';
}
function sumField(rows, field) {
@@ -37,7 +37,6 @@ export default function AnnualOverview() {
setLoading(true);
setError(null);
// Normalize nested API response to flat fields used by this component
function normalize(data) {
if (!data) return null;
return {
@@ -53,22 +52,13 @@ export default function AnnualOverview() {
Promise.all(
Array.from({ length: 12 }, (_, i) =>
fetch(`/api/summary/monthly?year=${year}&month=${i + 1}`)
.then(r => {
if (!r.ok) return null;
return r.json().then(normalize);
})
.then(r => r.ok ? r.json().then(normalize) : null)
.catch(() => null)
)
).then(results => {
if (!cancelled) {
setMonthData(results);
setLoading(false);
}
if (!cancelled) { setMonthData(results); setLoading(false); }
}).catch(err => {
if (!cancelled) {
setError(err.message || 'Failed to load data');
setLoading(false);
}
if (!cancelled) { setError(err.message || 'Failed to load data'); setLoading(false); }
});
return () => { cancelled = true; };
@@ -76,152 +66,93 @@ export default function AnnualOverview() {
const hasData = monthData.some(row => row != null);
const annualIncome = sumField(monthData, 'total_income');
const annualBills = sumField(monthData, 'total_bills');
const annualIncome = sumField(monthData, 'total_income');
const annualBills = sumField(monthData, 'total_bills');
const annualVariable = sumField(monthData, 'total_variable');
const annualOneTime = sumField(monthData, 'total_one_time');
const annualOneTime = sumField(monthData, 'total_one_time');
const annualSpending = sumField(monthData, 'total_spending');
const annualSurplus = sumField(monthData, 'surplus_deficit');
const cardStyle = {
background: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: 6,
padding: '1rem 1.25rem',
minWidth: 160,
flex: '1 1 160px',
};
const cardLabelStyle = {
fontSize: '0.75rem',
color: '#666',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 4,
};
const cardValueStyle = {
fontSize: '1.4rem',
fontWeight: 700,
};
const annualSurplus = sumField(monthData, 'surplus_deficit');
return (
<div style={{ maxWidth: 1000 }}>
{/* Year navigation */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem' }}>
<button
onClick={() => setYear(y => y - 1)}
style={{ fontSize: '1.2rem', cursor: 'pointer', background: 'none', border: '1px solid #ccc', borderRadius: 4, padding: '0.2rem 0.6rem' }}
aria-label="Previous year"
>
</button>
<h1 style={{ margin: 0, fontSize: '1.5rem' }}>{year} Annual Overview</h1>
<button
onClick={() => setYear(y => y + 1)}
style={{ fontSize: '1.2rem', cursor: 'pointer', background: 'none', border: '1px solid #ccc', borderRadius: 4, padding: '0.2rem 0.6rem' }}
aria-label="Next year"
>
</button>
<div>
<div className="period-nav">
<button className="btn-nav" onClick={() => setYear(y => y - 1)} aria-label="Previous year">&#8592;</button>
<span className="period-nav__label">{year}</span>
<button className="btn-nav" onClick={() => setYear(y => y + 1)} aria-label="Next year">&#8594;</button>
</div>
{/* Summary cards */}
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '2rem' }}>
<div style={cardStyle}>
<div style={cardLabelStyle}>Annual Income (net)</div>
<div style={cardValueStyle}>{hasData ? fmt(annualIncome) : '—'}</div>
<div className="stat-cards">
<div className="stat-card">
<div className="stat-card__label">Annual Income (net)</div>
<div className="stat-card__value">{hasData ? fmt(annualIncome) : '—'}</div>
</div>
<div style={cardStyle}>
<div style={cardLabelStyle}>Annual Bills</div>
<div style={cardValueStyle}>{hasData ? fmt(annualBills) : '—'}</div>
<div className="stat-card">
<div className="stat-card__label">Annual Bills</div>
<div className="stat-card__value">{hasData ? fmt(annualBills) : '—'}</div>
</div>
<div style={cardStyle}>
<div style={cardLabelStyle}>Annual Variable Spending</div>
<div style={cardValueStyle}>{hasData ? fmt(annualVariable) : '—'}</div>
<div className="stat-card">
<div className="stat-card__label">Variable Spending</div>
<div className="stat-card__value">{hasData ? fmt(annualVariable) : '—'}</div>
</div>
<div style={{ ...cardStyle }}>
<div style={cardLabelStyle}>Annual Surplus / Deficit</div>
<div style={{ ...cardValueStyle, ...surplusStyle(annualSurplus) }}>
<div className="stat-card">
<div className="stat-card__label">Annual Surplus / Deficit</div>
<div className={`stat-card__value ${surplusClass(annualSurplus)}`}>
{hasData ? fmt(annualSurplus) : '—'}
</div>
</div>
</div>
{/* Status messages */}
{loading && <p style={{ color: '#666' }}>Loading</p>}
{error && <p style={{ color: '#cc2222' }}>Error: {error}</p>}
{loading && <p className="text-muted">Loading</p>}
{error && <div className="alert alert-error">Error: {error}</div>}
{/* Monthly table */}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
<thead>
<tr style={{ background: '#f0f0f0', textAlign: 'right' }}>
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Month</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Income (net)</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Bills</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Variable</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>One-time</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Total Spending</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Surplus / Deficit</th>
</tr>
</thead>
<tbody>
{MONTH_NAMES.map((name, i) => {
const row = monthData[i];
const hasRow = row != null;
const surplus = hasRow ? row.surplus_deficit : null;
return (
<tr
key={name}
style={{ borderBottom: '1px solid #eee' }}
>
<td style={{ padding: '0.5rem 0.75rem' }}>{name}</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasRow ? fmt(row.total_income) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasRow ? fmt(row.total_bills) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasRow ? fmt(row.total_variable) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasRow ? fmt(row.total_one_time) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasRow ? fmt(row.total_spending) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem', ...surplusStyle(surplus) }}>
{hasRow ? fmt(surplus) : '—'}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid #ccc', fontWeight: 700, background: '#fafafa' }}>
<td style={{ padding: '0.5rem 0.75rem' }}>Total</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualIncome) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualBills) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualVariable) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualOneTime) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
{hasData ? fmt(annualSpending) : '—'}
</td>
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem', ...surplusStyle(annualSurplus) }}>
{hasData ? fmt(annualSurplus) : '—'}
</td>
</tr>
</tfoot>
</table>
<div className="card" style={{ overflow: 'hidden' }}>
<table className="data-table">
<thead>
<tr>
<th>Month</th>
<th className="text-right">Income (net)</th>
<th className="text-right">Bills</th>
<th className="text-right">Variable</th>
<th className="text-right">One-time</th>
<th className="text-right">Total Spending</th>
<th className="text-right">Surplus / Deficit</th>
</tr>
</thead>
<tbody>
{MONTH_NAMES.map((name, i) => {
const row = monthData[i];
const hasRow = row != null;
const surplus = hasRow ? row.surplus_deficit : null;
return (
<tr key={name}>
<td>{name}</td>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_income) : '—'}</td>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_bills) : '—'}</td>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_variable) : '—'}</td>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_one_time) : '—'}</td>
<td className="text-right font-tabular">{hasRow ? fmt(row.total_spending) : '—'}</td>
<td className={`text-right font-tabular ${surplusClass(surplus)}`}>
{hasRow ? fmt(surplus) : '—'}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td className="font-bold">Total</td>
<td className="text-right font-tabular">{hasData ? fmt(annualIncome) : '—'}</td>
<td className="text-right font-tabular">{hasData ? fmt(annualBills) : '—'}</td>
<td className="text-right font-tabular">{hasData ? fmt(annualVariable) : '—'}</td>
<td className="text-right font-tabular">{hasData ? fmt(annualOneTime) : '—'}</td>
<td className="text-right font-tabular">{hasData ? fmt(annualSpending) : '—'}</td>
<td className={`text-right font-tabular font-bold ${surplusClass(annualSurplus)}`}>
{hasData ? fmt(annualSurplus) : '—'}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
);
}

View File

@@ -1,13 +1,8 @@
import { useState, useEffect } from 'react';
const CATEGORIES = [
'Housing',
'Utilities',
'Subscriptions',
'Insurance',
'Transportation',
'Debt',
'General',
'Housing', 'Utilities', 'Subscriptions', 'Insurance',
'Transportation', 'Debt', 'General',
];
const EMPTY_FORM = {
@@ -47,8 +42,7 @@ function Bills() {
setLoading(true);
const res = await fetch('/api/bills');
if (!res.ok) throw new Error('Failed to load bills');
const data = await res.json();
setBills(data);
setBills(await res.json());
setError(null);
} catch (err) {
setError(err.message);
@@ -57,9 +51,7 @@ function Bills() {
}
}
useEffect(() => {
loadBills();
}, []);
useEffect(() => { loadBills(); }, []);
function openAddForm() {
setEditingId(null);
@@ -90,7 +82,7 @@ function Bills() {
function handleChange(e) {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
setForm(prev => ({ ...prev, [name]: value }));
}
async function handleSave(e) {
@@ -105,22 +97,15 @@ function Bills() {
assigned_paycheck: form.assigned_paycheck,
category: form.category,
};
const url = editingId ? `/api/bills/${editingId}` : '/api/bills';
const method = editingId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
setFormError(data.error || 'Failed to save bill');
return;
}
if (!res.ok) { setFormError(data.error || 'Failed to save bill'); return; }
await loadBills();
cancelForm();
} catch (err) {
@@ -135,9 +120,7 @@ function Bills() {
const res = await fetch(`/api/bills/${bill.id}/toggle`, { method: 'PATCH' });
if (!res.ok) throw new Error('Failed to toggle bill');
await loadBills();
} catch (err) {
alert(err.message);
}
} catch (err) { alert(err.message); }
}
async function handleDelete(bill) {
@@ -146,134 +129,13 @@ function Bills() {
const res = await fetch(`/api/bills/${bill.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete bill');
await loadBills();
} catch (err) {
alert(err.message);
}
} catch (err) { alert(err.message); }
}
return (
<div>
<style>{`
.bills-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.bills-header h1 {
margin: 0;
}
.btn {
padding: 0.4rem 0.9rem;
border: 1px solid #888;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
background: #fff;
}
.btn-primary {
background: #2563eb;
color: #fff;
border-color: #2563eb;
}
.btn-primary:hover { background: #1d4ed8; border-color: #1d4ed8; }
.btn-danger {
background: #fff;
color: #dc2626;
border-color: #dc2626;
}
.btn-danger:hover { background: #fef2f2; }
.btn-sm {
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
}
.form-card {
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 1.25rem;
margin-bottom: 1.5rem;
background: #f9fafb;
max-width: 560px;
}
.form-card h2 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
}
.form-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1 1 180px;
}
.form-group label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
}
.form-group input,
.form-group select {
padding: 0.35rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.9rem;
}
.form-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.form-error {
color: #dc2626;
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.bills-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.bills-table th,
.bills-table td {
text-align: left;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid #e5e7eb;
}
.bills-table th {
background: #f3f4f6;
font-weight: 600;
color: #374151;
}
.bills-table tr:hover td {
background: #f9fafb;
}
.inactive-row td {
color: #9ca3af;
}
.active-toggle {
cursor: pointer;
user-select: none;
font-size: 1.1rem;
}
.actions-cell {
display: flex;
gap: 0.4rem;
white-space: nowrap;
}
.empty-state {
color: #6b7280;
padding: 2rem 0;
text-align: center;
}
`}</style>
<div className="bills-header">
<h1>Bills</h1>
<div className="page-header">
<h1 className="page-title">Bills</h1>
{!showForm && (
<button className="btn btn-primary" onClick={openAddForm}>
+ Add Bill
@@ -282,155 +144,113 @@ function Bills() {
</div>
{showForm && (
<div className="form-card">
<h2>{editingId ? 'Edit Bill' : 'Add Bill'}</h2>
{formError && <div className="form-error">{formError}</div>}
<div className="card card-body mb-2" style={{ maxWidth: '580px' }}>
<h2 style={{ margin: '0 0 1rem', fontSize: '1rem', fontWeight: '700' }}>
{editingId ? 'Edit Bill' : 'Add Bill'}
</h2>
{formError && <div className="alert alert-error">{formError}</div>}
<form onSubmit={handleSave} autoComplete="off">
<div className="form-row">
<div className="form-group" style={{ flex: '2 1 240px' }}>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
value={form.name}
onChange={handleChange}
required
placeholder="e.g. Rent"
/>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '0.75rem', marginBottom: '0.75rem' }}>
<div className="form-group">
<label className="form-label" htmlFor="name">Name</label>
<input id="name" name="name" type="text" value={form.name} onChange={handleChange}
required placeholder="e.g. Rent" className="form-input" />
</div>
<div className="form-group">
<label htmlFor="amount">Amount ($)</label>
<input
id="amount"
name="amount"
type="number"
min="0"
step="0.01"
value={form.amount}
onChange={handleChange}
required
placeholder="0.00"
/>
<label className="form-label" htmlFor="amount">Amount ($)</label>
<input id="amount" name="amount" type="number" min="0" step="0.01"
value={form.amount} onChange={handleChange} required placeholder="0.00" className="form-input" />
</div>
</div>
<div className="form-row">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 2fr', gap: '0.75rem', marginBottom: '1rem' }}>
<div className="form-group">
<label htmlFor="due_day">Due Day (131)</label>
<input
id="due_day"
name="due_day"
type="number"
min="1"
max="31"
value={form.due_day}
onChange={handleChange}
required
placeholder="1"
/>
<label className="form-label" htmlFor="due_day">Due Day</label>
<input id="due_day" name="due_day" type="number" min="1" max="31"
value={form.due_day} onChange={handleChange} required placeholder="1" className="form-input" />
</div>
<div className="form-group">
<label htmlFor="assigned_paycheck">Paycheck</label>
<select
id="assigned_paycheck"
name="assigned_paycheck"
value={form.assigned_paycheck}
onChange={handleChange}
>
<label className="form-label" htmlFor="assigned_paycheck">Paycheck</label>
<select id="assigned_paycheck" name="assigned_paycheck"
value={form.assigned_paycheck} onChange={handleChange} className="form-select">
<option value="1">Paycheck 1</option>
<option value="2">Paycheck 2</option>
</select>
</div>
<div className="form-group" style={{ flex: '2 1 200px' }}>
<label htmlFor="category">Category</label>
<input
id="category"
name="category"
type="text"
list="category-list"
value={form.category}
onChange={handleChange}
placeholder="General"
/>
<div className="form-group">
<label className="form-label" htmlFor="category">Category</label>
<input id="category" name="category" type="text" list="category-list"
value={form.category} onChange={handleChange} placeholder="General" className="form-input" />
<datalist id="category-list">
{CATEGORIES.map((c) => (
<option key={c} value={c} />
))}
{CATEGORIES.map(c => <option key={c} value={c} />)}
</datalist>
</div>
</div>
<div className="form-actions">
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn btn-primary" type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
<button className="btn" type="button" onClick={cancelForm}>
Cancel
</button>
<button className="btn" type="button" onClick={cancelForm}>Cancel</button>
</div>
</form>
</div>
)}
{loading && <p>Loading bills</p>}
{error && <p style={{ color: '#dc2626' }}>Error: {error}</p>}
{loading && <p className="text-muted">Loading bills</p>}
{error && <div className="alert alert-error">Error: {error}</div>}
{!loading && !error && bills.length === 0 && (
<p className="empty-state">No bills yet. Click "Add Bill" to get started.</p>
<div className="card card-body" style={{ textAlign: 'center', color: 'var(--text-muted)' }}>
No bills yet. Click "Add Bill" to get started.
</div>
)}
{!loading && !error && bills.length > 0 && (
<table className="bills-table">
<thead>
<tr>
<th>Name</th>
<th>Amount</th>
<th>Due Day</th>
<th>Paycheck</th>
<th>Category</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{bills.map((bill) => (
<tr key={bill.id} className={bill.active ? '' : 'inactive-row'}>
<td>{bill.name}</td>
<td>{formatCurrency(bill.amount)}</td>
<td>{ordinal(bill.due_day)}</td>
<td>Paycheck {bill.assigned_paycheck}</td>
<td>{bill.category || 'General'}</td>
<td>
<span
className="active-toggle"
title={bill.active ? 'Click to deactivate' : 'Click to activate'}
onClick={() => handleToggle(bill)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleToggle(bill)}
>
{bill.active ? '✅' : '⬜'}
</span>
</td>
<td>
<div className="actions-cell">
<button
className="btn btn-sm"
onClick={() => openEditForm(bill)}
>
Edit
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => handleDelete(bill)}
>
Delete
</button>
</div>
</td>
<div className="card" style={{ overflow: 'hidden' }}>
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Amount</th>
<th>Due Day</th>
<th>Paycheck</th>
<th>Category</th>
<th>Active</th>
<th>Actions</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{bills.map((bill) => (
<tr key={bill.id} className={bill.active ? '' : 'row-muted'}>
<td>{bill.name}</td>
<td className="font-tabular">{formatCurrency(bill.amount)}</td>
<td>{ordinal(bill.due_day)}</td>
<td>#{bill.assigned_paycheck}</td>
<td>
{bill.category && <span className="badge badge-category">{bill.category}</span>}
</td>
<td>
<span
style={{ cursor: 'pointer', userSelect: 'none', fontSize: '1.1rem' }}
title={bill.active ? 'Click to deactivate' : 'Click to activate'}
onClick={() => handleToggle(bill)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleToggle(bill)}
>
{bill.active ? '✅' : '⬜'}
</span>
</td>
<td>
<div style={{ display: 'flex', gap: '0.375rem' }}>
<button className="btn btn-sm" onClick={() => openEditForm(bill)}>Edit</button>
<button className="btn btn-sm btn-danger" onClick={() => handleDelete(bill)}>Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);

View File

@@ -10,11 +10,11 @@ function formatCurrency(value) {
return '$' + num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function StatCard({ label, value, valueColor }) {
function StatCard({ label, value, valueClass }) {
return (
<div style={styles.card}>
<div style={styles.cardLabel}>{label}</div>
<div style={{ ...styles.cardValue, color: valueColor || '#222' }}>{value}</div>
<div className="stat-card">
<div className="stat-card__label">{label}</div>
<div className={`stat-card__value${valueClass ? ' ' + valueClass : ''}`}>{value}</div>
</div>
);
}
@@ -27,9 +27,7 @@ function MonthlySummary() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
loadSummary(year, month);
}, [year, month]);
useEffect(() => { loadSummary(year, month); }, [year, month]);
async function loadSummary(y, m) {
setLoading(true);
@@ -37,8 +35,7 @@ function MonthlySummary() {
try {
const res = await fetch(`/api/summary/monthly?year=${y}&month=${m}`);
if (!res.ok) throw new Error(`Server error: ${res.status}`);
const json = await res.json();
setData(json);
setData(await res.json());
} catch (err) {
setError(err.message);
} finally {
@@ -47,118 +44,78 @@ function MonthlySummary() {
}
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); }
}
const surplusColor = data && data.summary.surplus_deficit >= 0 ? '#2a7a2a' : '#c0392b';
const surplusClass = data
? (data.summary.surplus_deficit >= 0 ? 'text-success' : 'text-danger')
: '';
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>
)}
{loading && (
<div style={styles.loadingMsg}>Loading...</div>
)}
{error && <div className="alert alert-error">Error: {error}</div>}
{loading && <p className="text-muted">Loading</p>}
{!loading && data && (
<>
<div style={styles.cardRow}>
<StatCard
label="Total Income (net)"
value={formatCurrency(data.income.net)}
/>
<StatCard
label="Total Bills Planned"
value={formatCurrency(data.bills.planned)}
/>
<StatCard
label="Total Variable Spending"
value={formatCurrency(data.actuals.total)}
/>
<StatCard
label="Total One-time"
value={formatCurrency(data.one_time_expenses.total)}
/>
</div>
<div style={styles.cardRow}>
<StatCard
label="Total Spending"
value={formatCurrency(data.summary.total_spending)}
/>
<div className="stat-cards">
<StatCard label="Net Income" value={formatCurrency(data.income.net)} />
<StatCard label="Bills Planned" value={formatCurrency(data.bills.planned)} />
<StatCard label="Variable Spending" value={formatCurrency(data.actuals.total)} />
<StatCard label="One-time Expenses" value={formatCurrency(data.one_time_expenses.total)} />
<StatCard label="Total Spending" value={formatCurrency(data.summary.total_spending)} />
<StatCard
label="Surplus / Deficit"
value={formatCurrency(data.summary.surplus_deficit)}
valueColor={surplusColor}
/>
<StatCard
label="Bills Paid"
value={`${data.bills.paid_count} of ${data.bills.count}`}
valueClass={surplusClass}
/>
<StatCard label="Bills Paid" value={`${data.bills.paid_count} of ${data.bills.count}`} />
</div>
<div style={styles.tableWrapper}>
<table style={styles.table}>
<div className="card" style={{ overflow: 'hidden' }}>
<table className="data-table">
<thead>
<tr>
<th style={styles.th}>Category</th>
<th style={{ ...styles.th, textAlign: 'right' }}>Amount</th>
<th>Category</th>
<th className="text-right">Amount</th>
</tr>
</thead>
<tbody>
<tr style={styles.tr}>
<td style={styles.td}>Income (net)</td>
<td style={{ ...styles.td, ...styles.tdRight, color: '#2a7a2a' }}>
{formatCurrency(data.income.net)}
</td>
<tr>
<td>Income (net)</td>
<td className="text-right font-tabular text-success">{formatCurrency(data.income.net)}</td>
</tr>
<tr style={styles.tr}>
<td style={styles.td}>Bills (planned)</td>
<td style={{ ...styles.td, ...styles.tdRight }}>
-{formatCurrency(data.bills.planned)}
</td>
<tr>
<td>Bills (planned)</td>
<td className="text-right font-tabular">{formatCurrency(data.bills.planned)}</td>
</tr>
<tr style={styles.tr}>
<td style={styles.td}>Variable spending</td>
<td style={{ ...styles.td, ...styles.tdRight }}>
-{formatCurrency(data.actuals.total)}
</td>
<tr>
<td>Variable spending</td>
<td className="text-right font-tabular">{formatCurrency(data.actuals.total)}</td>
</tr>
<tr style={styles.tr}>
<td style={styles.td}>One-time expenses</td>
<td style={{ ...styles.td, ...styles.tdRight }}>
-{formatCurrency(data.one_time_expenses.total)}
</td>
<tr>
<td>One-time expenses</td>
<td className="text-right font-tabular">{formatCurrency(data.one_time_expenses.total)}</td>
</tr>
<tr style={styles.trTotal}>
<td style={{ ...styles.td, ...styles.tdBold }}>Surplus / Deficit</td>
<td style={{ ...styles.td, ...styles.tdRight, ...styles.tdBold, color: surplusColor }}>
</tbody>
<tfoot>
<tr>
<td className="font-bold">Surplus / Deficit</td>
<td className={`text-right font-tabular font-bold ${surplusClass}`}>
{formatCurrency(data.summary.surplus_deficit)}
</td>
</tr>
</tbody>
</tfoot>
</table>
</div>
</>
@@ -167,108 +124,4 @@ function MonthlySummary() {
);
}
const styles = {
container: {
maxWidth: '860px',
margin: '0 auto',
},
monthNav: {
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginBottom: '1.5rem',
},
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',
},
cardRow: {
display: 'flex',
gap: '1rem',
marginBottom: '1rem',
flexWrap: 'wrap',
},
card: {
flex: '1 1 160px',
border: '1px solid #ddd',
borderRadius: '6px',
padding: '1rem',
background: '#fafafa',
minWidth: '140px',
},
cardLabel: {
fontSize: '0.8rem',
color: '#666',
marginBottom: '0.4rem',
fontWeight: '500',
textTransform: 'uppercase',
letterSpacing: '0.03em',
},
cardValue: {
fontSize: '1.35rem',
fontWeight: '700',
fontVariantNumeric: 'tabular-nums',
},
tableWrapper: {
marginTop: '1.5rem',
border: '1px solid #ddd',
borderRadius: '6px',
overflow: 'hidden',
},
table: {
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.95rem',
},
th: {
padding: '0.6rem 1rem',
background: '#f0f0f0',
fontWeight: '600',
color: '#444',
borderBottom: '1px solid #ddd',
textAlign: 'left',
},
tr: {
borderBottom: '1px solid #eee',
},
trTotal: {
borderTop: '2px solid #ccc',
background: '#fafafa',
},
td: {
padding: '0.6rem 1rem',
color: '#333',
},
tdRight: {
textAlign: 'right',
fontVariantNumeric: 'tabular-nums',
},
tdBold: {
fontWeight: '700',
fontSize: '1rem',
},
};
export default MonthlySummary;

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;

View File

@@ -1,54 +1,5 @@
import { useState, useEffect } from 'react';
const fieldStyle = {
display: 'flex',
flexDirection: 'column',
gap: '4px',
marginBottom: '16px',
};
const labelStyle = {
fontWeight: '600',
fontSize: '14px',
};
const inputStyle = {
padding: '8px 10px',
fontSize: '14px',
border: '1px solid #ccc',
borderRadius: '4px',
width: '180px',
};
const sectionStyle = {
marginBottom: '32px',
};
const sectionTitleStyle = {
fontSize: '18px',
fontWeight: '700',
marginBottom: '16px',
borderBottom: '2px solid #e5e7eb',
paddingBottom: '8px',
};
const submitStyle = {
padding: '10px 24px',
fontSize: '15px',
fontWeight: '600',
background: '#2563eb',
color: '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
};
const successStyle = {
color: '#16a34a',
fontWeight: '600',
marginTop: '12px',
};
const DEFAULT_FORM = {
paycheck1_day: '',
paycheck2_day: '',
@@ -65,8 +16,8 @@ function Settings() {
useEffect(() => {
fetch('/api/config')
.then((res) => res.json())
.then((data) => {
.then(res => res.json())
.then(data => {
setForm({
paycheck1_day: data.paycheck1_day ?? '',
paycheck2_day: data.paycheck2_day ?? '',
@@ -82,7 +33,7 @@ function Settings() {
function handleChange(e) {
const { name, value } = e.target;
setSaved(false);
setForm((prev) => ({ ...prev, [name]: value }));
setForm(prev => ({ ...prev, [name]: value }));
}
function handleSubmit(e) {
@@ -92,9 +43,7 @@ function Settings() {
const payload = {};
for (const [key, val] of Object.entries(form)) {
if (val !== '' && val !== null) {
payload[key] = Number(val);
}
if (val !== '' && val !== null) payload[key] = Number(val);
}
fetch('/api/config', {
@@ -102,11 +51,11 @@ function Settings() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then((res) => {
.then(res => {
if (!res.ok) throw new Error('Save failed');
return res.json();
})
.then((data) => {
.then(data => {
setForm({
paycheck1_day: data.paycheck1_day ?? '',
paycheck2_day: data.paycheck2_day ?? '',
@@ -121,115 +70,74 @@ function Settings() {
}
return (
<div style={{ maxWidth: '480px', margin: '32px auto', padding: '0 16px' }}>
<h1 style={{ fontSize: '24px', fontWeight: '800', marginBottom: '24px' }}>Settings</h1>
<div style={{ maxWidth: '520px' }}>
<div className="page-header">
<h1 className="page-title">Settings</h1>
</div>
{error && <p style={{ color: '#dc2626', marginBottom: '16px' }}>{error}</p>}
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>Pay Schedule</div>
<div style={fieldStyle}>
<label style={labelStyle} htmlFor="paycheck1_day">Paycheck 1 Day</label>
<input
id="paycheck1_day"
name="paycheck1_day"
type="number"
min="1"
max="28"
value={form.paycheck1_day}
onChange={handleChange}
style={inputStyle}
/>
</div>
<div style={fieldStyle}>
<label style={labelStyle} htmlFor="paycheck2_day">Paycheck 2 Day</label>
<input
id="paycheck2_day"
name="paycheck2_day"
type="number"
min="1"
max="28"
value={form.paycheck2_day}
onChange={handleChange}
style={inputStyle}
/>
</div>
</div>
<div style={sectionStyle}>
<div style={sectionTitleStyle}>Paycheck Amounts</div>
<div style={{ marginBottom: '20px' }}>
<div style={{ fontWeight: '700', marginBottom: '10px' }}>Paycheck 1</div>
<div style={fieldStyle}>
<label style={labelStyle} htmlFor="paycheck1_gross">Gross</label>
<input
id="paycheck1_gross"
name="paycheck1_gross"
type="number"
min="0"
step="0.01"
value={form.paycheck1_gross}
onChange={handleChange}
style={inputStyle}
placeholder="0.00"
/>
</div>
<div style={fieldStyle}>
<label style={labelStyle} htmlFor="paycheck1_net">Net</label>
<input
id="paycheck1_net"
name="paycheck1_net"
type="number"
min="0"
step="0.01"
value={form.paycheck1_net}
onChange={handleChange}
style={inputStyle}
placeholder="0.00"
/>
<div className="card card-body mb-2">
<div className="settings-section">
<div className="settings-section__title">Pay Schedule</div>
<div className="settings-grid">
<div className="form-group">
<label className="form-label" htmlFor="paycheck1_day">Paycheck 1 Day</label>
<input id="paycheck1_day" name="paycheck1_day" type="number"
min="1" max="28" value={form.paycheck1_day} onChange={handleChange}
className="form-input" placeholder="e.g. 1" />
</div>
<div className="form-group">
<label className="form-label" htmlFor="paycheck2_day">Paycheck 2 Day</label>
<input id="paycheck2_day" name="paycheck2_day" type="number"
min="1" max="28" value={form.paycheck2_day} onChange={handleChange}
className="form-input" placeholder="e.g. 15" />
</div>
</div>
</div>
<div>
<div style={{ fontWeight: '700', marginBottom: '10px' }}>Paycheck 2</div>
<div style={fieldStyle}>
<label style={labelStyle} htmlFor="paycheck2_gross">Gross</label>
<input
id="paycheck2_gross"
name="paycheck2_gross"
type="number"
min="0"
step="0.01"
value={form.paycheck2_gross}
onChange={handleChange}
style={inputStyle}
placeholder="0.00"
/>
<div className="settings-section">
<div className="settings-section__title">Paycheck 1 Amounts</div>
<div className="settings-grid">
<div className="form-group">
<label className="form-label" htmlFor="paycheck1_gross">Gross</label>
<input id="paycheck1_gross" name="paycheck1_gross" type="number"
min="0" step="0.01" value={form.paycheck1_gross} onChange={handleChange}
className="form-input" placeholder="0.00" />
</div>
<div className="form-group">
<label className="form-label" htmlFor="paycheck1_net">Net</label>
<input id="paycheck1_net" name="paycheck1_net" type="number"
min="0" step="0.01" value={form.paycheck1_net} onChange={handleChange}
className="form-input" placeholder="0.00" />
</div>
</div>
<div style={fieldStyle}>
<label style={labelStyle} htmlFor="paycheck2_net">Net</label>
<input
id="paycheck2_net"
name="paycheck2_net"
type="number"
min="0"
step="0.01"
value={form.paycheck2_net}
onChange={handleChange}
style={inputStyle}
placeholder="0.00"
/>
</div>
<div className="settings-section" style={{ marginBottom: 0 }}>
<div className="settings-section__title">Paycheck 2 Amounts</div>
<div className="settings-grid">
<div className="form-group">
<label className="form-label" htmlFor="paycheck2_gross">Gross</label>
<input id="paycheck2_gross" name="paycheck2_gross" type="number"
min="0" step="0.01" value={form.paycheck2_gross} onChange={handleChange}
className="form-input" placeholder="0.00" />
</div>
<div className="form-group">
<label className="form-label" htmlFor="paycheck2_net">Net</label>
<input id="paycheck2_net" name="paycheck2_net" type="number"
min="0" step="0.01" value={form.paycheck2_net} onChange={handleChange}
className="form-input" placeholder="0.00" />
</div>
</div>
</div>
</div>
<button type="submit" style={submitStyle}>Save Settings</button>
{saved && <p style={successStyle}>Settings saved</p>}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<button type="submit" className="btn btn-primary">Save Settings</button>
{saved && <span className="alert-success" style={{ padding: '0.3rem 0' }}>Saved </span>}
</div>
</form>
</div>
);