06/03 Optimize codes, move account action buttons to Accounts page
This commit is contained in:
+10
-1
@@ -83,12 +83,21 @@ def index():
|
||||
).group_by(Transaction.account_id).all()
|
||||
monthly_charges = {row[0]: float(row[1]) for row in rows}
|
||||
|
||||
# Teller account mapping: pfm_account_id → TellerAccount
|
||||
from app.models.teller_enrollment import TellerAccount
|
||||
teller_accounts = TellerAccount.query.filter(
|
||||
TellerAccount.pfm_account_id.in_([a.id for a in all_accounts]),
|
||||
TellerAccount.is_active == True,
|
||||
).all()
|
||||
teller_map = {ta.pfm_account_id: ta for ta in teller_accounts}
|
||||
|
||||
return render_template('accounts/index.html',
|
||||
accounts=accounts,
|
||||
tab=tab,
|
||||
bank_count=len(bank_accounts),
|
||||
credit_count=len(credit_accounts),
|
||||
monthly_charges=monthly_charges)
|
||||
monthly_charges=monthly_charges,
|
||||
teller_map=teller_map)
|
||||
|
||||
|
||||
@accounts_bp.route('/new', methods=['GET', 'POST'])
|
||||
|
||||
@@ -294,6 +294,9 @@ def full_resync(teller_account_id):
|
||||
f'Next sync will fetch the last 90 days — duplicates will be skipped automatically.',
|
||||
'info'
|
||||
)
|
||||
next_url = request.form.get('next', '')
|
||||
if next_url and next_url.startswith('/'):
|
||||
return redirect(next_url)
|
||||
return redirect(url_for('teller.index'))
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
{% if accounts %}
|
||||
<div class="row g-3">
|
||||
{% for acct in accounts %}
|
||||
{% set ta = teller_map.get(acct.id) %}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="pcard" style="border-left: 4px solid {{ acct.color }};">
|
||||
|
||||
@@ -35,7 +36,12 @@
|
||||
<i class="bi {{ acct.icon }}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:14px;">{{ acct.name }}</div>
|
||||
<div style="font-weight:600;font-size:14px;">
|
||||
{{ acct.name }}
|
||||
{% if ta %}
|
||||
<span style="font-size:10px;background:#dbeafe;color:#1e40af;border-radius:4px;padding:1px 5px;margin-left:4px;vertical-align:middle;">Teller</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--muted);">{{ acct.account_type | replace('_',' ') | title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,19 +62,18 @@
|
||||
</div>
|
||||
|
||||
{% if tab == 'bank' %}
|
||||
<div class="mono {% if acct.balance >= 0 %}text-income{% else %}text-expense{% endif %}" style="font-size:26px;font-weight:700;margin-top:12px;">
|
||||
<div id="acct-bal-{{ acct.id }}" class="mono {% if acct.balance >= 0 %}text-income{% else %}text-expense{% endif %}" style="font-size:26px;font-weight:700;margin-top:12px;">
|
||||
{{ acct.balance | currency }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tab == 'credit' %}
|
||||
<!-- Amount Owed + Monthly Charges -->
|
||||
{% set owed = [0, -acct.balance] | max %}
|
||||
{% set charges = monthly_charges.get(acct.id, 0) %}
|
||||
<div class="d-flex gap-3 mt-3">
|
||||
<div style="flex:1;background:#fff5f5;border-radius:8px;padding:10px 12px;">
|
||||
<div style="font-size:10px;color:#991b1b;font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px;">Amount Owed</div>
|
||||
<div class="mono {% if owed > 0 %}text-expense{% else %}" style="color:#10b981;{% endif %}font-size:20px;font-weight:700;">
|
||||
<div id="acct-bal-{{ acct.id }}" class="mono {% if owed > 0 %}text-expense{% else %}" style="color:#10b981;{% endif %}font-size:20px;font-weight:700;">
|
||||
{% if owed > 0 %}-{% endif %}{{ owed | currency }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,7 +90,7 @@
|
||||
<div style="font-size:12px;color:var(--muted);margin-top:10px;">{{ acct.notes }}</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action buttons -->
|
||||
<!-- Main action buttons -->
|
||||
{% if tab == 'credit' %}
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<a href="{{ url_for('transactions.transfer', to_account_id=acct.id, description='Credit Card Payment — ' ~ acct.name) }}"
|
||||
@@ -114,6 +119,37 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Teller action buttons (only for accounts linked to Teller) -->
|
||||
{% if ta %}
|
||||
<div class="d-flex gap-2 mt-2 pt-2" style="border-top:1px solid var(--border);">
|
||||
<button id="refbtn-{{ acct.id }}"
|
||||
onclick="refreshTellerBalance({{ ta.id }}, {{ acct.id }}, {{ 'true' if acct.account_type == 'credit_card' else 'false' }}, this)"
|
||||
class="btn btn-sm btn-outline-primary flex-fill" style="font-size:11px;"
|
||||
title="Pull live balance from bank">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
||||
</button>
|
||||
<a href="{{ url_for('teller.sync_preview_view', teller_account_id=ta.id) }}"
|
||||
class="btn btn-sm btn-outline-primary flex-fill" style="font-size:11px;"
|
||||
title="Sync new transactions from bank">
|
||||
<i class="bi bi-cloud-download me-1"></i>Sync
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('teller.full_resync', teller_account_id=ta.id) }}" style="flex:1;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ url_for('accounts.index', tab=tab) }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning w-100" style="font-size:11px;"
|
||||
title="Re-fetch full 90-day history on next sync"
|
||||
onclick="return confirm('Reset sync cursor for {{ acct.name }}?\nNext sync will re-fetch last 90 days. Already-imported transactions will be skipped.')">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Reset
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if ta.last_sync_date %}
|
||||
<div style="font-size:10px;color:var(--muted);margin-top:4px;text-align:right;">
|
||||
Last synced {{ ta.last_sync_date.strftime('%b %d') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -137,3 +173,61 @@
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
const CSRF = document.querySelector('meta[name="csrf-token"]').content;
|
||||
|
||||
function refreshTellerBalance(taId, acctId, isCreditCard, btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>…';
|
||||
|
||||
fetch('/teller/balance/' + taId, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': CSRF },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Refresh';
|
||||
|
||||
if (data.balance != null || data.available != null) {
|
||||
const sym = '{{ current_user.currency_symbol }}';
|
||||
const fmt = v => {
|
||||
const n = parseFloat(v);
|
||||
const abs = Math.abs(n).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
|
||||
return (n < 0 ? '-' : '') + sym + abs;
|
||||
};
|
||||
|
||||
const rawVal = data.balance != null ? data.balance : data.available;
|
||||
const balEl = document.getElementById('acct-bal-' + acctId);
|
||||
|
||||
if (balEl) {
|
||||
if (isCreditCard) {
|
||||
const owed = Math.max(0, -parseFloat(rawVal));
|
||||
balEl.textContent = (owed > 0 ? '-' : '') + fmt(owed);
|
||||
} else {
|
||||
balEl.textContent = fmt(rawVal);
|
||||
}
|
||||
balEl.style.color = '#10b981';
|
||||
setTimeout(() => balEl.style.color = '', 2500);
|
||||
}
|
||||
|
||||
btn.style.color = '#10b981';
|
||||
btn.title = 'Balance updated just now';
|
||||
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from bank'; }, 3000);
|
||||
} else {
|
||||
btn.style.color = '#ef4444';
|
||||
btn.title = data.error || 'Refresh failed';
|
||||
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from bank'; }, 4000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Refresh';
|
||||
btn.style.color = '#ef4444';
|
||||
setTimeout(() => { btn.style.color = ''; }, 4000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+20
-129
@@ -3,15 +3,6 @@
|
||||
{% block page_title %}Bank Connections{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
{% if enrollments %}
|
||||
<form method="POST" action="{{ url_for('teller.refresh_all_balances') }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary me-1" style="font-size:12px;"
|
||||
title="Pull live balances from your bank for all connected accounts">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Refresh All Balances
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button id="tellerConnectBtn" class="btn btn-sm btn-primary" style="font-size:12px;"
|
||||
{% if not teller_app_id %}title="TELLER_APP_ID not set in .env" style="opacity:.6;"{% endif %}>
|
||||
<i class="bi bi-bank me-1"></i>Connect a Bank
|
||||
@@ -29,88 +20,45 @@
|
||||
<div>
|
||||
<div style="font-size:15px;font-weight:600;">{{ enrollment.institution_name or 'Unknown Bank' }}</div>
|
||||
<div style="font-size:12px;color:var(--muted);">
|
||||
Connected {{ enrollment.created_at.strftime('%b %d, %Y') }} ·
|
||||
Last synced: {{ enrollment.last_synced_at.strftime('%b %d, %H:%M') if enrollment.last_synced_at else 'Never' }}
|
||||
Connected {{ enrollment.created_at.strftime('%b %d, %Y') }}
|
||||
{% if enrollment.last_synced_at %}
|
||||
· Last synced {{ enrollment.last_synced_at.strftime('%b %d, %H:%M') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('teller.disconnect', enrollment_db_id=enrollment.id) }}"
|
||||
onsubmit="return confirm('Disconnect from {{ enrollment.institution_name }}? Existing transactions are kept.')">
|
||||
onsubmit="return confirm('Disconnect from {{ enrollment.institution_name }}?\nExisting transactions are kept.')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:11px;">Disconnect</button>
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:11px;">
|
||||
<i class="bi bi-x-circle me-1"></i>Disconnect
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% for ta in accounts %}
|
||||
<div class="d-flex justify-content-between align-items-center py-2" style="border-top:1px solid var(--border);">
|
||||
<div class="d-flex align-items-center gap-2" style="min-width:0;">
|
||||
<div style="width:32px;height:32px;border-radius:8px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div style="width:30px;height:30px;border-radius:8px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;">
|
||||
<i class="bi {% if ta.account_subtype == 'credit_card' %}bi-credit-card{% elif ta.account_subtype == 'savings' %}bi-piggy-bank{% else %}bi-bank{% endif %}"></i>
|
||||
</div>
|
||||
<div style="min-width:0;">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span style="font-size:13px;font-weight:500;">{{ ta.account_name }}</span>
|
||||
{% if ta.pfm_account %}
|
||||
{# Balance display — updated in-place by refreshBalance() #}
|
||||
<span id="bal-{{ ta.id }}" class="mono"
|
||||
style="font-size:13px;font-weight:600;color:var(--text);">
|
||||
{{ ta.pfm_account.balance | currency }}
|
||||
</span>
|
||||
{% if ta.last_sync_date %}
|
||||
<span style="font-size:10px;color:var(--muted);">
|
||||
synced {{ ta.last_sync_date.strftime('%b %d') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:500;">{{ ta.account_name }}</div>
|
||||
<div style="font-size:11px;color:var(--muted);">
|
||||
{{ ta.account_subtype | replace('_',' ') | title }}
|
||||
{% if ta.pfm_account %}
|
||||
· <span style="color:var(--muted);">{{ ta.pfm_account.name }}</span>
|
||||
· linked to <span style="color:var(--text);font-weight:500;">{{ ta.pfm_account.name }}</span>
|
||||
{% else %}
|
||||
· <span style="color:#f59e0b;">Not mapped</span>
|
||||
· <span style="color:#f59e0b;font-weight:500;">Not mapped</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-shrink-0 ms-2">
|
||||
{% if ta.pfm_account %}
|
||||
<button id="refbtn-{{ ta.id }}"
|
||||
onclick="refreshBalance({{ ta.id }}, this)"
|
||||
class="btn btn-sm btn-outline-secondary" style="font-size:11px;"
|
||||
title="Pull live balance from bank">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
<a href="{{ url_for('teller.sync_preview_view', teller_account_id=ta.id) }}"
|
||||
class="btn btn-sm btn-outline-primary" style="font-size:11px;">
|
||||
<i class="bi bi-cloud-download me-1"></i>Sync
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('teller.full_resync', teller_account_id=ta.id) }}"
|
||||
style="display:inline;"
|
||||
onsubmit="return confirm('Reset sync cursor for {{ ta.account_name }}?\nThe next Sync will re-fetch the last 90 days. Already-imported transactions will be skipped.')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning" style="font-size:11px;" title="Re-fetch full 90-day history on next sync">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="{{ url_for('teller.map_accounts', enrollment_id=enrollment.enrollment_id) }}"
|
||||
class="btn btn-sm btn-outline-warning" style="font-size:11px;">Map Account</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not ta.pfm_account %}
|
||||
<a href="{{ url_for('teller.map_accounts', enrollment_id=enrollment.enrollment_id) }}"
|
||||
class="btn btn-sm btn-outline-warning" style="font-size:11px;">Map Account</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Sync all -->
|
||||
{% if accounts | selectattr('pfm_account_id') | list %}
|
||||
<div class="mt-3 text-end">
|
||||
<form method="POST" action="{{ url_for('teller.sync_all') }}" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-primary" style="font-size:12px;">
|
||||
<i class="bi bi-cloud-download me-1"></i>Sync All Accounts
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -121,6 +69,7 @@
|
||||
<h5 class="mt-3 mb-1">No banks connected</h5>
|
||||
<p class="text-muted small mb-3">
|
||||
Connect your US bank accounts to automatically sync transactions and balances.
|
||||
Once connected, use the <strong>Accounts</strong> page to sync transactions and refresh balances.
|
||||
</p>
|
||||
<button id="tellerConnectBtn2" class="btn btn-primary">
|
||||
<i class="bi bi-bank me-1"></i>Connect a Bank
|
||||
@@ -178,7 +127,6 @@ function initTellerConnect(btn) {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': CSRF,
|
||||
},
|
||||
// Send full Teller payload: { accessToken, user, enrollment }
|
||||
body: JSON.stringify(enrollment),
|
||||
})
|
||||
.then(r => r.json())
|
||||
@@ -197,9 +145,7 @@ function initTellerConnect(btn) {
|
||||
btn.innerHTML = '<i class="bi bi-bank me-1"></i>Connect a Bank';
|
||||
});
|
||||
},
|
||||
onExit: function() {
|
||||
console.log('Teller Connect closed');
|
||||
},
|
||||
onExit: function() {},
|
||||
onFailure: function(error) {
|
||||
alert('Connection failed: ' + error.message);
|
||||
},
|
||||
@@ -210,60 +156,5 @@ function initTellerConnect(btn) {
|
||||
|
||||
initTellerConnect(document.getElementById('tellerConnectBtn'));
|
||||
initTellerConnect(document.getElementById('tellerConnectBtn2'));
|
||||
|
||||
function refreshBalance(taId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||
|
||||
fetch('/teller/balance/' + taId, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': CSRF },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
||||
|
||||
if (data.balance != null || data.available != null) {
|
||||
const sym = '{{ current_user.currency_symbol }}';
|
||||
const fmt = v => {
|
||||
const n = parseFloat(v);
|
||||
const abs = Math.abs(n).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
|
||||
return (n < 0 ? '-' : '') + sym + abs;
|
||||
};
|
||||
|
||||
// 'balance' is the correct value for the account type (ledger for credit cards,
|
||||
// available for bank accounts). Fall back to 'available' for older responses.
|
||||
const displayVal = data.balance != null ? data.balance : data.available;
|
||||
|
||||
// Update inline balance display
|
||||
const balEl = document.getElementById('bal-' + taId);
|
||||
if (balEl) {
|
||||
balEl.textContent = fmt(displayVal);
|
||||
balEl.style.color = displayVal < 0 ? '#ef4444' : '#10b981';
|
||||
setTimeout(() => balEl.style.color = '', 2500);
|
||||
}
|
||||
|
||||
// Show available vs ledger in button tooltip
|
||||
const ledgerNote = (data.ledger !== data.available)
|
||||
? ` · Ledger: ${fmt(data.ledger)}`
|
||||
: '';
|
||||
btn.title = `Available: ${fmt(data.available)}${ledgerNote} — updated just now`;
|
||||
btn.style.color = '#10b981';
|
||||
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from bank'; }, 3000);
|
||||
} else {
|
||||
btn.title = data.error || 'Refresh failed';
|
||||
btn.style.color = '#ef4444';
|
||||
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from bank'; }, 4000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
||||
btn.title = 'Network error — try again';
|
||||
btn.style.color = '#ef4444';
|
||||
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from bank'; }, 4000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user