diff --git a/client/src/TODO-one-time-expenses.md b/client/src/TODO-one-time-expenses.md new file mode 100644 index 0000000..1af5634 --- /dev/null +++ b/client/src/TODO-one-time-expenses.md @@ -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 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/server/src/index.js b/server/src/index.js index 7220617..287f469 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -6,6 +6,8 @@ const healthRouter = require('./routes/health'); const configRouter = require('./routes/config'); const billsRouter = require('./routes/bills'); const paychecksRouter = require('./routes/paychecks'); +const actualsRouter = require('./routes/actuals'); +const oneTimeExpensesRouter = require('./routes/one-time-expenses'); const db = require('./db'); const app = express(); @@ -19,6 +21,8 @@ app.use('/api', healthRouter); app.use('/api', configRouter); app.use('/api', billsRouter); app.use('/api', paychecksRouter); +app.use('/api', actualsRouter); +app.use('/api', oneTimeExpensesRouter); // Serve static client files in production const clientDist = path.join(__dirname, '../../client/dist'); diff --git a/server/src/routes/one-time-expenses.js b/server/src/routes/one-time-expenses.js new file mode 100644 index 0000000..62e5b59 --- /dev/null +++ b/server/src/routes/one-time-expenses.js @@ -0,0 +1,88 @@ +const express = require('express'); +const router = express.Router(); +const { pool } = require('../db'); + +// POST /api/one-time-expenses — create a new one-time expense +// Body: { paycheck_id, name, amount } +router.post('/one-time-expenses', async (req, res) => { + const { paycheck_id, name, amount } = req.body; + + if (!paycheck_id) { + return res.status(400).json({ message: 'paycheck_id is required' }); + } + if (!name || typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ message: 'name is required' }); + } + if (amount === undefined || amount === null || isNaN(parseFloat(amount))) { + return res.status(400).json({ message: 'amount is required' }); + } + + try { + const result = await pool.query( + `INSERT INTO one_time_expenses (paycheck_id, name, amount) + VALUES ($1, $2, $3) + RETURNING id, paycheck_id, name, amount, paid, paid_at`, + [paycheck_id, name.trim(), parseFloat(amount)] + ); + res.status(201).json(result.rows[0]); + } catch (err) { + console.error('POST /api/one-time-expenses error:', err); + res.status(500).json({ message: 'Failed to create one-time expense' }); + } +}); + +// DELETE /api/one-time-expenses/:id — remove a one-time expense +router.delete('/one-time-expenses/:id', async (req, res) => { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ message: 'Invalid id' }); + } + + try { + const result = await pool.query( + 'DELETE FROM one_time_expenses WHERE id = $1 RETURNING id', + [id] + ); + if (result.rows.length === 0) { + return res.status(404).json({ message: 'One-time expense not found' }); + } + res.json({ id: result.rows[0].id }); + } catch (err) { + console.error('DELETE /api/one-time-expenses/:id error:', err); + res.status(500).json({ message: 'Failed to delete one-time expense' }); + } +}); + +// PATCH /api/one-time-expenses/:id/paid — toggle paid status +// Body: { paid: true|false } +router.patch('/one-time-expenses/:id/paid', async (req, res) => { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ message: 'Invalid id' }); + } + + const { paid } = req.body; + if (typeof paid !== 'boolean') { + return res.status(400).json({ message: 'paid must be a boolean' }); + } + + try { + const result = await pool.query( + `UPDATE one_time_expenses + SET paid = $1, + paid_at = CASE WHEN $1 THEN NOW() ELSE NULL END + WHERE id = $2 + RETURNING id, paycheck_id, name, amount, paid, paid_at`, + [paid, id] + ); + if (result.rows.length === 0) { + return res.status(404).json({ message: 'One-time expense not found' }); + } + res.json(result.rows[0]); + } catch (err) { + console.error('PATCH /api/one-time-expenses/:id/paid error:', err); + res.status(500).json({ message: 'Failed to update paid status' }); + } +}); + +module.exports = router;