356 lines
19 KiB
HTML
356 lines
19 KiB
HTML
{% extends "base.html" %}
|
||
{% 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; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
{% 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;"><i class="bi bi-calendar-check"></i><span class="btn-label ms-1">This Month</span></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;"><i class="bi bi-calendar"></i><span class="btn-label ms-1">Last Month</span></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"><i class="bi bi-calendar-range"></i><span class="btn-label ms-1">Custom</span></button>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
{% if schwab_warning %}
|
||
<div class="alert alert-warning d-flex align-items-center gap-2 mb-3" style="font-size:13px;">
|
||
<i class="bi bi-exclamation-triangle-fill flex-shrink-0"></i>
|
||
<div>
|
||
{{ schwab_warning }}
|
||
<a href="{{ url_for('schwab.connect') }}" class="alert-link ms-1">Reconnect now →</a>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
<div class="d-flex align-items-start align-items-sm-center justify-content-between flex-wrap gap-2 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"></i><span class="btn-label ms-1">Income</span></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-dash-lg"></i><span class="btn-label ms-1">Expense</span></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" id="fxWidget">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div onclick="toggleFxChart()" style="cursor:pointer;flex:1;">
|
||
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:#475569;">USD → VND</div>
|
||
<div class="mono" id="fxRate" 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;" id="fxMeta">
|
||
1 USD · <span id="fxDate">{{ fx.date.strftime('%b %d, %Y') }}</span>
|
||
· <span id="fxSource" style="opacity:.6;">{{ fx.source }}</span>
|
||
{% if fx.is_stale %}<span id="fxStale" title="Rate may be outdated" style="color:#f59e0b;"> ⚠ stale</span>{% endif %}
|
||
</div>
|
||
</div>
|
||
<button id="fxRefreshBtn" onclick="refreshFxRate()" title="Refresh rate"
|
||
style="background:none;border:none;color:#475569;cursor:pointer;font-size:14px;padding:2px 4px;line-height:1;margin-top:2px;">
|
||
<i class="bi bi-arrow-clockwise"></i>
|
||
</button>
|
||
</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 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 style="font-size:13px;color:#94a3b8;margin-top:6px;">Rate unavailable</div>
|
||
</div>
|
||
<button onclick="refreshFxRate()" style="background:none;border:none;color:#475569;cursor:pointer;font-size:14px;">
|
||
<i class="bi bi-arrow-clockwise"></i>
|
||
</button>
|
||
</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>
|
||
|
||
|
||
<!-- AI Daily Insight -->
|
||
{% if ai_insight %}
|
||
<div class="pcard mb-4" style="background:linear-gradient(135deg,#0f172a 0%,#1e293b 100%);border-color:#334155;color:#f1f5f9;">
|
||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||
<div class="d-flex align-items-center gap-2">
|
||
<i class="bi bi-stars" style="color:#818cf8;font-size:16px;"></i>
|
||
<span style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:#818cf8;">AI Daily Insight</span>
|
||
<span style="font-size:10px;color:#475569;">{{ ai_insight.insight_date.strftime('%b %d') }}</span>
|
||
</div>
|
||
<a href="{{ url_for('ai.index') }}" style="font-size:12px;color:#818cf8;">Ask AI →</a>
|
||
</div>
|
||
<p style="font-size:13px;color:#cbd5e1;line-height:1.6;margin:0;">{{ ai_insight.content }}</p>
|
||
</div>
|
||
{% else %}
|
||
<div class="pcard mb-4" style="background:#0f172a;border-color:#1e293b;color:#94a3b8;">
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<div class="d-flex align-items-center gap-2">
|
||
<i class="bi bi-stars" style="color:#475569;font-size:16px;"></i>
|
||
<span style="font-size:13px;">AI daily insight not yet generated.</span>
|
||
</div>
|
||
<a href="{{ url_for('ai.index') }}" class="btn btn-sm" style="font-size:12px;background:#1e293b;color:#818cf8;border:1px solid #334155;">Open AI</a>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Accounts + Recent Transactions -->
|
||
<div class="row g-3">
|
||
<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>
|
||
{% if acct.account_type == 'credit_card' %}
|
||
{% set cc_owed = [0, -acct.balance] | max %}
|
||
<div class="text-end">
|
||
<span class="mono {% if cc_owed > 0 %}text-expense{% else %}text-income{% endif %}" style="font-size:13px;font-weight:600;">
|
||
{% if cc_owed > 0 %}-{% endif %}{{ cc_owed | currency }}
|
||
</span>
|
||
<div style="font-size:10px;color:var(--muted);">owed</div>
|
||
</div>
|
||
{% else %}
|
||
<span class="mono {% if acct.balance >= 0 %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:600;">{{ acct.balance | currency }}</span>
|
||
{% endif %}
|
||
</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 refreshFxRate() {
|
||
const btn = document.getElementById('fxRefreshBtn');
|
||
if (btn) { btn.innerHTML = '<i class="bi bi-arrow-clockwise" style="animation:spin .7s linear infinite;display:inline-block;"></i>'; }
|
||
fetch('/api/fx-refresh', { method:'POST', headers:{'Content-Type':'application/json','X-CSRFToken': csrfToken} })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.rate) {
|
||
const rateEl = document.getElementById('fxRate');
|
||
const dateEl = document.getElementById('fxDate');
|
||
const srcEl = document.getElementById('fxSource');
|
||
const staleEl = document.getElementById('fxStale');
|
||
if (rateEl) rateEl.textContent = '₫' + Math.round(data.rate).toLocaleString();
|
||
if (dateEl) dateEl.textContent = data.date;
|
||
if (srcEl) srcEl.textContent = data.source;
|
||
if (staleEl) staleEl.style.display = data.is_stale ? '' : 'none';
|
||
}
|
||
if (btn) btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
||
})
|
||
.catch(() => { if (btn) btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>'; });
|
||
}
|
||
|
||
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 %}
|