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; let socket; let debounceTimer; let lastRequest = null; window.lastSimulationData = null; 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 })).filter(x => x.month > 0 && x.amount > 0); return { principal: num('principal'), years: num('years'), margin: num('margin'), base_rate: num('baseRate'), installment_type: $('installmentType').value, overpayment_effect: $('overpaymentEffect').value, rate_changes: rateChanges, overpayments: overpayments }; } function recalc() { 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'; div.innerHTML = `
`; div.querySelector('button').onclick = () => { div.remove(); recalc(); }; div.querySelectorAll('input').forEach(x => x.addEventListener('input', recalc)); $('rateChanges').appendChild(div); recalc(); } function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', 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.querySelectorAll('input,select').forEach(x => x.addEventListener('input', recalc)); $('overpayments').appendChild(div); recalc(); } function render(data) { window.lastSimulationData = data; const s = data.summary; $('summaryCards').innerHTML = [ ['Odsetki', money(s.total_interest)], ['Oszczędność', money(s.interest_saved)], ['Nadpłaty', money(s.total_overpayment)], ['Okres', `${s.months} mies. / ${Math.ceil(s.months / 12)} lat`], ['Skrócenie', `${s.months_saved} mies.`], ['Średnia rata', money(s.average_payment)] ].map(([label, value]) => `
${label}
${value}
`).join(''); $('resultText').textContent = `Dzięki nadpłatom w wysokości ${money(s.total_overpayment)} oszczędzasz około ${money(s.interest_saved)} na odsetkach. Kredyt kończy się po ${s.months} miesiącach zamiast ${s.baseline_months}. Łącznie płacisz ${money(s.total_paid)}, z czego odsetki to ${money(s.total_interest)}.`; $('scheduleTable').innerHTML = data.schedule.slice(0, 180).map(r => `${r.month}${r.rate.toFixed(2)}%${money(r.payment)}${money(r.principal_part)}${money(r.interest_part)}${money(r.overpayment)}${money(r.remaining)}`).join(''); renderCharts(data); } function renderCharts(data) { if (!data) return; const labels = data.schedule.map(r => r.month); const balance = data.schedule.map(r => r.remaining); const payment = data.schedule.map(r => r.payment); const yearly = new Map(); data.schedule.forEach(r => { const year = Math.ceil(r.month / 12); yearly.set(year, (yearly.get(year) || 0) + r.interest_part); }); lineChart?.destroy(); lineChart = new Chart($('lineChart'), { type: 'line', data: { labels, datasets: [{ label: 'Saldo', data: balance, yAxisID: 'y' }, { label: 'Rata', data: payment, yAxisID: 'y1' }] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { y: { beginAtZero: true }, y1: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false } } } } }); pieChart?.destroy(); pieChart = new Chart($('pieChart'), { type: 'pie', data: { labels: ['Kapitał', 'Odsetki', 'Nadpłaty'], datasets: [{ data: [lastRequest.principal, data.summary.total_interest, data.summary.total_overpayment] }] }, options: { responsive: true, maintainAspectRatio: false } }); barChart?.destroy(); barChart = new Chart($('barChart'), { type: 'bar', data: { labels: [...yearly.keys()].map(x => `Rok ${x}`), datasets: [{ label: 'Odsetki', data: [...yearly.values()] }] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } } }); } async function download(endpoint, filename) { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(buildRequest()) }); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } async function loadNbp() { const btn = $('loadNbp'); const old = btn.textContent; btn.textContent = 'Pobieram...'; try { const res = await fetch('/api/rate/nbp'); const data = await res.json(); if (!res.ok || data.error) throw new Error(data.error || 'Błąd pobierania'); $('baseRate').value = data.rate; recalc(); btn.textContent = `NBP: ${data.rate}%`; setTimeout(() => btn.textContent = old, 2500); } catch (e) { alert(`Nie udało się pobrać stopy NBP: ${e.message}`); btn.textContent = old; } } ['principal','years','baseRate','margin','installmentType','overpaymentEffect'].forEach(id => $(id).addEventListener('input', recalc)); $('addRate').onclick = () => addRateRow(); $('addOverpayment').onclick = () => addOverpaymentRow(); $('exportCsv').onclick = () => download('/api/export/csv', 'symulacja-kredytu.csv'); $('exportPdf').onclick = () => download('/api/export/pdf', 'symulacja-kredytu.pdf'); $('loadNbp').onclick = loadNbp; initTheme(); addOverpaymentRow(24, 20000, 'once', ''); addOverpaymentRow(36, 500, 'monthly', 120); connectWs();