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
+191 -31
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, Response, stream_with_context
from flask_login import login_required
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, TextAreaField, SubmitField, DecimalField, DateField, HiddenField
@@ -61,39 +61,10 @@ class TransferForm(FlaskForm):
submit = SubmitField('Transfer')
@transactions_bp.route('/')
@login_required
def index():
tab = request.args.get('tab', 'expense') # 'income' | 'expense'
page = request.args.get('page', 1, type=int)
search = request.args.get('q', '').strip()
category_id = request.args.get('category_id', '', type=str)
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()
this_month_from = today.replace(day=1).strftime('%Y-%m-%d')
this_month_to = today.strftime('%Y-%m-%d')
last_month_last = today.replace(day=1) - timedelta(days=1)
last_month_first = last_month_last.replace(day=1)
last_month_from = last_month_first.strftime('%Y-%m-%d')
last_month_to = last_month_last.strftime('%Y-%m-%d')
if date_from == this_month_from and date_to == this_month_to:
active_quick = 'this_month'
elif date_from == last_month_from and date_to == last_month_to:
active_quick = 'last_month'
else:
active_quick = ''
def _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min='', amount_max=''):
query = Transaction.query.filter(
Transaction.transaction_type == tab
).order_by(Transaction.date.desc(), Transaction.id.desc())
if search:
query = query.filter(
or_(
@@ -125,6 +96,39 @@ def index():
query = query.filter(Transaction.amount <= float(amount_max))
except (ValueError, TypeError):
pass
return query
@transactions_bp.route('/')
@login_required
def index():
tab = request.args.get('tab', 'expense') # 'income' | 'expense'
page = request.args.get('page', 1, type=int)
search = request.args.get('q', '').strip()
category_id = request.args.get('category_id', '', type=str)
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()
this_month_from = today.replace(day=1).strftime('%Y-%m-%d')
this_month_to = today.strftime('%Y-%m-%d')
last_month_last = today.replace(day=1) - timedelta(days=1)
last_month_first = last_month_last.replace(day=1)
last_month_from = last_month_first.strftime('%Y-%m-%d')
last_month_to = last_month_last.strftime('%Y-%m-%d')
if date_from == this_month_from and date_to == this_month_to:
active_quick = 'this_month'
elif date_from == last_month_from and date_to == last_month_to:
active_quick = 'last_month'
else:
active_quick = ''
query = _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min, amount_max)
pagination = query.paginate(page=page, per_page=30, error_out=False)
@@ -483,3 +487,159 @@ def ocr_receipt_file():
'category_suggestion': result['category_suggestion'],
'category_id': category_id,
})
# ── Export filtered transactions ──────────────────────────────────────────────
@transactions_bp.route('/export/csv')
@login_required
def export_csv():
from app.services.export_service import transactions_csv_stream
tab = request.args.get('tab', 'expense')
search = request.args.get('q', '').strip()
category_id = request.args.get('category_id', '')
account_id = request.args.get('account_id', '')
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', '')
query = _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min, amount_max)
filename = f'transactions_{tab}_{date.today().strftime("%Y%m%d")}.csv'
return Response(
stream_with_context(transactions_csv_stream(query)),
mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
@transactions_bp.route('/export/excel')
@login_required
def export_excel():
from app.services.export_service import transactions_to_excel
tab = request.args.get('tab', 'expense')
search = request.args.get('q', '').strip()
category_id = request.args.get('category_id', '')
account_id = request.args.get('account_id', '')
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', '')
query = _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min, amount_max)
label = f'{tab.title()} {date_from or "all"}{"-" + date_to if date_to else ""}'[:31]
excel_bytes = transactions_to_excel(query, label)
filename = f'transactions_{tab}_{date.today().strftime("%Y%m%d")}.xlsx'
return Response(
excel_bytes,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
# ── Duplicate detection ───────────────────────────────────────────────────────
@transactions_bp.route('/api/check-duplicate')
@login_required
def check_duplicate():
txn_date = request.args.get('date', '')
amount = request.args.get('amount', '')
txn_type = request.args.get('type', 'expense')
exclude_id = request.args.get('exclude_id', '', type=str)
if not txn_date or not amount:
return jsonify({'duplicates': []})
try:
d = datetime.strptime(txn_date, '%Y-%m-%d').date()
amt = float(amount)
except (ValueError, TypeError):
return jsonify({'duplicates': []})
q = Transaction.query.filter(
Transaction.transaction_type == txn_type,
Transaction.date == d,
Transaction.amount == amt,
)
if exclude_id:
try:
q = q.filter(Transaction.id != int(exclude_id))
except (ValueError, TypeError):
pass
dupes = q.limit(5).all()
return jsonify({'duplicates': [
{'id': t.id, 'description': t.description, 'date': t.date.strftime('%b %d, %Y'),
'account': t.account.name if t.account else ''}
for t in dupes
]})
# ── Split transaction ─────────────────────────────────────────────────────────
@transactions_bp.route('/<int:id>/split', methods=['GET', 'POST'])
@login_required
def split(id):
txn = db.get_or_404(Transaction, id)
all_cats = Category.query.filter(
Category.category_type.in_([txn.transaction_type, 'both']),
Category.is_active == True,
).order_by(Category.name).all()
if request.method == 'POST':
amounts = request.form.getlist('split_amount')
cat_ids = request.form.getlist('split_category')
descs = request.form.getlist('split_description')
parts = []
total_split = 0.0
for amt_str, cid_str, desc_str in zip(amounts, cat_ids, descs):
try:
amt = float(amt_str)
except (ValueError, TypeError):
flash('Invalid amount in split.', 'danger')
return redirect(url_for('transactions.split', id=id))
if amt <= 0:
continue
parts.append({
'amount': amt,
'category_id': int(cid_str) if cid_str else None,
'description': desc_str.strip() or txn.description,
})
total_split += amt
if not parts:
flash('Add at least one split row.', 'danger')
return redirect(url_for('transactions.split', id=id))
if abs(total_split - float(txn.amount)) > 0.005:
flash(f'Split total {total_split:.2f} must equal original {float(txn.amount):.2f}.', 'danger')
return redirect(url_for('transactions.split', id=id))
account_id = txn.account_id
txn_type = txn.transaction_type
txn_date = txn.date
txn_notes = txn.notes
db.session.delete(txn)
db.session.flush()
for part in parts:
new_txn = Transaction(
transaction_type=txn_type,
account_id=account_id,
category_id=part['category_id'],
amount=part['amount'],
description=part['description'],
date=txn_date,
notes=txn_notes,
)
db.session.add(new_txn)
db.session.commit()
calc_balance(account_id)
flash(f'Transaction split into {len(parts)} part(s).', 'success')
return redirect(url_for('transactions.index', tab=txn_type))
return render_template('transactions/split.html', txn=txn, categories=all_cats)
+82
View File
@@ -58,6 +58,17 @@
</div>
{% endif %}
<!-- Budget vs Actual chart -->
{% set chart_cats = summary | selectattr('has_budget') | list %}
{% if chart_cats %}
<div class="pcard mb-4">
<div style="font-size:13px;font-weight:600;margin-bottom:12px;">Budget vs Actual</div>
<div style="position:relative;height:{{ [[chart_cats|length * 42, 180]|max, 380]|min }}px;">
<canvas id="budgetChart"></canvas>
</div>
</div>
{% endif %}
<!-- Budget Items -->
{% if summary %}
<div class="pcard p-0">
@@ -148,3 +159,74 @@
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
{% set chart_cats = summary | selectattr('has_budget') | list %}
{% if chart_cats %}
<script>
(function() {
const labels = {{ chart_cats | map(attribute='category') | map(attribute='name') | list | tojson }};
const spent = {{ chart_cats | map(attribute='spent') | list | tojson }};
const limits = {{ chart_cats | map(attribute='limit') | list | tojson }};
const colors = {{ chart_cats | map(attribute='category') | map(attribute='color') | list | tojson }};
const ctx = document.getElementById('budgetChart');
if (!ctx) return;
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Spent',
data: spent,
backgroundColor: colors.map((c, i) => {
const pct = limits[i] > 0 ? spent[i] / limits[i] : 0;
return pct >= 1 ? '#ef4444cc' : pct >= 0.8 ? '#f59e0bcc' : '#10b981cc';
}),
borderRadius: 4,
barPercentage: 0.55,
categoryPercentage: 0.9,
},
{
label: 'Budget',
data: limits,
backgroundColor: '#e2e8f0cc',
borderRadius: 4,
barPercentage: 0.55,
categoryPercentage: 0.9,
},
],
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: { boxWidth: 10, font: { size: 11 } } },
tooltip: {
callbacks: {
label: ctx => {
const sym = '{{ current_user.currency_symbol }}';
return ` ${ctx.dataset.label}: ${sym}${ctx.parsed.x.toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2})}`;
}
}
},
},
scales: {
x: {
beginAtZero: true,
grid: { color: '#f1f5f9' },
ticks: {
font: { size: 10 },
callback: v => '{{ current_user.currency_symbol }}' + v.toLocaleString(),
},
},
y: { ticks: { font: { size: 11 } } },
},
},
});
})();
</script>
{% endif %}
{% endblock %}
+22 -1
View File
@@ -79,7 +79,7 @@
{% else %}
<!-- Price alerts banner (populated by AJAX) -->
<div id="price-alert-banner" style="display:none;" class="alert d-flex align-items-start gap-2 mb-3" style="background:#fef3c7;border:1px solid #fcd34d;color:#78350f;font-size:13px;border-radius:8px;padding:10px 14px;">
<div id="price-alert-banner" style="display:none;background:#fef3c7;border:1px solid #fcd34d;color:#78350f;font-size:13px;border-radius:8px;padding:10px 14px;" class="d-flex align-items-start gap-2 mb-3">
<i class="bi bi-graph-up-arrow flex-shrink-0 mt-1" style="color:#d97706;"></i>
<div class="flex-grow-1" id="price-alert-text"></div>
<button type="button" onclick="document.getElementById('price-alert-banner').style.display='none';"
@@ -525,6 +525,27 @@
}
})();
// ── Price alert banner ────────────────────────────────────────────────────────
(function () {
fetch('{{ url_for("investments.api_price_alerts") }}')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data || !data.alerts || !data.alerts.length) return;
const sym = '{{ current_user.currency_symbol or "$" }}';
const parts = data.alerts.map(a => {
const sign = a.day_change_pct >= 0 ? '+' : '';
const color = a.day_change_pct >= 0 ? '#065f46' : '#991b1b';
return `<strong style="color:${color};">${a.ticker} (${sign}${a.day_change_pct}%)</strong>`;
});
const banner = document.getElementById('price-alert-banner');
const textEl = document.getElementById('price-alert-text');
const label = data.alerts.length === 1 ? 'holding moved' : 'holdings moved';
textEl.innerHTML = `<strong>${data.alerts.length} ${label} ≥5% today:</strong> ${parts.join(', ')}`;
banner.style.display = '';
})
.catch(() => {});
})();
</script>
{% endif %}
{% endblock %}
+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 %}