374 lines
20 KiB
HTML
374 lines
20 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Accounts{% endblock %}
|
|
{% block page_title %}Accounts{% endblock %}
|
|
|
|
{% block topbar_actions %}
|
|
<a href="{{ url_for('accounts.new') }}" class="btn btn-sm btn-primary" style="font-size:12px;"><i class="bi bi-plus-lg me-1"></i>New Account</a>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
|
|
<!-- Tabs -->
|
|
<div class="d-flex gap-1 mb-3">
|
|
<a href="{{ url_for('accounts.index', tab='bank') }}"
|
|
class="btn btn-sm {% if tab=='bank' %}btn-primary{% else %}btn-outline-secondary{% endif %}" style="font-size:13px;">
|
|
<i class="bi bi-bank me-1"></i>Bank Accounts
|
|
<span class="badge ms-1" style="font-size:10px;background:{% if tab=='bank' %}rgba(255,255,255,.25){% else %}#e2e8f0;color:#475569{% endif %};">{{ bank_count }}</span>
|
|
</a>
|
|
<a href="{{ url_for('accounts.index', tab='credit') }}"
|
|
class="btn btn-sm {% if tab=='credit' %}btn-danger{% else %}btn-outline-secondary{% endif %}" style="font-size:13px;">
|
|
<i class="bi bi-credit-card me-1"></i>Credit Cards
|
|
<span class="badge ms-1" style="font-size:10px;background:{% if tab=='credit' %}rgba(255,255,255,.25){% else %}#fee2e2;color:#991b1b{% endif %};">{{ credit_count }}</span>
|
|
</a>
|
|
</div>
|
|
|
|
{% if accounts %}
|
|
<div class="row g-3">
|
|
{% for acct in accounts %}
|
|
{% set ta = teller_map.get(acct.id) %}
|
|
{% set sa = schwab_map.get(acct.id) %}
|
|
{% set pa = plaid_map.get(acct.id) %}
|
|
<div class="col-12 col-md-6 col-xl-4">
|
|
<div class="pcard" style="border-left: 4px solid {{ acct.color }};">
|
|
|
|
<!-- Header: icon + name + menu -->
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<div style="width:36px;height:36px;border-radius:9px;background:{{ acct.color }}22;color:{{ acct.color }};display:flex;align-items:center;justify-content:center;font-size:18px;">
|
|
<i class="bi {{ acct.icon }}"></i>
|
|
</div>
|
|
<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 %}
|
|
{% if sa %}
|
|
<span style="font-size:10px;background:#d1fae5;color:#065f46;border-radius:4px;padding:1px 5px;margin-left:4px;vertical-align:middle;">Schwab</span>
|
|
{% endif %}
|
|
{% if pa %}
|
|
<span style="font-size:10px;background:#ede9fe;color:#5b21b6;border-radius:4px;padding:1px 5px;margin-left:4px;vertical-align:middle;">Plaid</span>
|
|
{% endif %}
|
|
</div>
|
|
<div style="font-size:11px;color:var(--muted);">{{ acct.account_type | replace('_',' ') | title }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="dropdown">
|
|
<button class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;" data-bs-toggle="dropdown"><i class="bi bi-three-dots"></i></button>
|
|
<ul class="dropdown-menu dropdown-menu-end" style="font-size:13px;">
|
|
<li><a class="dropdown-item" href="{{ url_for('accounts.edit', id=acct.id) }}"><i class="bi bi-pencil me-2"></i>Edit</a></li>
|
|
<li><a class="dropdown-item" href="{{ url_for('accounts.adjust_balance', id=acct.id) }}"><i class="bi bi-sliders me-2"></i>Adjust Balance</a></li>
|
|
<li><hr class="dropdown-divider"></li>
|
|
<li>
|
|
<form method="POST" action="{{ url_for('accounts.delete', id=acct.id) }}" onsubmit="return confirm('Remove this account?')">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="dropdown-item text-danger"><i class="bi bi-trash me-2"></i>Remove</button>
|
|
</form>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
{% if tab == 'bank' %}
|
|
<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' %}
|
|
{% 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 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>
|
|
<div style="flex:1;background:#fafafa;border-radius:8px;padding:10px 12px;">
|
|
<div style="font-size:10px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px;">This Month</div>
|
|
<div class="mono text-expense" style="font-size:20px;font-weight:700;">
|
|
{% if charges > 0 %}-{% endif %}{{ charges | currency }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Plaid credit card billing details -->
|
|
{% if pa and tab == 'credit' and (pa.cc_due_date or pa.cc_minimum_payment) %}
|
|
<div class="d-flex gap-2 mt-3">
|
|
{% if pa.cc_due_date %}
|
|
{% set days_left = (pa.cc_due_date - today).days %}
|
|
<div style="flex:1;background:#f5f3ff;border-radius:8px;padding:10px 12px;">
|
|
<div style="font-size:10px;color:#6d28d9;font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px;">Due Date</div>
|
|
<div style="font-size:16px;font-weight:700;color:#0f172a;">{{ pa.cc_due_date.strftime('%b %d') }}</div>
|
|
{% if days_left <= 0 %}
|
|
<div style="font-size:10px;color:#ef4444;font-weight:600;">Overdue!</div>
|
|
{% elif days_left <= 3 %}
|
|
<div style="font-size:10px;color:#f59e0b;">{{ days_left }} day{{ 's' if days_left != 1 }} left</div>
|
|
{% else %}
|
|
<div style="font-size:10px;color:var(--muted);">{{ days_left }} days left</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
{% if pa.cc_minimum_payment %}
|
|
<div style="flex:1;background:#f5f3ff;border-radius:8px;padding:10px 12px;">
|
|
<div style="font-size:10px;color:#6d28d9;font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px;">Min Payment</div>
|
|
<div style="font-size:16px;font-weight:700;color:#0f172a;">{{ pa.cc_minimum_payment | currency }}</div>
|
|
{% if pa.cc_last_statement_balance %}
|
|
<div style="font-size:10px;color:var(--muted);">Stmt: {{ pa.cc_last_statement_balance | currency }}</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if acct.notes %}
|
|
<div style="font-size:12px;color:var(--muted);margin-top:10px;">{{ acct.notes }}</div>
|
|
{% endif %}
|
|
|
|
<!-- 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) }}"
|
|
class="btn btn-sm flex-fill btn-danger" style="font-size:12px;">
|
|
<i class="bi bi-send me-1"></i>Pay
|
|
</a>
|
|
<a href="{{ url_for('accounts.payments', id=acct.id) }}"
|
|
class="btn btn-sm flex-fill btn-outline-secondary" style="font-size:12px;">
|
|
<i class="bi bi-clock-history me-1"></i>Payments
|
|
</a>
|
|
<a href="{{ url_for('transactions.index', tab='expense', account_id=acct.id) }}"
|
|
class="btn btn-sm flex-fill btn-outline-secondary" style="font-size:12px;">
|
|
<i class="bi bi-list-ul me-1"></i>Charges
|
|
</a>
|
|
</div>
|
|
{% else %}
|
|
<div class="d-flex gap-2 mt-3">
|
|
<a href="{{ url_for('transactions.index', tab='expense', account_id=acct.id) }}"
|
|
class="btn btn-sm flex-fill btn-outline-secondary" style="font-size:12px;">
|
|
<i class="bi bi-list-ul me-1"></i>Transactions
|
|
</a>
|
|
<a href="{{ url_for('transactions.new', type='income') }}"
|
|
class="btn btn-sm flex-fill" style="font-size:12px;background:#d1fae5;color:#065f46;border:none;">
|
|
<i class="bi bi-plus-lg me-1"></i>Add Income
|
|
</a>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Teller action buttons -->
|
|
{% 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 %}
|
|
|
|
<!-- Plaid action buttons -->
|
|
{% if pa %}
|
|
<div class="d-flex gap-2 mt-2 pt-2" style="border-top:1px solid var(--border);">
|
|
<button onclick="refreshPlaidBalance({{ pa.id }}, {{ acct.id }}, '{{ pa.account_type }}', this)"
|
|
class="btn btn-sm btn-outline-primary flex-fill" style="font-size:11px;"
|
|
title="Pull live balance from Plaid">
|
|
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
|
</button>
|
|
<a href="{{ url_for('plaid.sync_preview_view', item_db_id=pa.item_id) }}"
|
|
class="btn btn-sm btn-outline-primary flex-fill" style="font-size:11px;"
|
|
title="Sync new transactions from Plaid">
|
|
<i class="bi bi-cloud-download me-1"></i>Sync
|
|
</a>
|
|
{% if pa.account_type == 'credit' %}
|
|
<form method="POST" action="{{ url_for('plaid.refresh_liabilities_view', item_db_id=pa.item_id) }}" style="flex:1;">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="next" value="{{ url_for('accounts.index', tab='credit') }}">
|
|
<button type="submit" class="btn btn-sm btn-outline-secondary w-100" style="font-size:11px;"
|
|
title="Refresh due date and minimum payment from Plaid">
|
|
<i class="bi bi-credit-card me-1"></i>Billing
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
{% if pa.last_sync_date %}
|
|
<div style="font-size:10px;color:var(--muted);margin-top:4px;text-align:right;">
|
|
Last synced {{ pa.last_sync_date.strftime('%b %d') }}
|
|
</div>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
<!-- Schwab action buttons -->
|
|
{% if sa %}
|
|
<div class="d-flex gap-2 mt-2 pt-2" style="border-top:1px solid var(--border);">
|
|
<form method="POST" action="{{ url_for('schwab.sync_snapshot', schwab_account_id=sa.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-success w-100" style="font-size:11px;"
|
|
title="Pull live balance and investment positions from Schwab">
|
|
<i class="bi bi-arrow-clockwise me-1"></i>Balance & Positions
|
|
</button>
|
|
</form>
|
|
<a href="{{ url_for('schwab.sync_preview_view', schwab_account_id=sa.id) }}"
|
|
class="btn btn-sm btn-outline-primary flex-fill" style="font-size:11px;"
|
|
title="Import new transactions from Schwab">
|
|
<i class="bi bi-cloud-download me-1"></i>Transactions
|
|
</a>
|
|
</div>
|
|
{% if sa.last_sync_date %}
|
|
<div style="font-size:10px;color:var(--muted);margin-top:4px;text-align:right;">
|
|
Last synced {{ sa.last_sync_date.strftime('%b %d') }}
|
|
</div>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
{% else %}
|
|
<div class="pcard text-center py-5">
|
|
{% if tab == 'credit' %}
|
|
<i class="bi bi-credit-card text-muted" style="font-size:3rem;"></i>
|
|
<h5 class="mt-3 mb-1">No credit cards yet</h5>
|
|
<p class="text-muted small mb-3">Add a credit card account to track charges and payments.</p>
|
|
{% else %}
|
|
<i class="bi bi-bank text-muted" style="font-size:3rem;"></i>
|
|
<h5 class="mt-3 mb-1">No bank accounts yet</h5>
|
|
<p class="text-muted small mb-3">Add your checking, savings, or cash accounts.</p>
|
|
{% endif %}
|
|
<a href="{{ url_for('accounts.new') }}" class="btn btn-primary btn-sm">
|
|
<i class="bi bi-plus-lg me-1"></i>New Account
|
|
</a>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
const CSRF = document.querySelector('meta[name="csrf-token"]').content;
|
|
|
|
function refreshPlaidBalance(paId, acctId, acctType, btn) {
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>…';
|
|
fetch('/plaid/balance/' + paId, {
|
|
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.status === 'ok') {
|
|
btn.style.color = '#10b981';
|
|
btn.title = 'Balance updated just now';
|
|
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from Plaid'; }, 3000);
|
|
// Update displayed balance element
|
|
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 balEl = document.getElementById('acct-bal-' + acctId);
|
|
if (balEl && data.balance != null) {
|
|
const isCc = acctType === 'credit';
|
|
if (isCc) {
|
|
const owed = Math.max(0, -parseFloat(data.balance));
|
|
balEl.textContent = (owed > 0 ? '-' : '') + fmt(owed);
|
|
} else {
|
|
balEl.textContent = fmt(data.balance);
|
|
}
|
|
balEl.style.color = '#10b981';
|
|
setTimeout(() => balEl.style.color = '', 2500);
|
|
}
|
|
} else {
|
|
btn.style.color = '#ef4444';
|
|
btn.title = data.error || 'Refresh failed';
|
|
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from Plaid'; }, 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);
|
|
});
|
|
}
|
|
|
|
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 %}
|