702 lines
36 KiB
HTML
702 lines
36 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Dashboard{% endblock %}
|
||
{% block page_title %}Dashboard{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
.fx-card { background: #eff6ff; border-color: #bfdbfe; color: #0f172a; 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="d-flex align-items-center justify-content-between mb-2">
|
||
<div id="reconcile-notice" style="display:none;font-size:11px;color:#6d28d9;background:#f5f3ff;border:1px solid #ddd6fe;border-radius:6px;padding:3px 10px;">
|
||
<i class="bi bi-check2-circle me-1"></i><span id="reconcile-notice-text"></span>
|
||
</div>
|
||
<div class="ms-auto">
|
||
<button id="reconcile-btn" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"
|
||
title="Recalculate income & expenses excluding internal transfers">
|
||
<i class="bi bi-calculator me-1"></i>Reconcile
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="row g-3 mb-4">
|
||
<div class="col-6 col-xl-3">
|
||
<div class="stat-card" id="card-income">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div>
|
||
<div class="stat-label">Income</div>
|
||
<div id="val-income" 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" id="card-expense">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div>
|
||
<div class="stat-label">Expenses</div>
|
||
<div id="val-expense" 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 id="val-net" 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">Savings Rate</div>
|
||
<div id="val-savings" class="stat-value {% if savings_rate >= 20 %}text-income{% elif savings_rate > 0 %}text-invest{% else %}text-expense{% endif %}">
|
||
{% if savings_rate >= 0 %}+{% endif %}{{ savings_rate }}%
|
||
</div>
|
||
</div>
|
||
<div id="icon-savings" class="stat-icon" style="background:{% if savings_rate >= 20 %}#d1fae5;color:#065f46{% elif savings_rate > 0 %}#dbeafe;color:#1e40af{% else %}#fee2e2;color:#991b1b{% endif %};">
|
||
<i class="bi bi-piggy-bank"></i>
|
||
</div>
|
||
</div>
|
||
<div style="font-size:10px;color:var(--muted);margin-top:4px;">of income saved this period</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">Checking & Savings</div>
|
||
<div class="stat-value {% if total_cash >= 0 %}text-income{% else %}text-expense{% endif %}">{{ total_cash | currency }}</div>
|
||
</div>
|
||
<div class="stat-icon" style="background:#d1fae5;color:#065f46;"><i class="bi bi-wallet2"></i></div>
|
||
</div>
|
||
<div style="font-size:10px;color:var(--muted);margin-top:4px;">checking · savings · cash</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">Investments</div>
|
||
<div class="stat-value {% if total_investments >= 0 %}text-invest{% else %}text-expense{% endif %}">{{ total_investments | currency }}</div>
|
||
</div>
|
||
<div class="stat-icon" style="background:#dbeafe;color:#1e40af;"><i class="bi bi-graph-up-arrow"></i></div>
|
||
</div>
|
||
<div style="font-size:10px;color:var(--muted);margin-top:4px;">investment · crypto</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 style="font-size:10px;color:var(--muted);margin-top:4px;">assets − liabilities</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Financial Health Score -->
|
||
<div id="health-score-card" class="pcard mb-4" style="display:none;">
|
||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||
<span class="pcard-title mb-0">Financial Health Score</span>
|
||
<span id="hs-grade-badge" class="badge fw-bold" style="font-size:13px;padding:4px 12px;"></span>
|
||
</div>
|
||
<div class="row g-3 align-items-center">
|
||
<!-- Score ring -->
|
||
<div class="col-12 col-sm-auto d-flex justify-content-center">
|
||
<div style="position:relative;width:96px;height:96px;">
|
||
<svg viewBox="0 0 36 36" style="width:96px;height:96px;transform:rotate(-90deg);">
|
||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="var(--border)" stroke-width="3"/>
|
||
<circle id="hs-ring" cx="18" cy="18" r="15.9155" fill="none" stroke="#10b981" stroke-width="3"
|
||
stroke-dasharray="0 100" stroke-linecap="round"
|
||
style="transition:stroke-dasharray .8s ease, stroke .4s;"/>
|
||
</svg>
|
||
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;">
|
||
<div id="hs-score" class="mono fw-bold" style="font-size:22px;line-height:1;">—</div>
|
||
<div style="font-size:10px;color:var(--muted);">/ 100</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Component bars -->
|
||
<div class="col">
|
||
<div id="hs-components" class="d-flex flex-column gap-2"></div>
|
||
</div>
|
||
</div>
|
||
<!-- Tips -->
|
||
<div id="hs-tips" class="mt-3" style="display:none;">
|
||
<div class="d-flex flex-wrap gap-2" id="hs-tips-list"></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:700;text-transform:uppercase;letter-spacing:.07em;color:#1d4ed8;">USD → VND</div>
|
||
<div class="mono" id="fxRate" style="font-size:24px;font-weight:700;margin-top:4px;color:#0f172a;">₫{{ "{:,.0f}".format(fx.rate) }}</div>
|
||
<div style="font-size:11px;color:#475569;margin-top:2px;" id="fxMeta">
|
||
1 USD · <span id="fxDate">{{ fx.date.strftime('%b %d, %Y') }}</span>
|
||
· <span id="fxSource" style="opacity:.7;">{{ fx.source }}</span>
|
||
{% if fx.is_stale %}<span id="fxStale" title="Rate may be outdated" style="color:#d97706;"> ⚠ stale</span>{% endif %}
|
||
</div>
|
||
</div>
|
||
<button id="fxRefreshBtn" onclick="refreshFxRate()" title="Refresh rate"
|
||
style="background:none;border:none;color:#3b82f6;cursor:pointer;font-size:15px;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:#eff6ff;border-color:#bfdbfe;">
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#1d4ed8;">USD → VND</div>
|
||
<div style="font-size:13px;color:#64748b;margin-top:6px;">Rate unavailable</div>
|
||
</div>
|
||
<button onclick="refreshFxRate()" style="background:none;border:none;color:#3b82f6;cursor:pointer;font-size:15px;">
|
||
<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 %}
|
||
|
||
<!-- Spending Anomalies -->
|
||
<div id="anomaly-card" class="pcard mb-4" style="display:none;">
|
||
<div class="d-flex align-items-center gap-2 mb-3">
|
||
<i class="bi bi-exclamation-triangle" style="color:#f59e0b;font-size:15px;"></i>
|
||
<span class="pcard-title mb-0">Unusual Spending Detected</span>
|
||
</div>
|
||
<div id="anomaly-list"></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>
|
||
{% 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 %}
|
||
<div class="table-wrap">
|
||
<table class="pfm-table">
|
||
<thead>
|
||
<tr><th>Date</th><th>Description</th><th class="d-mob-none">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 class="d-mob-none">
|
||
{% 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>
|
||
</div>
|
||
{% 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>
|
||
// ── Period memory (localStorage) ─────────────────────────────────────────────
|
||
(function () {
|
||
var KEY_P = 'pfm_dash_period', KEY_Q = 'pfm_dash_params';
|
||
var params = new URLSearchParams(window.location.search);
|
||
|
||
// If landing with no period param, restore last saved period
|
||
if (!params.has('period') && !params.has('date_from')) {
|
||
var saved = localStorage.getItem(KEY_Q);
|
||
if (saved) {
|
||
window.location.replace('/?' + saved);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Save current period whenever a period button is clicked
|
||
document.querySelectorAll('a.period-btn').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
var url = new URL(this.href, location.origin);
|
||
var p = url.searchParams.get('period');
|
||
if (p) {
|
||
localStorage.setItem(KEY_P, p);
|
||
localStorage.setItem(KEY_Q, url.search.slice(1));
|
||
}
|
||
});
|
||
});
|
||
|
||
// Save when custom range form is submitted
|
||
var customForm = document.querySelector('#customModal form');
|
||
if (customForm) {
|
||
customForm.addEventListener('submit', function () {
|
||
var fd = new FormData(this);
|
||
localStorage.setItem(KEY_P, 'custom');
|
||
localStorage.setItem(KEY_Q, new URLSearchParams(fd).toString());
|
||
});
|
||
}
|
||
|
||
// Save the current period (handles direct URL visits with ?period=xxx)
|
||
var cur = params.get('period');
|
||
if (cur) {
|
||
localStorage.setItem(KEY_P, cur);
|
||
localStorage.setItem(KEY_Q, params.toString());
|
||
}
|
||
})();
|
||
|
||
(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>'; });
|
||
}
|
||
|
||
// ── Reconcile ─────────────────────────────────────────────────────────────────
|
||
(function () {
|
||
const btn = document.getElementById('reconcile-btn');
|
||
const notice = document.getElementById('reconcile-notice');
|
||
const noticeText = document.getElementById('reconcile-notice-text');
|
||
if (!btn) return;
|
||
|
||
const SYM = '{{ current_user.currency_symbol }}';
|
||
let reconciled = false;
|
||
|
||
// Store original values from the rendered HTML so we can restore them
|
||
const orig = {
|
||
income: document.getElementById('val-income').textContent,
|
||
expense: document.getElementById('val-expense').textContent,
|
||
net: document.getElementById('val-net').textContent,
|
||
savings: document.getElementById('val-savings').textContent,
|
||
};
|
||
|
||
function fmtCurrency(n) {
|
||
const abs = Math.abs(n);
|
||
let s;
|
||
if (abs >= 1000000) s = SYM + (abs / 1000000).toFixed(2) + 'M';
|
||
else if (abs >= 1000) s = SYM + (abs / 1000).toFixed(1) + 'K';
|
||
else s = SYM + abs.toFixed(2);
|
||
return n < 0 ? '-' + s : s;
|
||
}
|
||
|
||
function colorClass(n, positive_class, negative_class) {
|
||
return n >= 0 ? positive_class : negative_class;
|
||
}
|
||
|
||
function applyValues(data) {
|
||
const income = document.getElementById('val-income');
|
||
const expense = document.getElementById('val-expense');
|
||
const net = document.getElementById('val-net');
|
||
const savings = document.getElementById('val-savings');
|
||
|
||
income.textContent = fmtCurrency(data.income);
|
||
expense.textContent = fmtCurrency(data.expense);
|
||
|
||
net.textContent = fmtCurrency(data.net_cash_flow);
|
||
net.className = 'stat-value ' + colorClass(data.net_cash_flow, 'text-income', 'text-expense');
|
||
|
||
const sr = data.savings_rate;
|
||
savings.textContent = (sr >= 0 ? '+' : '') + sr + '%';
|
||
savings.className = 'stat-value ' + (sr >= 20 ? 'text-income' : sr > 0 ? 'text-invest' : 'text-expense');
|
||
}
|
||
|
||
function restoreOriginal() {
|
||
document.getElementById('val-income').textContent = orig.income;
|
||
document.getElementById('val-expense').textContent = orig.expense;
|
||
document.getElementById('val-net').textContent = orig.net;
|
||
document.getElementById('val-savings').textContent = orig.savings;
|
||
// restore original color classes
|
||
document.getElementById('val-net').className = 'stat-value {% if net_cash_flow >= 0 %}text-income{% else %}text-expense{% endif %}';
|
||
document.getElementById('val-savings').className = 'stat-value {% if savings_rate >= 20 %}text-income{% elif savings_rate > 0 %}text-invest{% else %}text-expense{% endif %}';
|
||
}
|
||
|
||
btn.addEventListener('click', function () {
|
||
if (reconciled) {
|
||
// Toggle back to original
|
||
restoreOriginal();
|
||
notice.style.display = 'none';
|
||
btn.innerHTML = '<i class="bi bi-calculator me-1"></i>Reconcile';
|
||
btn.classList.remove('btn-primary');
|
||
btn.classList.add('btn-outline-secondary');
|
||
reconciled = false;
|
||
return;
|
||
}
|
||
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Calculating…';
|
||
|
||
const params = new URLSearchParams({ period: '{{ period }}' });
|
||
{% if period == 'custom' %}
|
||
params.set('date_from', '{{ date_from.strftime("%Y-%m-%d") }}');
|
||
params.set('date_to', '{{ date_to.strftime("%Y-%m-%d") }}');
|
||
{% endif %}
|
||
|
||
fetch('/api/reconcile?' + params)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
applyValues(data);
|
||
|
||
const excl_i = fmtCurrency(data.excluded_income);
|
||
const excl_e = fmtCurrency(data.excluded_expense);
|
||
const cats = data.transfer_categories.length
|
||
? data.transfer_categories.join(', ')
|
||
: 'none found';
|
||
|
||
if (data.excluded_income === 0 && data.excluded_expense === 0) {
|
||
noticeText.textContent = 'No internal transfers found for this period (' + cats + ')';
|
||
} else {
|
||
noticeText.textContent = 'Excluding transfers — income −' + excl_i + ', expenses −' + excl_e + ' (' + cats + ')';
|
||
}
|
||
notice.style.display = '';
|
||
btn.innerHTML = '<i class="bi bi-calculator-fill me-1"></i>Original';
|
||
btn.classList.remove('btn-outline-secondary');
|
||
btn.classList.add('btn-primary');
|
||
btn.disabled = false;
|
||
reconciled = true;
|
||
})
|
||
.catch(err => {
|
||
btn.innerHTML = '<i class="bi bi-calculator me-1"></i>Reconcile';
|
||
btn.disabled = false;
|
||
alert('Reconcile failed: ' + err);
|
||
});
|
||
});
|
||
})();
|
||
|
||
// ── Financial health score ───────────────────────────────────────────────────
|
||
(function(){
|
||
var card = document.getElementById('health-score-card');
|
||
var ring = document.getElementById('hs-ring');
|
||
var scoreEl = document.getElementById('hs-score');
|
||
var gradeEl = document.getElementById('hs-grade-badge');
|
||
var compsEl = document.getElementById('hs-components');
|
||
var tipsWrap = document.getElementById('hs-tips');
|
||
var tipsList = document.getElementById('hs-tips-list');
|
||
if (!card) return;
|
||
|
||
const SYM = '{{ current_user.currency_symbol }}';
|
||
|
||
fetch('/api/health-score')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.error) return;
|
||
card.style.display = '';
|
||
|
||
// Score ring
|
||
var circ = 100;
|
||
ring.style.strokeDasharray = (data.score / 100 * circ) + ' ' + circ;
|
||
ring.style.stroke = data.color;
|
||
scoreEl.textContent = data.score;
|
||
scoreEl.style.color = data.color;
|
||
|
||
// Grade badge
|
||
gradeEl.textContent = 'Grade ' + data.grade;
|
||
gradeEl.style.background = data.color + '22';
|
||
gradeEl.style.color = data.color;
|
||
|
||
// Component bars
|
||
var icons = {
|
||
savings: 'bi-piggy-bank',
|
||
budgets: 'bi-pie-chart',
|
||
goals: 'bi-bullseye',
|
||
emergency_fund: 'bi-shield-check',
|
||
};
|
||
compsEl.innerHTML = data.components.map(c => `
|
||
<div>
|
||
<div class="d-flex justify-content-between align-items-center mb-1" style="font-size:12px;">
|
||
<span><i class="bi ${c.icon} me-1" style="color:var(--muted);"></i>${c.name}</span>
|
||
<span class="mono" style="color:var(--muted);">${c.points}/${c.max}</span>
|
||
</div>
|
||
<div class="progress" style="height:5px;border-radius:3px;">
|
||
<div class="progress-bar" role="progressbar"
|
||
style="width:${c.pct}%;background:${c.pct>=80?'#10b981':c.pct>=50?'#3b82f6':c.pct>=25?'#f59e0b':'#ef4444'};transition:width .6s ease;">
|
||
</div>
|
||
</div>
|
||
${c.label ? `<div style="font-size:10px;color:var(--muted);margin-top:1px;">${c.label}</div>` : ''}
|
||
</div>`).join('');
|
||
|
||
// Tips
|
||
if (data.tips && data.tips.length) {
|
||
tipsWrap.style.display = '';
|
||
tipsList.innerHTML = data.tips.map(t =>
|
||
`<span style="font-size:11px;background:#fef3c7;color:#92400e;border-radius:20px;padding:3px 10px;display:inline-block;">
|
||
<i class="bi bi-lightbulb me-1"></i>${t}
|
||
</span>`
|
||
).join('');
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
})();
|
||
|
||
// ── Spending anomalies ───────────────────────────────────────────────────────
|
||
(function(){
|
||
const card = document.getElementById('anomaly-card');
|
||
const list = document.getElementById('anomaly-list');
|
||
if (!card || !list) return;
|
||
const SYM = '{{ current_user.currency_symbol }}';
|
||
function fmt(n){ return SYM + Math.abs(n).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2}); }
|
||
fetch('/api/anomalies')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const items = data.anomalies || [];
|
||
if (!items.length) return; // stay hidden
|
||
list.innerHTML = items.map(a => `
|
||
<div class="d-flex justify-content-between align-items-center py-2" style="border-bottom:1px solid var(--border);font-size:13px;">
|
||
<div class="d-flex align-items-center gap-2">
|
||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#f59e0b;flex-shrink:0;"></span>
|
||
<div>
|
||
<div class="fw-medium">${a.description}</div>
|
||
<div class="text-muted" style="font-size:11px;">${a.category} · ${a.date}</div>
|
||
</div>
|
||
</div>
|
||
<div class="text-end flex-shrink-0 ms-3">
|
||
<div class="mono text-expense">${fmt(a.amount)}</div>
|
||
<div style="font-size:11px;color:#f59e0b;">${a.multiple.toFixed(1)}× avg ${fmt(a.avg_amount)}</div>
|
||
</div>
|
||
</div>`).join('');
|
||
card.style.display = '';
|
||
})
|
||
.catch(() => {}); // silently ignore on error
|
||
})();
|
||
|
||
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', backgroundColor:'#dbeafe55', borderWidth:1.5, pointRadius:0, tension:.3, fill:true }] },
|
||
options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ display:false } },
|
||
scales:{ x:{ display:false }, y:{ grid:{ color:'#dbeafe' }, ticks:{ color:'#64748b', font:{ size:9 }, callback: v=>'₫'+(v/1000).toFixed(0)+'K' } } } }
|
||
});
|
||
}
|
||
}
|
||
</script>
|
||
{% endblock %}
|