Add monthly summary and annual overview (Phase 4)
Monthly summary: - GET /api/summary/monthly returns income, bills, actuals, one-time expense totals and surplus/deficit for a given month - MonthlySummary page shows stat cards and breakdown table Annual overview: - AnnualOverview page fetches all 12 months in parallel via Promise.all - Year navigation, summary cards, monthly table with totals row - Fix: normalize nested API response to flat fields expected by component Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,257 +0,0 @@
|
|||||||
# 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 88–101 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"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</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
|
|
||||||
@@ -37,12 +37,25 @@ export default function AnnualOverview() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// Normalize nested API response to flat fields used by this component
|
||||||
|
function normalize(data) {
|
||||||
|
if (!data) return null;
|
||||||
|
return {
|
||||||
|
total_income: data.income?.net ?? 0,
|
||||||
|
total_bills: data.bills?.planned ?? 0,
|
||||||
|
total_variable: data.actuals?.total ?? 0,
|
||||||
|
total_one_time: data.one_time_expenses?.total ?? 0,
|
||||||
|
total_spending: data.summary?.total_spending ?? 0,
|
||||||
|
surplus_deficit: data.summary?.surplus_deficit ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Promise.all(
|
Promise.all(
|
||||||
Array.from({ length: 12 }, (_, i) =>
|
Array.from({ length: 12 }, (_, i) =>
|
||||||
fetch(`/api/summary/monthly?year=${year}&month=${i + 1}`)
|
fetch(`/api/summary/monthly?year=${year}&month=${i + 1}`)
|
||||||
.then(r => {
|
.then(r => {
|
||||||
if (!r.ok) return null;
|
if (!r.ok) return null;
|
||||||
return r.json();
|
return r.json().then(normalize);
|
||||||
})
|
})
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user