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:
257
client/src/TODO-one-time-expenses.md
Normal file
257
client/src/TODO-one-time-expenses.md
Normal 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 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
|
||||||
@@ -6,6 +6,8 @@ const healthRouter = require('./routes/health');
|
|||||||
const configRouter = require('./routes/config');
|
const configRouter = require('./routes/config');
|
||||||
const billsRouter = require('./routes/bills');
|
const billsRouter = require('./routes/bills');
|
||||||
const paychecksRouter = require('./routes/paychecks');
|
const paychecksRouter = require('./routes/paychecks');
|
||||||
|
const actualsRouter = require('./routes/actuals');
|
||||||
|
const oneTimeExpensesRouter = require('./routes/one-time-expenses');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -19,6 +21,8 @@ app.use('/api', healthRouter);
|
|||||||
app.use('/api', configRouter);
|
app.use('/api', configRouter);
|
||||||
app.use('/api', billsRouter);
|
app.use('/api', billsRouter);
|
||||||
app.use('/api', paychecksRouter);
|
app.use('/api', paychecksRouter);
|
||||||
|
app.use('/api', actualsRouter);
|
||||||
|
app.use('/api', oneTimeExpensesRouter);
|
||||||
|
|
||||||
// Serve static client files in production
|
// Serve static client files in production
|
||||||
const clientDist = path.join(__dirname, '../../client/dist');
|
const clientDist = path.join(__dirname, '../../client/dist');
|
||||||
|
|||||||
88
server/src/routes/one-time-expenses.js
Normal file
88
server/src/routes/one-time-expenses.js
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user