06/05 Optimize app

This commit is contained in:
2026-06-05 17:50:46 -04:00
parent 025f3f8823
commit e4007348f8
6 changed files with 497 additions and 32 deletions
+41
View File
@@ -120,6 +120,13 @@
{{ form.notes(class="form-control", rows=2, placeholder="Optional notes", id="field_notes") }}
</div>
<!-- Duplicate warning -->
<div id="dupe-banner" style="display:none;background:#fef9c3;border:1px solid #fcd34d;border-radius:8px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#854d0e;align-items:center;gap-8px;">
<i class="bi bi-exclamation-triangle-fill me-2" style="color:#d97706;"></i>
<span id="dupe-text"></span>
<button type="button" onclick="document.getElementById('dupe-banner').style.display='none';" style="background:none;border:none;font-size:14px;color:#854d0e;cursor:pointer;padding:0;margin-left:auto;">×</button>
</div>
<div class="d-flex gap-2">
<button type="submit" id="submitBtn"
class="btn {% if txn_type=='income' %}btn-success{% else %}btn-danger{% endif %}">
@@ -426,5 +433,39 @@ function setTxnType(type) {
sel.value = prevVal;
}
}
// ── Duplicate detection ───────────────────────────────────────────────────────
{% if not txn %}
const DUPE_EXCLUDE_ID = '';
{% else %}
const DUPE_EXCLUDE_ID = '{{ txn.id }}';
{% endif %}
let _dupeTimer = null;
function checkDuplicate() {
const amt = document.getElementById('field_amount').value;
const dt = document.getElementById('field_date').value;
const typ = document.querySelector('[name=transaction_type]').value;
const banner = document.getElementById('dupe-banner');
if (!amt || !dt || parseFloat(amt) <= 0) { banner.style.display = 'none'; return; }
clearTimeout(_dupeTimer);
_dupeTimer = setTimeout(() => {
const url = `/transactions/api/check-duplicate?date=${dt}&amount=${amt}&type=${typ}${DUPE_EXCLUDE_ID ? '&exclude_id=' + DUPE_EXCLUDE_ID : ''}`;
fetch(url).then(r => r.json()).then(data => {
if (data.duplicates && data.duplicates.length > 0) {
const list = data.duplicates.map(d => `"${d.description}" on ${d.date}${d.account ? ' (' + d.account + ')' : ''}`).join('; ');
document.getElementById('dupe-text').textContent = `Possible duplicate: ${list}`;
banner.style.display = 'flex';
} else {
banner.style.display = 'none';
}
}).catch(() => {});
}, 600);
}
['field_amount', 'field_date'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', checkDuplicate);
});
</script>
{% endblock %}
+9
View File
@@ -93,6 +93,14 @@
<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">
@@ -185,6 +193,7 @@
</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>
+152
View File
@@ -0,0 +1,152 @@
{% extends "base.html" %}
{% block title %}Split Transaction{% endblock %}
{% block page_title %}Split Transaction{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('transactions.index', tab=txn.transaction_type) }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
{% endblock %}
{% block extra_css %}
.split-row { display:grid; grid-template-columns:1fr 2fr 110px 36px; gap:8px; align-items:center; }
@media (max-width:575px) { .split-row { grid-template-columns:1fr 1fr 90px 28px; } }
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-7">
<!-- Original transaction summary -->
<div class="pcard mb-3" style="border-left:4px solid {% if txn.transaction_type=='income' %}var(--income){% else %}var(--expense){% endif %};">
<div style="font-size:12px;color:var(--muted);margin-bottom:4px;">Original transaction</div>
<div class="d-flex justify-content-between align-items-center">
<div>
<div style="font-weight:600;font-size:14px;">{{ txn.description }}</div>
<div style="font-size:12px;color:var(--muted);">{{ txn.date.strftime('%b %d, %Y') }} · {{ txn.account.name if txn.account else '—' }}</div>
</div>
<div class="mono {% if txn.transaction_type=='income' %}text-income{% else %}text-expense{% endif %}" style="font-size:18px;font-weight:700;">
{{ txn.amount | currency }}
</div>
</div>
</div>
<!-- Split form -->
<div class="pcard">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0" style="font-size:14px;font-weight:600;">Split into parts</h6>
<div style="font-size:12px;color:var(--muted);">
Remaining: <span id="remaining" class="mono fw-bold">{{ txn.amount | currency }}</span>
</div>
</div>
<form method="POST" id="splitForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Column headers -->
<div class="split-row mb-2" style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">
<span>Category</span>
<span>Description</span>
<span>Amount</span>
<span></span>
</div>
<div id="split-rows">
<!-- Two default rows -->
<div class="split-row mb-2 split-entry">
<select name="split_category" class="form-select form-select-sm">
<option value="">— None —</option>
{% for cat in categories %}
<option value="{{ cat.id }}"{% if txn.category_id == cat.id %} selected{% endif %}>{{ cat.name }}</option>
{% endfor %}
</select>
<input type="text" name="split_description" class="form-control form-control-sm" placeholder="{{ txn.description }}" value="{{ txn.description }}">
<input type="number" name="split_amount" class="form-control form-control-sm split-amt" placeholder="0.00" step="0.01" min="0.01" oninput="updateRemaining()">
<button type="button" onclick="removeRow(this)" class="btn btn-sm btn-outline-danger" style="padding:2px 8px;"><i class="bi bi-x-lg"></i></button>
</div>
<div class="split-row mb-2 split-entry">
<select name="split_category" class="form-select form-select-sm">
<option value="">— None —</option>
{% for cat in categories %}
<option value="{{ cat.id }}">{{ cat.name }}</option>
{% endfor %}
</select>
<input type="text" name="split_description" class="form-control form-control-sm" placeholder="{{ txn.description }}" value="{{ txn.description }}">
<input type="number" name="split_amount" class="form-control form-control-sm split-amt" placeholder="0.00" step="0.01" min="0.01" oninput="updateRemaining()">
<button type="button" onclick="removeRow(this)" class="btn btn-sm btn-outline-danger" style="padding:2px 8px;"><i class="bi bi-x-lg"></i></button>
</div>
</div>
<button type="button" onclick="addRow()" class="btn btn-sm btn-outline-secondary mb-3" style="font-size:12px;">
<i class="bi bi-plus-lg me-1"></i>Add row
</button>
<!-- Total mismatch warning -->
<div id="total-warn" style="display:none;background:#fee2e2;border:1px solid #fca5a5;border-radius:6px;padding:8px 12px;font-size:12px;color:#991b1b;margin-bottom:12px;">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
Split total must equal <strong>{{ txn.amount | currency }}</strong>.
</div>
<div class="d-flex gap-2">
<button type="submit" id="splitBtn" class="btn btn-primary" style="font-size:13px;">
<i class="bi bi-scissors me-1"></i>Confirm Split
</button>
<a href="{{ url_for('transactions.index', tab=txn.transaction_type) }}" class="btn btn-outline-secondary" style="font-size:13px;">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const ORIGINAL = {{ txn.amount | float }};
const CSRF = '{{ csrf_token() }}';
const catOptions = `{% for cat in categories %}<option value="{{ cat.id }}">{{ cat.name }}</option>{% endfor %}`;
const defaultDesc = {{ txn.description | tojson }};
function updateRemaining() {
const amts = [...document.querySelectorAll('.split-amt')].map(i => parseFloat(i.value) || 0);
const total = amts.reduce((a, b) => a + b, 0);
const rem = ORIGINAL - total;
const el = document.getElementById('remaining');
el.textContent = (rem < 0 ? '-' : '') + Math.abs(rem).toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2});
el.style.color = Math.abs(rem) < 0.005 ? '#10b981' : (rem < 0 ? '#ef4444' : 'var(--text)');
document.getElementById('total-warn').style.display = Math.abs(total - ORIGINAL) > 0.005 && total > 0 ? 'block' : 'none';
}
function addRow() {
const container = document.getElementById('split-rows');
const div = document.createElement('div');
div.className = 'split-row mb-2 split-entry';
div.innerHTML = `
<select name="split_category" class="form-select form-select-sm">
<option value="">— None —</option>${catOptions}
</select>
<input type="text" name="split_description" class="form-control form-control-sm" placeholder="${defaultDesc}" value="${defaultDesc}">
<input type="number" name="split_amount" class="form-control form-control-sm split-amt" placeholder="0.00" step="0.01" min="0.01" oninput="updateRemaining()">
<button type="button" onclick="removeRow(this)" class="btn btn-sm btn-outline-danger" style="padding:2px 8px;"><i class="bi bi-x-lg"></i></button>`;
container.appendChild(div);
}
function removeRow(btn) {
const rows = document.querySelectorAll('.split-entry');
if (rows.length <= 2) { alert('Keep at least 2 rows.'); return; }
btn.closest('.split-entry').remove();
updateRemaining();
}
document.getElementById('splitForm').addEventListener('submit', function(e) {
const amts = [...document.querySelectorAll('.split-amt')].map(i => parseFloat(i.value) || 0);
const total = amts.reduce((a, b) => a + b, 0);
if (Math.abs(total - ORIGINAL) > 0.005) {
e.preventDefault();
document.getElementById('total-warn').style.display = 'block';
document.getElementById('total-warn').scrollIntoView({behavior:'smooth', block:'nearest'});
}
});
</script>
{% endblock %}