06/05 Optimize app: Dashboard adds Reconcile button and separate Cashes and Investment

This commit is contained in:
2026-06-05 13:20:17 -04:00
parent 3a113c89c4
commit 452374365c
2 changed files with 234 additions and 7 deletions
+153 -7
View File
@@ -39,24 +39,35 @@
</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">
<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 class="stat-value text-income">{{ total_income | currency }}</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">
<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 class="stat-value text-expense">{{ total_expense | currency }}</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>
@@ -67,7 +78,7 @@
<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 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>
@@ -78,17 +89,41 @@
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Savings Rate</div>
<div class="stat-value {% if savings_rate >= 20 %}text-income{% elif savings_rate > 0 %}text-invest{% else %}text-expense{% endif %}">
<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 class="stat-icon" style="background:{% if savings_rate >= 20 %}#d1fae5;color:#065f46{% elif savings_rate > 0 %}#dbeafe;color:#1e40af{% else %}#fee2e2;color:#991b1b{% endif %};">
<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">
@@ -98,6 +133,7 @@
</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>
@@ -354,6 +390,116 @@ function refreshFxRate() {
.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);
});
});
})();
function toggleFxChart(){
const wrap = document.getElementById('fxChartWrap');
wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';