486 lines
25 KiB
HTML
486 lines
25 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Transactions{% endblock %}
|
||
{% block page_title %}Transactions{% endblock %}
|
||
|
||
{% block topbar_actions %}
|
||
<a href="{{ url_for('transactions.new', type='income') }}" class="btn btn-sm" style="background:#d1fae5;color:#065f46;font-size:12px;"><i class="bi bi-plus-lg"></i><span class="btn-label ms-1">Income</span></a>
|
||
<a href="{{ url_for('transactions.new', type='expense') }}" class="btn btn-sm ms-1" style="background:#fee2e2;color:#991b1b;font-size:12px;"><i class="bi bi-dash-lg"></i><span class="btn-label ms-1">Expense</span></a>
|
||
<a href="{{ url_for('transactions.transfer') }}" class="btn btn-sm btn-outline-secondary ms-1" style="font-size:12px;"><i class="bi bi-arrow-left-right"></i><span class="btn-label ms-1">Transfer</span></a>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
{% if plaid_review_count > 0 %}
|
||
<div class="alert d-flex align-items-center gap-2 mb-3" style="background:#f5f3ff;border:1px solid #ddd6fe;color:#5b21b6;font-size:13px;border-radius:8px;padding:10px 14px;">
|
||
<i class="bi bi-cloud-download flex-shrink-0"></i>
|
||
<div class="flex-grow-1">
|
||
<strong>{{ plaid_review_count }} Plaid transaction{{ 's' if plaid_review_count != 1 else '' }}</strong>
|
||
imported via webhook without a category.
|
||
<a href="{{ url_for('transactions.index') }}" style="color:#7c3aed;font-weight:600;" class="ms-1">Review & categorize →</a>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
<!-- Tabs -->
|
||
<div class="d-flex gap-1 mb-3">
|
||
<a href="{{ url_for('transactions.index', tab='expense', q=search, account_id=account_id, category_id=category_id, date_from=date_from, date_to=date_to) }}"
|
||
class="btn btn-sm {% if tab=='expense' %}btn-danger{% else %}btn-outline-secondary{% endif %}" style="font-size:13px;">
|
||
<i class="bi bi-arrow-up-circle me-1"></i>Expenses
|
||
<span class="badge ms-1" style="font-size:10px;background:{% if tab=='expense' %}rgba(255,255,255,.25){% else %}#fee2e2;color:#991b1b{% endif %};">{{ expense_count }}</span>
|
||
</a>
|
||
<a href="{{ url_for('transactions.index', tab='income', q=search, account_id=account_id, category_id=category_id, date_from=date_from, date_to=date_to) }}"
|
||
class="btn btn-sm {% if tab=='income' %}btn-success{% else %}btn-outline-secondary{% endif %}" style="font-size:13px;">
|
||
<i class="bi bi-arrow-down-circle me-1"></i>Income
|
||
<span class="badge ms-1" style="font-size:10px;background:{% if tab=='income' %}rgba(255,255,255,.25){% else %}#d1fae5;color:#065f46{% endif %};">{{ income_count }}</span>
|
||
</a>
|
||
</div>
|
||
|
||
<!-- Quick date filters -->
|
||
<div class="d-flex gap-1 mb-3 align-items-center flex-wrap">
|
||
<a href="{{ url_for('transactions.index', tab=tab, date_from=this_month_from, date_to=this_month_to, q=search, category_id=category_id, account_id=account_id) }}"
|
||
class="btn btn-sm {% if active_quick == 'this_month' %}btn-primary{% else %}btn-outline-secondary{% endif %}" style="font-size:12px;">
|
||
<i class="bi bi-calendar-check me-1"></i>This Month
|
||
</a>
|
||
<a href="{{ url_for('transactions.index', tab=tab, date_from=last_month_from, date_to=last_month_to, q=search, category_id=category_id, account_id=account_id) }}"
|
||
class="btn btn-sm {% if active_quick == 'last_month' %}btn-primary{% else %}btn-outline-secondary{% endif %}" style="font-size:12px;">
|
||
<i class="bi bi-calendar-minus me-1"></i>Last Month
|
||
</a>
|
||
{% if active_quick %}
|
||
<a href="{{ url_for('transactions.index', tab=tab, q=search, category_id=category_id, account_id=account_id) }}"
|
||
class="btn btn-sm btn-outline-secondary" style="font-size:12px;" title="Clear date filter">
|
||
<i class="bi bi-x-lg"></i>
|
||
</a>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Filters -->
|
||
<div class="pcard pcard-sm mb-3">
|
||
<form id="filter-form" method="GET" action="{{ url_for('transactions.index') }}" class="row g-2 align-items-end">
|
||
<input type="hidden" name="tab" value="{{ tab }}">
|
||
<!-- Row 1: main filters -->
|
||
<div class="col-12 col-md-3">
|
||
<input type="text" name="q" id="f-q" class="form-control form-control-sm" placeholder="Search description & notes…" value="{{ search }}">
|
||
</div>
|
||
<div class="col-6 col-md-2">
|
||
<select name="category_id" id="f-cat" class="form-select form-select-sm">
|
||
<option value="">All categories</option>
|
||
{% for cat in categories %}
|
||
<option value="{{ cat.id }}" {% if category_id == cat.id|string %}selected{% endif %}>{{ cat.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-6 col-md-2">
|
||
<select name="account_id" id="f-acct" class="form-select form-select-sm">
|
||
<option value="">All accounts</option>
|
||
{% for acct in accounts %}
|
||
<option value="{{ acct.id }}" {% if account_id == acct.id|string %}selected{% endif %}>{{ acct.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-6 col-md-2">
|
||
<input type="date" name="date_from" id="f-df" class="form-control form-control-sm" value="{{ date_from }}" placeholder="From">
|
||
</div>
|
||
<div class="col-6 col-md-2">
|
||
<input type="date" name="date_to" id="f-dt" class="form-control form-control-sm" value="{{ date_to }}" placeholder="To">
|
||
</div>
|
||
<div class="col-12 col-md-1 d-flex gap-1">
|
||
<button type="submit" class="btn btn-sm btn-primary flex-grow-1"><i class="bi bi-search"></i></button>
|
||
<a href="{{ url_for('transactions.index', tab=tab) }}" class="btn btn-sm btn-outline-secondary" title="Clear filters"><i class="bi bi-x-lg"></i></a>
|
||
</div>
|
||
<!-- Row 2: amount range + preset controls -->
|
||
<div class="col-6 col-md-2">
|
||
<input type="number" name="amount_min" id="f-amin" class="form-control form-control-sm" placeholder="Min amount" min="0" step="0.01" value="{{ amount_min }}">
|
||
</div>
|
||
<div class="col-6 col-md-2">
|
||
<input type="number" name="amount_max" id="f-amax" class="form-control form-control-sm" placeholder="Max amount" min="0" step="0.01" value="{{ amount_max }}">
|
||
</div>
|
||
<div class="col-12 col-md-8 d-flex align-items-center gap-2 flex-wrap">
|
||
<a href="{{ url_for('transactions.export_csv', tab=tab, q=search, category_id=category_id, account_id=account_id, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max) }}"
|
||
class="btn btn-sm btn-outline-secondary" style="font-size:12px;" title="Export filtered view as CSV">
|
||
<i class="bi bi-filetype-csv me-1"></i>CSV
|
||
</a>
|
||
<a href="{{ url_for('transactions.export_excel', tab=tab, q=search, category_id=category_id, account_id=account_id, date_from=date_from, date_to=date_to, amount_min=amount_min, amount_max=amount_max) }}"
|
||
class="btn btn-sm btn-outline-secondary" style="font-size:12px;" title="Export filtered view as Excel">
|
||
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Excel
|
||
</a>
|
||
<span style="font-size:11px;color:var(--muted);">Saved filters:</span>
|
||
<div class="dropdown">
|
||
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" style="font-size:12px;" id="preset-dropdown" data-bs-toggle="dropdown">
|
||
<i class="bi bi-bookmark me-1"></i><span id="preset-label">Load preset</span>
|
||
</button>
|
||
<ul class="dropdown-menu" id="preset-menu" style="font-size:13px;min-width:200px;">
|
||
<li><span class="dropdown-item text-muted" id="no-presets-msg">No saved presets</span></li>
|
||
</ul>
|
||
</div>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" style="font-size:12px;" id="save-preset-btn" title="Save current filter as preset">
|
||
<i class="bi bi-bookmark-plus me-1"></i>Save
|
||
</button>
|
||
</div>
|
||
</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">
|
||
{% if transactions %}
|
||
<table class="pfm-table">
|
||
<thead>
|
||
<tr>
|
||
<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>
|
||
<th class="text-end">Amount</th>
|
||
<th class="text-end" style="padding-right:20px;">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for txn in transactions %}
|
||
<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 %}
|
||
</td>
|
||
<td class="d-mob-none" style="min-width:150px;">
|
||
<div class="d-flex align-items-center gap-1">
|
||
<i id="cat-icon-{{ txn.id }}"
|
||
class="bi {{ txn.category.icon if txn.category else 'bi-tag' }}"
|
||
style="font-size:12px;color:{{ txn.category.color if txn.category else '#94a3b8' }};flex-shrink:0;"></i>
|
||
<select class="cat-select"
|
||
data-txn-id="{{ txn.id }}"
|
||
title="Change category"
|
||
style="font-size:12px;border:1px solid transparent;border-radius:4px;background:transparent;padding:2px 4px;cursor:pointer;max-width:130px;color:var(--text);">
|
||
<option value="">— None —</option>
|
||
{% for cat in categories %}
|
||
<option value="{{ cat.id }}"
|
||
data-icon="{{ cat.icon }}"
|
||
data-color="{{ cat.color }}"
|
||
{% if txn.category_id == cat.id %}selected{% endif %}>{{ cat.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
</td>
|
||
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">{{ txn.account.name if txn.account else '—' }}</td>
|
||
<td class="text-end mono {% if txn.transaction_type=='income' %}text-income{% else %}text-expense{% endif %}" style="font-size:13px;font-weight:600;white-space:nowrap;">
|
||
{% if txn.transaction_type=='income' %}+{% else %}-{% endif %}{{ txn.amount | currency }}
|
||
</td>
|
||
<td class="text-end" style="padding-right:20px;white-space:nowrap;">
|
||
<a href="{{ url_for('transactions.edit', id=txn.id, next=request.full_path) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;padding:2px 8px;">Edit</a>
|
||
<a href="{{ url_for('transactions.split', id=txn.id) }}" class="btn btn-sm btn-outline-secondary ms-1" style="font-size:11px;padding:2px 8px;" title="Split into multiple categories"><i class="bi bi-scissors"></i></a>
|
||
<form method="POST" action="{{ url_for('transactions.delete', id=txn.id) }}" style="display:inline;" onsubmit="return confirm('Delete this transaction?')">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||
<button type="submit" class="btn btn-sm btn-outline-danger ms-1" style="font-size:11px;padding:2px 8px;">Del</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
|
||
<!-- Pagination -->
|
||
{% if pagination.pages > 1 %}
|
||
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-top:1px solid var(--border);font-size:12px;color:var(--muted);">
|
||
<span>Page {{ pagination.page }} of {{ pagination.pages }} · {{ ((pagination.page-1)*30)+1 }}–{{ [pagination.page*30, pagination.total]|min }} of {{ pagination.total }}</span>
|
||
<div class="d-flex gap-1">
|
||
{% if pagination.has_prev %}
|
||
<a href="{{ url_for('transactions.index', tab=tab, page=pagination.prev_num, q=search, category_id=category_id, account_id=account_id, date_from=date_from, date_to=date_to) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">← Prev</a>
|
||
{% endif %}
|
||
{% if pagination.has_next %}
|
||
<a href="{{ url_for('transactions.index', tab=tab, page=pagination.next_num, q=search, category_id=category_id, account_id=account_id, date_from=date_from, date_to=date_to) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">Next →</a>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% else %}
|
||
<div class="text-center py-5 px-3">
|
||
{% if has_filter %}
|
||
<i class="bi bi-funnel text-muted" style="font-size:2.5rem;opacity:.4;"></i>
|
||
<p class="fw-semibold mt-3 mb-1">No matching transactions</p>
|
||
<p class="text-muted small mb-3">Try adjusting your filters or search term.</p>
|
||
<a href="{{ url_for('transactions.index', tab=tab) }}" class="btn btn-sm btn-outline-secondary">
|
||
<i class="bi bi-x-lg me-1"></i>Clear filters
|
||
</a>
|
||
{% else %}
|
||
<i class="bi bi-arrow-left-right text-muted" style="font-size:2.5rem;opacity:.4;"></i>
|
||
<p class="fw-semibold mt-3 mb-1">No {{ tab }} transactions yet</p>
|
||
<p class="text-muted small mb-3">Record your first {{ tab }} to start tracking.</p>
|
||
<a href="{{ url_for('transactions.new', type=tab) }}" class="btn btn-sm btn-primary">
|
||
<i class="bi bi-plus-lg me-1"></i>Add {{ tab | title }}
|
||
</a>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<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;
|
||
const catId = this.value || null;
|
||
const opt = this.options[this.selectedIndex];
|
||
const icon = document.getElementById('cat-icon-' + txnId);
|
||
|
||
if (icon) {
|
||
icon.className = 'bi ' + (catId ? opt.dataset.icon : 'bi-tag');
|
||
icon.style.color = catId ? opt.dataset.color : '#94a3b8';
|
||
}
|
||
|
||
fetch(`/transactions/${txnId}/set-category`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF },
|
||
body: JSON.stringify({ category_id: catId ? parseInt(catId) : null }),
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.ok) {
|
||
sel.style.color = '#10b981';
|
||
setTimeout(() => sel.style.color = '', 1500);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
sel.style.color = '#ef4444';
|
||
setTimeout(() => sel.style.color = '', 2000);
|
||
});
|
||
});
|
||
});
|
||
|
||
/* ── 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.'));
|
||
});
|
||
|
||
/* ── Saved filter presets (localStorage) ── */
|
||
(function () {
|
||
const PKEY = 'pfm_txn_presets';
|
||
|
||
function loadPresets() {
|
||
try { return JSON.parse(localStorage.getItem(PKEY) || '[]'); }
|
||
catch { return []; }
|
||
}
|
||
|
||
function savePresets(list) {
|
||
localStorage.setItem(PKEY, JSON.stringify(list));
|
||
}
|
||
|
||
function readForm() {
|
||
return {
|
||
q: document.getElementById('f-q')?.value || '',
|
||
category_id:document.getElementById('f-cat')?.value || '',
|
||
account_id: document.getElementById('f-acct')?.value || '',
|
||
date_from: document.getElementById('f-df')?.value || '',
|
||
date_to: document.getElementById('f-dt')?.value || '',
|
||
amount_min: document.getElementById('f-amin')?.value || '',
|
||
amount_max: document.getElementById('f-amax')?.value || '',
|
||
tab: '{{ tab }}',
|
||
};
|
||
}
|
||
|
||
function applyPreset(p) {
|
||
const base = '/transactions/?' + new URLSearchParams(p).toString();
|
||
window.location.href = base;
|
||
}
|
||
|
||
function renderMenu() {
|
||
const presets = loadPresets();
|
||
const menu = document.getElementById('preset-menu');
|
||
const noMsg = document.getElementById('no-presets-msg');
|
||
// remove old preset items (keep no-presets-msg li)
|
||
menu.querySelectorAll('.preset-item').forEach(el => el.remove());
|
||
|
||
if (!presets.length) {
|
||
noMsg.style.display = '';
|
||
return;
|
||
}
|
||
noMsg.style.display = 'none';
|
||
|
||
presets.forEach((p, idx) => {
|
||
const li = document.createElement('li');
|
||
li.className = 'preset-item d-flex align-items-center px-2 gap-1';
|
||
li.innerHTML = `
|
||
<button type="button" class="dropdown-item py-1 flex-grow-1 text-start" style="font-size:13px;">${p.name}</button>
|
||
<button type="button" class="btn btn-sm p-0 text-danger preset-del" title="Delete" data-idx="${idx}" style="font-size:13px;line-height:1;background:none;border:none;">
|
||
<i class="bi bi-x-circle"></i>
|
||
</button>`;
|
||
li.querySelector('.dropdown-item').addEventListener('click', () => applyPreset(p.filters));
|
||
li.querySelector('.preset-del').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const list = loadPresets();
|
||
list.splice(idx, 1);
|
||
savePresets(list);
|
||
renderMenu();
|
||
});
|
||
menu.appendChild(li);
|
||
});
|
||
}
|
||
|
||
document.getElementById('save-preset-btn')?.addEventListener('click', () => {
|
||
const name = prompt('Name this filter preset:');
|
||
if (!name?.trim()) return;
|
||
const list = loadPresets();
|
||
list.push({ name: name.trim(), filters: readForm() });
|
||
savePresets(list);
|
||
renderMenu();
|
||
// Flash the save button
|
||
const btn = document.getElementById('save-preset-btn');
|
||
btn.innerHTML = '<i class="bi bi-bookmark-check-fill me-1"></i>Saved!';
|
||
btn.classList.replace('btn-outline-secondary', 'btn-success');
|
||
setTimeout(() => {
|
||
btn.innerHTML = '<i class="bi bi-bookmark-plus me-1"></i>Save';
|
||
btn.classList.replace('btn-success', 'btn-outline-secondary');
|
||
}, 1500);
|
||
});
|
||
|
||
renderMenu();
|
||
})();
|
||
</script>
|
||
{% endblock %}
|