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()
|
total_liabilities = get_total_liabilities()
|
||||||
net_worth = total_assets - 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 expense categories ────────────────────────
|
||||||
top_categories = db.session.query(
|
top_categories = db.session.query(
|
||||||
Category.name,
|
Category.name,
|
||||||
@@ -158,6 +172,8 @@ def index():
|
|||||||
total_assets=total_assets,
|
total_assets=total_assets,
|
||||||
total_liabilities=total_liabilities,
|
total_liabilities=total_liabilities,
|
||||||
net_worth=net_worth,
|
net_worth=net_worth,
|
||||||
|
total_cash=total_cash,
|
||||||
|
total_investments=total_investments,
|
||||||
top_categories=top_categories,
|
top_categories=top_categories,
|
||||||
chart_months=chart_months,
|
chart_months=chart_months,
|
||||||
chart_income=chart_income,
|
chart_income=chart_income,
|
||||||
@@ -169,6 +185,71 @@ def index():
|
|||||||
schwab_warning=schwab_warning)
|
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')
|
@dashboard_bp.route('/api/fx-history')
|
||||||
@login_required
|
@login_required
|
||||||
def fx_history_api():
|
def fx_history_api():
|
||||||
|
|||||||
@@ -39,24 +39,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Cards -->
|
<!-- 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="row g-3 mb-4">
|
||||||
<div class="col-6 col-xl-3">
|
<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 class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<div class="stat-label">Income</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>
|
||||||
<div class="stat-icon" style="background:#d1fae5;color:#065f46;"><i class="bi bi-arrow-down-circle"></i></div>
|
<div class="stat-icon" style="background:#d1fae5;color:#065f46;"><i class="bi bi-arrow-down-circle"></i></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-xl-3">
|
<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 class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<div class="stat-label">Expenses</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>
|
||||||
<div class="stat-icon" style="background:#fee2e2;color:#991b1b;"><i class="bi bi-arrow-up-circle"></i></div>
|
<div class="stat-icon" style="background:#fee2e2;color:#991b1b;"><i class="bi bi-arrow-up-circle"></i></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +78,7 @@
|
|||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<div class="stat-label">Net Cash Flow</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>
|
||||||
<div class="stat-icon" style="background:#dbeafe;color:#1e40af;"><i class="bi bi-arrow-left-right"></i></div>
|
<div class="stat-icon" style="background:#dbeafe;color:#1e40af;"><i class="bi bi-arrow-left-right"></i></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,17 +89,41 @@
|
|||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
<div class="stat-label">Savings Rate</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 }}%
|
{% if savings_rate >= 0 %}+{% endif %}{{ savings_rate }}%
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<i class="bi bi-piggy-bank"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:10px;color:var(--muted);margin-top:4px;">of income saved this period</div>
|
<div style="font-size:10px;color:var(--muted);margin-top:4px;">of income saved this period</div>
|
||||||
</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">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="col-6 col-xl-3">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
@@ -98,6 +133,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-icon" style="background:#ede9fe;color:#5b21b6;"><i class="bi bi-bank"></i></div>
|
<div class="stat-icon" style="background:#ede9fe;color:#5b21b6;"><i class="bi bi-bank"></i></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="font-size:10px;color:var(--muted);margin-top:4px;">assets − liabilities</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -354,6 +390,116 @@ function refreshFxRate() {
|
|||||||
.catch(() => { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
function toggleFxChart(){
|
function toggleFxChart(){
|
||||||
const wrap = document.getElementById('fxChartWrap');
|
const wrap = document.getElementById('fxChartWrap');
|
||||||
wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';
|
wrap.style.display = wrap.style.display === 'none' ? 'block' : 'none';
|
||||||
|
|||||||
Reference in New Issue
Block a user