06/05 Optimize app
This commit is contained in:
+191
-31
@@ -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_login import login_required
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, SelectField, TextAreaField, SubmitField, DecimalField, DateField, HiddenField
|
from wtforms import StringField, SelectField, TextAreaField, SubmitField, DecimalField, DateField, HiddenField
|
||||||
@@ -61,39 +61,10 @@ class TransferForm(FlaskForm):
|
|||||||
submit = SubmitField('Transfer')
|
submit = SubmitField('Transfer')
|
||||||
|
|
||||||
|
|
||||||
@transactions_bp.route('/')
|
def _build_filter_query(tab, search, category_id, account_id, date_from, date_to, amount_min='', amount_max=''):
|
||||||
@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 = Transaction.query.filter(
|
query = Transaction.query.filter(
|
||||||
Transaction.transaction_type == tab
|
Transaction.transaction_type == tab
|
||||||
).order_by(Transaction.date.desc(), Transaction.id.desc())
|
).order_by(Transaction.date.desc(), Transaction.id.desc())
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
or_(
|
or_(
|
||||||
@@ -125,6 +96,39 @@ def index():
|
|||||||
query = query.filter(Transaction.amount <= float(amount_max))
|
query = query.filter(Transaction.amount <= float(amount_max))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
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)
|
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_suggestion': result['category_suggestion'],
|
||||||
'category_id': category_id,
|
'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)
|
||||||
|
|||||||
@@ -58,6 +58,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 -->
|
<!-- Budget Items -->
|
||||||
{% if summary %}
|
{% if summary %}
|
||||||
<div class="pcard p-0">
|
<div class="pcard p-0">
|
||||||
@@ -148,3 +159,74 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
<!-- Price alerts banner (populated by AJAX) -->
|
<!-- 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>
|
<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>
|
<div class="flex-grow-1" id="price-alert-text"></div>
|
||||||
<button type="button" onclick="document.getElementById('price-alert-banner').style.display='none';"
|
<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>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -120,6 +120,13 @@
|
|||||||
{{ form.notes(class="form-control", rows=2, placeholder="Optional notes", id="field_notes") }}
|
{{ form.notes(class="form-control", rows=2, placeholder="Optional notes", id="field_notes") }}
|
||||||
</div>
|
</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">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" id="submitBtn"
|
<button type="submit" id="submitBtn"
|
||||||
class="btn {% if txn_type=='income' %}btn-success{% else %}btn-danger{% endif %}">
|
class="btn {% if txn_type=='income' %}btn-success{% else %}btn-danger{% endif %}">
|
||||||
@@ -426,5 +433,39 @@ function setTxnType(type) {
|
|||||||
sel.value = prevVal;
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 }}">
|
<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>
|
||||||
<div class="col-12 col-md-8 d-flex align-items-center gap-2 flex-wrap">
|
<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>
|
<span style="font-size:11px;color:var(--muted);">Saved filters:</span>
|
||||||
<div class="dropdown">
|
<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">
|
<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>
|
||||||
<td class="text-end" style="padding-right:20px;white-space:nowrap;">
|
<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.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?')">
|
<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() }}">
|
<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>
|
<button type="submit" class="btn btn-sm btn-outline-danger ms-1" style="font-size:11px;padding:2px 8px;">Del</button>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user