Lazy paycheck generation, regenerate button, inline gross/net edit
- GET /api/paychecks returns virtual data (id: null) without writing to DB when no records exist for the month - First interaction (bill toggle, OTE add, actual add) lazily calls POST /api/paychecks/generate to persist the paycheck - New PATCH /api/paychecks/:id to update gross and net - Regenerate/refresh button syncs gross/net from current Settings - Inline pencil edit for gross/net on each paycheck column header - 'preview' badge and info banner shown for unsaved months Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user