changes3
This commit is contained in:
+87
-11
@@ -34,6 +34,41 @@ function defaultProtection() {
|
||||
};
|
||||
}
|
||||
|
||||
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 => {
|
||||
@@ -42,6 +77,7 @@ function applyProtectionToOverpayments() {
|
||||
if (commissionInput) commissionInput.value = defaults.commission || 0;
|
||||
if (untilInput) untilInput.value = defaults.until || '';
|
||||
});
|
||||
updateProtectionHint();
|
||||
recalc();
|
||||
}
|
||||
|
||||
@@ -119,6 +155,7 @@ function buildRequest() {
|
||||
}
|
||||
|
||||
function recalc() {
|
||||
updateProtectionHint();
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
lastRequest = buildRequest();
|
||||
@@ -139,6 +176,21 @@ function addRateRow(month = 13, rate = 7.0) {
|
||||
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;
|
||||
@@ -147,16 +199,20 @@ function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until =
|
||||
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><label class="form-label">Kwota</label><input data-field="amount" type="number" min="1" class="form-control form-control-sm" value="${amount}"></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>`;
|
||||
<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.querySelectorAll('input,select').forEach(x => x.addEventListener('input', 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();
|
||||
}
|
||||
|
||||
@@ -192,7 +248,8 @@ function render(data) {
|
||||
['Ś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('');
|
||||
|
||||
$('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)}.`;
|
||||
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('');
|
||||
|
||||
@@ -201,23 +258,37 @@ function render(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 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);
|
||||
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', data: balance, yAxisID: 'y' }, { label: 'Rata', data: payment, yAxisID: 'y1' }] },
|
||||
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 } } } }
|
||||
});
|
||||
|
||||
@@ -228,10 +299,14 @@ function renderCharts(data) {
|
||||
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()] }] },
|
||||
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 } } }
|
||||
});
|
||||
|
||||
@@ -328,7 +403,8 @@ async function loadNbp() {
|
||||
}
|
||||
}
|
||||
|
||||
['principal','baseRate','margin','installmentType','overpaymentEffect','loanStartDate','dueDay','moveDueDate','protectionMonths','protectionCommission'].forEach(id => $(id).addEventListener('input', recalc));
|
||||
['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();
|
||||
|
||||
Reference in New Issue
Block a user