-
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);