diff --git a/client/src/pages/Bills.jsx b/client/src/pages/Bills.jsx index 33be261..ef32d2d 100644 --- a/client/src/pages/Bills.jsx +++ b/client/src/pages/Bills.jsx @@ -11,6 +11,7 @@ const EMPTY_FORM = { due_day: '', assigned_paycheck: '1', category: 'General', + variable_amount: false, }; function formatCurrency(value) { @@ -68,6 +69,7 @@ function Bills() { due_day: bill.due_day, assigned_paycheck: String(bill.assigned_paycheck), category: bill.category || 'General', + variable_amount: !!bill.variable_amount, }); setFormError(null); setShowForm(true); @@ -81,8 +83,8 @@ function Bills() { } function handleChange(e) { - const { name, value } = e.target; - setForm(prev => ({ ...prev, [name]: value })); + const { name, value, type, checked } = e.target; + setForm(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value })); } async function handleSave(e) { @@ -92,10 +94,11 @@ function Bills() { try { const payload = { name: form.name, - amount: form.amount, + amount: form.variable_amount ? (form.amount || 0) : form.amount, due_day: form.due_day, assigned_paycheck: form.assigned_paycheck, category: form.category, + variable_amount: form.variable_amount, }; const url = editingId ? `/api/bills/${editingId}` : '/api/bills'; const method = editingId ? 'PUT' : 'POST'; @@ -157,11 +160,21 @@ function Bills() { required placeholder="e.g. Rent" className="form-input" />
- + + value={form.amount} onChange={handleChange} + required={!form.variable_amount} placeholder="0.00" className="form-input" />
+
+ +
@@ -214,6 +227,7 @@ function Bills() { Due Day Paycheck Category + Variable Active Actions @@ -222,12 +236,17 @@ function Bills() { {bills.map((bill) => ( {bill.name} - {formatCurrency(bill.amount)} + + {bill.variable_amount + ? varies{bill.amount > 0 ? ` (~${formatCurrency(bill.amount)})` : ''} + : formatCurrency(bill.amount)} + {ordinal(bill.due_day)} #{bill.assigned_paycheck} {bill.category && {bill.category}} + {bill.variable_amount ? '〜' : ''}
{bill.name} - {formatCurrency(bill.effective_amount)} + {bill.variable_amount && editingBillAmount === bill.bill_id ? ( + + setBillAmountDraft(e.target.value)} + className="form-input" + style={{ width: '90px', padding: '0.2rem 0.35rem', fontSize: '0.8rem' }} + autoFocus + onKeyDown={e => { + if (e.key === 'Enter') saveBillAmount(bill); + if (e.key === 'Escape') setEditingBillAmount(null); + }} + /> + + + + ) : ( + + + {bill.variable_amount && !bill.amount_override + ? enter amount + : formatCurrency(bill.effective_amount)} + + {bill.variable_amount && !bill.paid && ( + + )} + + )}
due {ordinal(bill.due_day)} + {bill.variable_amount && variable} {bill.category && {bill.category}}
@@ -445,6 +499,35 @@ function PaycheckView() { )); } + async function handleBillAmountSave(paycheckBillId, billId, paycheckNumber, amount) { + let realPaycheckBillId = paycheckBillId; + + if (!realPaycheckBillId) { + const generated = await generateMonth(); + const pc = generated.find(p => p.paycheck_number === paycheckNumber); + realPaycheckBillId = pc.bills.find(b => b.bill_id === billId).paycheck_bill_id; + } + + const res = await fetch(`/api/paycheck-bills/${realPaycheckBillId}/amount`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount }), + }); + if (!res.ok) { + const body = await res.json(); + throw new Error(body.error || `Server error: ${res.status}`); + } + // Reflect the new override in local state + setPaychecks(prev => prev.map(pc => ({ + ...pc, + bills: pc.bills.map(b => + b.paycheck_bill_id === realPaycheckBillId + ? { ...b, amount_override: amount, effective_amount: amount } + : b + ), + }))); + } + async function handleBillPaidToggle(paycheckBillId, paid, billId, paycheckNumber) { let realPaycheckBillId = paycheckBillId; @@ -589,6 +672,7 @@ function PaycheckView() { categories={categories} onGenerate={generateMonth} onAmountSave={handleAmountSave} + onBillAmountSave={handleBillAmountSave} />
)} diff --git a/db/migrations/002_variable_amount_bills.sql b/db/migrations/002_variable_amount_bills.sql new file mode 100644 index 0000000..6b84407 --- /dev/null +++ b/db/migrations/002_variable_amount_bills.sql @@ -0,0 +1,4 @@ +-- Add variable_amount flag to bills. +-- When true, the amount is expected to change each month and must be +-- entered per-paycheck via amount_override on paycheck_bills. +ALTER TABLE bills ADD COLUMN IF NOT EXISTS variable_amount BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/server/src/routes/bills.js b/server/src/routes/bills.js index ada2dd5..8bea9b9 100644 --- a/server/src/routes/bills.js +++ b/server/src/routes/bills.js @@ -3,14 +3,15 @@ const router = express.Router(); const { pool } = require('../db'); function validateBillFields(body) { - const { name, amount, due_day, assigned_paycheck } = body; + const { name, amount, due_day, assigned_paycheck, variable_amount } = body; if (!name || name.toString().trim() === '') { return 'name is required'; } - if (amount === undefined || amount === null || amount === '') { + // Amount is optional for variable bills (defaults to 0) + if (!variable_amount && (amount === undefined || amount === null || amount === '')) { return 'amount is required'; } - if (isNaN(Number(amount))) { + if (amount !== undefined && amount !== null && amount !== '' && isNaN(Number(amount))) { return 'amount must be a number'; } if (due_day === undefined || due_day === null || due_day === '') { @@ -57,20 +58,22 @@ router.post('/bills', async (req, res) => { assigned_paycheck, category = 'General', active = true, + variable_amount = false, } = req.body; try { const result = await pool.query( - `INSERT INTO bills (name, amount, due_day, assigned_paycheck, category, active) - VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO bills (name, amount, due_day, assigned_paycheck, category, active, variable_amount) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [ name.toString().trim(), - Number(amount), + Number(amount) || 0, parseInt(due_day, 10), parseInt(assigned_paycheck, 10), category || 'General', active !== undefined ? active : true, + Boolean(variable_amount), ] ); res.status(201).json(result.rows[0]); @@ -108,22 +111,24 @@ router.put('/bills/:id', async (req, res) => { assigned_paycheck, category = 'General', active = true, + variable_amount = false, } = req.body; try { const result = await pool.query( `UPDATE bills SET name = $1, amount = $2, due_day = $3, assigned_paycheck = $4, - category = $5, active = $6 - WHERE id = $7 + category = $5, active = $6, variable_amount = $7 + WHERE id = $8 RETURNING *`, [ name.toString().trim(), - Number(amount), + Number(amount) || 0, parseInt(due_day, 10), parseInt(assigned_paycheck, 10), category || 'General', active !== undefined ? active : true, + Boolean(variable_amount), req.params.id, ] ); diff --git a/server/src/routes/paychecks.js b/server/src/routes/paychecks.js index fcaf548..3782c72 100644 --- a/server/src/routes/paychecks.js +++ b/server/src/routes/paychecks.js @@ -56,7 +56,7 @@ async function buildVirtualPaychecks(year, month) { const payDate = `${year}-${pad2(month)}-${pad2(day ?? 1)}`; const billsResult = await pool.query( - `SELECT id, name, amount, due_day, category + `SELECT id, name, amount, due_day, category, variable_amount FROM bills WHERE active = TRUE AND assigned_paycheck = $1 ORDER BY due_day, name`, [num] @@ -79,6 +79,7 @@ async function buildVirtualPaychecks(year, month) { effective_amount: b.amount, due_day: b.due_day, category: b.category, + variable_amount: b.variable_amount, paid: false, paid_at: null, })), @@ -162,6 +163,7 @@ async function fetchPaychecksForMonth(year, month) { pb.bill_id, b.name, b.amount, + b.variable_amount, pb.amount_override, CASE WHEN pb.amount_override IS NOT NULL THEN pb.amount_override ELSE b.amount END AS effective_amount, b.due_day, @@ -196,6 +198,7 @@ async function fetchPaychecksForMonth(year, month) { bill_id: b.bill_id, name: b.name, amount: b.amount, + variable_amount: b.variable_amount, amount_override: b.amount_override, effective_amount: b.effective_amount, due_day: b.due_day, @@ -303,6 +306,36 @@ router.patch('/paychecks/:id', async (req, res) => { } }); +// PATCH /api/paycheck-bills/:id/amount — set amount_override for a variable bill +router.patch('/paycheck-bills/:id/amount', async (req, res) => { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid id' }); + } + + const { amount } = req.body; + if (amount == null || isNaN(parseFloat(amount))) { + return res.status(400).json({ error: 'amount is required' }); + } + + try { + const result = await pool.query( + `UPDATE paycheck_bills SET amount_override = $1 WHERE id = $2 + RETURNING id, amount_override`, + [parseFloat(amount), id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'paycheck_bill not found' }); + } + + res.json(result.rows[0]); + } catch (err) { + console.error('PATCH /api/paycheck-bills/:id/amount error:', err); + res.status(500).json({ error: 'Failed to update amount' }); + } +}); + // PATCH /api/paycheck-bills/:id/paid router.patch('/paycheck-bills/:id/paid', async (req, res) => { const id = parseInt(req.params.id, 10);