06/05 Optimize app: add Plaid reset sync

This commit is contained in:
2026-06-05 10:27:58 -04:00
parent 9f100af491
commit aeac9a4228
4 changed files with 220 additions and 3 deletions
+7
View File
@@ -60,6 +60,13 @@
<i class="bi bi-credit-card me-1"></i>Billing Details
</button>
</form>
<form method="POST" action="{{ url_for('plaid.full_resync', item_db_id=item.id) }}"
onsubmit="return confirm('Reset sync cursor for {{ item.institution_name }}?\nThe next sync will re-fetch full available history. Duplicate transactions will be skipped automatically.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-warning" style="font-size:12px;">
<i class="bi bi-arrow-counterclockwise me-1"></i>Reset
</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() }}">
+149 -3
View File
@@ -59,6 +59,28 @@
</form>
</div>
<!-- Bulk Actions Toolbar (hidden until rows are checked) -->
<div id="bulk-bar" style="display:none;position:sticky;top:0;z-index:100;background:#1e293b;color:#fff;border-radius:8px;padding:10px 16px;margin-bottom:12px;display:none;align-items:center;gap:12px;flex-wrap:wrap;">
<span id="bulk-count" style="font-size:13px;font-weight:600;white-space:nowrap;">0 selected</span>
<div class="d-flex align-items-center gap-2 ms-auto flex-wrap">
<select id="bulk-cat-sel" class="form-select form-select-sm" style="font-size:12px;width:auto;min-width:150px;background:#334155;color:#fff;border-color:#475569;">
<option value="">— Set category —</option>
{% for cat in categories %}
<option value="{{ cat.id }}">{{ cat.name }}</option>
{% endfor %}
</select>
<button id="bulk-cat-btn" class="btn btn-sm" style="font-size:12px;background:#3b82f6;color:#fff;border:none;" title="Apply category to selected">
<i class="bi bi-tag-fill me-1"></i>Apply
</button>
<button id="bulk-del-btn" class="btn btn-sm" style="font-size:12px;background:#ef4444;color:#fff;border:none;" title="Delete selected">
<i class="bi bi-trash me-1"></i>Delete
</button>
<button id="bulk-clear-btn" class="btn btn-sm btn-outline-light" style="font-size:12px;" title="Clear selection">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<!-- Table -->
{% set has_filter = search or category_id or account_id or date_from or date_to %}
<div class="pcard p-0">
@@ -66,7 +88,10 @@
<table class="pfm-table">
<thead>
<tr>
<th style="padding-left:20px;">Date</th>
<th style="padding-left:14px;width:36px;">
<input type="checkbox" id="sel-all" title="Select all" style="cursor:pointer;width:15px;height:15px;">
</th>
<th style="padding-left:4px;">Date</th>
<th>Description</th>
<th class="d-mob-none">Category</th>
<th class="d-mob-none">Account</th>
@@ -76,8 +101,11 @@
</thead>
<tbody>
{% for txn in transactions %}
<tr>
<td style="padding-left:20px;font-size:12px;color:var(--muted);white-space:nowrap;">{{ txn.date.strftime('%b %d, %Y') }}</td>
<tr data-txn-id="{{ txn.id }}">
<td style="padding-left:14px;width:36px;">
<input type="checkbox" class="row-chk" value="{{ txn.id }}" style="cursor:pointer;width:15px;height:15px;">
</td>
<td style="padding-left:4px;font-size:12px;color:var(--muted);white-space:nowrap;">{{ txn.date.strftime('%b %d, %Y') }}</td>
<td>
<div style="font-size:13px;font-weight:500;">{{ txn.description }}</div>
{% if txn.notes %}<div style="font-size:11px;color:var(--muted);">{{ txn.notes | truncate(60) }}</div>{% endif %}
@@ -158,10 +186,12 @@
<style>
.cat-select:hover { border-color: var(--border) !important; background: #f8fafc !important; }
.cat-select:focus { outline: none; border-color: #3b82f6 !important; background: #fff !important; }
tr.row-selected { background: #eff6ff !important; }
</style>
<script>
const CSRF = document.querySelector('meta[name="csrf-token"]').content;
/* ── Inline category change ── */
document.querySelectorAll('.cat-select').forEach(sel => {
sel.addEventListener('change', function () {
const txnId = this.dataset.txnId;
@@ -192,5 +222,121 @@ document.querySelectorAll('.cat-select').forEach(sel => {
});
});
});
/* ── Bulk actions ── */
const bulkBar = document.getElementById('bulk-bar');
const bulkCount = document.getElementById('bulk-count');
const selAll = document.getElementById('sel-all');
function getChecked() {
return Array.from(document.querySelectorAll('.row-chk:checked'));
}
function updateBulkBar() {
const checked = getChecked();
const n = checked.length;
if (n > 0) {
bulkBar.style.display = 'flex';
bulkCount.textContent = n + ' selected';
} else {
bulkBar.style.display = 'none';
}
// Sync select-all checkbox state
const total = document.querySelectorAll('.row-chk').length;
selAll.indeterminate = n > 0 && n < total;
selAll.checked = n === total && total > 0;
}
function highlightRows() {
document.querySelectorAll('.row-chk').forEach(chk => {
chk.closest('tr').classList.toggle('row-selected', chk.checked);
});
}
document.querySelectorAll('.row-chk').forEach(chk => {
chk.addEventListener('change', () => { highlightRows(); updateBulkBar(); });
});
if (selAll) {
selAll.addEventListener('change', function () {
document.querySelectorAll('.row-chk').forEach(chk => { chk.checked = this.checked; });
highlightRows();
updateBulkBar();
});
}
document.getElementById('bulk-clear-btn')?.addEventListener('click', () => {
document.querySelectorAll('.row-chk').forEach(chk => { chk.checked = false; });
if (selAll) { selAll.checked = false; selAll.indeterminate = false; }
highlightRows();
updateBulkBar();
});
document.getElementById('bulk-del-btn')?.addEventListener('click', () => {
const ids = getChecked().map(c => parseInt(c.value));
if (!ids.length) return;
if (!confirm(`Delete ${ids.length} transaction(s)? This cannot be undone.`)) return;
fetch('/transactions/bulk-action', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF },
body: JSON.stringify({ action: 'delete', ids }),
})
.then(r => r.json())
.then(data => {
if (data.ok) {
ids.forEach(id => {
const row = document.querySelector(`.row-chk[value="${id}"]`)?.closest('tr');
if (row) row.remove();
});
updateBulkBar();
} else {
alert('Error: ' + (data.error || 'Could not delete'));
}
})
.catch(() => alert('Network error — please try again.'));
});
document.getElementById('bulk-cat-btn')?.addEventListener('click', () => {
const ids = getChecked().map(c => parseInt(c.value));
const catSel = document.getElementById('bulk-cat-sel');
if (!ids.length) return;
const catId = catSel.value ? parseInt(catSel.value) : null;
const catName = catId ? catSel.options[catSel.selectedIndex].text : '— None —';
fetch('/transactions/bulk-action', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF },
body: JSON.stringify({ action: 'set_category', ids, category_id: catId }),
})
.then(r => r.json())
.then(data => {
if (data.ok) {
ids.forEach(id => {
// Update the inline category select for each affected row
const rowSel = document.querySelector(`.cat-select[data-txn-id="${id}"]`);
if (rowSel) {
rowSel.value = catId || '';
const opt = rowSel.options[rowSel.selectedIndex];
const icon = document.getElementById('cat-icon-' + id);
if (icon && catId && opt) {
icon.className = 'bi ' + opt.dataset.icon;
icon.style.color = opt.dataset.color;
} else if (icon) {
icon.className = 'bi bi-tag';
icon.style.color = '#94a3b8';
}
rowSel.style.color = '#10b981';
setTimeout(() => rowSel.style.color = '', 1500);
}
});
bulkCount.textContent = `${ids.length} updated → ${catName}`;
setTimeout(updateBulkBar, 2000);
} else {
alert('Error: ' + (data.error || 'Could not update category'));
}
})
.catch(() => alert('Network error — please try again.'));
});
</script>
{% endblock %}