06/05 Optimize app: add Plaid reset sync
This commit is contained in:
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user