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:
@@ -8,7 +8,12 @@
|
|||||||
"Bash(td list:*)",
|
"Bash(td list:*)",
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(td start:*)"
|
"Bash(td start:*)",
|
||||||
|
"Bash(td close:*)",
|
||||||
|
"Bash(td review:*)",
|
||||||
|
"Bash(td approve:*)",
|
||||||
|
"Bash(td complete:*)",
|
||||||
|
"Bash(td update:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ function todayISO() {
|
|||||||
return new Date().toISOString().slice(0, 10);
|
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 [actuals, setActuals] = useState([]);
|
||||||
const [actualsLoading, setActualsLoading] = useState(false);
|
const [actualsLoading, setActualsLoading] = useState(false);
|
||||||
const [actualsError, setActualsError] = useState(null);
|
const [actualsError, setActualsError] = useState(null);
|
||||||
@@ -180,12 +182,50 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories }) {
|
|||||||
<div style={styles.emptyNote}>(none)</div>
|
<div style={styles.emptyNote}>(none)</div>
|
||||||
) : (
|
) : (
|
||||||
paycheck.one_time_expenses.map((ote) => (
|
paycheck.one_time_expenses.map((ote) => (
|
||||||
<div key={ote.id} style={styles.oteRow}>
|
<div key={ote.id} style={{ ...styles.oteRow, opacity: ote.paid ? 0.6 : 1 }}>
|
||||||
<span style={styles.oteName}>{ote.name}</span>
|
<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>
|
<span style={styles.oteAmount}>{formatCurrency(ote.amount)}</span>
|
||||||
|
<button onClick={() => onOteDelete(ote.id)} style={styles.deleteButton} title="Remove expense">×</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div style={styles.section}>
|
<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) {
|
async function handleBillPaidToggle(paycheckBillId, paid) {
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
setPaychecks(prev =>
|
setPaychecks(prev =>
|
||||||
@@ -411,11 +490,17 @@ function PaycheckView() {
|
|||||||
<PaycheckColumn
|
<PaycheckColumn
|
||||||
paycheck={pc1}
|
paycheck={pc1}
|
||||||
onBillPaidToggle={handleBillPaidToggle}
|
onBillPaidToggle={handleBillPaidToggle}
|
||||||
|
onOtePaidToggle={handleOtePaidToggle}
|
||||||
|
onOteDelete={handleOteDelete}
|
||||||
|
onOteAdd={handleOteAdd}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
/>
|
/>
|
||||||
<PaycheckColumn
|
<PaycheckColumn
|
||||||
paycheck={pc2}
|
paycheck={pc2}
|
||||||
onBillPaidToggle={handleBillPaidToggle}
|
onBillPaidToggle={handleBillPaidToggle}
|
||||||
|
onOtePaidToggle={handleOtePaidToggle}
|
||||||
|
onOteDelete={handleOteDelete}
|
||||||
|
onOteAdd={handleOteAdd}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -661,6 +746,28 @@ const styles = {
|
|||||||
color: '#c0392b',
|
color: '#c0392b',
|
||||||
fontSize: '0.8rem',
|
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: {
|
remainingRow: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
|||||||
Reference in New Issue
Block a user