Files
Personal-Finance-Management/app/templates/dashboard/index.html
T
2026-06-03 16:06:30 -04:00

356 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}