06/05 Optimize app: Dashboard adds Reconcile button and separate Cashes and Investment
This commit is contained in:
@@ -81,6 +81,20 @@ def index():
|
||||
total_liabilities = get_total_liabilities()
|
||||
net_worth = total_assets - total_liabilities
|
||||
|
||||
total_cash = float(db.session.query(
|
||||
func.coalesce(func.sum(Account.balance), 0)
|
||||
).filter(
|
||||
Account.is_active == True,
|
||||
Account.account_type.in_(['checking', 'savings', 'cash']),
|
||||
).scalar())
|
||||
|
||||
total_investments = float(db.session.query(
|
||||
func.coalesce(func.sum(Account.balance), 0)
|
||||
).filter(
|
||||
Account.is_active == True,
|
||||
Account.account_type.in_(['investment', 'crypto']),
|
||||
).scalar())
|
||||
|
||||
# ── Top expense categories ────────────────────────
|
||||
top_categories = db.session.query(
|
||||
Category.name,
|
||||
@@ -158,6 +172,8 @@ def index():
|
||||
total_assets=total_assets,
|
||||
total_liabilities=total_liabilities,
|
||||
net_worth=net_worth,
|
||||
total_cash=total_cash,
|
||||
total_investments=total_investments,
|
||||
top_categories=top_categories,
|
||||
chart_months=chart_months,
|
||||
chart_income=chart_income,
|
||||
@@ -169,6 +185,71 @@ def index():
|
||||
schwab_warning=schwab_warning)
|
||||
|
||||
|
||||
@dashboard_bp.route('/api/reconcile')
|
||||
@login_required
|
||||
def reconcile_api():
|
||||
"""
|
||||
Return income/expense totals for the period excluding any categories
|
||||
whose name contains 'transfer' (case-insensitive).
|
||||
These are internal account moves that inflate both sides artificially.
|
||||
"""
|
||||
period = request.args.get('period', 'this_month')
|
||||
date_from, date_to, _ = _parse_date_range(period)
|
||||
|
||||
# Find all transfer-like category IDs
|
||||
transfer_cats = Category.query.filter(
|
||||
Category.name.ilike('%transfer%')
|
||||
).all()
|
||||
transfer_ids = [c.id for c in transfer_cats]
|
||||
|
||||
def _sum(txn_type):
|
||||
q = db.session.query(
|
||||
func.coalesce(func.sum(Transaction.amount), 0)
|
||||
).filter(
|
||||
Transaction.transaction_type == txn_type,
|
||||
Transaction.date >= date_from,
|
||||
Transaction.date <= date_to,
|
||||
)
|
||||
if transfer_ids:
|
||||
q = q.filter(
|
||||
db.or_(
|
||||
Transaction.category_id.notin_(transfer_ids),
|
||||
Transaction.category_id.is_(None),
|
||||
)
|
||||
)
|
||||
return float(q.scalar())
|
||||
|
||||
def _sum_transfer(txn_type):
|
||||
if not transfer_ids:
|
||||
return 0.0
|
||||
return float(db.session.query(
|
||||
func.coalesce(func.sum(Transaction.amount), 0)
|
||||
).filter(
|
||||
Transaction.transaction_type == txn_type,
|
||||
Transaction.date >= date_from,
|
||||
Transaction.date <= date_to,
|
||||
Transaction.category_id.in_(transfer_ids),
|
||||
).scalar())
|
||||
|
||||
income = _sum('income')
|
||||
expense = _sum('expense')
|
||||
net = income - expense
|
||||
savings = round(net / income * 100, 1) if income else 0
|
||||
|
||||
excluded_income = _sum_transfer('income')
|
||||
excluded_expense = _sum_transfer('expense')
|
||||
|
||||
return jsonify({
|
||||
'income': income,
|
||||
'expense': expense,
|
||||
'net_cash_flow': net,
|
||||
'savings_rate': savings,
|
||||
'excluded_income': excluded_income,
|
||||
'excluded_expense': excluded_expense,
|
||||
'transfer_categories': [c.name for c in transfer_cats],
|
||||
})
|
||||
|
||||
|
||||
@dashboard_bp.route('/api/fx-history')
|
||||
@login_required
|
||||
def fx_history_api():
|
||||
|
||||
@@ -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