This commit is contained in:
Mateusz Gruszczyński
2026-06-03 13:48:15 +02:00
parent 3a4e1b90e2
commit 4bdb20d9f5
6 changed files with 223 additions and 22 deletions
+87 -11
View File
@@ -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();
+1 -1
View File
@@ -101,7 +101,7 @@
</div>
<button id="applyProtection" class="btn btn-sm btn-outline-secondary" type="button">Uzupełnij</button>
</div>
<div class="row g-2 align-items-end">
<div id="protectionBox" class="row g-2 align-items-end protection-box">
<div class="col-6">
<label class="form-label">Prowizja %</label>
<input id="protectionCommission" type="number" min="0" max="20" step="0.01" class="form-control form-control-sm" value="0">
+21 -2
View File
@@ -102,7 +102,7 @@ body {
padding: .55rem;
border-radius: 12px;
}
.dynamic-row.overpay { grid-template-columns: .65fr .9fr .7fr .9fr .9fr .9fr auto; }
.dynamic-row.overpay { grid-template-columns: .65fr 1.35fr .7fr .9fr .9fr .9fr auto; }
.dynamic-row.historical { grid-template-columns: .65fr .8fr 1fr .9fr .7fr 1.2fr auto; }
.stat-card { border-radius: 16px; }
.stat-value { font-size: 1.15rem; font-weight: 700; }
@@ -148,4 +148,23 @@ body[data-theme="dark"] hr { border-color: var(--border); opacity: 1; }
.slim-card > .card-body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.28);
}
}
.overpay-slider { grid-column: 1 / -1; min-width: 0; }
.overpay-slider .form-range { width: 100%; }
.overpay-amount { min-width: 0; }
.protection-box {
border: 1px solid transparent;
border-radius: 14px;
padding: .35rem .25rem;
transition: border-color .15s ease, background-color .15s ease;
}
.protection-warning-box {
border-color: #f59e0b;
background: rgba(245, 158, 11, .10);
}
.btn.protection-warning {
color: #111827;
background: #fbbf24;
border-color: #f59e0b;
box-shadow: 0 0 0 .2rem rgba(245, 158, 11, .20);
}