diff --git a/client/src/TODO-one-time-expenses.md b/client/src/TODO-one-time-expenses.md deleted file mode 100644 index 1af5634..0000000 --- a/client/src/TODO-one-time-expenses.md +++ /dev/null @@ -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 -
-
One-time expenses
-
- {paycheck.one_time_expenses.length === 0 ? ( -
(none)
- ) : ( - paycheck.one_time_expenses.map((ote) => ( -
- {ote.name} - {formatCurrency(ote.amount)} -
- )) - )} -
-``` - -Replace it with: - -```jsx -
-
One-time expenses
-
- {paycheck.one_time_expenses.length === 0 ? ( -
(none)
- ) : ( - paycheck.one_time_expenses.map((ote) => ( -
- onOtePaidToggle(ote.id, !ote.paid)} - style={styles.checkbox} - /> - - {ote.name} - - {formatCurrency(ote.amount)} - -
- )) - )} - - {/* Inline add form */} -
- setNewName(e.target.value)} - style={styles.oteAddInput} - /> - setNewAmount(e.target.value)} - min="0" - step="0.01" - style={{ ...styles.oteAddInput, width: '80px' }} - /> - -
-
-``` - ---- - -## 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 `
` block, update both `` calls -to pass the three new handler props: - -```jsx - - -``` - ---- - -## 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 diff --git a/client/src/pages/AnnualOverview.jsx b/client/src/pages/AnnualOverview.jsx index fdf4920..bc4c52a 100644 --- a/client/src/pages/AnnualOverview.jsx +++ b/client/src/pages/AnnualOverview.jsx @@ -37,12 +37,25 @@ export default function AnnualOverview() { setLoading(true); 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( Array.from({ length: 12 }, (_, i) => fetch(`/api/summary/monthly?year=${year}&month=${i + 1}`) .then(r => { if (!r.ok) return null; - return r.json(); + return r.json().then(normalize); }) .catch(() => null) )