Add annual overview page
Year-at-a-glance table with monthly income, bills, variable spending, and surplus/deficit. Fetches all 12 months in parallel. Summary cards show annual totals. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,214 @@
|
|||||||
function AnnualOverview() {
|
import { useState, useEffect } from 'react';
|
||||||
return <div><h1>Annual Overview</h1><p>Placeholder — coming soon.</p></div>;
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmt(value) {
|
||||||
|
if (value == null) return '—';
|
||||||
|
const num = Number(value);
|
||||||
|
if (isNaN(num)) return '—';
|
||||||
|
const abs = Math.abs(num).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
return num < 0 ? `-$${abs}` : `$${abs}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AnnualOverview;
|
function surplusStyle(value) {
|
||||||
|
if (value == null || isNaN(Number(value))) return {};
|
||||||
|
return { color: Number(value) >= 0 ? '#2a9d2a' : '#cc2222', fontWeight: 500 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumField(rows, field) {
|
||||||
|
return rows.reduce((acc, row) => {
|
||||||
|
const v = row && row[field] != null ? Number(row[field]) : 0;
|
||||||
|
return acc + (isNaN(v) ? 0 : v);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnnualOverview() {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const [year, setYear] = useState(currentYear);
|
||||||
|
const [monthData, setMonthData] = useState(Array(12).fill(null));
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
Array.from({ length: 12 }, (_, i) =>
|
||||||
|
fetch(`/api/summary/monthly?year=${year}&month=${i + 1}`)
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) return null;
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.catch(() => null)
|
||||||
|
)
|
||||||
|
).then(results => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setMonthData(results);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err.message || 'Failed to load data');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [year]);
|
||||||
|
|
||||||
|
const hasData = monthData.some(row => row != null);
|
||||||
|
|
||||||
|
const annualIncome = sumField(monthData, 'total_income');
|
||||||
|
const annualBills = sumField(monthData, 'total_bills');
|
||||||
|
const annualVariable = sumField(monthData, 'total_variable');
|
||||||
|
const annualOneTime = sumField(monthData, 'total_one_time');
|
||||||
|
const annualSpending = sumField(monthData, 'total_spending');
|
||||||
|
const annualSurplus = sumField(monthData, 'surplus_deficit');
|
||||||
|
|
||||||
|
const cardStyle = {
|
||||||
|
background: '#f5f5f5',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '1rem 1.25rem',
|
||||||
|
minWidth: 160,
|
||||||
|
flex: '1 1 160px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardLabelStyle = {
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: '#666',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
marginBottom: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardValueStyle = {
|
||||||
|
fontSize: '1.4rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1000 }}>
|
||||||
|
{/* Year navigation */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setYear(y => y - 1)}
|
||||||
|
style={{ fontSize: '1.2rem', cursor: 'pointer', background: 'none', border: '1px solid #ccc', borderRadius: 4, padding: '0.2rem 0.6rem' }}
|
||||||
|
aria-label="Previous year"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<h1 style={{ margin: 0, fontSize: '1.5rem' }}>{year} Annual Overview</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setYear(y => y + 1)}
|
||||||
|
style={{ fontSize: '1.2rem', cursor: 'pointer', background: 'none', border: '1px solid #ccc', borderRadius: 4, padding: '0.2rem 0.6rem' }}
|
||||||
|
aria-label="Next year"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '2rem' }}>
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={cardLabelStyle}>Annual Income (net)</div>
|
||||||
|
<div style={cardValueStyle}>{hasData ? fmt(annualIncome) : '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={cardLabelStyle}>Annual Bills</div>
|
||||||
|
<div style={cardValueStyle}>{hasData ? fmt(annualBills) : '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={cardLabelStyle}>Annual Variable Spending</div>
|
||||||
|
<div style={cardValueStyle}>{hasData ? fmt(annualVariable) : '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...cardStyle }}>
|
||||||
|
<div style={cardLabelStyle}>Annual Surplus / Deficit</div>
|
||||||
|
<div style={{ ...cardValueStyle, ...surplusStyle(annualSurplus) }}>
|
||||||
|
{hasData ? fmt(annualSurplus) : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status messages */}
|
||||||
|
{loading && <p style={{ color: '#666' }}>Loading…</p>}
|
||||||
|
{error && <p style={{ color: '#cc2222' }}>Error: {error}</p>}
|
||||||
|
|
||||||
|
{/* Monthly table */}
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f0f0f0', textAlign: 'right' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Month</th>
|
||||||
|
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Income (net)</th>
|
||||||
|
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Bills</th>
|
||||||
|
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Variable</th>
|
||||||
|
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>One-time</th>
|
||||||
|
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Total Spending</th>
|
||||||
|
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ccc' }}>Surplus / Deficit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{MONTH_NAMES.map((name, i) => {
|
||||||
|
const row = monthData[i];
|
||||||
|
const hasRow = row != null;
|
||||||
|
const surplus = hasRow ? row.surplus_deficit : null;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={name}
|
||||||
|
style={{ borderBottom: '1px solid #eee' }}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '0.5rem 0.75rem' }}>{name}</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
|
||||||
|
{hasRow ? fmt(row.total_income) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
|
||||||
|
{hasRow ? fmt(row.total_bills) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
|
||||||
|
{hasRow ? fmt(row.total_variable) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
|
||||||
|
{hasRow ? fmt(row.total_one_time) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
|
||||||
|
{hasRow ? fmt(row.total_spending) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem', ...surplusStyle(surplus) }}>
|
||||||
|
{hasRow ? fmt(surplus) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style={{ borderTop: '2px solid #ccc', fontWeight: 700, background: '#fafafa' }}>
|
||||||
|
<td style={{ padding: '0.5rem 0.75rem' }}>Total</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
|
||||||
|
{hasData ? fmt(annualIncome) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
|
||||||
|
{hasData ? fmt(annualBills) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
|
||||||
|
{hasData ? fmt(annualVariable) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
|
||||||
|
{hasData ? fmt(annualOneTime) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>
|
||||||
|
{hasData ? fmt(annualSpending) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem', ...surplusStyle(annualSurplus) }}>
|
||||||
|
{hasData ? fmt(annualSurplus) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user