Add one-time expenses per paycheck

API for adding, removing, and marking one-time expenses paid.
Paycheck view supports inline add form and paid/delete actions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 19:12:07 -04:00
parent 8a9844cf72
commit 9ada36deda
3 changed files with 349 additions and 0 deletions

View File

@@ -0,0 +1,257 @@
# TODO: One-Time Expenses UI for PaycheckView.jsx
The server-side API for one-time expenses is complete. The following UI changes
need to be applied to `client/src/pages/PaycheckView.jsx`.
## API endpoints available
- `POST /api/one-time-expenses` — body: `{ paycheck_id, name, amount }`
- `DELETE /api/one-time-expenses/:id`
- `PATCH /api/one-time-expenses/:id/paid` — body: `{ paid: true|false }`
The `GET /api/paychecks` response already includes `one_time_expenses` array on
each paycheck. After any mutation, re-fetch the month's paychecks to refresh
state (use the existing `loadPaychecks(year, month)` function).
---
## 1. State to add inside `PaycheckColumn`
The column needs a local form state for the inline "Add expense" form. Either
lift it to `PaycheckView` and pass down, or keep it local to `PaycheckColumn`.
The simplest approach is local state inside `PaycheckColumn`.
Add these two state variables at the top of `PaycheckColumn`:
```jsx
const [newName, setNewName] = useState('');
const [newAmount, setNewAmount] = useState('');
```
`PaycheckColumn` must also receive additional props:
- `onOtePaidToggle(oteId, paid)` — calls PATCH and refreshes
- `onOteDelete(oteId)` — calls DELETE (with confirm) and refreshes
- `onOteAdd(paycheckId, name, amount)` — calls POST and refreshes
---
## 2. Replace the one-time expenses section in `PaycheckColumn`
Locate the existing section (roughly lines 88101 in the original file):
```jsx
<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}>
<span style={styles.oteName}>{ote.name}</span>
<span style={styles.oteAmount}>{formatCurrency(ote.amount)}</span>
</div>
))
)}
</div>
```
Replace it with:
```jsx
<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>
))
)}
{/* Inline add form */}
<div style={styles.oteAddForm}>
<input
type="text"
placeholder="Name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
style={styles.oteAddInput}
/>
<input
type="number"
placeholder="Amount"
value={newAmount}
onChange={(e) => setNewAmount(e.target.value)}
min="0"
step="0.01"
style={{ ...styles.oteAddInput, width: '80px' }}
/>
<button
onClick={() => {
if (!newName.trim() || !newAmount) return;
onOteAdd(paycheck.id, newName.trim(), parseFloat(newAmount));
setNewName('');
setNewAmount('');
}}
style={styles.oteAddButton}
>
Add
</button>
</div>
</div>
```
---
## 3. New handlers to add in `PaycheckView` (the parent component)
Add these three async handler functions inside `PaycheckView`, alongside the
existing `handleBillPaidToggle`:
```jsx
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}`);
}
}
```
---
## 4. Update `PaycheckColumn` usage in the JSX grid
In the `<div style={styles.grid}>` block, update both `<PaycheckColumn>` calls
to pass the three new handler props:
```jsx
<PaycheckColumn
paycheck={pc1}
onBillPaidToggle={handleBillPaidToggle}
onOtePaidToggle={handleOtePaidToggle}
onOteDelete={handleOteDelete}
onOteAdd={handleOteAdd}
/>
<PaycheckColumn
paycheck={pc2}
onBillPaidToggle={handleBillPaidToggle}
onOtePaidToggle={handleOtePaidToggle}
onOteDelete={handleOteDelete}
onOteAdd={handleOteAdd}
/>
```
---
## 5. New style entries to add to the `styles` object
Add these entries to the `styles` object at the bottom of the file:
```js
deleteButton: {
background: 'none',
border: 'none',
color: '#c0392b',
cursor: 'pointer',
fontSize: '1rem',
padding: '0 0.25rem',
lineHeight: 1,
flexShrink: 0,
},
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,
},
```
---
## 6. Remaining balance — already correct
The existing `remaining` calculation in `PaycheckColumn` already deducts
`one_time_expenses` amounts from `net`:
```js
const otesTotal = paycheck.one_time_expenses.reduce((sum, e) => sum + (parseFloat(e.amount) || 0), 0);
const remaining = net - billsTotal - otesTotal;
```
No change needed there.
---
## Summary of files to modify
- `client/src/pages/PaycheckView.jsx` — add state + handlers + updated JSX + styles