Files
mortgage-simulator/app/static/app.js
T
Mateusz Gruszczyński 4bdb20d9f5 changes3
2026-06-03 13:48:15 +02:00

427 lines
22 KiB
JavaScript

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><label class="form-label">Od miesiąca</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
<div><label class="form-label">Oproc. roczne %</label><input data-field="rate" type="number" step="0.01" class="form-control form-control-sm" value="${rate}"></div>
<button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>`;
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><label class="form-label">Miesiąc</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
<div class="overpay-amount"><label class="form-label">Kwota</label><input data-field="amount" type="number" min="1" class="form-control form-control-sm" value="${amount}"></div>
<div><label class="form-label">Prowizja %</label><input data-field="commission" type="number" min="0" step="0.01" class="form-control form-control-sm" value="${commission}"></div>
<div><label class="form-label">Prowizja do mies.</label><input data-field="commissionUntil" type="number" min="1" class="form-control form-control-sm" value="${commissionUntil || ''}" placeholder="bez limitu"></div>
<div><label class="form-label">Powtarzaj</label><select data-field="repeat" class="form-select form-select-sm"><option value="once">raz</option><option value="monthly">co miesiąc</option><option value="yearly">co rok</option></select></div>
<div><label class="form-label">Nadpłaty do mies.</label><input data-field="until" type="number" min="1" class="form-control form-control-sm" value="${until || ''}"></div>
<button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>
<div class="overpay-slider mt-1"><div class="d-flex justify-content-between align-items-center"><label class="form-label mb-0">Suwak kwoty</label><span data-field="amountSliderLabel" class="small text-muted">${money(amount)}</span></div><input data-field="amountSlider" type="range" min="0" max="50000" step="100" class="form-range" value="${Math.min(50000, Number(amount || 0))}"></div>`;
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><label class="form-label">Miesiąc</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
<div><label class="form-label">Oproc. %</label><input data-field="rate" type="number" step="0.01" class="form-control form-control-sm" value="${rate ?? ''}" placeholder="auto"></div>
<div><label class="form-label">Karencja</label><select data-field="grace" class="form-select form-select-sm"><option value="none">brak</option><option value="interest_only">tylko odsetki</option><option value="full">pełna</option></select></div>
<div><label class="form-label">Nadpłata</label><input data-field="overpayment" type="number" min="0" class="form-control form-control-sm" value="${overpayment || 0}"></div>
<div><label class="form-label">Prow. %</label><input data-field="commission" type="number" min="0" step="0.01" class="form-control form-control-sm" value="${commission || 0}"></div>
<div><label class="form-label">Opis</label><input data-field="note" type="text" class="form-control form-control-sm" value="${String(note || '').replaceAll('"', '&quot;')}"></div>
<button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>`;
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]) => `<div class="col-6 col-md-4 col-xxl-3"><div class="card border-0 shadow-sm stat-card"><div class="card-body py-3"><div class="stat-label">${label}</div><div class="stat-value">${value}</div></div></div></div>`).join('');
const c = comparisonMetrics(data);
$('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}. Porównanie kosztów: nominalnie ${money(c.nominalCosts)}, po symulacji ${money(c.simulatedCosts)}, różnica ${money(c.costDiff)}. Porównanie łącznych płatności: nominalnie ${money(c.nominalPayments)}, po symulacji ${money(c.simulatedPayments)}, różnica ${money(c.paymentDiff)}.`;
$('scheduleTable').innerHTML = data.schedule.slice(0, 240).map(r => `<tr><td>${r.month}</td><td>${r.due_date}</td><td>${r.days}</td><td>${r.rate.toFixed(2)}%</td><td>${money(r.payment)}</td><td>${money(r.principal_part)}</td><td>${money(r.interest_part)}</td><td>${money(r.overpayment)}</td><td>${money(r.overpayment_fee)}</td><td>${money(r.cumulative_cost)}</td><td>${money(r.remaining)}</td></tr>`).join('');
renderCharts(data);
}
function renderCharts(data) {
if (!data) return;
const maxMonths = Math.max(data.schedule.length, (data.baseline_schedule || []).length);
const labels = Array.from({ length: maxMonths }, (_, i) => i + 1);
const balance = labels.map(m => data.schedule[m - 1]?.remaining ?? null);
const payment = labels.map(m => data.schedule[m - 1]?.payment ?? null);
const baselineBalance = labels.map(m => data.baseline_schedule?.[m - 1]?.remaining ?? null);
const baselinePayment = labels.map(m => data.baseline_schedule?.[m - 1]?.payment ?? null);
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();
const yearlyBaseline = new Map();
data.schedule.forEach(r => {
const year = Math.ceil(r.month / 12);
yearly.set(year, (yearly.get(year) || 0) + r.interest_part + r.overpayment_fee);
});
(data.baseline_schedule || []).forEach(r => {
const year = Math.ceil(r.month / 12);
yearlyBaseline.set(year, (yearlyBaseline.get(year) || 0) + r.interest_part);
});
const yearLabels = Array.from(new Set([...yearly.keys(), ...yearlyBaseline.keys()])).sort((a, b) => a - b);
lineChart?.destroy();
lineChart = new Chart($('lineChart'), {
type: 'line',
data: { labels, datasets: [
{ label: 'Saldo po modyfikacjach', data: balance, yAxisID: 'y' },
{ label: 'Saldo nominalne', data: baselineBalance, yAxisID: 'y' },
{ label: 'Rata po modyfikacjach', data: payment, yAxisID: 'y1' },
{ label: 'Rata nominalna', data: baselinePayment, 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: yearLabels.map(x => `Rok ${x}`), datasets: [
{ label: 'Koszty nominalne', data: yearLabels.map(x => yearlyBaseline.get(x) || 0) },
{ label: 'Koszty po symulacji', data: yearLabels.map(x => yearly.get(x) || 0) }
] },
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'].forEach(id => $(id).addEventListener('input', recalc));
['protectionMonths','protectionCommission'].forEach(id => $(id).addEventListener('input', () => { updateProtectionHint(); 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();