From 22b1cfbe6b28df3c4663014cb4308e3b7245ae95 Mon Sep 17 00:00:00 2001 From: Christian Hood Date: Thu, 19 Mar 2026 19:15:25 -0400 Subject: [PATCH] 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 --- client/src/pages/MonthlySummary.jsx | 273 +++++++++++++++++++++++++++- server/src/index.js | 2 + server/src/routes/summary.js | 128 +++++++++++++ 3 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 server/src/routes/summary.js diff --git a/client/src/pages/MonthlySummary.jsx b/client/src/pages/MonthlySummary.jsx index 264bf7c..5d16779 100644 --- a/client/src/pages/MonthlySummary.jsx +++ b/client/src/pages/MonthlySummary.jsx @@ -1,5 +1,274 @@ -function MonthlySummary() { - return

Monthly Summary

Placeholder — coming soon.

; +import { useState, useEffect } from 'react'; + +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +function formatCurrency(value) { + const num = parseFloat(value) || 0; + return '$' + num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } +function StatCard({ label, value, valueColor }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function MonthlySummary() { + const now = new Date(); + const [year, setYear] = useState(now.getFullYear()); + const [month, setMonth] = useState(now.getMonth() + 1); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + loadSummary(year, month); + }, [year, month]); + + async function loadSummary(y, m) { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/summary/monthly?year=${y}&month=${m}`); + if (!res.ok) throw new Error(`Server error: ${res.status}`); + const json = await res.json(); + setData(json); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + function prevMonth() { + if (month === 1) { + setYear(y => y - 1); + setMonth(12); + } else { + setMonth(m => m - 1); + } + } + + function nextMonth() { + if (month === 12) { + setYear(y => y + 1); + setMonth(1); + } else { + setMonth(m => m + 1); + } + } + + const surplusColor = data && data.summary.surplus_deficit >= 0 ? '#2a7a2a' : '#c0392b'; + + return ( +
+
+ + {MONTH_NAMES[month - 1]} {year} + +
+ + {error && ( +
Error: {error}
+ )} + + {loading && ( +
Loading...
+ )} + + {!loading && data && ( + <> +
+ + + + +
+ +
+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CategoryAmount
Income (net) + {formatCurrency(data.income.net)} +
Bills (planned) + -{formatCurrency(data.bills.planned)} +
Variable spending + -{formatCurrency(data.actuals.total)} +
One-time expenses + -{formatCurrency(data.one_time_expenses.total)} +
Surplus / Deficit + {formatCurrency(data.summary.surplus_deficit)} +
+
+ + )} +
+ ); +} + +const styles = { + container: { + maxWidth: '860px', + margin: '0 auto', + }, + monthNav: { + display: 'flex', + alignItems: 'center', + gap: '1rem', + marginBottom: '1.5rem', + }, + navButton: { + padding: '0.3rem 0.75rem', + fontSize: '1rem', + cursor: 'pointer', + border: '1px solid #bbb', + borderRadius: '4px', + background: '#f5f5f5', + }, + monthLabel: { + fontSize: '1.25rem', + fontWeight: '600', + minWidth: '160px', + textAlign: 'center', + }, + errorBanner: { + background: '#fde8e8', + border: '1px solid #f5a0a0', + borderRadius: '4px', + padding: '0.75rem 1rem', + marginBottom: '1rem', + color: '#c0392b', + }, + loadingMsg: { + padding: '2rem', + color: '#888', + }, + cardRow: { + display: 'flex', + gap: '1rem', + marginBottom: '1rem', + flexWrap: 'wrap', + }, + card: { + flex: '1 1 160px', + border: '1px solid #ddd', + borderRadius: '6px', + padding: '1rem', + background: '#fafafa', + minWidth: '140px', + }, + cardLabel: { + fontSize: '0.8rem', + color: '#666', + marginBottom: '0.4rem', + fontWeight: '500', + textTransform: 'uppercase', + letterSpacing: '0.03em', + }, + cardValue: { + fontSize: '1.35rem', + fontWeight: '700', + fontVariantNumeric: 'tabular-nums', + }, + tableWrapper: { + marginTop: '1.5rem', + border: '1px solid #ddd', + borderRadius: '6px', + overflow: 'hidden', + }, + table: { + width: '100%', + borderCollapse: 'collapse', + fontSize: '0.95rem', + }, + th: { + padding: '0.6rem 1rem', + background: '#f0f0f0', + fontWeight: '600', + color: '#444', + borderBottom: '1px solid #ddd', + textAlign: 'left', + }, + tr: { + borderBottom: '1px solid #eee', + }, + trTotal: { + borderTop: '2px solid #ccc', + background: '#fafafa', + }, + td: { + padding: '0.6rem 1rem', + color: '#333', + }, + tdRight: { + textAlign: 'right', + fontVariantNumeric: 'tabular-nums', + }, + tdBold: { + fontWeight: '700', + fontSize: '1rem', + }, +}; + export default MonthlySummary; diff --git a/server/src/index.js b/server/src/index.js index 287f469..068e93a 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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'); diff --git a/server/src/routes/summary.js b/server/src/routes/summary.js new file mode 100644 index 0000000..c72eb11 --- /dev/null +++ b/server/src/routes/summary.js @@ -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;