Files
Personal-Finance-Management/app/templates/dashboard/index.html
T
2026-06-05 15:23:23 -04:00

702 lines
36 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: #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 &amp; 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 &amp; 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 %}