361 lines
18 KiB
HTML
361 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Reports{% endblock %}
|
|
{% block page_title %}Reports{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
.report-tab { font-size:13px; border-radius:6px; padding:6px 14px; border:1px solid var(--border); background:var(--card-bg); color:var(--muted); text-decoration:none; transition:all .15s; }
|
|
.report-tab:hover { background:#f1f5f9; color:var(--text); }
|
|
.report-tab.active { background:#0f172a; color:#f1f5f9; border-color:#0f172a; }
|
|
{% endblock %}
|
|
|
|
{% block topbar_actions %}
|
|
<!-- Export buttons -->
|
|
<div class="d-flex gap-1">
|
|
<a href="{{ url_for('reports.export_csv',
|
|
year=selected_year, month=selected_month or '') }}"
|
|
class="btn btn-sm btn-outline-secondary" style="font-size:12px;" title="Export CSV">
|
|
<i class="bi bi-filetype-csv me-1"></i>CSV
|
|
</a>
|
|
<a href="{{ url_for('reports.export_excel',
|
|
year=selected_year, month=selected_month or '') }}"
|
|
class="btn btn-sm btn-outline-secondary" style="font-size:12px;" title="Export Excel">
|
|
<i class="bi bi-file-earmark-excel me-1"></i>Excel
|
|
</a>
|
|
<a href="{{ url_for('reports.export_pdf',
|
|
type=report_type, year=selected_year,
|
|
month=selected_month or '', quarter=selected_quarter or '') }}"
|
|
class="btn btn-sm btn-outline-secondary" style="font-size:12px;" title="Export PDF">
|
|
<i class="bi bi-file-earmark-pdf me-1"></i>PDF
|
|
</a>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<!-- Report type tabs + period selector -->
|
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
|
|
<div class="d-flex gap-1 flex-wrap">
|
|
<a href="{{ url_for('reports.monthly', year=selected_year, month=selected_month or current_month) }}"
|
|
class="report-tab {% if report_type=='monthly' %}active{% endif %}">Monthly</a>
|
|
<a href="{{ url_for('reports.quarterly', year=selected_year, quarter=selected_quarter or 1) }}"
|
|
class="report-tab {% if report_type=='quarterly' %}active{% endif %}">Quarterly</a>
|
|
<a href="{{ url_for('reports.yearly', year=selected_year) }}"
|
|
class="report-tab {% if report_type=='yearly' %}active{% endif %}">Yearly</a>
|
|
<a href="{{ url_for('reports.tax', year=selected_year) }}"
|
|
class="report-tab">Tax Year</a>
|
|
</div>
|
|
|
|
<!-- Period picker -->
|
|
<form method="GET" action="{{ url_for('reports.' + report_type) }}" class="d-flex gap-2 align-items-center">
|
|
<select name="year" class="form-select form-select-sm" style="width:auto;">
|
|
{% for y in years %}
|
|
<option value="{{ y }}" {% if y == selected_year %}selected{% endif %}>{{ y }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% if report_type == 'monthly' %}
|
|
<select name="month" class="form-select form-select-sm" style="width:auto;">
|
|
{% for m in range(1, 13) %}
|
|
<option value="{{ m }}" {% if m == selected_month %}selected{% endif %}>
|
|
{{ ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][m-1] }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% elif report_type == 'quarterly' %}
|
|
<select name="quarter" class="form-select form-select-sm" style="width:auto;">
|
|
{% for q in range(1, 5) %}
|
|
<option value="{{ q }}" {% if q == selected_quarter %}selected{% endif %}>Q{{ q }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% endif %}
|
|
<button type="submit" class="btn btn-sm btn-primary" style="font-size:12px;">Go</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Summary cards -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-6 col-xl-3">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Income</div>
|
|
<div class="stat-value text-income">{{ report.income | currency }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-xl-3">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Expenses</div>
|
|
<div class="stat-value text-expense">{{ report.expense | currency }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-xl-3">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Net</div>
|
|
<div class="stat-value {% if report.net >= 0 %}text-income{% else %}text-expense{% endif %}">{{ report.net | currency }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-xl-3">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Savings Rate</div>
|
|
<div class="stat-value {% if report.savings_rate >= 0 %}text-invest{% else %}text-expense{% endif %}">{{ report.savings_rate }}%</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts row -->
|
|
<div class="row g-3 mb-4">
|
|
<!-- Period chart: bar for monthly, monthly bars for quarterly/yearly -->
|
|
<div class="col-12 col-lg-7">
|
|
<div class="pcard h-100">
|
|
<div class="pcard-title mb-3">
|
|
{% if report_type == 'monthly' %}Income vs Expenses — {{ report.period }}
|
|
{% elif report_type == 'quarterly' %}Monthly Breakdown — {{ report.period }}
|
|
{% else %}Monthly Breakdown — {{ report.period }}
|
|
{% endif %}
|
|
</div>
|
|
<div style="position:relative;height:240px;"><canvas id="periodChart"></canvas></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Expense donut -->
|
|
<div class="col-12 col-lg-5">
|
|
<div class="pcard h-100">
|
|
<div class="pcard-title mb-3">Expense Breakdown</div>
|
|
{% if report.expense_categories %}
|
|
<div style="position:relative;height:180px;"><canvas id="expDonut"></canvas></div>
|
|
<div class="mt-3">
|
|
{% for cat in report.expense_categories[:5] %}
|
|
<div class="d-flex justify-content-between py-1" style="font-size:12px;border-bottom:1px solid var(--border);">
|
|
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:{{ cat.color }};margin-right:6px;"></span>{{ cat.name }}</span>
|
|
<span class="mono">{{ cat.total | currency }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<p class="text-muted small">No expenses this period.</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Month-over-month comparison (monthly report only) -->
|
|
{% if report_type == 'monthly' and mom_data %}
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-12">
|
|
<div class="pcard">
|
|
<div class="pcard-title mb-3">Month-over-Month Comparison</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm" style="font-size:13px;">
|
|
<thead>
|
|
<tr style="border-bottom:2px solid var(--border);">
|
|
<th>Category</th>
|
|
<th class="text-end">This Month</th>
|
|
<th class="text-end">Last Month</th>
|
|
<th class="text-end d-none d-md-table-cell">3-Mo Avg</th>
|
|
<th class="text-end">Change</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for row in mom_data %}
|
|
<tr>
|
|
<td>
|
|
<span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:{{ row.color }};margin-right:6px;"></span>
|
|
{{ row.icon }} {{ row.name }}
|
|
</td>
|
|
<td class="text-end mono">{{ row.this_month | currency }}</td>
|
|
<td class="text-end mono text-muted">{{ row.last_month | currency }}</td>
|
|
<td class="text-end mono text-muted d-none d-md-table-cell">{{ row.avg_3mo | currency }}</td>
|
|
<td class="text-end">
|
|
{% if row.change_pct is none %}
|
|
<span class="text-muted">—</span>
|
|
{% elif row.change_pct > 0 %}
|
|
<span class="badge text-expense" style="background:#ef444415;font-size:11px;">+{{ row.change_pct | round(0) | int }}%</span>
|
|
{% elif row.change_pct < 0 %}
|
|
<span class="badge text-income" style="background:#10b98115;font-size:11px;">{{ row.change_pct | round(0) | int }}%</span>
|
|
{% else %}
|
|
<span class="text-muted" style="font-size:11px;">0%</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Net worth history -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-12">
|
|
<div class="pcard">
|
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|
<span class="pcard-title mb-0">Net Worth History</span>
|
|
{% if nw_history.projected_1yr is defined and nw_history.projected_1yr %}
|
|
<span class="badge" style="background:#3b82f615;color:#3b82f6;font-size:11px;font-weight:500;">
|
|
Proj. 1yr: {{ nw_history.projected_1yr | currency }}
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<form method="POST" action="{{ url_for('reports.manual_snapshot') }}">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="btn btn-sm btn-outline-secondary" style="font-size:11px;" title="Save today's snapshot">
|
|
<i class="bi bi-camera me-1"></i>Snapshot Now
|
|
</button>
|
|
</form>
|
|
<form method="POST" action="{{ url_for('reports.rebuild_snapshot') }}"
|
|
onsubmit="return confirm('Recalculate all balances and rebuild today\'s snapshot?\nThis fixes the current net worth data point.')">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="btn btn-sm btn-outline-warning" style="font-size:11px;" title="Recalculate all balances then rebuild today's snapshot">
|
|
<i class="bi bi-arrow-repeat me-1"></i>Rebuild
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{% if nw_history.count > 1 %}
|
|
<div style="position:relative;height:200px;"><canvas id="nwChart"></canvas></div>
|
|
{% else %}
|
|
<p class="text-muted small">Not enough snapshots yet. Snapshots save automatically on the 1st of each month, or click "Snapshot Now".</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Category spending trends -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-12">
|
|
<div class="pcard">
|
|
<div class="pcard-title mb-3">Spending Trends — Top Categories (Last 6 Months)</div>
|
|
{% if cat_trend.datasets %}
|
|
<div style="position:relative;height:220px;"><canvas id="trendChart"></canvas></div>
|
|
{% else %}
|
|
<p class="text-muted small">Not enough data for trends yet.</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Yearly avg (only for yearly report) -->
|
|
{% if report_type == 'yearly' %}
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-6 col-md-3">
|
|
<div class="pcard pcard-sm text-center">
|
|
<div class="pcard-title">Avg Monthly Income</div>
|
|
<div class="mono text-income fw-bold" style="font-size:16px;">{{ report.avg_monthly_income | currency }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-6 col-md-3">
|
|
<div class="pcard pcard-sm text-center">
|
|
<div class="pcard-title">Avg Monthly Expense</div>
|
|
<div class="mono text-expense fw-bold" style="font-size:16px;">{{ report.avg_monthly_expense | currency }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
|
<script>
|
|
const sym = '{{ current_user.currency_symbol }}';
|
|
const fmtCur = v => sym + Math.abs(v).toLocaleString(undefined, {minimumFractionDigits:0, maximumFractionDigits:0});
|
|
|
|
// Period chart
|
|
(function(){
|
|
{% if report_type == 'monthly' %}
|
|
const ctx = document.getElementById('periodChart').getContext('2d');
|
|
new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: ['{{ report.period }}'],
|
|
datasets: [
|
|
{ label:'Income', data:[{{ report.income }}], backgroundColor:'#10b98133', borderColor:'#10b981', borderWidth:2, borderRadius:6 },
|
|
{ label:'Expenses', data:[{{ report.expense }}], backgroundColor:'#ef444433', borderColor:'#ef4444', borderWidth:2, borderRadius:6 },
|
|
]
|
|
},
|
|
options: { responsive:true, maintainAspectRatio:false, plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 } } } }, scales:{ y:{ ticks:{ callback: v => fmtCur(v) }, grid:{ color:'#f1f5f9' } }, x:{ grid:{ display:false } } } }
|
|
});
|
|
{% else %}
|
|
const months = {{ report.months | map(attribute='label') | list | tojson }};
|
|
const incomes = {{ report.months | map(attribute='income') | list | tojson }};
|
|
const expenses = {{ report.months | map(attribute='expense') | list | tojson }};
|
|
const ctx = document.getElementById('periodChart').getContext('2d');
|
|
new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: months,
|
|
datasets: [
|
|
{ label:'Income', data:incomes, backgroundColor:'#10b98133', borderColor:'#10b981', borderWidth:2, borderRadius:4 },
|
|
{ label:'Expenses', data:expenses, backgroundColor:'#ef444433', borderColor:'#ef4444', borderWidth:2, borderRadius:4 },
|
|
]
|
|
},
|
|
options: { responsive:true, maintainAspectRatio:false, plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 } } } }, scales:{ y:{ ticks:{ callback: v => fmtCur(v) }, grid:{ color:'#f1f5f9' } }, x:{ grid:{ display:false }, ticks:{ font:{ size:11 } } } } }
|
|
});
|
|
{% endif %}
|
|
})();
|
|
|
|
// Expense donut
|
|
{% if report.expense_categories %}
|
|
(function(){
|
|
const ctx = document.getElementById('expDonut').getContext('2d');
|
|
new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: {{ report.expense_categories | map(attribute='name') | list | tojson }},
|
|
datasets: [{ data: {{ report.expense_categories | map(attribute='total') | list | tojson }}, backgroundColor: {{ report.expense_categories | map(attribute='color') | list | tojson }}, borderWidth:2, borderColor:'#fff', hoverOffset:4 }]
|
|
},
|
|
options: { responsive:true, maintainAspectRatio:false, cutout:'65%', plugins:{ legend:{ display:false }, tooltip:{ callbacks:{ label: ctx => ' ' + fmtCur(ctx.parsed) } } } }
|
|
});
|
|
})();
|
|
{% endif %}
|
|
|
|
// Net worth history
|
|
{% if nw_history.count > 1 %}
|
|
(function(){
|
|
const ctx = document.getElementById('nwChart').getContext('2d');
|
|
const histLabels = {{ nw_history.labels | tojson }};
|
|
const histValues = {{ nw_history['values'] | tojson }};
|
|
{% if nw_history.proj_labels is defined and nw_history.proj_labels %}
|
|
const projLabels = {{ nw_history.proj_labels | tojson }};
|
|
const projValues = {{ nw_history.proj_values | tojson }};
|
|
// Stitch: last actual point is first projected point
|
|
const allLabels = histLabels.concat(projLabels);
|
|
const histPad = new Array(projLabels.length).fill(null);
|
|
const projPad = new Array(histLabels.length - 1).fill(null);
|
|
{% else %}
|
|
const allLabels = histLabels;
|
|
{% endif %}
|
|
new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: allLabels,
|
|
datasets: [
|
|
{ label:'Net Worth', data: {% if nw_history.proj_labels is defined and nw_history.proj_labels %}histValues.concat(histPad){% else %}histValues{% endif %}, borderColor:'#3b82f6', backgroundColor:'#3b82f611', borderWidth:2, pointRadius:3, tension:.3, fill:true },
|
|
{ label:'Assets', data: {% if nw_history.proj_labels is defined and nw_history.proj_labels %}{{ nw_history.assets | tojson }}.concat(histPad){% else %}{{ nw_history.assets | tojson }}{% endif %}, borderColor:'#10b981', borderWidth:1.5, pointRadius:2, tension:.3, fill:false, borderDash:[4,3] },
|
|
{% if nw_history.proj_labels is defined and nw_history.proj_labels %}
|
|
{ label:'Projected', data: projPad.concat([histValues[histValues.length-1]]).concat(projValues), borderColor:'#3b82f6', borderWidth:1.5, pointRadius:2, tension:.3, fill:false, borderDash:[6,4], backgroundColor:'transparent' },
|
|
{% endif %}
|
|
]
|
|
},
|
|
options: { responsive:true, maintainAspectRatio:false, plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 } } } }, scales:{ y:{ ticks:{ callback: v => fmtCur(v) }, grid:{ color:'#f1f5f9' } }, x:{ grid:{ display:false }, ticks:{ font:{ size:10 } } } } }
|
|
});
|
|
})();
|
|
{% endif %}
|
|
|
|
// Category trends
|
|
{% if cat_trend.datasets %}
|
|
(function(){
|
|
const ctx = document.getElementById('trendChart').getContext('2d');
|
|
new Chart(ctx, {
|
|
type: 'line',
|
|
data: { labels: {{ cat_trend.labels | tojson }}, datasets: {{ cat_trend.datasets | tojson }} },
|
|
options: {
|
|
responsive:true, maintainAspectRatio:false,
|
|
plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 }, boxWidth:10 } } },
|
|
scales:{ y:{ ticks:{ callback: v => fmtCur(v) }, grid:{ color:'#f1f5f9' } }, x:{ grid:{ display:false }, ticks:{ font:{ size:11 } } } }
|
|
}
|
|
});
|
|
})();
|
|
{% endif %}
|
|
</script>
|
|
{% endblock %}
|