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 (
+
+ );
+}
+
+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 && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Category |
+ Amount |
+
+
+
+
+ | 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;