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() {
|
||||
return <div><h1>Monthly Summary</h1><p>Placeholder — coming soon.</p></div>;
|
||||
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 (
|
||||
<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;
|
||||
|
||||
Reference in New Issue
Block a user