06/05 Optimize app
This commit is contained in:
@@ -200,7 +200,8 @@ def sync_preview_view(schwab_account_id):
|
|||||||
return redirect(url_for('schwab.index'))
|
return redirect(url_for('schwab.index'))
|
||||||
|
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
cat_map = {c.id: c.name for c in Category.query.filter_by(is_active=True).all()}
|
cats = Category.query.filter_by(is_active=True).order_by(Category.name).all()
|
||||||
|
cat_map = {c.id: c.name for c in cats}
|
||||||
|
|
||||||
session['schwab_preview'] = [
|
session['schwab_preview'] = [
|
||||||
{
|
{
|
||||||
@@ -222,7 +223,8 @@ def sync_preview_view(schwab_account_id):
|
|||||||
sa=sa,
|
sa=sa,
|
||||||
preview=preview,
|
preview=preview,
|
||||||
count=len(preview),
|
count=len(preview),
|
||||||
cat_map=cat_map)
|
cat_map=cat_map,
|
||||||
|
categories=cats)
|
||||||
|
|
||||||
|
|
||||||
# ── Sync: confirm ─────────────────────────────────────────────────────────────
|
# ── Sync: confirm ─────────────────────────────────────────────────────────────
|
||||||
@@ -248,6 +250,9 @@ def sync_confirm():
|
|||||||
override = request.form.get(f'type_{r["schwab_id"]}')
|
override = request.form.get(f'type_{r["schwab_id"]}')
|
||||||
if override in ('income', 'expense'):
|
if override in ('income', 'expense'):
|
||||||
r['transaction_type'] = override
|
r['transaction_type'] = override
|
||||||
|
cat_override = request.form.get(f'category_{r["schwab_id"]}', '')
|
||||||
|
if cat_override.isdigit():
|
||||||
|
r['category_id'] = int(cat_override)
|
||||||
parsed.append(r)
|
parsed.append(r)
|
||||||
|
|
||||||
if not parsed:
|
if not parsed:
|
||||||
|
|||||||
@@ -214,13 +214,15 @@ def sync_preview_view(teller_account_id):
|
|||||||
session['teller_account_id'] = teller_account_id
|
session['teller_account_id'] = teller_account_id
|
||||||
|
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
cat_map = {c.id: c.name for c in Category.query.filter_by(is_active=True).all()}
|
cats = Category.query.filter_by(is_active=True).order_by(Category.name).all()
|
||||||
|
cat_map = {c.id: c.name for c in cats}
|
||||||
|
|
||||||
return render_template('teller/preview.html',
|
return render_template('teller/preview.html',
|
||||||
ta=ta,
|
ta=ta,
|
||||||
preview=preview,
|
preview=preview,
|
||||||
count=len(preview),
|
count=len(preview),
|
||||||
cat_map=cat_map)
|
cat_map=cat_map,
|
||||||
|
categories=cats)
|
||||||
|
|
||||||
|
|
||||||
@teller_bp.route('/sync/confirm', methods=['POST'])
|
@teller_bp.route('/sync/confirm', methods=['POST'])
|
||||||
@@ -256,6 +258,9 @@ def sync_confirm():
|
|||||||
override = request.form.get(f'type_{r["teller_id"]}')
|
override = request.form.get(f'type_{r["teller_id"]}')
|
||||||
if override in ('income', 'expense'):
|
if override in ('income', 'expense'):
|
||||||
r['transaction_type'] = override
|
r['transaction_type'] = override
|
||||||
|
cat_override = request.form.get(f'category_{r["teller_id"]}', '')
|
||||||
|
if cat_override.isdigit():
|
||||||
|
r['category_id'] = int(cat_override)
|
||||||
parsed.append(r)
|
parsed.append(r)
|
||||||
|
|
||||||
if not parsed:
|
if not parsed:
|
||||||
|
|||||||
@@ -177,9 +177,18 @@
|
|||||||
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
|
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
|
||||||
<span class="fmt-badge"><i class="bi bi-check-circle"></i><span id="fmt-label"></span></span>
|
<span class="fmt-badge"><i class="bi bi-check-circle"></i><span id="fmt-label"></span></span>
|
||||||
<span class="text-muted" style="font-size:13px;" id="row-count-label"></span>
|
<span class="text-muted" style="font-size:13px;" id="row-count-label"></span>
|
||||||
<div class="ms-auto d-flex gap-2 align-items-center">
|
<div class="ms-auto d-flex gap-2 align-items-center flex-wrap">
|
||||||
<button id="select-all-btn" class="btn btn-sm btn-outline-secondary">Select All</button>
|
<button id="select-all-btn" class="btn btn-sm btn-outline-secondary">Select All</button>
|
||||||
<button id="select-none-btn" class="btn btn-sm btn-outline-secondary">None</button>
|
<button id="select-none-btn" class="btn btn-sm btn-outline-secondary">None</button>
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<select id="bulk-cat-select" class="form-select form-select-sm" style="min-width:140px;font-size:12px;">
|
||||||
|
<option value="">— Set category —</option>
|
||||||
|
{% for c in categories %}
|
||||||
|
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button id="bulk-cat-btn" class="btn btn-sm btn-outline-secondary" style="font-size:12px;white-space:nowrap;">Apply to Selected</button>
|
||||||
|
</div>
|
||||||
<button id="import-btn" class="btn btn-sm btn-success px-3">
|
<button id="import-btn" class="btn btn-sm btn-success px-3">
|
||||||
<span id="import-spinner" class="spinner-border spinner-border-sm me-1 d-none"></span>
|
<span id="import-spinner" class="spinner-border spinner-border-sm me-1 d-none"></span>
|
||||||
<i class="bi bi-cloud-upload me-1" id="import-icon"></i>
|
<i class="bi bi-cloud-upload me-1" id="import-icon"></i>
|
||||||
@@ -536,6 +545,19 @@
|
|||||||
updateImportCount();
|
updateImportCount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bulk: set category for all selected rows
|
||||||
|
$('bulk-cat-btn').addEventListener('click', () => {
|
||||||
|
const catId = $('bulk-cat-select').value;
|
||||||
|
if (!catId) return;
|
||||||
|
document.querySelectorAll('#preview-body tr').forEach(tr => {
|
||||||
|
const chk = tr.querySelector('.row-chk');
|
||||||
|
if (!chk || !chk.checked) return;
|
||||||
|
const sel = tr.querySelector('.cat-select');
|
||||||
|
if (sel) sel.value = catId;
|
||||||
|
});
|
||||||
|
$('bulk-cat-select').value = '';
|
||||||
|
});
|
||||||
|
|
||||||
// ── Import ────────────────────────────────────────────────────────────────
|
// ── Import ────────────────────────────────────────────────────────────────
|
||||||
$('import-btn').addEventListener('click', () => {
|
$('import-btn').addEventListener('click', () => {
|
||||||
const accountId = $('account-select').value;
|
const accountId = $('account-select').value;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
<form method="POST" action="{{ url_for('schwab.sync_confirm') }}" id="importForm">
|
<form method="POST" action="{{ url_for('schwab.sync_confirm') }}" id="importForm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div class="pcard mb-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
<div class="pcard mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:14px;font-weight:600;">{{ sa.account_name }} ({{ sa.account_number_display }})</div>
|
<div style="font-size:14px;font-weight:600;">{{ sa.account_name }} ({{ sa.account_number_display }})</div>
|
||||||
<div style="font-size:12px;color:var(--muted);">
|
<div style="font-size:12px;color:var(--muted);">
|
||||||
@@ -24,6 +25,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk actions bar -->
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="pcard p-0">
|
<div class="pcard p-0">
|
||||||
<table class="pfm-table">
|
<table class="pfm-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -35,7 +57,7 @@
|
|||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th class="d-mob-none">Schwab Type</th>
|
<th class="d-mob-none">Schwab Type</th>
|
||||||
<th style="width:160px;">PFM Type</th>
|
<th style="width:160px;">PFM Type</th>
|
||||||
<th class="d-mob-none">Category</th>
|
<th>Category</th>
|
||||||
<th class="text-end" style="padding-right:20px;">Amount</th>
|
<th class="text-end" style="padding-right:20px;">Amount</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -61,12 +83,14 @@
|
|||||||
<option value="income" {% if txn.transaction_type == 'income' %}selected{% endif %}>Income</option>
|
<option value="income" {% if txn.transaction_type == 'income' %}selected{% endif %}>Income</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">
|
<td>
|
||||||
{% if txn.category_id and txn.category_id in cat_map %}
|
<select name="category_{{ txn.schwab_id }}" class="cat-select form-select form-select-sm"
|
||||||
{{ cat_map[txn.category_id] }}
|
style="font-size:12px;min-width:140px;">
|
||||||
{% else %}
|
<option value="">— Uncategorised —</option>
|
||||||
<span style="color:#f59e0b;">Uncategorised</span>
|
{% for c in categories %}
|
||||||
{% endif %}
|
<option value="{{ c.id }}" {% if txn.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end mono amount-cell
|
<td class="text-end mono amount-cell
|
||||||
{% if txn.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}"
|
{% if txn.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}"
|
||||||
@@ -121,18 +145,47 @@
|
|||||||
});
|
});
|
||||||
checks.forEach(cb => cb.addEventListener('change', updateCount));
|
checks.forEach(cb => cb.addEventListener('change', updateCount));
|
||||||
|
|
||||||
document.querySelectorAll('.type-select').forEach(sel => {
|
function applyTypeStyle(sel) {
|
||||||
sel.addEventListener('change', function () {
|
const isIncome = sel.value === 'income';
|
||||||
const isIncome = this.value === 'income';
|
sel.style.borderColor = isIncome ? '#10b981' : '#ef4444';
|
||||||
this.style.borderColor = isIncome ? '#10b981' : '#ef4444';
|
sel.style.color = isIncome ? '#10b981' : '#ef4444';
|
||||||
this.style.color = isIncome ? '#10b981' : '#ef4444';
|
const row = sel.closest('.preview-row');
|
||||||
const row = this.closest('.preview-row');
|
|
||||||
const cell = row.querySelector('.amount-cell');
|
const cell = row.querySelector('.amount-cell');
|
||||||
const sign = row.querySelector('.sign');
|
const sign = row.querySelector('.sign');
|
||||||
cell.classList.toggle('text-income', isIncome);
|
cell.classList.toggle('text-income', isIncome);
|
||||||
cell.classList.toggle('text-expense', !isIncome);
|
cell.classList.toggle('text-expense', !isIncome);
|
||||||
sign.textContent = isIncome ? '+' : '-';
|
sign.textContent = isIncome ? '+' : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.type-select').forEach(sel => {
|
||||||
|
sel.addEventListener('change', function () { applyTypeStyle(this); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bulk: set 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: set 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>
|
</script>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
<form method="POST" action="{{ url_for('teller.sync_confirm') }}" id="importForm">
|
<form method="POST" action="{{ url_for('teller.sync_confirm') }}" id="importForm">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div class="pcard mb-3 d-flex justify-content-between align-items-center">
|
<div class="pcard mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:14px;font-weight:600;">{{ ta.institution_name }} — {{ ta.account_name }}</div>
|
<div style="font-size:14px;font-weight:600;">{{ ta.institution_name }} — {{ ta.account_name }}</div>
|
||||||
<div style="font-size:12px;color:var(--muted);">
|
<div style="font-size:12px;color:var(--muted);">
|
||||||
@@ -24,6 +25,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk actions bar -->
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="pcard p-0">
|
<div class="pcard p-0">
|
||||||
<table class="pfm-table">
|
<table class="pfm-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -61,12 +83,14 @@
|
|||||||
<option value="income" {% if txn.transaction_type == 'income' %}selected{% endif %}>Income</option>
|
<option value="income" {% if txn.transaction_type == 'income' %}selected{% endif %}>Income</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td style="font-size:12px;color:var(--muted);">
|
<td>
|
||||||
{% if txn.category_id and txn.category_id in cat_map %}
|
<select name="category_{{ txn.teller_id }}" class="cat-select form-select form-select-sm"
|
||||||
{{ cat_map[txn.category_id] }}
|
style="font-size:12px;min-width:140px;">
|
||||||
{% else %}
|
<option value="">— Uncategorised —</option>
|
||||||
<span style="color:#f59e0b;">Uncategorised</span>
|
{% for c in categories %}
|
||||||
{% endif %}
|
<option value="{{ c.id }}" {% if txn.category_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end mono amount-cell
|
<td class="text-end mono amount-cell
|
||||||
{% if txn.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}"
|
{% if txn.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}"
|
||||||
@@ -109,12 +133,10 @@
|
|||||||
countEl.textContent = n;
|
countEl.textContent = n;
|
||||||
btnCount.textContent = n;
|
btnCount.textContent = n;
|
||||||
btnCount2.textContent = n;
|
btnCount2.textContent = n;
|
||||||
// Dim unchecked rows
|
|
||||||
document.querySelectorAll('.preview-row').forEach(row => {
|
document.querySelectorAll('.preview-row').forEach(row => {
|
||||||
const cb = row.querySelector('.row-check');
|
const cb = row.querySelector('.row-check');
|
||||||
row.style.opacity = cb.checked ? '1' : '0.4';
|
row.style.opacity = cb.checked ? '1' : '0.4';
|
||||||
});
|
});
|
||||||
// Sync select-all state
|
|
||||||
selectAll.indeterminate = n > 0 && n < checks.length;
|
selectAll.indeterminate = n > 0 && n < checks.length;
|
||||||
selectAll.checked = n === checks.length;
|
selectAll.checked = n === checks.length;
|
||||||
}
|
}
|
||||||
@@ -123,23 +145,50 @@
|
|||||||
checks.forEach(cb => { cb.checked = selectAll.checked; });
|
checks.forEach(cb => { cb.checked = selectAll.checked; });
|
||||||
updateCount();
|
updateCount();
|
||||||
});
|
});
|
||||||
|
|
||||||
checks.forEach(cb => cb.addEventListener('change', updateCount));
|
checks.forEach(cb => cb.addEventListener('change', updateCount));
|
||||||
|
|
||||||
// Update type-select color + amount sign on change
|
// Type-select color + amount sign
|
||||||
document.querySelectorAll('.type-select').forEach(sel => {
|
function applyTypeStyle(sel) {
|
||||||
sel.addEventListener('change', function () {
|
const isIncome = sel.value === 'income';
|
||||||
const isIncome = this.value === 'income';
|
sel.style.borderColor = isIncome ? '#10b981' : '#ef4444';
|
||||||
this.style.borderColor = isIncome ? '#10b981' : '#ef4444';
|
sel.style.color = isIncome ? '#10b981' : '#ef4444';
|
||||||
this.style.color = isIncome ? '#10b981' : '#ef4444';
|
const row = sel.closest('.preview-row');
|
||||||
|
|
||||||
const row = this.closest('.preview-row');
|
|
||||||
const cell = row.querySelector('.amount-cell');
|
const cell = row.querySelector('.amount-cell');
|
||||||
const sign = row.querySelector('.sign');
|
const sign = row.querySelector('.sign');
|
||||||
cell.classList.toggle('text-income', isIncome);
|
cell.classList.toggle('text-income', isIncome);
|
||||||
cell.classList.toggle('text-expense', !isIncome);
|
cell.classList.toggle('text-expense', !isIncome);
|
||||||
sign.textContent = isIncome ? '+' : '-';
|
sign.textContent = isIncome ? '+' : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.type-select').forEach(sel => {
|
||||||
|
sel.addEventListener('change', function () { applyTypeStyle(this); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bulk: set 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: set 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>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user