06/05 Optimize app: implement Plaid

This commit is contained in:
2026-06-05 09:52:11 -04:00
parent 8481e9c214
commit fbfb7ab33c
15 changed files with 1612 additions and 15 deletions
+112
View File
@@ -27,6 +27,7 @@
{% 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 }};">
@@ -45,6 +46,9 @@
{% 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>
@@ -90,6 +94,35 @@
</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 %}
@@ -154,6 +187,37 @@
{% 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);">
@@ -206,6 +270,54 @@
<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>…';
+1 -1
View File
@@ -305,7 +305,7 @@
</a>
<a
href="{{ url_for('settings.index') }}"
class="sb-link {% if request.blueprint in ('settings', 'logs') %}active{% endif %}"
class="sb-link {% if request.blueprint in ('settings', 'logs', 'plaid', 'teller', 'schwab') %}active{% endif %}"
>
<i class="bi bi-gear"></i><span class="lt">Settings</span>
</a>
+250
View File
@@ -0,0 +1,250 @@
{% extends "base.html" %}
{% block title %}Plaid Bank Sync{% endblock %}
{% block page_title %}Plaid Bank Sync{% endblock %}
{% block content %}
{% if not plaid_configured %}
<div class="alert alert-warning" style="font-size:13px;">
<i class="bi bi-exclamation-triangle me-2"></i>
Plaid is not configured. Add <code>PLAID_CLIENT_ID</code>, <code>PLAID_SECRET</code>, and
<code>PLAID_ENV</code> to your <code>.env</code> file and restart the app.
</div>
{% endif %}
<!-- Connect card -->
<div class="pcard mb-4">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div>
<div class="d-flex align-items-center gap-2 mb-1">
<div style="width:32px;height:32px;border-radius:8px;background:#f5f3ff;display:flex;align-items:center;justify-content:center;">
<i class="bi bi-bank2" style="color:#7c3aed;font-size:16px;"></i>
</div>
<span style="font-weight:600;font-size:15px;">Connect a Bank Account</span>
</div>
<div style="font-size:12px;color:var(--muted);">
Plaid supports 12,000+ US financial institutions — checking, savings, credit cards, and investments.
Trial accounts work in sandbox and development environments.
</div>
</div>
<button id="plaid-link-btn" class="btn btn-primary"
style="font-size:13px;min-width:150px;"
{% if not plaid_configured %}disabled{% endif %}>
<i class="bi bi-plus-lg me-1"></i>Connect Bank
</button>
</div>
</div>
{% if items %}
{% for item in items %}
<div class="pcard mb-3">
<!-- Item header -->
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<div style="font-size:15px;font-weight:600;">{{ item.institution_name }}</div>
<div style="font-size:11px;color:var(--muted);">
{{ item.accounts.filter_by(is_active=True).count() }} account(s) connected
{% if item.last_synced_at %}· Last synced {{ item.last_synced_at.strftime('%b %d, %Y %H:%M') }}{% endif %}
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<a href="{{ url_for('plaid.sync_preview_view', item_db_id=item.id) }}"
class="btn btn-sm btn-outline-primary" style="font-size:12px;">
<i class="bi bi-cloud-download me-1"></i>Sync Transactions
</a>
<form method="POST" action="{{ url_for('plaid.refresh_liabilities_view', item_db_id=item.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('plaid.index') }}">
<button type="submit" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"
title="Refresh credit card due dates and minimum payments">
<i class="bi bi-credit-card me-1"></i>Billing Details
</button>
</form>
<form method="POST" action="{{ url_for('plaid.disconnect', item_db_id=item.id) }}"
onsubmit="return confirm('Disconnect {{ item.institution_name }}?\nImported transactions will be kept.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:12px;">
<i class="bi bi-x-lg me-1"></i>Disconnect
</button>
</form>
</div>
</div>
<!-- Accounts table -->
{% set accts = item.accounts.filter_by(is_active=True).all() %}
{% if accts %}
<div style="overflow-x:auto;">
<table class="pfm-table mb-0">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th>Mapped to</th>
<th>Last Sync</th>
<th>Credit Card Billing</th>
<th style="text-align:right;padding-right:12px;">Actions</th>
</tr>
</thead>
<tbody>
{% for pa in accts %}
<tr>
<td>
<div style="font-size:13px;font-weight:500;">{{ pa.display_name }}</div>
<div style="font-size:11px;color:var(--muted);">{{ pa.account_type | title }} · {{ pa.account_subtype | replace('_',' ') | title }}</div>
</td>
<td>
<span class="badge" style="font-size:10px;
{% if pa.account_type == 'credit' %}background:#fee2e2;color:#991b1b;
{% elif pa.account_type == 'depository' %}background:#dbeafe;color:#1e40af;
{% else %}background:#f1f5f9;color:#475569;{% endif %}">
{{ pa.account_type | title }}
</span>
</td>
<td style="font-size:12px;">
{% if pa.pfm_account %}
<span style="color:#10b981;font-weight:500;">{{ pa.pfm_account.name }}</span>
{% else %}
<a href="{{ url_for('plaid.map_accounts', item_db_id=item.id) }}"
style="font-size:11px;color:#f59e0b;">Map Account</a>
{% endif %}
</td>
<td style="font-size:11px;color:var(--muted);">
{{ pa.last_sync_date.strftime('%b %d') if pa.last_sync_date else '—' }}
</td>
<td style="font-size:11px;">
{% if pa.account_type == 'credit' %}
{% if pa.cc_due_date %}
{% set days_left = (pa.cc_due_date - today).days %}
<div>
<span style="color:#6d28d9;font-weight:600;">Due {{ pa.cc_due_date.strftime('%b %d') }}</span>
{% if days_left <= 0 %}
<span style="color:#ef4444;font-size:10px;"> · Overdue!</span>
{% elif days_left <= 3 %}
<span style="color:#f59e0b;font-size:10px;"> · {{ days_left }}d left</span>
{% else %}
<span style="color:var(--muted);font-size:10px;"> · {{ days_left }}d</span>
{% endif %}
</div>
{% if pa.cc_minimum_payment %}
<div style="color:var(--muted);">Min: {{ pa.cc_minimum_payment | currency }}</div>
{% endif %}
{% else %}
<span style="color:var(--muted);"></span>
{% endif %}
{% else %}
<span style="color:var(--muted);">N/A</span>
{% endif %}
</td>
<td style="text-align:right;padding-right:12px;">
{% if pa.pfm_account_id %}
<button onclick="refreshPlaidBalance({{ pa.id }}, {{ pa.pfm_account_id }}, '{{ pa.account_type }}', this)"
class="btn btn-sm btn-outline-secondary" style="font-size:11px;">
<i class="bi bi-arrow-clockwise me-1"></i>Balance
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="pcard text-center py-5 text-muted">
<i class="bi bi-bank" style="font-size:3rem;opacity:.25;display:block;margin-bottom:16px;"></i>
<div style="font-size:14px;font-weight:500;margin-bottom:4px;">No banks connected yet</div>
<div style="font-size:12px;">Click "Connect Bank" to link your first account via Plaid.</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
<script>
(function () {
const CSRF = document.querySelector('meta[name="csrf-token"]').content;
const linkBtn = document.getElementById('plaid-link-btn');
linkBtn && linkBtn.addEventListener('click', async () => {
linkBtn.disabled = true;
linkBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Loading…';
try {
const r = await fetch('{{ url_for("plaid.create_link_token_view") }}',
{ method: 'POST', headers: { 'X-CSRFToken': CSRF } });
const data = await r.json();
if (!r.ok || data.error) throw new Error(data.error || 'Failed to create link token');
const handler = Plaid.create({
token: data.link_token,
onSuccess: async (publicToken, metadata) => {
linkBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Connecting…';
const resp = await fetch('{{ url_for("plaid.exchange_token") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF },
body: JSON.stringify({
public_token: publicToken,
institution_id: metadata.institution?.institution_id || '',
institution_name: metadata.institution?.name || 'Unknown Bank',
accounts: metadata.accounts || [],
}),
});
const result = await resp.json();
if (result.error) {
alert('Error: ' + result.error);
resetBtn();
} else {
window.location.href = result.redirect;
}
},
onExit: (err) => {
if (err) console.error('[Plaid] Link exit with error:', err);
resetBtn();
},
});
handler.open();
} catch (err) {
alert('Could not open Plaid Link: ' + err.message);
resetBtn();
}
function resetBtn() {
linkBtn.disabled = false;
linkBtn.innerHTML = '<i class="bi bi-plus-lg me-1"></i>Connect Bank';
}
});
// Balance refresh (AJAX)
window.refreshPlaidBalance = function (paId, acctId, acctType, btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></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>Balance';
if (data.error) {
btn.title = data.error;
btn.style.color = '#ef4444';
setTimeout(() => { btn.style.color = ''; btn.title = ''; }, 4000);
} else {
btn.style.color = '#10b981';
btn.title = 'Updated just now';
setTimeout(() => { btn.style.color = ''; btn.title = ''; }, 3000);
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Balance';
btn.style.color = '#ef4444';
setTimeout(() => { btn.style.color = ''; }, 4000);
});
};
})();
</script>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Map Plaid Accounts{% endblock %}
{% block page_title %}Map Accounts — {{ item.institution_name }}{% endblock %}
{% block content %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="pcard mb-3">
<div class="pcard-title mb-1">{{ item.institution_name }}</div>
<div style="font-size:12px;color:var(--muted);">
Map each Plaid account to a PFM account, or create a new one automatically.
Accounts left unmapped will be skipped during sync.
</div>
</div>
{% for pa in plaid_accounts %}
<div class="pcard mb-3">
<div class="d-flex align-items-start gap-3 flex-wrap">
<div style="flex:1;min-width:180px;">
<div style="font-size:14px;font-weight:600;">{{ pa.display_name }}</div>
<div style="font-size:11px;color:var(--muted);">
{{ pa.account_type | title }} · {{ pa.account_subtype | replace('_',' ') | title }}
</div>
</div>
<div style="flex:2;min-width:240px;">
<label style="font-size:12px;color:var(--muted);display:block;margin-bottom:4px;">Map to PFM Account</label>
<select name="pfm_account_{{ pa.id }}" class="form-select form-select-sm">
<option value="">— Skip this account —</option>
<option value="new">✦ Create new PFM account</option>
<optgroup label="Existing Accounts">
{% for a in pfm_accounts %}
<option value="{{ a.id }}"
{% if pa.pfm_account_id == a.id %}selected{% endif %}>
{{ a.name }} ({{ a.account_type | replace('_',' ') | title }})
</option>
{% endfor %}
</optgroup>
</select>
</div>
</div>
</div>
{% endfor %}
<div class="d-flex gap-2 justify-content-end">
<a href="{{ url_for('plaid.index') }}" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save Mapping
</button>
</div>
</form>
{% endblock %}
+201
View File
@@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block title %}Plaid Sync Preview{% endblock %}
{% block page_title %}Sync Preview — {{ item.institution_name }}{% endblock %}
{% block content %}
<form method="POST" action="{{ url_for('plaid.sync_confirm') }}" id="importForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="pcard mb-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
<div>
<div style="font-size:14px;font-weight:600;">{{ item.institution_name }}</div>
<div style="font-size:12px;color:var(--muted);">
<span id="selectedCount">{{ count }}</span> of {{ count }} transactions selected
</div>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('plaid.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">Cancel</a>
<button type="submit" class="btn btn-sm btn-success" id="importBtn" style="font-size:12px;">
<i class="bi bi-check-lg me-1"></i>Import <span id="importBtnCount">{{ count }}</span> Transaction(s)
</button>
</div>
</div>
<!-- Bulk actions -->
<div class="mt-3 pt-3 d-flex align-items-center flex-wrap gap-2" style="border-top:1px solid var(--border);">
<span style="font-size:12px;color:var(--muted);font-weight:500;">Bulk set selected:</span>
<button type="button" class="btn btn-sm btn-outline-danger" id="bulkExpense" style="font-size:12px;">
<i class="bi bi-dash-circle me-1"></i>Expense
</button>
<button type="button" class="btn btn-sm btn-outline-success" id="bulkIncome" style="font-size:12px;">
<i class="bi bi-plus-circle me-1"></i>Income
</button>
<div class="d-flex align-items-center gap-1 ms-1">
<select id="bulkCategory" class="form-select form-select-sm" style="min-width:150px;font-size:12px;">
<option value="">— Category —</option>
{% for c in categories %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulkApplyCat" style="font-size:12px;">Apply</button>
</div>
</div>
</div>
{% set ns = namespace(cur_acct_id=None) %}
{% for txn in preview %}
{% set pa = plaid_accounts.get(txn.plaid_account_id) %}
{% set acct_id = pa.pfm_account_id if pa else None %}
{% if acct_id != ns.cur_acct_id %}
{% if ns.cur_acct_id is not none %}</div></div>{% endif %}
{% set ns.cur_acct_id = acct_id %}
<div class="pcard p-0 mb-3">
<div class="px-4 py-2" style="background:#f8fafc;border-bottom:1px solid var(--border);border-radius:12px 12px 0 0;">
<div style="font-size:13px;font-weight:600;">
{% if pa and pa.pfm_account %}{{ pa.pfm_account.name }}{% else %}Unknown Account{% endif %}
</div>
{% if pa %}
<div style="font-size:11px;color:var(--muted);">{{ pa.display_name }} · {{ pa.account_type | title }}</div>
{% endif %}
</div>
<div>
<table class="pfm-table mb-0">
<thead>
<tr>
<th style="width:36px;padding-left:16px;">
<input type="checkbox" class="section-all" checked
style="width:15px;height:15px;cursor:pointer;">
</th>
<th style="white-space:nowrap;">Date</th>
<th>Description</th>
<th style="width:150px;">Type</th>
<th>Category</th>
<th class="text-end" style="padding-right:20px;">Amount</th>
</tr>
</thead>
<tbody>
{% endif %}
<tr class="preview-row" data-id="{{ txn.plaid_id }}">
<td style="padding-left:16px;">
<input type="checkbox" name="selected" value="{{ txn.plaid_id }}"
class="row-check" checked style="width:15px;height:15px;cursor:pointer;">
</td>
<td style="font-size:12px;color:var(--muted);white-space:nowrap;">
{{ txn.date.strftime('%b %d, %Y') }}
</td>
<td style="font-size:13px;">{{ txn.description }}</td>
<td>
<select name="type_{{ txn.plaid_id }}" class="type-select form-select form-select-sm"
style="width:130px;font-size:12px;border-radius:6px;
{% if txn.transaction_type == 'income' %}border-color:#10b981;color:#10b981;{% else %}border-color:#ef4444;color:#ef4444;{% endif %}">
<option value="expense" {% if txn.transaction_type == 'expense' %}selected{% endif %}>Expense</option>
<option value="income" {% if txn.transaction_type == 'income' %}selected{% endif %}>Income</option>
</select>
</td>
<td>
<select name="category_{{ txn.plaid_id }}" class="cat-select form-select form-select-sm"
style="font-size:12px;min-width:140px;">
<option value="">— Uncategorised —</option>
{% for c in categories %}
<option value="{{ c.id }}" {% if txn.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</td>
<td class="text-end mono amount-cell
{% if txn.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}"
style="font-size:13px;font-weight:600;padding-right:20px;">
<span class="sign">{% if txn.transaction_type == 'income' %}+{% else %}-{% endif %}</span>{{ txn.amount | currency }}
</td>
</tr>
{% endfor %}
{% if ns.cur_acct_id is not none %}</tbody></table></div></div>{% endif %}
<div class="d-flex justify-content-end mt-3">
<button type="submit" class="btn btn-success">
<i class="bi bi-check-lg me-1"></i>Confirm Import (<span id="importBtnCount2">{{ count }}</span> transactions)
</button>
</div>
</form>
<script>
(function () {
const checks = () => document.querySelectorAll('.row-check');
const countEl = document.getElementById('selectedCount');
const btnCount = document.getElementById('importBtnCount');
const btnCount2 = document.getElementById('importBtnCount2');
function updateCount() {
const n = document.querySelectorAll('.row-check:checked').length;
countEl.textContent = n;
btnCount.textContent = n;
btnCount2.textContent = n;
document.querySelectorAll('.preview-row').forEach(row => {
row.style.opacity = row.querySelector('.row-check').checked ? '1' : '0.4';
});
// Sync per-section checkboxes
document.querySelectorAll('.section-all').forEach(sa => {
const tbody = sa.closest('table').querySelector('tbody');
const rowChk = tbody.querySelectorAll('.row-check');
const chkd = tbody.querySelectorAll('.row-check:checked').length;
sa.indeterminate = chkd > 0 && chkd < rowChk.length;
sa.checked = chkd === rowChk.length;
});
}
// Section select-all
document.querySelectorAll('.section-all').forEach(sa => {
sa.addEventListener('change', () => {
const tbody = sa.closest('table').querySelector('tbody');
tbody.querySelectorAll('.row-check').forEach(cb => { cb.checked = sa.checked; });
updateCount();
});
});
document.addEventListener('change', e => {
if (e.target.classList.contains('row-check')) updateCount();
});
function applyTypeStyle(sel) {
const isIncome = sel.value === 'income';
sel.style.borderColor = isIncome ? '#10b981' : '#ef4444';
sel.style.color = isIncome ? '#10b981' : '#ef4444';
const row = sel.closest('.preview-row');
const cell = row.querySelector('.amount-cell');
const sign = row.querySelector('.sign');
cell.classList.toggle('text-income', isIncome);
cell.classList.toggle('text-expense', !isIncome);
sign.textContent = isIncome ? '+' : '-';
}
document.querySelectorAll('.type-select').forEach(sel => {
sel.addEventListener('change', function () { applyTypeStyle(this); });
});
// Bulk: type
document.getElementById('bulkExpense').addEventListener('click', () => {
document.querySelectorAll('.row-check:checked').forEach(cb => {
const sel = cb.closest('.preview-row').querySelector('.type-select');
sel.value = 'expense'; applyTypeStyle(sel);
});
});
document.getElementById('bulkIncome').addEventListener('click', () => {
document.querySelectorAll('.row-check:checked').forEach(cb => {
const sel = cb.closest('.preview-row').querySelector('.type-select');
sel.value = 'income'; applyTypeStyle(sel);
});
});
// Bulk: category
document.getElementById('bulkApplyCat').addEventListener('click', () => {
const catId = document.getElementById('bulkCategory').value;
if (!catId) return;
document.querySelectorAll('.row-check:checked').forEach(cb => {
const sel = cb.closest('.preview-row').querySelector('.cat-select');
if (sel) sel.value = catId;
});
document.getElementById('bulkCategory').value = '';
});
})();
</script>
{% endblock %}
+9
View File
@@ -22,6 +22,15 @@
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Connect Charles Schwab directly</div>
</div>
</a>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<a href="{{ url_for('plaid.index') }}" class="text-decoration-none">
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#7c3aed'" onmouseout="this.style.borderColor='var(--border)'">
<i class="bi bi-bank2" style="font-size:2rem;color:#7c3aed;"></i>
<div style="font-size:14px;font-weight:600;margin-top:10px;">Plaid Sync</div>
<div style="font-size:12px;color:var(--muted);margin-top:4px;">12,000+ US banks · CC billing dates</div>
</div>
</a>
</div>
<div class="col-12 col-sm-6 col-lg-3">
<a href="{{ url_for('settings.profile') }}" class="text-decoration-none">