Files
Personal-Finance-Management/app/templates/transactions/index.html
T
2026-06-05 15:06:14 -04:00

467 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}
<!-- 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 &amp; 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">
<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>
<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 }} &nbsp;·&nbsp; {{ ((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 %}