Files
Personal-Finance-Management/app/templates/reports/index.html
T

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 %}