Add one-time expenses UI and merge Phase 3 features

- One-time expenses: paid toggle, delete, and inline add form in
  paycheck columns (wired to POST/DELETE/PATCH /api/one-time-expenses)
- Variable spending actuals section with category select, amount,
  note, date fields; actuals included in remaining balance
- Both Phase 3 features fully integrated into PaycheckView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 19:13:32 -04:00
parent 17af71a7c7
commit 368786a9e1
2 changed files with 116 additions and 4 deletions

View File

@@ -26,7 +26,9 @@ function todayISO() {
return new Date().toISOString().slice(0, 10);
}
function PaycheckColumn({ paycheck, onBillPaidToggle, categories }) {
function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd }) {
const [newOteName, setNewOteName] = useState('');
const [newOteAmount, setNewOteAmount] = useState('');
const [actuals, setActuals] = useState([]);
const [actualsLoading, setActualsLoading] = useState(false);
const [actualsError, setActualsError] = useState(null);
@@ -180,12 +182,50 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories }) {
<div style={styles.emptyNote}>(none)</div>
) : (
paycheck.one_time_expenses.map((ote) => (
<div key={ote.id} style={styles.oteRow}>
<span style={styles.oteName}>{ote.name}</span>
<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}>
@@ -341,6 +381,45 @@ function PaycheckView() {
}
}
async function handleOtePaidToggle(oteId, paid) {
try {
const res = await fetch(`/api/one-time-expenses/${oteId}/paid`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paid }),
});
if (!res.ok) throw new Error(`Server error: ${res.status}`);
await loadPaychecks(year, month);
} catch (err) {
alert(`Failed to update expense: ${err.message}`);
}
}
async function handleOteDelete(oteId) {
if (!window.confirm('Remove this expense?')) return;
try {
const res = await fetch(`/api/one-time-expenses/${oteId}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Server error: ${res.status}`);
await loadPaychecks(year, month);
} catch (err) {
alert(`Failed to delete expense: ${err.message}`);
}
}
async function handleOteAdd(paycheckId, name, amount) {
try {
const res = await fetch('/api/one-time-expenses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paycheck_id: paycheckId, name, amount }),
});
if (!res.ok) throw new Error(`Server error: ${res.status}`);
await loadPaychecks(year, month);
} catch (err) {
alert(`Failed to add expense: ${err.message}`);
}
}
async function handleBillPaidToggle(paycheckBillId, paid) {
// Optimistic update
setPaychecks(prev =>
@@ -411,11 +490,17 @@ function PaycheckView() {
<PaycheckColumn
paycheck={pc1}
onBillPaidToggle={handleBillPaidToggle}
onOtePaidToggle={handleOtePaidToggle}
onOteDelete={handleOteDelete}
onOteAdd={handleOteAdd}
categories={categories}
/>
<PaycheckColumn
paycheck={pc2}
onBillPaidToggle={handleBillPaidToggle}
onOtePaidToggle={handleOtePaidToggle}
onOteDelete={handleOteDelete}
onOteAdd={handleOteAdd}
categories={categories}
/>
</div>
@@ -661,6 +746,28 @@ const styles = {
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',