06/05 Optimize app
This commit is contained in:
@@ -197,6 +197,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Import progress bar (shown only during chunked import) #}
|
||||
<div id="import-progress-wrap" class="d-none mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1" style="font-size:12px;color:var(--muted);">
|
||||
<span id="import-progress-label">Importing…</span>
|
||||
<span id="import-progress-pct" class="mono fw-bold">0%</span>
|
||||
</div>
|
||||
<div class="progress" style="height:6px;border-radius:3px;">
|
||||
<div id="import-progress-bar" class="progress-bar bg-success" role="progressbar" style="width:0%;transition:width .3s ease;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Parse errors (non-fatal) #}
|
||||
<div id="parse-warnings" class="alert alert-warning d-none mb-3" style="font-size:12px;">
|
||||
<strong>Warnings</strong> — these rows were skipped:<br>
|
||||
@@ -580,31 +591,56 @@
|
||||
setImportLoading(true);
|
||||
$('import-error').classList.add('d-none');
|
||||
|
||||
fetch('{{ url_for("bank_import.confirm_import") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': CSRF,
|
||||
},
|
||||
body: JSON.stringify({ account_id: parseInt(accountId), skip_dupes: skipDupes, rows }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const CHUNK = 50;
|
||||
const useChunks = rows.length > CHUNK;
|
||||
let totalImported = 0, totalSkipped = 0;
|
||||
|
||||
function setProgress(done, total) {
|
||||
const pct = Math.round(done / total * 100);
|
||||
$('import-progress-bar').style.width = pct + '%';
|
||||
$('import-progress-pct').textContent = pct + '%';
|
||||
$('import-progress-label').textContent = `Importing… ${done} of ${total}`;
|
||||
}
|
||||
|
||||
async function sendChunks() {
|
||||
if (useChunks) {
|
||||
$('import-progress-wrap').classList.remove('d-none');
|
||||
setProgress(0, rows.length);
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
for (let i = 0; i < rows.length; i += CHUNK) chunks.push(rows.slice(i, i + CHUNK));
|
||||
let sent = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const r = await fetch('{{ url_for("bank_import.confirm_import") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF },
|
||||
body: JSON.stringify({ account_id: parseInt(accountId), skip_dupes: skipDupes, rows: chunk }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
totalImported += data.imported;
|
||||
totalSkipped += data.skipped;
|
||||
sent += chunk.length;
|
||||
if (useChunks) setProgress(sent, rows.length);
|
||||
}
|
||||
}
|
||||
|
||||
sendChunks()
|
||||
.then(() => {
|
||||
setImportLoading(false);
|
||||
if (data.error) {
|
||||
$('import-error').textContent = data.error;
|
||||
$('import-error').classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
$('import-progress-wrap').classList.add('d-none');
|
||||
$('done-title').textContent =
|
||||
data.imported + ' transaction' + (data.imported !== 1 ? 's' : '') + ' imported successfully';
|
||||
totalImported + ' transaction' + (totalImported !== 1 ? 's' : '') + ' imported successfully';
|
||||
$('done-subtitle').textContent =
|
||||
data.skipped > 0 ? data.skipped + ' duplicate(s) skipped.' : 'All transactions were new.';
|
||||
totalSkipped > 0 ? totalSkipped + ' duplicate(s) skipped.' : 'All transactions were new.';
|
||||
showStep('step-done');
|
||||
})
|
||||
.catch(err => {
|
||||
setImportLoading(false);
|
||||
$('import-error').textContent = 'Request failed: ' + err;
|
||||
$('import-progress-wrap').classList.add('d-none');
|
||||
$('import-error').textContent = 'Import failed: ' + err;
|
||||
$('import-error').classList.remove('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,8 +198,88 @@
|
||||
.sb-overlay.on { display: block; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Dark mode ─────────────────────────────────────────────────────── */
|
||||
body.dark-mode {
|
||||
--body-bg: #0f172a; --card-bg: #1e293b; --text: #e2e8f0;
|
||||
--muted: #94a3b8; --border: #334155;
|
||||
color-scheme: dark;
|
||||
}
|
||||
body.dark-mode #topbar { background: #1e293b; border-color: #334155; }
|
||||
body.dark-mode .pfm-table tbody tr:hover { background: #263548; }
|
||||
body.dark-mode .pfm-table th, body.dark-mode .pfm-table td { border-color: #334155; }
|
||||
body.dark-mode .form-control, body.dark-mode .form-select {
|
||||
background: #0f172a; border-color: #334155; color: #e2e8f0;
|
||||
}
|
||||
body.dark-mode .form-control:focus, body.dark-mode .form-select:focus {
|
||||
background: #0f172a; border-color: #3b82f6; color: #e2e8f0;
|
||||
box-shadow: 0 0 0 .2rem rgba(59,130,246,.25);
|
||||
}
|
||||
body.dark-mode .form-control::placeholder { color: #475569; }
|
||||
body.dark-mode .form-check-input { background-color: #334155; border-color: #475569; }
|
||||
body.dark-mode .form-check-input:checked { background-color: #3b82f6; border-color: #3b82f6; }
|
||||
body.dark-mode .btn-outline-secondary { color: #94a3b8; border-color: #334155; }
|
||||
body.dark-mode .btn-outline-secondary:hover,
|
||||
body.dark-mode .btn-outline-secondary:focus { background: #334155; color: #e2e8f0; border-color: #334155; }
|
||||
body.dark-mode .btn-outline-primary { color: #93c5fd; border-color: #1e40af; }
|
||||
body.dark-mode .btn-outline-primary:hover { background: #1e40af; color: #fff; }
|
||||
body.dark-mode .btn-outline-warning { color: #fcd34d; border-color: #92400e; }
|
||||
body.dark-mode .btn-outline-warning:hover { background: #92400e; color: #fff; }
|
||||
body.dark-mode .btn-outline-danger { color: #fca5a5; border-color: #991b1b; }
|
||||
body.dark-mode .btn-outline-danger:hover { background: #991b1b; color: #fff; }
|
||||
body.dark-mode .dropdown-menu { background: #1e293b; border-color: #334155; }
|
||||
body.dark-mode .dropdown-item { color: #e2e8f0; }
|
||||
body.dark-mode .dropdown-item:hover, body.dark-mode .dropdown-item:focus { background: #334155; color: #f1f5f9; }
|
||||
body.dark-mode .dropdown-divider { border-color: #334155; }
|
||||
body.dark-mode .modal-content { background: #1e293b; border-color: #334155; color: #e2e8f0; }
|
||||
body.dark-mode .modal-header, body.dark-mode .modal-footer { border-color: #334155; }
|
||||
body.dark-mode .modal-header .btn-close { filter: invert(1) grayscale(1); }
|
||||
body.dark-mode .alert-warning { background: #451a03; border-color: #92400e; color: #fcd34d; }
|
||||
body.dark-mode .alert-danger { background: #450a0a; border-color: #991b1b; color: #fca5a5; }
|
||||
body.dark-mode .alert-success { background: #052e16; border-color: #166534; color: #86efac; }
|
||||
body.dark-mode .alert-info { background: #0c1a2e; border-color: #1e40af; color: #93c5fd; }
|
||||
body.dark-mode .progress { background: #334155; }
|
||||
body.dark-mode .table { color: #e2e8f0; }
|
||||
body.dark-mode .input-group-text { background: #334155; border-color: #334155; color: #94a3b8; }
|
||||
body.dark-mode .badge-income { background: #064e3b; color: #6ee7b7; }
|
||||
body.dark-mode .badge-expense { background: #450a0a; color: #fca5a5; }
|
||||
body.dark-mode .badge-transfer { background: #1e3a5f; color: #93c5fd; }
|
||||
body.dark-mode #kbd-modal kbd { background: #334155; border-color: #475569; color: #e2e8f0; }
|
||||
body.dark-mode .report-tab { background: #1e293b; border-color: #334155; color: #94a3b8; }
|
||||
body.dark-mode .report-tab:hover { background: #334155; color: #e2e8f0; }
|
||||
body.dark-mode .report-tab.active { background: #e2e8f0; color: #0f172a; border-color: #e2e8f0; }
|
||||
/* Override common hardcoded light backgrounds in component inline styles */
|
||||
body.dark-mode [style*="background:#f8fafc"], body.dark-mode [style*="background: #f8fafc"],
|
||||
body.dark-mode [style*="background:#f1f5f9"], body.dark-mode [style*="background: #f1f5f9"]
|
||||
{ background: #263548 !important; }
|
||||
body.dark-mode [style*="background:#eff6ff"], body.dark-mode [style*="background: #eff6ff"]
|
||||
{ background: #1e3a5f !important; }
|
||||
body.dark-mode [style*="background:#fef2f2"], body.dark-mode [style*="background: #fef2f2"]
|
||||
{ background: #450a0a !important; }
|
||||
body.dark-mode [style*="background:#f0fdf4"], body.dark-mode [style*="background: #f0fdf4"]
|
||||
{ background: #052e16 !important; }
|
||||
body.dark-mode [style*="background:#fff5e6"], body.dark-mode [style*="background:#fffbeb"]
|
||||
{ background: #451a03 !important; }
|
||||
/* Text color overrides for hardcoded darks */
|
||||
body.dark-mode [style*="color:#0f172a"] { color: #e2e8f0 !important; }
|
||||
body.dark-mode [style*="color:#1e293b"] { color: #94a3b8 !important; }
|
||||
body.dark-mode [style*="color:#374151"] { color: #94a3b8 !important; }
|
||||
/* Border overrides */
|
||||
body.dark-mode [style*="border-bottom:1px solid #e2e8f0"],
|
||||
body.dark-mode [style*="border-bottom: 1px solid #e2e8f0"] { border-color: #334155 !important; }
|
||||
body.dark-mode [style*="border:1px solid #e2e8f0"],
|
||||
body.dark-mode [style*="border: 1px solid #e2e8f0"] { border-color: #334155 !important; }
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</style>
|
||||
<script>
|
||||
/* Apply dark mode class to body before first paint to avoid flash */
|
||||
(function(){ if(localStorage.getItem('pfm_dark')==='1') document.documentElement.setAttribute('data-pfm-dark','1'); })();
|
||||
</script>
|
||||
<style>
|
||||
/* Instant pre-body dark (avoids FOUC) — matches body.dark-mode vars */
|
||||
html[data-pfm-dark] body { --body-bg:#0f172a; --card-bg:#1e293b; --text:#e2e8f0; --muted:#94a3b8; --border:#334155; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sb-overlay" id="sbOverlay"></div>
|
||||
@@ -318,6 +398,11 @@
|
||||
<span class="tb-title">{% block page_title %}{% endblock %}</span>
|
||||
<div class="tb-right">
|
||||
{% block topbar_actions %}{% endblock %}
|
||||
<button id="dark-toggle" type="button" title="Toggle dark mode"
|
||||
style="background:none;border:none;color:var(--muted);font-size:15px;padding:3px 6px;border-radius:6px;cursor:pointer;line-height:1;"
|
||||
class="d-inline-flex align-items-center">
|
||||
<i class="bi bi-moon-stars"></i>
|
||||
</button>
|
||||
<button type="button" data-bs-toggle="modal" data-bs-target="#kbd-modal"
|
||||
title="Keyboard shortcuts (?)"
|
||||
style="background:none;border:none;color:var(--muted);font-size:13px;padding:3px 6px;border-radius:6px;cursor:pointer;line-height:1;"
|
||||
@@ -473,6 +558,29 @@
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Dark mode toggle ─────────────────────────────────────────────────
|
||||
(function () {
|
||||
var DKEY = 'pfm_dark';
|
||||
var body = document.body;
|
||||
var btn = document.getElementById('dark-toggle');
|
||||
|
||||
function applyDark(on) {
|
||||
body.classList.toggle('dark-mode', on);
|
||||
document.documentElement.setAttribute('data-pfm-dark', on ? '1' : '0');
|
||||
if (btn) btn.querySelector('i').className = on ? 'bi bi-sun' : 'bi bi-moon-stars';
|
||||
}
|
||||
|
||||
// Initialise from storage
|
||||
var saved = localStorage.getItem(DKEY) === '1';
|
||||
applyDark(saved);
|
||||
|
||||
if (btn) btn.addEventListener('click', function () {
|
||||
var next = !body.classList.contains('dark-mode');
|
||||
localStorage.setItem(DKEY, next ? '1' : '0');
|
||||
applyDark(next);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -138,6 +138,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financial Health Score -->
|
||||
<div id="health-score-card" class="pcard mb-4" style="display:none;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<span class="pcard-title mb-0">Financial Health Score</span>
|
||||
<span id="hs-grade-badge" class="badge fw-bold" style="font-size:13px;padding:4px 12px;"></span>
|
||||
</div>
|
||||
<div class="row g-3 align-items-center">
|
||||
<!-- Score ring -->
|
||||
<div class="col-12 col-sm-auto d-flex justify-content-center">
|
||||
<div style="position:relative;width:96px;height:96px;">
|
||||
<svg viewBox="0 0 36 36" style="width:96px;height:96px;transform:rotate(-90deg);">
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="var(--border)" stroke-width="3"/>
|
||||
<circle id="hs-ring" cx="18" cy="18" r="15.9155" fill="none" stroke="#10b981" stroke-width="3"
|
||||
stroke-dasharray="0 100" stroke-linecap="round"
|
||||
style="transition:stroke-dasharray .8s ease, stroke .4s;"/>
|
||||
</svg>
|
||||
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;">
|
||||
<div id="hs-score" class="mono fw-bold" style="font-size:22px;line-height:1;">—</div>
|
||||
<div style="font-size:10px;color:var(--muted);">/ 100</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Component bars -->
|
||||
<div class="col">
|
||||
<div id="hs-components" class="d-flex flex-column gap-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tips -->
|
||||
<div id="hs-tips" class="mt-3" style="display:none;">
|
||||
<div class="d-flex flex-wrap gap-2" id="hs-tips-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-xl-8">
|
||||
@@ -555,6 +588,71 @@ function refreshFxRate() {
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Financial health score ───────────────────────────────────────────────────
|
||||
(function(){
|
||||
var card = document.getElementById('health-score-card');
|
||||
var ring = document.getElementById('hs-ring');
|
||||
var scoreEl = document.getElementById('hs-score');
|
||||
var gradeEl = document.getElementById('hs-grade-badge');
|
||||
var compsEl = document.getElementById('hs-components');
|
||||
var tipsWrap = document.getElementById('hs-tips');
|
||||
var tipsList = document.getElementById('hs-tips-list');
|
||||
if (!card) return;
|
||||
|
||||
const SYM = '{{ current_user.currency_symbol }}';
|
||||
|
||||
fetch('/api/health-score')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) return;
|
||||
card.style.display = '';
|
||||
|
||||
// Score ring
|
||||
var circ = 100;
|
||||
ring.style.strokeDasharray = (data.score / 100 * circ) + ' ' + circ;
|
||||
ring.style.stroke = data.color;
|
||||
scoreEl.textContent = data.score;
|
||||
scoreEl.style.color = data.color;
|
||||
|
||||
// Grade badge
|
||||
gradeEl.textContent = 'Grade ' + data.grade;
|
||||
gradeEl.style.background = data.color + '22';
|
||||
gradeEl.style.color = data.color;
|
||||
|
||||
// Component bars
|
||||
var icons = {
|
||||
savings: 'bi-piggy-bank',
|
||||
budgets: 'bi-pie-chart',
|
||||
goals: 'bi-bullseye',
|
||||
emergency_fund: 'bi-shield-check',
|
||||
};
|
||||
compsEl.innerHTML = data.components.map(c => `
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-1" style="font-size:12px;">
|
||||
<span><i class="bi ${c.icon} me-1" style="color:var(--muted);"></i>${c.name}</span>
|
||||
<span class="mono" style="color:var(--muted);">${c.points}/${c.max}</span>
|
||||
</div>
|
||||
<div class="progress" style="height:5px;border-radius:3px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width:${c.pct}%;background:${c.pct>=80?'#10b981':c.pct>=50?'#3b82f6':c.pct>=25?'#f59e0b':'#ef4444'};transition:width .6s ease;">
|
||||
</div>
|
||||
</div>
|
||||
${c.label ? `<div style="font-size:10px;color:var(--muted);margin-top:1px;">${c.label}</div>` : ''}
|
||||
</div>`).join('');
|
||||
|
||||
// Tips
|
||||
if (data.tips && data.tips.length) {
|
||||
tipsWrap.style.display = '';
|
||||
tipsList.innerHTML = data.tips.map(t =>
|
||||
`<span style="font-size:11px;background:#fef3c7;color:#92400e;border-radius:20px;padding:3px 10px;display:inline-block;">
|
||||
<i class="bi bi-lightbulb me-1"></i>${t}
|
||||
</span>`
|
||||
).join('');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
})();
|
||||
|
||||
// ── Spending anomalies ───────────────────────────────────────────────────────
|
||||
(function(){
|
||||
const card = document.getElementById('anomaly-card');
|
||||
|
||||
@@ -94,4 +94,145 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cash Flow Projection -->
|
||||
<div class="pcard mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<span class="pcard-title mb-0">Projected Cash Flow</span>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm proj-btn btn-primary" data-days="30" style="font-size:12px;">30d</button>
|
||||
<button class="btn btn-sm proj-btn btn-outline-secondary" data-days="60" style="font-size:12px;">60d</button>
|
||||
<button class="btn btn-sm proj-btn btn-outline-secondary" data-days="90" style="font-size:12px;">90d</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="row g-2 mb-3" id="proj-stats">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="pcard pcard-sm text-center p-2" style="background:#f0fdf4;">
|
||||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Income</div>
|
||||
<div id="proj-income" class="mono text-income fw-bold" style="font-size:15px;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="pcard pcard-sm text-center p-2" style="background:#fef2f2;">
|
||||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Expenses</div>
|
||||
<div id="proj-expense" class="mono text-expense fw-bold" style="font-size:15px;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="pcard pcard-sm text-center p-2">
|
||||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Net</div>
|
||||
<div id="proj-net" class="mono fw-bold" style="font-size:15px;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="pcard pcard-sm text-center p-2" style="background:#eff6ff;">
|
||||
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">End Balance</div>
|
||||
<div id="proj-end" class="mono text-invest fw-bold" style="font-size:15px;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<div id="proj-chart-wrap" style="position:relative;height:200px;">
|
||||
<canvas id="projChart"></canvas>
|
||||
</div>
|
||||
<div id="proj-loading" class="text-center py-4 text-muted" style="display:none;">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>Loading…
|
||||
</div>
|
||||
|
||||
<!-- Event list -->
|
||||
<div id="proj-events" class="mt-3" style="max-height:260px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
const SYM = '{{ current_user.currency_symbol }}';
|
||||
const fmtC = v => SYM + Math.abs(v).toLocaleString(undefined,{minimumFractionDigits:0,maximumFractionDigits:0});
|
||||
|
||||
let projChart = null;
|
||||
|
||||
function loadProjection(days) {
|
||||
// update active button
|
||||
document.querySelectorAll('.proj-btn').forEach(b => {
|
||||
const active = b.dataset.days == days;
|
||||
b.className = 'btn btn-sm proj-btn ' + (active ? 'btn-primary' : 'btn-outline-secondary');
|
||||
b.style.fontSize = '12px';
|
||||
});
|
||||
|
||||
document.getElementById('proj-loading').style.display = '';
|
||||
document.getElementById('proj-chart-wrap').style.display = 'none';
|
||||
|
||||
fetch('/settings/recurring/projection?days=' + days)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('proj-loading').style.display = 'none';
|
||||
document.getElementById('proj-chart-wrap').style.display = '';
|
||||
|
||||
// Summary stats
|
||||
document.getElementById('proj-income').textContent = fmtC(data.total_income);
|
||||
document.getElementById('proj-expense').textContent = fmtC(data.total_expense);
|
||||
const net = data.net;
|
||||
const netEl = document.getElementById('proj-net');
|
||||
netEl.textContent = (net >= 0 ? '+' : '-') + fmtC(Math.abs(net));
|
||||
netEl.style.color = net >= 0 ? '#10b981' : '#ef4444';
|
||||
document.getElementById('proj-end').textContent = fmtC(data.ending_balance);
|
||||
|
||||
// Chart
|
||||
if (projChart) projChart.destroy();
|
||||
const ctx = document.getElementById('projChart').getContext('2d');
|
||||
projChart = new Chart(ctx, {
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{ type:'bar', label:'Income', data:data.income, backgroundColor:'#10b98133', borderColor:'#10b981', borderWidth:1.5, borderRadius:3, yAxisID:'y' },
|
||||
{ type:'bar', label:'Expense', data:data.expense, backgroundColor:'#ef444433', borderColor:'#ef4444', borderWidth:1.5, borderRadius:3, yAxisID:'y' },
|
||||
{ type:'line', label:'Balance', data:data.balance, borderColor:'#3b82f6', backgroundColor:'transparent', borderWidth:2, pointRadius:3, tension:.3, yAxisID:'y2' },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive:true, maintainAspectRatio:false,
|
||||
interaction:{ mode:'index', intersect:false },
|
||||
plugins:{ legend:{ position:'bottom', labels:{ font:{ size:11 }, boxWidth:10 } } },
|
||||
scales:{
|
||||
y: { position:'left', grid:{ color:'#f1f5f9' }, ticks:{ font:{size:10}, callback: v=>fmtC(v) } },
|
||||
y2: { position:'right', grid:{ display:false }, ticks:{ font:{size:10}, callback: v=>fmtC(v) } },
|
||||
x: { grid:{ display:false }, ticks:{ font:{size:10} } },
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Event list
|
||||
const evList = document.getElementById('proj-events');
|
||||
if (!data.events.length) {
|
||||
evList.innerHTML = '<p class="text-muted small">No recurring transactions in this period.</p>';
|
||||
return;
|
||||
}
|
||||
evList.innerHTML = data.events.map(ev => `
|
||||
<div class="d-flex justify-content-between align-items-center py-2" style="border-top:1px solid var(--border);font-size:13px;">
|
||||
<div>
|
||||
<span class="fw-medium">${ev.description}</span>
|
||||
<span class="text-muted ms-2" style="font-size:11px;">${ev.date}</span>
|
||||
</div>
|
||||
<span class="mono ${ev.type === 'income' ? 'text-income' : 'text-expense'}" style="font-size:13px;white-space:nowrap;">
|
||||
${ev.type === 'income' ? '+' : '-'}${SYM}${Math.abs(ev.amount).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}
|
||||
</span>
|
||||
</div>`).join('');
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('proj-loading').style.display = 'none';
|
||||
document.getElementById('proj-chart-wrap').style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.proj-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => loadProjection(parseInt(btn.dataset.days)));
|
||||
});
|
||||
|
||||
// Load 30-day projection on page load
|
||||
loadProjection(30);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user