diff --git a/client/src/pages/PaycheckView.jsx b/client/src/pages/PaycheckView.jsx index b5bb37e..f0e511d 100644 --- a/client/src/pages/PaycheckView.jsx +++ b/client/src/pages/PaycheckView.jsx @@ -25,7 +25,9 @@ function todayISO() { return new Date().toISOString().slice(0, 10); } -function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd }) { +// ─── PaycheckColumn ─────────────────────────────────────────────────────────── + +function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave }) { const [newOteName, setNewOteName] = useState(''); const [newOteAmount, setNewOteAmount] = useState(''); const [actuals, setActuals] = useState([]); @@ -39,8 +41,15 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl const [formSubmitting, setFormSubmitting] = useState(false); const [formError, setFormError] = useState(null); + // Inline gross/net editing + const [editingAmounts, setEditingAmounts] = useState(false); + const [editGross, setEditGross] = useState(''); + const [editNet, setEditNet] = useState(''); + const [amountSaving, setAmountSaving] = useState(false); + const [amountError, setAmountError] = useState(null); + useEffect(() => { - if (!paycheck) return; + if (!paycheck?.id) { setActuals([]); return; } loadActuals(paycheck.id); }, [paycheck?.id]); @@ -50,8 +59,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl try { const res = await fetch(`/api/actuals?paycheck_id=${paycheckId}`); if (!res.ok) throw new Error(`Server error: ${res.status}`); - const data = await res.json(); - setActuals(data); + setActuals(await res.json()); } catch (err) { setActualsError(err.message); } finally { @@ -65,11 +73,18 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl setFormSubmitting(true); setFormError(null); try { + // Lazy generate if this is a virtual paycheck + let paycheckId = paycheck.id; + if (!paycheckId) { + const generated = await onGenerate(); + paycheckId = generated.find(p => p.paycheck_number === paycheck.paycheck_number).id; + } + const res = await fetch('/api/actuals', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - paycheck_id: paycheck.id, + paycheck_id: paycheckId, category_id: formCategoryId || null, amount: parseFloat(formAmount), note: formNote || null, @@ -80,7 +95,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl const body = await res.json(); throw new Error(body.error || `Server error: ${res.status}`); } - await loadActuals(paycheck.id); + await loadActuals(paycheckId); setFormCategoryId(''); setFormAmount(''); setFormNote(''); @@ -105,6 +120,27 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl } } + function openAmountEdit() { + setEditGross(parseFloat(paycheck.gross) || ''); + setEditNet(parseFloat(paycheck.net) || ''); + setAmountError(null); + setEditingAmounts(true); + } + + async function saveAmounts(e) { + e.preventDefault(); + setAmountSaving(true); + setAmountError(null); + try { + await onAmountSave(paycheck.paycheck_number, parseFloat(editGross) || 0, parseFloat(editNet) || 0); + setEditingAmounts(false); + } catch (err) { + setAmountError(err.message); + } finally { + setAmountSaving(false); + } + } + if (!paycheck) { return (
@@ -115,6 +151,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl ); } + const isVirtual = paycheck.id === null; const net = parseFloat(paycheck.net) || 0; const billsTotal = paycheck.bills.reduce((sum, b) => sum + (parseFloat(b.effective_amount) || 0), 0); const otesTotal = paycheck.one_time_expenses.reduce((sum, e) => sum + (parseFloat(e.amount) || 0), 0); @@ -124,12 +161,67 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl return (
-
Paycheck {paycheck.paycheck_number}
-
{formatPayDate(paycheck.pay_date)}
-
- Gross: {formatCurrency(paycheck.gross)} - Net: {formatCurrency(paycheck.net)} +
+
+
+ Paycheck {paycheck.paycheck_number} + {isVirtual && ( + preview + )} +
+
{formatPayDate(paycheck.pay_date)}
+
+ + {editingAmounts ? ( +
+
+
+ + setEditGross(e.target.value)} className="form-input" /> +
+
+ + setEditNet(e.target.value)} className="form-input" /> +
+
+ {amountError &&
{amountError}
} +
+ + +
+
+ ) : ( +
+
+ Gross: {formatCurrency(paycheck.gross)} + Net: {formatCurrency(paycheck.net)} +
+ +
+ )}
@@ -140,11 +232,11 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl

(none)

) : ( paycheck.bills.map((bill) => ( -
+
onBillPaidToggle(bill.paycheck_bill_id, !bill.paid)} + onChange={() => onBillPaidToggle(bill.paycheck_bill_id, !bill.paid, bill.bill_id, paycheck.paycheck_number)} className="bill-row__check" />
@@ -183,32 +275,17 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl )) )}
- setNewOteName(e.target.value)} - className="form-input" - /> - setNewOteAmount(e.target.value)} - min="0" - step="0.01" - className="form-input" - style={{ maxWidth: '100px' }} - /> -
@@ -217,7 +294,6 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl {/* Variable spending */}
Variable Spending
- {actualsLoading &&

Loading…

} {actualsError &&
Error: {actualsError}
} {!actualsLoading && actuals.length === 0 &&

(none)

} @@ -242,43 +318,21 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
- setFormCategoryId(e.target.value)} className="form-select"> {categories.map(cat => ( ))} - setFormAmount(e.target.value)} - step="0.01" - min="0" - className="form-input" - style={{ maxWidth: '110px' }} - required - /> + setFormAmount(e.target.value)} step="0.01" min="0" + className="form-input" style={{ maxWidth: '110px' }} required />
- setFormNote(e.target.value)} - className="form-input" - /> - setFormDate(e.target.value)} - className="form-input" - style={{ maxWidth: '140px' }} - /> + setFormNote(e.target.value)} className="form-input" /> + setFormDate(e.target.value)} + className="form-input" style={{ maxWidth: '140px' }} /> @@ -299,6 +353,8 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl ); } +// ─── PaycheckView ───────────────────────────────────────────────────────────── + function PaycheckView() { const now = new Date(); const [year, setYear] = useState(now.getFullYear()); @@ -307,6 +363,7 @@ function PaycheckView() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [categories, setCategories] = useState([]); + const [regenerating, setRegenerating] = useState(false); useEffect(() => { loadPaychecks(year, month); }, [year, month]); useEffect(() => { loadCategories(); }, []); @@ -333,6 +390,27 @@ function PaycheckView() { } catch { /* silent */ } } + // Generates (or regenerates) paychecks for the current month, updates state, + // and returns the new paychecks array. + async function generateMonth() { + const res = await fetch(`/api/paychecks/generate?year=${year}&month=${month}`, { method: 'POST' }); + if (!res.ok) throw new Error(`Server error: ${res.status}`); + const data = await res.json(); + setPaychecks(data); + return data; + } + + async function handleRegenerate() { + setRegenerating(true); + try { + await generateMonth(); + } catch (err) { + alert(`Failed to regenerate: ${err.message}`); + } finally { + setRegenerating(false); + } + } + function prevMonth() { if (month === 1) { setYear(y => y - 1); setMonth(12); } else { setMonth(m => m - 1); } } @@ -341,6 +419,81 @@ function PaycheckView() { if (month === 12) { setYear(y => y + 1); setMonth(1); } else { setMonth(m => m + 1); } } + // Saves gross/net for a paycheck. If virtual, generates first then patches. + async function handleAmountSave(paycheckNumber, gross, net) { + let pc = paychecks.find(p => p.paycheck_number === paycheckNumber); + + if (!pc.id) { + const generated = await generateMonth(); + pc = generated.find(p => p.paycheck_number === paycheckNumber); + } + + const res = await fetch(`/api/paychecks/${pc.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ gross, net }), + }); + if (!res.ok) { + const body = await res.json(); + throw new Error(body.error || `Server error: ${res.status}`); + } + const updated = await res.json(); + setPaychecks(prev => prev.map(p => + p.paycheck_number === paycheckNumber + ? { ...p, id: updated.id, gross: updated.gross, net: updated.net } + : p + )); + } + + async function handleBillPaidToggle(paycheckBillId, paid, billId, paycheckNumber) { + let realPaycheckBillId = paycheckBillId; + + if (!realPaycheckBillId) { + // Virtual paycheck — generate first, then find the real paycheck_bill_id + const generated = await generateMonth(); + const pc = generated.find(p => p.paycheck_number === paycheckNumber); + const bill = pc.bills.find(b => b.bill_id === billId); + realPaycheckBillId = bill.paycheck_bill_id; + } + + // Optimistic update + setPaychecks(prev => prev.map(pc => ({ + ...pc, + bills: pc.bills.map(b => + b.paycheck_bill_id === realPaycheckBillId + ? { ...b, paid, paid_at: paid ? new Date().toISOString() : null } + : b + ), + }))); + + try { + const res = await fetch(`/api/paycheck-bills/${realPaycheckBillId}/paid`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paid }), + }); + if (!res.ok) throw new Error(`Server error: ${res.status}`); + const updated = await res.json(); + setPaychecks(prev => prev.map(pc => ({ + ...pc, + bills: pc.bills.map(b => + b.paycheck_bill_id === realPaycheckBillId + ? { ...b, paid: updated.paid, paid_at: updated.paid_at } + : b + ), + }))); + } catch (err) { + // Revert on failure + setPaychecks(prev => prev.map(pc => ({ + ...pc, + bills: pc.bills.map(b => + b.paycheck_bill_id === realPaycheckBillId ? { ...b, paid: !paid } : b + ), + }))); + alert(`Failed to update bill: ${err.message}`); + } + } + async function handleOtePaidToggle(oteId, paid) { try { const res = await fetch(`/api/one-time-expenses/${oteId}/paid`, { @@ -366,7 +519,15 @@ function PaycheckView() { } } - async function handleOteAdd(paycheckId, name, amount) { + async function handleOteAdd(paycheckNumber, name, amount) { + let pc = paychecks.find(p => p.paycheck_number === paycheckNumber); + let paycheckId = pc?.id; + + if (!paycheckId) { + const generated = await generateMonth(); + paycheckId = generated.find(p => p.paycheck_number === paycheckNumber).id; + } + try { const res = await fetch('/api/one-time-expenses', { method: 'POST', @@ -380,51 +541,9 @@ function PaycheckView() { } } - async function handleBillPaidToggle(paycheckBillId, paid) { - setPaychecks(prev => - prev.map(pc => ({ - ...pc, - bills: pc.bills.map(b => - b.paycheck_bill_id === paycheckBillId - ? { ...b, paid, paid_at: paid ? new Date().toISOString() : null } - : b - ), - })) - ); - - try { - const res = await fetch(`/api/paycheck-bills/${paycheckBillId}/paid`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ paid }), - }); - if (!res.ok) throw new Error(`Server error: ${res.status}`); - const updated = await res.json(); - setPaychecks(prev => - prev.map(pc => ({ - ...pc, - bills: pc.bills.map(b => - b.paycheck_bill_id === paycheckBillId - ? { ...b, paid: updated.paid, paid_at: updated.paid_at } - : b - ), - })) - ); - } catch (err) { - setPaychecks(prev => - prev.map(pc => ({ - ...pc, - bills: pc.bills.map(b => - b.paycheck_bill_id === paycheckBillId ? { ...b, paid: !paid } : b - ), - })) - ); - alert(`Failed to update bill: ${err.message}`); - } - } - const pc1 = paychecks.find(p => p.paycheck_number === 1) || null; const pc2 = paychecks.find(p => p.paycheck_number === 2) || null; + const isVirtual = paychecks.length > 0 && paychecks.every(p => p.id === null); return (
@@ -432,8 +551,29 @@ function PaycheckView() { {MONTH_NAMES[month - 1]} {year} +
+ {isVirtual && ( +
+ Previewing from current settings — no data saved yet for this month. +
+ )} + {error &&
Error: {error}
} {loading ? ( @@ -447,6 +587,8 @@ function PaycheckView() { onOteDelete={handleOteDelete} onOteAdd={handleOteAdd} categories={categories} + onGenerate={generateMonth} + onAmountSave={handleAmountSave} />
)} diff --git a/server/src/routes/paychecks.js b/server/src/routes/paychecks.js index 666a307..fcaf548 100644 --- a/server/src/routes/paychecks.js +++ b/server/src/routes/paychecks.js @@ -38,13 +38,59 @@ async function getConfig() { return config; } -// Pad a number to two digits function pad2(n) { return String(n).padStart(2, '0'); } +// Build virtual (unsaved) paycheck data from config + active bills. +// Returns the same shape as fetchPaychecksForMonth but with id: null +// and paycheck_bill_id: null — nothing is written to the DB. +async function buildVirtualPaychecks(year, month) { + const config = await getConfig(); + const paychecks = []; + + for (const num of [1, 2]) { + const day = num === 1 ? config.paycheck1_day : config.paycheck2_day; + const gross = num === 1 ? config.paycheck1_gross : config.paycheck2_gross; + const net = num === 1 ? config.paycheck1_net : config.paycheck2_net; + const payDate = `${year}-${pad2(month)}-${pad2(day ?? 1)}`; + + const billsResult = await pool.query( + `SELECT id, name, amount, due_day, category + FROM bills WHERE active = TRUE AND assigned_paycheck = $1 + ORDER BY due_day, name`, + [num] + ); + + paychecks.push({ + id: null, + period_year: year, + period_month: month, + paycheck_number: num, + pay_date: payDate, + gross: gross || 0, + net: net || 0, + bills: billsResult.rows.map(b => ({ + paycheck_bill_id: null, + bill_id: b.id, + name: b.name, + amount: b.amount, + amount_override: null, + effective_amount: b.amount, + due_day: b.due_day, + category: b.category, + paid: false, + paid_at: null, + })), + one_time_expenses: [], + }); + } + + return paychecks; +} + // Generate (upsert) paycheck records for the given year/month. -// Returns the two paycheck rows with their assigned bills. +// Returns the two paycheck IDs. async function generatePaychecks(year, month) { const config = await getConfig(); @@ -55,12 +101,11 @@ async function generatePaychecks(year, month) { const paycheckIds = []; for (const num of [1, 2]) { - const day = num === 1 ? config.paycheck1_day : config.paycheck2_day; - const gross = num === 1 ? config.paycheck1_gross : config.paycheck2_gross; - const net = num === 1 ? config.paycheck1_net : config.paycheck2_net; + const day = num === 1 ? config.paycheck1_day : config.paycheck2_day; + const gross = num === 1 ? config.paycheck1_gross : config.paycheck2_gross; + const net = num === 1 ? config.paycheck1_net : config.paycheck2_net; const payDate = `${year}-${pad2(month)}-${pad2(day)}`; - // Upsert paycheck record const pcResult = await client.query( `INSERT INTO paychecks (period_year, period_month, paycheck_number, pay_date, gross, net) VALUES ($1, $2, $3, $4, $5, $6) @@ -74,13 +119,11 @@ async function generatePaychecks(year, month) { const paycheckId = pcResult.rows[0].id; paycheckIds.push(paycheckId); - // Fetch all active bills assigned to this paycheck number const billsResult = await client.query( 'SELECT id FROM bills WHERE active = TRUE AND assigned_paycheck = $1', [num] ); - // Idempotently insert each bill into paycheck_bills for (const bill of billsResult.rows) { await client.query( `INSERT INTO paycheck_bills (paycheck_id, bill_id) @@ -103,7 +146,6 @@ async function generatePaychecks(year, month) { // Fetch both paycheck records for a month with full bill and one_time_expense data. async function fetchPaychecksForMonth(year, month) { - // Fetch paycheck rows const pcResult = await pool.query( `SELECT id, period_year, period_month, paycheck_number, pay_date, gross, net FROM paychecks @@ -115,7 +157,6 @@ async function fetchPaychecksForMonth(year, month) { const paychecks = []; for (const pc of pcResult.rows) { - // Fetch associated bills joined with bill definitions const billsResult = await pool.query( `SELECT pb.id AS paycheck_bill_id, pb.bill_id, @@ -134,7 +175,6 @@ async function fetchPaychecksForMonth(year, month) { [pc.id] ); - // Fetch one-time expenses const oteResult = await pool.query( `SELECT id, name, amount, paid, paid_at FROM one_time_expenses @@ -172,7 +212,7 @@ async function fetchPaychecksForMonth(year, month) { // POST /api/paychecks/generate?year=&month= router.post('/paychecks/generate', async (req, res) => { - const year = parseInt(req.query.year, 10); + const year = parseInt(req.query.year, 10); const month = parseInt(req.query.month, 10); if (isNaN(year) || isNaN(month) || month < 1 || month > 12) { @@ -190,8 +230,9 @@ router.post('/paychecks/generate', async (req, res) => { }); // GET /api/paychecks?year=&month= +// Returns virtual (unsaved) data when no DB records exist for the month. router.get('/paychecks', async (req, res) => { - const year = parseInt(req.query.year, 10); + const year = parseInt(req.query.year, 10); const month = parseInt(req.query.month, 10); if (isNaN(year) || isNaN(month) || month < 1 || month > 12) { @@ -199,14 +240,14 @@ router.get('/paychecks', async (req, res) => { } try { - // Check if paychecks exist for this month; if not, auto-generate const existing = await pool.query( 'SELECT id FROM paychecks WHERE period_year = $1 AND period_month = $2 LIMIT 1', [year, month] ); if (existing.rows.length === 0) { - await generatePaychecks(year, month); + const virtual = await buildVirtualPaychecks(year, month); + return res.json(virtual); } const paychecks = await fetchPaychecksForMonth(year, month); @@ -232,6 +273,36 @@ router.get('/paychecks/months', async (req, res) => { } }); +// PATCH /api/paychecks/:id — update gross and net +router.patch('/paychecks/:id', async (req, res) => { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ error: 'Invalid id' }); + } + + const { gross, net } = req.body; + if (gross == null || net == null) { + return res.status(400).json({ error: 'gross and net are required' }); + } + + try { + const result = await pool.query( + `UPDATE paychecks SET gross = $1, net = $2 WHERE id = $3 + RETURNING id, gross, net`, + [parseFloat(gross), parseFloat(net), id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Paycheck not found' }); + } + + res.json(result.rows[0]); + } catch (err) { + console.error('PATCH /api/paychecks/:id error:', err); + res.status(500).json({ error: 'Failed to update paycheck' }); + } +}); + // PATCH /api/paycheck-bills/:id/paid router.patch('/paycheck-bills/:id/paid', async (req, res) => { const id = parseInt(req.params.id, 10);