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:
@@ -1,5 +1,274 @@
|
|||||||
function MonthlySummary() {
|
import { useState, useEffect } from 'react';
|
||||||
return <div><h1>Monthly Summary</h1><p>Placeholder — coming soon.</p></div>;
|
|
||||||
|
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 (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.cardLabel}>{label}</div>
|
||||||
|
<div style={{ ...styles.cardValue, color: valueColor || '#222' }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<div style={styles.monthNav}>
|
||||||
|
<button style={styles.navButton} onClick={prevMonth}>←</button>
|
||||||
|
<span style={styles.monthLabel}>{MONTH_NAMES[month - 1]} {year}</span>
|
||||||
|
<button style={styles.navButton} onClick={nextMonth}>→</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={styles.errorBanner}>Error: {error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div style={styles.loadingMsg}>Loading...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && data && (
|
||||||
|
<>
|
||||||
|
<div style={styles.cardRow}>
|
||||||
|
<StatCard
|
||||||
|
label="Total Income (net)"
|
||||||
|
value={formatCurrency(data.income.net)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Total Bills Planned"
|
||||||
|
value={formatCurrency(data.bills.planned)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Total Variable Spending"
|
||||||
|
value={formatCurrency(data.actuals.total)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Total One-time"
|
||||||
|
value={formatCurrency(data.one_time_expenses.total)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.cardRow}>
|
||||||
|
<StatCard
|
||||||
|
label="Total Spending"
|
||||||
|
value={formatCurrency(data.summary.total_spending)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Surplus / Deficit"
|
||||||
|
value={formatCurrency(data.summary.surplus_deficit)}
|
||||||
|
valueColor={surplusColor}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Bills Paid"
|
||||||
|
value={`${data.bills.paid_count} of ${data.bills.count}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.tableWrapper}>
|
||||||
|
<table style={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={styles.th}>Category</th>
|
||||||
|
<th style={{ ...styles.th, textAlign: 'right' }}>Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style={styles.tr}>
|
||||||
|
<td style={styles.td}>Income (net)</td>
|
||||||
|
<td style={{ ...styles.td, ...styles.tdRight, color: '#2a7a2a' }}>
|
||||||
|
{formatCurrency(data.income.net)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style={styles.tr}>
|
||||||
|
<td style={styles.td}>Bills (planned)</td>
|
||||||
|
<td style={{ ...styles.td, ...styles.tdRight }}>
|
||||||
|
-{formatCurrency(data.bills.planned)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style={styles.tr}>
|
||||||
|
<td style={styles.td}>Variable spending</td>
|
||||||
|
<td style={{ ...styles.td, ...styles.tdRight }}>
|
||||||
|
-{formatCurrency(data.actuals.total)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style={styles.tr}>
|
||||||
|
<td style={styles.td}>One-time expenses</td>
|
||||||
|
<td style={{ ...styles.td, ...styles.tdRight }}>
|
||||||
|
-{formatCurrency(data.one_time_expenses.total)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr style={styles.trTotal}>
|
||||||
|
<td style={{ ...styles.td, ...styles.tdBold }}>Surplus / Deficit</td>
|
||||||
|
<td style={{ ...styles.td, ...styles.tdRight, ...styles.tdBold, color: surplusColor }}>
|
||||||
|
{formatCurrency(data.summary.surplus_deficit)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
export default MonthlySummary;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const billsRouter = require('./routes/bills');
|
|||||||
const paychecksRouter = require('./routes/paychecks');
|
const paychecksRouter = require('./routes/paychecks');
|
||||||
const actualsRouter = require('./routes/actuals');
|
const actualsRouter = require('./routes/actuals');
|
||||||
const oneTimeExpensesRouter = require('./routes/one-time-expenses');
|
const oneTimeExpensesRouter = require('./routes/one-time-expenses');
|
||||||
|
const summaryRouter = require('./routes/summary');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -23,6 +24,7 @@ app.use('/api', billsRouter);
|
|||||||
app.use('/api', paychecksRouter);
|
app.use('/api', paychecksRouter);
|
||||||
app.use('/api', actualsRouter);
|
app.use('/api', actualsRouter);
|
||||||
app.use('/api', oneTimeExpensesRouter);
|
app.use('/api', oneTimeExpensesRouter);
|
||||||
|
app.use('/api', summaryRouter);
|
||||||
|
|
||||||
// Serve static client files in production
|
// Serve static client files in production
|
||||||
const clientDist = path.join(__dirname, '../../client/dist');
|
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