277 lines
15 KiB
HTML
277 lines
15 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; }
|
||
{% 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 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 %}
|