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 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 || ''; }); 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() { 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 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.querySelectorAll('input,select').forEach(x => x.addEventListener('input', recalc)); $('overpayments').appendChild(div); 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]) => `
${label}
${value}
`).join(''); $('resultText').textContent = `Dzięki nadpłatom ${money(s.total_overpayment)} oszczędność netto wynosi około ${money(s.interest_saved)} po uwzględnieniu prowizji ${money(s.total_overpayment_fees)}. Kredyt kończy się ${s.payoff_date || '—'} po ${s.months} miesiącach zamiast ${s.baseline_months}. Odsetki: ${money(s.total_interest)}.`; $('scheduleTable').innerHTML = data.schedule.slice(0, 240).map(r => `${r.month}${r.due_date}${r.days}${r.rate.toFixed(2)}%${money(r.payment)}${money(r.principal_part)}${money(r.interest_part)}${money(r.overpayment)}${money(r.overpayment_fee)}${money(r.cumulative_cost)}${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 principal = data.schedule.map(r => r.principal_part); const interest = data.schedule.map(r => r.interest_part); const cumulativeCost = data.schedule.map(r => r.cumulative_cost); const overpayments = data.schedule.map(r => r.overpayment); 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', 'Prowizje'], datasets: [{ data: [lastRequest.principal, data.summary.total_interest, data.summary.total_overpayment, data.summary.total_overpayment_fees] }] }, 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 } } } }); detailChart?.destroy(); detailChart = new Chart($('detailChart'), { data: { labels, datasets: [ { type: 'bar', label: 'Kapitał w racie', data: principal, stack: 'rata', yAxisID: 'y' }, { type: 'bar', label: 'Odsetki w racie', data: interest, stack: 'rata', yAxisID: 'y' }, { type: 'bar', label: 'Nadpłata', data: overpayments, stack: 'rata', yAxisID: 'y' }, { type: 'line', label: 'Koszt narastająco', data: cumulativeCost, yAxisID: 'y1' } ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true }, y1: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false } } } } }); } 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); } function exportJson() { const blob = new Blob([JSON.stringify(buildRequest(), null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'parametry-kredytu.json'; a.click(); URL.revokeObjectURL(url); } function clearRows() { $('rateChanges').innerHTML = ''; $('overpayments').innerHTML = ''; $('historicalMonths').innerHTML = ''; } function applyRequest(data) { $('principal').value = data.principal ?? 600000; const loadedMonths = data.term_months ?? ((data.years ?? 25) * 12); $('termMonths').value = loadedMonths; updateTermLabel(); $('margin').value = data.margin ?? 2; $('baseRate').value = data.base_rate ?? 5.75; $('installmentType').value = data.installment_type ?? 'equal'; $('overpaymentEffect').value = data.overpayment_effect ?? 'shorten'; $('loanStartDate').value = data.loan_start_date ?? todayIso(); $('dueDay').value = data.due_day ?? 5; $('moveDueDate').checked = data.move_due_date_to_business_day ?? true; $('protectionMonths').value = data.overpayment_protection_months ?? ''; $('protectionCommission').value = data.overpayment_protection_commission_percent ?? 0; clearRows(); (data.rate_changes || []).forEach(x => addRateRow(x.month, x.annual_rate)); (data.overpayments || []).forEach(x => addOverpaymentRow(x.month, x.amount, x.repeat, x.until_month || '', x.commission_percent || 0, x.commission_until_month || '')); (data.historical_months || []).forEach(x => addHistoricalRow(x.month, x.annual_rate ?? '', x.grace_type || 'none', x.overpayment || 0, x.overpayment_commission_percent || 0, x.note || '')); recalc(); } function importJsonFile(file) { const reader = new FileReader(); reader.onload = () => { try { applyRequest(JSON.parse(reader.result)); } catch (e) { alert(`Nie udało się wczytać JSON: ${e.message}`); } }; reader.readAsText(file, 'utf-8'); } 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','baseRate','margin','installmentType','overpaymentEffect','loanStartDate','dueDay','moveDueDate','protectionMonths','protectionCommission'].forEach(id => $(id).addEventListener('input', recalc)); $('termMonths').addEventListener('input', () => { updateTermLabel(); recalc(); }); $('yearsSlider').addEventListener('input', setMonthsFromYearsSlider); $('addRate').onclick = () => addRateRow(); $('addOverpayment').onclick = () => addOverpaymentRow(); $('applyProtection').onclick = applyProtectionToOverpayments; $('addHistorical').onclick = () => addHistoricalRow(); $('exportCsv').onclick = () => download('/api/export/csv', 'symulacja-kredytu.csv'); $('exportPdf').onclick = () => download('/api/export/pdf', 'symulacja-kredytu.pdf'); $('exportJson').onclick = exportJson; $('importJson').onclick = () => $('jsonFile').click(); $('jsonFile').onchange = (e) => e.target.files?.[0] && importJsonFile(e.target.files[0]); $('loadNbp').onclick = loadNbp; $('loanStartDate').value = todayIso(); updateTermLabel(); initTheme(); addOverpaymentRow(24, 20000, 'once', '', 0); addOverpaymentRow(36, 500, 'monthly', 120, 0); connectWs();