06/05 Optimize app: Dashboard adds Reconcile button and separate Cashes and Investment
This commit is contained in:
@@ -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 & 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 & 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';
|
||||
|
||||
Reference in New Issue
Block a user