06/05 Optimize app
This commit is contained in:
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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