Add monthly summary view
GET /api/summary/monthly returns income, bills, actuals, and one-time expense totals. Summary page shows stat cards and a breakdown table with surplus/deficit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ const billsRouter = require('./routes/bills');
|
||||
const paychecksRouter = require('./routes/paychecks');
|
||||
const actualsRouter = require('./routes/actuals');
|
||||
const oneTimeExpensesRouter = require('./routes/one-time-expenses');
|
||||
const summaryRouter = require('./routes/summary');
|
||||
const db = require('./db');
|
||||
|
||||
const app = express();
|
||||
@@ -23,6 +24,7 @@ app.use('/api', billsRouter);
|
||||
app.use('/api', paychecksRouter);
|
||||
app.use('/api', actualsRouter);
|
||||
app.use('/api', oneTimeExpensesRouter);
|
||||
app.use('/api', summaryRouter);
|
||||
|
||||
// Serve static client files in production
|
||||
const clientDist = path.join(__dirname, '../../client/dist');
|
||||
|
||||
128
server/src/routes/summary.js
Normal file
128
server/src/routes/summary.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../db');
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
|
||||
// GET /api/summary/monthly?year=&month=
|
||||
router.get('/summary/monthly', async (req, res) => {
|
||||
const year = parseInt(req.query.year, 10);
|
||||
const month = parseInt(req.query.month, 10);
|
||||
|
||||
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
|
||||
return res.status(400).json({ error: 'year and month (1-12) are required query params' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch paychecks for this month
|
||||
const pcResult = await pool.query(
|
||||
`SELECT id, gross, net FROM paychecks
|
||||
WHERE period_year = $1 AND period_month = $2`,
|
||||
[year, month]
|
||||
);
|
||||
|
||||
// If no paychecks exist, return zeros
|
||||
if (pcResult.rows.length === 0) {
|
||||
return res.json({
|
||||
year,
|
||||
month,
|
||||
month_name: MONTH_NAMES[month - 1],
|
||||
income: { gross: 0, net: 0 },
|
||||
bills: { planned: 0, paid: 0, unpaid: 0, count: 0, paid_count: 0 },
|
||||
actuals: { total: 0, count: 0 },
|
||||
one_time_expenses: { total: 0, count: 0 },
|
||||
summary: { total_spending: 0, surplus_deficit: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
const paycheckIds = pcResult.rows.map(r => r.id);
|
||||
|
||||
// Income totals
|
||||
const incomeGross = pcResult.rows.reduce((sum, r) => sum + (parseFloat(r.gross) || 0), 0);
|
||||
const incomeNet = pcResult.rows.reduce((sum, r) => sum + (parseFloat(r.net) || 0), 0);
|
||||
|
||||
// Bills aggregates
|
||||
const billsResult = await pool.query(
|
||||
`SELECT
|
||||
COUNT(*)::int AS count,
|
||||
COALESCE(SUM(CASE WHEN pb.amount_override IS NOT NULL THEN pb.amount_override ELSE b.amount END), 0) AS planned,
|
||||
COALESCE(SUM(CASE WHEN pb.paid THEN CASE WHEN pb.amount_override IS NOT NULL THEN pb.amount_override ELSE b.amount END ELSE 0 END), 0) AS paid_amount,
|
||||
COUNT(*) FILTER (WHERE pb.paid)::int AS paid_count
|
||||
FROM paycheck_bills pb
|
||||
JOIN bills b ON b.id = pb.bill_id
|
||||
WHERE pb.paycheck_id = ANY($1)`,
|
||||
[paycheckIds]
|
||||
);
|
||||
|
||||
const billsRow = billsResult.rows[0];
|
||||
const billsPlanned = parseFloat(billsRow.planned) || 0;
|
||||
const billsPaid = parseFloat(billsRow.paid_amount) || 0;
|
||||
const billsUnpaid = billsPlanned - billsPaid;
|
||||
const billsCount = billsRow.count || 0;
|
||||
const billsPaidCount = billsRow.paid_count || 0;
|
||||
|
||||
// Actuals aggregates
|
||||
const actualsResult = await pool.query(
|
||||
`SELECT COUNT(*)::int AS count, COALESCE(SUM(amount), 0) AS total
|
||||
FROM actuals
|
||||
WHERE paycheck_id = ANY($1)`,
|
||||
[paycheckIds]
|
||||
);
|
||||
|
||||
const actualsRow = actualsResult.rows[0];
|
||||
const actualsTotal = parseFloat(actualsRow.total) || 0;
|
||||
const actualsCount = actualsRow.count || 0;
|
||||
|
||||
// One-time expenses aggregates
|
||||
const oteResult = await pool.query(
|
||||
`SELECT COUNT(*)::int AS count, COALESCE(SUM(amount), 0) AS total
|
||||
FROM one_time_expenses
|
||||
WHERE paycheck_id = ANY($1)`,
|
||||
[paycheckIds]
|
||||
);
|
||||
|
||||
const oteRow = oteResult.rows[0];
|
||||
const oteTotal = parseFloat(oteRow.total) || 0;
|
||||
const oteCount = oteRow.count || 0;
|
||||
|
||||
const totalSpending = billsPlanned + actualsTotal + oteTotal;
|
||||
const surplusDeficit = incomeNet - totalSpending;
|
||||
|
||||
res.json({
|
||||
year,
|
||||
month,
|
||||
month_name: MONTH_NAMES[month - 1],
|
||||
income: {
|
||||
gross: parseFloat(incomeGross.toFixed(2)),
|
||||
net: parseFloat(incomeNet.toFixed(2)),
|
||||
},
|
||||
bills: {
|
||||
planned: parseFloat(billsPlanned.toFixed(2)),
|
||||
paid: parseFloat(billsPaid.toFixed(2)),
|
||||
unpaid: parseFloat(billsUnpaid.toFixed(2)),
|
||||
count: billsCount,
|
||||
paid_count: billsPaidCount,
|
||||
},
|
||||
actuals: {
|
||||
total: parseFloat(actualsTotal.toFixed(2)),
|
||||
count: actualsCount,
|
||||
},
|
||||
one_time_expenses: {
|
||||
total: parseFloat(oteTotal.toFixed(2)),
|
||||
count: oteCount,
|
||||
},
|
||||
summary: {
|
||||
total_spending: parseFloat(totalSpending.toFixed(2)),
|
||||
surplus_deficit: parseFloat(surplusDeficit.toFixed(2)),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('GET /api/summary/monthly error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch monthly summary' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user