06/05 Optimize app: filters
This commit is contained in:
@@ -71,6 +71,8 @@ def index():
|
||||
account_id = request.args.get('account_id', '', type=str)
|
||||
date_from = request.args.get('date_from', '')
|
||||
date_to = request.args.get('date_to', '')
|
||||
amount_min = request.args.get('amount_min', '')
|
||||
amount_max = request.args.get('amount_max', '')
|
||||
|
||||
from datetime import timedelta
|
||||
today = date.today()
|
||||
@@ -93,7 +95,12 @@ def index():
|
||||
).order_by(Transaction.date.desc(), Transaction.id.desc())
|
||||
|
||||
if search:
|
||||
query = query.filter(Transaction.description.ilike(f'%{search}%'))
|
||||
query = query.filter(
|
||||
or_(
|
||||
Transaction.description.ilike(f'%{search}%'),
|
||||
Transaction.notes.ilike(f'%{search}%'),
|
||||
)
|
||||
)
|
||||
try:
|
||||
if category_id:
|
||||
query = query.filter(Transaction.category_id == int(category_id))
|
||||
@@ -111,6 +118,13 @@ def index():
|
||||
query = query.filter(Transaction.date <= datetime.strptime(date_to, '%Y-%m-%d').date())
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
if amount_min:
|
||||
query = query.filter(Transaction.amount >= float(amount_min))
|
||||
if amount_max:
|
||||
query = query.filter(Transaction.amount <= float(amount_max))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
pagination = query.paginate(page=page, per_page=30, error_out=False)
|
||||
|
||||
@@ -137,6 +151,8 @@ def index():
|
||||
account_id=account_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
amount_min=amount_min,
|
||||
amount_max=amount_max,
|
||||
active_quick=active_quick,
|
||||
this_month_from=this_month_from,
|
||||
this_month_to=this_month_to,
|
||||
|
||||
@@ -43,13 +43,14 @@
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="pcard pcard-sm mb-3">
|
||||
<form method="GET" action="{{ url_for('transactions.index') }}" class="row g-2 align-items-end">
|
||||
<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" class="form-control form-control-sm" placeholder="Search description…" value="{{ search }}">
|
||||
<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" class="form-select form-select-sm">
|
||||
<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>
|
||||
@@ -57,7 +58,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<select name="account_id" class="form-select form-select-sm">
|
||||
<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>
|
||||
@@ -65,14 +66,35 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<input type="date" name="date_from" class="form-control form-control-sm" value="{{ date_from }}" placeholder="From">
|
||||
<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" class="form-control form-control-sm" value="{{ date_to }}" placeholder="To">
|
||||
<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"><i class="bi bi-x-lg"></i></a>
|
||||
<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">
|
||||
<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>
|
||||
@@ -356,5 +378,89 @@ document.getElementById('bulk-cat-btn')?.addEventListener('click', () => {
|
||||
})
|
||||
.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 %}
|
||||
|
||||
Reference in New Issue
Block a user