const $ = (id) => document.getElementById(id); const money = (v) => new Intl.NumberFormat('pl-PL', { style: 'currency', currency: 'PLN', maximumFractionDigits: 0 }).format(v || 0); const num = (id) => Number($(id).value || 0); let lineChart, pieChart, barChart, detailChart; let socket; let debounceTimer; let lastRequest = null; window.lastSimulationData = null; function todayIso() { const d = new Date(); return d.toISOString().slice(0, 10); } function updateTermLabel() { const months = Math.max(1, Number($('termMonths').value || 1)); const years = Math.max(1, Math.round(months / 12)); $('yearsSlider').value = Math.min(50, years); $('yearsSliderLabel').textContent = `${months} mies. ≈ ${(months / 12).toFixed(1)} lat`; } function setMonthsFromYearsSlider() { const years = Number($('yearsSlider').value || 1); $('termMonths').value = years * 12; $('yearsSliderLabel').textContent = `${years} lat = ${years * 12} mies.`; recalc(); } function defaultProtection() { return { commission: Number($('protectionCommission')?.value || 0), until: Number($('protectionMonths')?.value || 0) || '' }; } function sumRows(rows, selector) { return (rows || []).reduce((acc, row) => acc + Number(selector(row) || 0), 0); } function comparisonMetrics(data) { const schedule = data?.schedule || []; const baseline = data?.baseline_schedule || []; const simulatedCosts = sumRows(schedule, r => r.interest_part + r.overpayment_fee); const nominalCosts = sumRows(baseline, r => r.interest_part); const simulatedPayments = sumRows(schedule, r => r.payment + r.overpayment + r.overpayment_fee); const nominalPayments = sumRows(baseline, r => r.payment); return { simulatedCosts, nominalCosts, simulatedPayments, nominalPayments, costDiff: nominalCosts - simulatedCosts, paymentDiff: nominalPayments - simulatedPayments }; } function updateProtectionHint() { const defaults = defaultProtection(); const hasProtection = defaults.commission > 0 || defaults.until !== ''; const rows = [...document.querySelectorAll('#overpayments .dynamic-row')]; const hasMismatch = rows.some(row => { const commission = Number(row.querySelector('[data-field="commission"]')?.value || 0); const until = Number(row.querySelector('[data-field="commissionUntil"]')?.value || 0) || ''; return commission !== defaults.commission || until !== defaults.until; }); const shouldWarn = hasProtection && rows.length > 0 && hasMismatch; $('applyProtection')?.classList.toggle('protection-warning', shouldWarn); $('protectionBox')?.classList.toggle('protection-warning-box', shouldWarn); } function applyProtectionToOverpayments() { const defaults = defaultProtection(); document.querySelectorAll('#overpayments .dynamic-row').forEach(row => { const commissionInput = row.querySelector('[data-field="commission"]'); const untilInput = row.querySelector('[data-field="commissionUntil"]'); if (commissionInput) commissionInput.value = defaults.commission || 0; if (untilInput) untilInput.value = defaults.until || ''; }); updateProtectionHint(); recalc(); } function setTheme(theme) { document.body.dataset.theme = theme; localStorage.setItem('mortgage-theme', theme); const btn = $('themeToggle'); if (btn) btn.textContent = theme === 'dark' ? '☀️ Jasny' : '🌙 Ciemny'; } function initTheme() { const saved = localStorage.getItem('mortgage-theme') || 'dark'; setTheme(saved); $('themeToggle').onclick = () => setTheme(document.body.dataset.theme === 'dark' ? 'light' : 'dark'); } function connectWs() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; socket = new WebSocket(`${proto}://${location.host}/ws/simulate`); socket.onopen = () => recalc(); socket.onmessage = (event) => { const data = JSON.parse(event.data); if (data.error) return console.error(data.error); render(data); }; socket.onclose = () => setTimeout(connectWs, 1200); } function buildRequest() { const rateChanges = [...document.querySelectorAll('#rateChanges .dynamic-row')].map(row => ({ month: Number(row.querySelector('[data-field="month"]').value || 1), annual_rate: Number(row.querySelector('[data-field="rate"]').value || 0) })).filter(x => x.month > 0 && x.annual_rate >= 0); const overpayments = [...document.querySelectorAll('#overpayments .dynamic-row')].map(row => ({ month: Number(row.querySelector('[data-field="month"]').value || 1), amount: Number(row.querySelector('[data-field="amount"]').value || 0), repeat: row.querySelector('[data-field="repeat"]').value, until_month: Number(row.querySelector('[data-field="until"]').value || 0) || null, commission_percent: Number(row.querySelector('[data-field="commission"]').value || 0), commission_until_month: Number(row.querySelector('[data-field="commissionUntil"]').value || 0) || null })).filter(x => x.month > 0 && x.amount > 0); const historicalMonths = [...document.querySelectorAll('#historicalMonths .dynamic-row')].map(row => { const rateRaw = row.querySelector('[data-field="rate"]').value; return { month: Number(row.querySelector('[data-field="month"]').value || 1), annual_rate: rateRaw === '' ? null : Number(rateRaw), grace_type: row.querySelector('[data-field="grace"]').value, overpayment: Number(row.querySelector('[data-field="overpayment"]').value || 0), overpayment_commission_percent: Number(row.querySelector('[data-field="commission"]').value || 0), note: row.querySelector('[data-field="note"]').value || '' }; }).filter(x => x.month > 0); const termMonths = Math.max(1, Math.round(num('termMonths') || 1)); return { principal: num('principal'), years: Math.max(1, Math.ceil(termMonths / 12)), term_months: termMonths, margin: num('margin'), base_rate: num('baseRate'), installment_type: $('installmentType').value, overpayment_effect: $('overpaymentEffect').value, loan_start_date: $('loanStartDate').value || todayIso(), due_day: num('dueDay') || 5, move_due_date_to_business_day: $('moveDueDate').checked, overpayment_protection_months: Number($('protectionMonths').value || 0) || null, overpayment_protection_commission_percent: Number($('protectionCommission').value || 0), rate_changes: rateChanges, overpayments: overpayments, historical_months: historicalMonths }; } function recalc() { updateProtectionHint(); clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { lastRequest = buildRequest(); if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(lastRequest)); }, 120); } function addRateRow(month = 13, rate = 7.0) { const div = document.createElement('div'); div.className = 'dynamic-row rate-row'; div.innerHTML = `
`; div.querySelector('button').onclick = () => { div.remove(); recalc(); }; div.querySelectorAll('input').forEach(x => x.addEventListener('input', recalc)); $('rateChanges').appendChild(div); recalc(); } function syncOverpaymentAmountSlider(row, source) { const amountInput = row.querySelector('[data-field="amount"]'); const slider = row.querySelector('[data-field="amountSlider"]'); const label = row.querySelector('[data-field="amountSliderLabel"]'); if (!amountInput || !slider || !label) return; const max = Number(slider.max); const min = Number(slider.min); if (source === 'slider') amountInput.value = slider.value; if (source === 'input') { const amount = Number(amountInput.value || 0); slider.value = Math.min(max, Math.max(min, amount)); } label.textContent = money(Number(amountInput.value || 0)); } function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = '', commission = null, commissionUntil = null) { const defaults = defaultProtection(); if (commission === null || commission === undefined || commission === '') commission = defaults.commission; if (commissionUntil === null || commissionUntil === undefined || commissionUntil === '') commissionUntil = defaults.until; const div = document.createElement('div'); div.className = 'dynamic-row overpay'; div.innerHTML = ` `; div.querySelector('[data-field="repeat"]').value = repeat; div.querySelector('button').onclick = () => { div.remove(); recalc(); }; div.querySelector('[data-field="amountSlider"]').addEventListener('input', () => { syncOverpaymentAmountSlider(div, 'slider'); recalc(); }); div.querySelector('[data-field="amount"]').addEventListener('input', () => { syncOverpaymentAmountSlider(div, 'input'); recalc(); }); div.querySelectorAll('input:not([data-field="amount"]):not([data-field="amountSlider"]),select').forEach(x => x.addEventListener('input', recalc)); $('overpayments').appendChild(div); syncOverpaymentAmountSlider(div, 'input'); recalc(); } function addHistoricalRow(month = 1, rate = '', grace = 'none', overpayment = 0, commission = 0, note = '') { const div = document.createElement('div'); div.className = 'dynamic-row historical'; div.innerHTML = ` `; div.querySelector('[data-field="grace"]').value = grace || 'none'; div.querySelector('button').onclick = () => { div.remove(); recalc(); }; div.querySelectorAll('input,select').forEach(x => x.addEventListener('input', recalc)); $('historicalMonths').appendChild(div); recalc(); } function render(data) { window.lastSimulationData = data; const s = data.summary; $('summaryCards').innerHTML = [ ['Odsetki', money(s.total_interest)], ['Oszczędność netto', money(s.interest_saved)], ['Nadpłaty', money(s.total_overpayment)], ['Prowizje', money(s.total_overpayment_fees)], ['Okres', `${s.months} mies. / ${Math.ceil(s.months / 12)} lat`], ['Data spłaty', s.payoff_date || '-'], ['Skrócenie', `${s.months_saved} mies.`], ['Średnia rata', money(s.average_payment)] ].map(([label, value]) => `