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:
@@ -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">×</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',
|
||||
|
||||
Reference in New Issue
Block a user