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:
@@ -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">←</button>
|
||||
<span className="period-nav__label">{year}</span>
|
||||
<button className="btn-nav" onClick={() => setYear(y => y + 1)} aria-label="Next year">→</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (1–31)</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>
|
||||
);
|
||||
|
||||
@@ -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}>←</button>
|
||||
<span style={styles.monthLabel}>{MONTH_NAMES[month - 1]} {year}</span>
|
||||
<button style={styles.navButton} onClick={nextMonth}>→</button>
|
||||
<div>
|
||||
<div className="period-nav">
|
||||
<button className="btn-nav" onClick={prevMonth}>←</button>
|
||||
<span className="period-nav__label">{MONTH_NAMES[month - 1]} {year}</span>
|
||||
<button className="btn-nav" onClick={nextMonth}>→</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;
|
||||
|
||||
@@ -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">×</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"
|
||||
>
|
||||
×
|
||||
</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">×</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">×</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}>←</button>
|
||||
<span style={styles.monthLabel}>{MONTH_NAMES[month - 1]} {year}</span>
|
||||
<button style={styles.navButton} onClick={nextMonth}>→</button>
|
||||
<div>
|
||||
<div className="period-nav">
|
||||
<button className="btn-nav" onClick={prevMonth}>←</button>
|
||||
<span className="period-nav__label">{MONTH_NAMES[month - 1]} {year}</span>
|
||||
<button className="btn-nav" onClick={nextMonth}>→</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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user