05/31 Phase 6

This commit is contained in:
2026-05-31 16:55:43 -04:00
parent b3d495e57c
commit 46a604a73a
8 changed files with 1172 additions and 1 deletions
+1 -1
View File
@@ -200,7 +200,7 @@
<a href="{{ url_for('investments.index') }}" class="sb-link {% if request.blueprint == 'investments' %}active{% endif %}">
<i class="bi bi-graph-up-arrow"></i><span class="lt">Investments</span>
</a>
<a href="#" class="sb-link">
<a href="{{ url_for('reports.index') }}" class="sb-link {% if request.blueprint == 'reports' %}active{% endif %}">
<i class="bi bi-file-earmark-bar-graph"></i><span class="lt">Reports</span>
</a>
+281
View File
@@ -0,0 +1,281 @@
{% 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>
<!-- 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">
<span class="pcard-title mb-0">Net Worth History</span>
<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>
</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');
new Chart(ctx, {
type: 'line',
data: {
labels: {{ nw_history.labels | tojson }},
datasets: [
{ label:'Net Worth', data: {{ nw_history.values | tojson }}, borderColor:'#3b82f6', backgroundColor:'#3b82f611', borderWidth:2, pointRadius:3, tension:.3, fill:true },
{ label:'Assets', data: {{ nw_history.assets | tojson }}, borderColor:'#10b981', borderWidth:1.5, pointRadius:2, tension:.3, fill:false, borderDash:[4,3] },
]
},
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 %}
+147
View File
@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}Tax Summary {{ report.year }}{% endblock %}
{% block page_title %}Tax Year Summary{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('reports.export_csv', year=selected_year) }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">
<i class="bi bi-filetype-csv me-1"></i>Export CSV
</a>
{% endblock %}
{% block content %}
<!-- Year selector -->
<div class="d-flex align-items-center gap-3 mb-4">
<form method="GET" action="{{ url_for('reports.tax') }}" class="d-flex gap-2">
<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>
<button type="submit" class="btn btn-sm btn-primary" style="font-size:12px;">Go</button>
</form>
<span class="text-muted" style="font-size:12px;">{{ report.date_from.strftime('%b %d, %Y') }} {{ report.date_to.strftime('%b %d, %Y') }}</span>
</div>
<!-- Summary -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-label">Total Income</div>
<div class="stat-value text-income">{{ report.total_income | currency }}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-label">Total Expenses</div>
<div class="stat-value text-expense">{{ report.total_expense | currency }}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-label">Net for Year</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-md-3">
<div class="stat-card">
<div class="stat-label">Income Sources</div>
<div class="stat-value text-invest">{{ report.income_categories | length }}</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Income by category -->
<div class="col-12 col-lg-6">
<div class="pcard p-0">
<div class="px-4 py-3" style="border-bottom:1px solid var(--border);">
<span class="pcard-title mb-0">Income by Source</span>
</div>
<table class="pfm-table">
<thead><tr><th style="padding-left:20px;">Category</th><th class="text-end" style="padding-right:20px;">Total</th></tr></thead>
<tbody>
{% if report.income_categories %}
{% for cat in report.income_categories %}
<tr>
<td style="padding-left:20px;">
<div class="d-flex align-items-center gap-2">
<div style="width:8px;height:8px;border-radius:2px;background:{{ cat.color }};"></div>
<span style="font-size:13px;">{{ cat.name }}</span>
</div>
</td>
<td class="text-end mono text-income" style="font-size:13px;font-weight:500;padding-right:20px;">{{ cat.total | currency }}</td>
</tr>
{% endfor %}
<tr style="font-weight:700;background:#f8fafc;">
<td style="padding-left:20px;font-size:13px;">Total</td>
<td class="text-end mono text-income" style="font-size:13px;padding-right:20px;">{{ report.total_income | currency }}</td>
</tr>
{% else %}
<tr><td colspan="2" class="text-muted text-center py-3" style="font-size:13px;">No income recorded</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Expense by category -->
<div class="col-12 col-lg-6">
<div class="pcard p-0">
<div class="px-4 py-3" style="border-bottom:1px solid var(--border);">
<span class="pcard-title mb-0">Expenses by Category</span>
</div>
<table class="pfm-table">
<thead><tr><th style="padding-left:20px;">Category</th><th class="text-end" style="padding-right:20px;">Total</th></tr></thead>
<tbody>
{% if report.expense_categories %}
{% for cat in report.expense_categories %}
<tr>
<td style="padding-left:20px;">
<div class="d-flex align-items-center gap-2">
<div style="width:8px;height:8px;border-radius:2px;background:{{ cat.color }};"></div>
<span style="font-size:13px;">{{ cat.name }}</span>
</div>
</td>
<td class="text-end mono text-expense" style="font-size:13px;font-weight:500;padding-right:20px;">{{ cat.total | currency }}</td>
</tr>
{% endfor %}
<tr style="font-weight:700;background:#f8fafc;">
<td style="padding-left:20px;font-size:13px;">Total</td>
<td class="text-end mono text-expense" style="font-size:13px;padding-right:20px;">{{ report.total_expense | currency }}</td>
</tr>
{% else %}
<tr><td colspan="2" class="text-muted text-center py-3" style="font-size:13px;">No expenses recorded</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- All income transactions for tax year -->
{% if report.income_transactions %}
<div class="pcard p-0 mt-3">
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
<span class="pcard-title mb-0">All Income — {{ report.year }}</span>
<span class="text-muted" style="font-size:12px;">{{ report.income_transactions | length }} transactions</span>
</div>
<table class="pfm-table">
<thead><tr><th style="padding-left:20px;">Date</th><th>Description</th><th>Category</th><th class="text-end" style="padding-right:20px;">Amount</th></tr></thead>
<tbody>
{% for txn in report.income_transactions %}
<tr>
<td style="padding-left:20px;font-size:12px;color:var(--muted);white-space:nowrap;">{{ txn.date.strftime('%b %d, %Y') }}</td>
<td style="font-size:13px;">{{ txn.description }}</td>
<td style="font-size:12px;color:var(--muted);">{{ txn.category.name if txn.category else '—' }}</td>
<td class="text-end mono text-income" style="font-size:13px;font-weight:500;padding-right:20px;">+{{ txn.amount | currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<div class="mt-3">
<a href="{{ url_for('reports.index') }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Back to Reports</a>
</div>
{% endblock %}