05/31 Phase 2

This commit is contained in:
2026-05-31 11:17:53 -04:00
parent 1c34dcbd28
commit 9f2a0acc38
18 changed files with 1817 additions and 391 deletions
+266 -5
View File
@@ -2,14 +2,275 @@
{% block title %}Dashboard{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block extra_css %}
.fx-card { background: #0f172a; border-color: #1e293b; color: #f1f5f9; cursor: pointer; transition: all .2s; }
.fx-card:hover { border-color: #3b82f6 !important; }
.period-btn.active { background: #3b82f6; color: #fff; border-color: #3b82f6; }
{% endblock %}
{% block topbar_actions %}
<div class="d-flex align-items-center gap-1">
<a href="?period=this_month" class="btn btn-sm btn-outline-secondary period-btn {% if period=='this_month' %}active{% endif %}" style="font-size:12px;">This Month</a>
<a href="?period=last_month" class="btn btn-sm btn-outline-secondary period-btn {% if period=='last_month' %}active{% endif %}" style="font-size:12px;">Last Month</a>
<button class="btn btn-sm btn-outline-secondary period-btn {% if period=='custom' %}active{% endif %}" style="font-size:12px;" data-bs-toggle="modal" data-bs-target="#customModal">Custom</button>
</div>
{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h5 class="mb-0 fw-semibold">{{ period_label }}</h5>
<small class="text-muted">{{ date_from.strftime('%b %d') }} {{ date_to.strftime('%b %d, %Y') }}</small>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('transactions.new', type='income') }}" class="btn btn-sm" style="background:#d1fae5;color:#065f46;font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Income</a>
<a href="{{ url_for('transactions.new', type='expense') }}" class="btn btn-sm" style="background:#fee2e2;color:#991b1b;font-size:12px;"><i class="bi bi-plus-lg me-1"></i>Expense</a>
</div>
</div>
<!-- Summary Cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Income</div>
<div class="stat-value text-income">{{ total_income | currency }}</div>
</div>
<div class="stat-icon" style="background:#d1fae5;color:#065f46;"><i class="bi bi-arrow-down-circle"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Expenses</div>
<div class="stat-value text-expense">{{ total_expense | currency }}</div>
</div>
<div class="stat-icon" style="background:#fee2e2;color:#991b1b;"><i class="bi bi-arrow-up-circle"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Net Cash Flow</div>
<div class="stat-value {% if net_cash_flow >= 0 %}text-income{% else %}text-expense{% endif %}">{{ net_cash_flow | currency }}</div>
</div>
<div class="stat-icon" style="background:#dbeafe;color:#1e40af;"><i class="bi bi-arrow-left-right"></i></div>
</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Net Worth</div>
<div class="stat-value {% if net_worth >= 0 %}text-invest{% else %}text-expense{% endif %}">{{ net_worth | currency }}</div>
</div>
<div class="stat-icon" style="background:#ede9fe;color:#5b21b6;"><i class="bi bi-bank"></i></div>
</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="row g-3 mb-4">
<div class="col-12 col-xl-8">
<div class="pcard h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="pcard-title mb-0">Cash Flow — Last 6 Months</span>
<div class="d-flex gap-3" style="font-size:11px;color:var(--muted);">
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#10b981;margin-right:4px;"></span>Income</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:#ef4444;margin-right:4px;"></span>Expenses</span>
</div>
</div>
<div style="position:relative;height:220px;"><canvas id="cashflowChart"></canvas></div>
</div>
</div>
<div class="col-12 col-xl-4 d-flex flex-column gap-3">
<!-- USD/VND Widget -->
{% if fx %}
<div class="pcard fx-card" onclick="toggleFxChart()">
<div class="d-flex justify-content-between align-items-center">
<div>
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:#475569;">USD → VND</div>
<div class="mono" style="font-size:24px;font-weight:600;margin-top:4px;">₫{{ "{:,.0f}".format(fx.rate) }}</div>
<div style="font-size:11px;color:#64748b;margin-top:2px;">
1 USD · {{ fx.date.strftime('%b %d, %Y') }}
{% if fx.is_stale %}<span title="Rate may be outdated">⚠️</span>{% endif %}
</div>
</div>
<div style="font-size:32px;opacity:.15;font-family:serif;"></div>
</div>
<div id="fxChartWrap" style="display:none;margin-top:12px;">
<div style="height:70px;"><canvas id="fxChart"></canvas></div>
</div>
</div>
{% else %}
<div class="pcard" style="background:#1e293b;border-color:#334155;">
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:#475569;">USD → VND</div>
<div style="font-size:13px;color:#94a3b8;margin-top:6px;">Rate unavailable</div>
</div>
{% endif %}
<!-- Top Spending -->
<div class="pcard flex-grow-1">
<div class="pcard-title">Top Spending</div>
{% if top_categories %}
{% set max_val = top_categories[0].total %}
{% for cat in top_categories %}
<div class="mb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<span style="font-size:13px;"><i class="bi {{ cat.icon }} me-1" style="color:{{ cat.color }};"></i>{{ cat.name }}</span>
<span class="mono" style="font-size:12px;color:var(--muted);">{{ cat.total | currency }}</span>
</div>
<div style="height:3px;background:#f1f5f9;border-radius:2px;">
<div style="height:3px;background:{{ cat.color }};border-radius:2px;width:{{ ((cat.total / max_val) * 100)|round(1) }}%;"></div>
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted small mb-0">No expenses this period.</p>
{% endif %}
</div>
</div>
</div>
<!-- Accounts + Recent Transactions -->
<div class="row g-3">
<div class="col-12">
<div class="pfm-card text-center py-5">
<i class="bi bi-grid-1x2 text-muted" style="font-size:3rem;"></i>
<h5 class="mt-3 mb-1">Dashboard</h5>
<p class="text-muted small">Phase 2 will populate this with live data.</p>
<div class="col-12 col-lg-4">
<div class="pcard h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="pcard-title mb-0">Accounts</span>
<a href="{{ url_for('accounts.new') }}" class="btn btn-sm btn-outline-primary" style="font-size:11px;padding:2px 8px;"><i class="bi bi-plus-lg"></i></a>
</div>
{% if accounts %}
{% for acct in accounts %}
<div class="d-flex justify-content-between align-items-center py-2" style="border-bottom:1px solid var(--border);">
<div class="d-flex align-items-center gap-2">
<div style="width:28px;height:28px;border-radius:7px;background:{{ acct.color }}22;color:{{ acct.color }};display:flex;align-items:center;justify-content:center;font-size:14px;">
<i class="bi {{ acct.icon }}"></i>
</div>
<div>
<div style="font-size:13px;font-weight:500;">{{ acct.name }}</div>
<div style="font-size:11px;color:var(--muted);">{{ acct.account_type | replace('_',' ') | title }}</div>
</div>
</div>
<span class="mono {% if acct.balance >= 0 %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:600;">{{ acct.balance | currency }}</span>
</div>
{% endfor %}
<div class="mt-3 pt-1" style="font-size:12px;color:var(--muted);">
<div class="d-flex justify-content-between"><span>Assets</span><span class="mono text-income">{{ total_assets | currency }}</span></div>
<div class="d-flex justify-content-between mt-1"><span>Liabilities</span><span class="mono text-expense">{{ total_liabilities | currency }}</span></div>
</div>
{% else %}
<p class="text-muted small">No accounts. <a href="{{ url_for('accounts.new') }}">Add one</a>.</p>
{% endif %}
</div>
</div>
<div class="col-12 col-lg-8">
<div class="pcard h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="pcard-title mb-0">Recent Transactions</span>
<a href="{{ url_for('transactions.index') }}" style="font-size:12px;color:#3b82f6;">View all</a>
</div>
{% if recent_txns %}
<table class="pfm-table">
<thead>
<tr><th>Date</th><th>Description</th><th>Category</th><th class="text-end">Amount</th></tr>
</thead>
<tbody>
{% for txn in recent_txns %}
<tr>
<td style="font-size:12px;color:var(--muted);white-space:nowrap;">{{ txn.date.strftime('%b %d') }}</td>
<td>
<div style="font-size:13px;font-weight:500;">{{ txn.description }}</div>
<div style="font-size:11px;color:var(--muted);">{{ txn.account.name if txn.account else '—' }}</div>
</td>
<td>
{% if txn.category %}
<span style="font-size:12px;"><i class="bi {{ txn.category.icon }}" style="color:{{ txn.category.color }};"></i> {{ txn.category.name }}</span>
{% else %}<span class="text-muted" style="font-size:12px;"></span>{% endif %}
</td>
<td class="text-end mono {% if txn.transaction_type=='income' %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:600;">
{% if txn.transaction_type=='income' %}+{% else %}-{% endif %}{{ txn.amount | currency }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted small">No transactions yet. <a href="{{ url_for('transactions.new', type='expense') }}">Add one</a>.</p>
{% endif %}
</div>
</div>
</div>
<!-- Custom Range Modal -->
<div class="modal fade" id="customModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2 px-3"><h6 class="modal-title mb-0">Custom Range</h6><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<form method="GET" action="{{ url_for('dashboard.index') }}">
<input type="hidden" name="period" value="custom">
<div class="modal-body">
<div class="mb-3">
<label class="form-label" style="font-size:12px;">From</label>
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from.strftime('%Y-%m-%d') }}">
</div>
<div>
<label class="form-label" style="font-size:12px;">To</label>
<input type="date" name="date_to" class="form-control form-control-sm" value="{{ date_to.strftime('%Y-%m-%d') }}">
</div>
</div>
<div class="modal-footer py-2 px-3"><button type="submit" class="btn btn-primary btn-sm">Apply</button></div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function(){
const ctx = document.getElementById('cashflowChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: {{ chart_months | tojson }},
datasets: [
{ label:'Income', data: {{ chart_income | tojson }}, backgroundColor:'#10b98133', borderColor:'#10b981', borderWidth:2, borderRadius:4 },
{ label:'Expenses', data: {{ chart_expense | tojson }}, backgroundColor:'#ef444433', borderColor:'#ef4444', borderWidth:2, borderRadius:4 }
]
},
options: {
responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ display:false } },
scales:{
x:{ grid:{ display:false }, ticks:{ font:{ size:11 } } },
y:{ grid:{ color:'#f1f5f9' }, ticks:{ font:{ size:11 }, callback: v => '{{ current_user.currency_symbol }}' + (v>=1000?(v/1000).toFixed(0)+'K':v) } }
}
}
});
})();
var fxChart = null;
function toggleFxChart(){
const wrap = document.getElementById('fxChartWrap');
wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';
if (wrap.style.display === 'block' && !fxChart) {
const h = {{ fx_history_data | tojson }};
fxChart = new Chart(document.getElementById('fxChart').getContext('2d'), {
type:'line',
data:{ labels:h.dates, datasets:[{ data:h.rates, borderColor:'#3b82f6', borderWidth:1.5, pointRadius:0, tension:.3, fill:false }] },
options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ display:false } },
scales:{ x:{ display:false }, y:{ grid:{ color:'#1e293b' }, ticks:{ color:'#64748b', font:{ size:9 }, callback: v=>'₫'+(v/1000).toFixed(0)+'K' } } } }
});
}
}
</script>
{% endblock %}