This commit is contained in:
Mateusz Gruszczyński
2026-06-03 13:02:07 +02:00
parent fdc946989f
commit 7cb2eddafe
8 changed files with 532 additions and 84 deletions
+125 -14
View File
@@ -2,12 +2,17 @@ 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 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 setTheme(theme) {
document.body.dataset.theme = theme;
localStorage.setItem('mortgage-theme', theme);
@@ -43,9 +48,22 @@ function buildRequest() {
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
until_month: Number(row.querySelector('[data-field="until"]').value || 0) || null,
commission_percent: Number(row.querySelector('[data-field="commission"]').value || 0)
})).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);
return {
principal: num('principal'),
years: num('years'),
@@ -53,8 +71,12 @@ function buildRequest() {
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,
rate_changes: rateChanges,
overpayments: overpayments
overpayments: overpayments,
historical_months: historicalMonths
};
}
@@ -68,7 +90,7 @@ function recalc() {
function addRateRow(month = 13, rate = 7.0) {
const div = document.createElement('div');
div.className = 'dynamic-row';
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>
@@ -79,14 +101,15 @@ function addRateRow(month = 13, rate = 7.0) {
recalc();
}
function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = '') {
function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = '', commission = 0) {
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><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">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">Do mies.</label><input data-field="until" type="number" min="1" class="form-control form-control-sm" value="${until}"></div>
<div><label class="form-label">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.querySelector('[data-field="repeat"]').value = repeat;
div.querySelector('button').onclick = () => { div.remove(); recalc(); };
@@ -95,21 +118,41 @@ function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until =
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ść', money(s.interest_saved)],
['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-2"><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('');
].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 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)}.`;
$('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, 180).map(r => `<tr><td>${r.month}</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.remaining)}</td></tr>`).join('');
$('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);
}
@@ -119,6 +162,10 @@ function renderCharts(data) {
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);
@@ -135,7 +182,7 @@ function renderCharts(data) {
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] }] },
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 }
});
@@ -145,6 +192,20 @@ function renderCharts(data) {
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) {
@@ -158,6 +219,51 @@ async function download(endpoint, filename) {
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;
$('years').value = data.years ?? 25;
$('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;
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));
(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;
@@ -176,14 +282,19 @@ async function loadNbp() {
}
}
['principal','years','baseRate','margin','installmentType','overpaymentEffect'].forEach(id => $(id).addEventListener('input', recalc));
['principal','years','baseRate','margin','installmentType','overpaymentEffect','loanStartDate','dueDay','moveDueDate'].forEach(id => $(id).addEventListener('input', recalc));
$('addRate').onclick = () => addRateRow();
$('addOverpayment').onclick = () => addOverpaymentRow();
$('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();
initTheme();
addOverpaymentRow(24, 20000, 'once', '');
addOverpaymentRow(36, 500, 'monthly', 120);
addOverpaymentRow(24, 20000, 'once', '', 0);
addOverpaymentRow(36, 500, 'monthly', 120, 0);
connectWs();
+36 -1
View File
@@ -18,8 +18,11 @@
<div class="d-flex gap-2 flex-wrap justify-content-end">
<button id="themeToggle" class="btn btn-outline-secondary btn-sm" type="button" aria-label="Przełącz motyw">☀️ Jasny</button>
<button id="loadNbp" class="btn btn-outline-primary btn-sm">Pobierz stopę NBP</button>
<button id="exportJson" class="btn btn-outline-secondary btn-sm">Eksport JSON</button>
<button id="importJson" class="btn btn-outline-secondary btn-sm">Import JSON</button>
<button id="exportCsv" class="btn btn-outline-secondary btn-sm">CSV</button>
<button id="exportPdf" class="btn btn-primary btn-sm">PDF</button>
<input id="jsonFile" type="file" accept="application/json,.json" hidden>
</div>
</div>
</header>
@@ -60,6 +63,20 @@
<option value="lower_payment">Zmniejszenie raty</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Data startu kredytu</label>
<input id="loanStartDate" type="date" class="form-control form-control-sm">
</div>
<div class="col-6">
<label class="form-label">Dzień spłaty raty</label>
<input id="dueDay" type="number" min="1" max="28" class="form-control form-control-sm" value="5">
</div>
<div class="col-12">
<label class="form-check small mb-0">
<input id="moveDueDate" class="form-check-input" type="checkbox" checked>
<span class="form-check-label">Przesuwaj spłatę na pierwszy dzień roboczy, jeśli wypada w dzień wolny</span>
</label>
</div>
</div>
<hr>
@@ -75,6 +92,16 @@
<button id="addOverpayment" class="btn btn-sm btn-outline-primary">+</button>
</div>
<div id="overpayments" class="stack"></div>
<hr>
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<h3 class="h6 mb-0">Kredyt historyczny</h3>
<div class="text-muted small">Miesięczne oprocentowanie, karencja i faktyczne nadpłaty</div>
</div>
<button id="addHistorical" class="btn btn-sm btn-outline-primary">+</button>
</div>
<div id="historicalMonths" class="stack"></div>
</div>
</div>
</section>
@@ -106,6 +133,14 @@
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm border-0 chart-card">
<div class="card-body">
<h2 class="h6">Podział raty i koszt narastająco</h2>
<canvas id="detailChart"></canvas>
</div>
</div>
</div>
<div class="col-12">
<div class="card shadow-sm border-0">
<div class="card-body">
@@ -119,7 +154,7 @@
<div class="card-body table-responsive">
<h2 class="h6">Harmonogram</h2>
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Mies.</th><th>Oproc.</th><th>Rata</th><th>Kapitał</th><th>Odsetki</th><th>Nadpłata</th><th>Saldo</th></tr></thead>
<thead><tr><th>Mies.</th><th>Data</th><th>Dni</th><th>Oproc.</th><th>Rata</th><th>Kapitał</th><th>Odsetki</th><th>Nadpłata</th><th>Prow.</th><th>Koszt nar.</th><th>Saldo</th></tr></thead>
<tbody id="scheduleTable"></tbody>
</table>
</div>
+39 -7
View File
@@ -27,13 +27,13 @@
}
body[data-theme="dark"] {
--bg: #05070d;
--bg: #03050a;
--text: #e5e7eb;
--card: #0d1422;
--card: #0b1220;
--muted: #9aa7bb;
--border: #10192a;
--row: #111a2b;
--input-bg: #070b13;
--border: #2a3547;
--row: #101827;
--input-bg: #050914;
--input-text: #e5e7eb;
--table-border: #3b4658;
}
@@ -100,14 +100,15 @@ body {
padding: .55rem;
border-radius: 12px;
}
.dynamic-row.overpay { grid-template-columns: .75fr 1fr .9fr .75fr auto; }
.dynamic-row.overpay { grid-template-columns: .75fr 1fr .75fr .9fr .75fr 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; }
.table { font-size: .84rem; }
.btn { border-radius: 999px; }
@media (max-width: 768px) {
.dynamic-row, .dynamic-row.overpay { grid-template-columns: 1fr 1fr; }
.dynamic-row, .dynamic-row.overpay, .dynamic-row.historical { grid-template-columns: 1fr 1fr; }
.dynamic-row button { grid-column: span 2; }
}
@@ -115,3 +116,34 @@ body {
body[data-theme="dark"] .card { box-shadow: 0 14px 38px rgba(0, 0, 0, .34) !important; }
body[data-theme="dark"] .btn-outline-secondary { border-color: var(--border); color: var(--text); }
body[data-theme="dark"] .btn-outline-primary { border-color: #52637a; }
.form-check-input { background-color: var(--input-bg); border-color: var(--border); }
body[data-theme="dark"] hr { border-color: var(--border); opacity: 1; }
@media (min-width: 1200px) {
.slim-card {
max-height: calc(100vh - 2rem);
display: flex;
flex-direction: column;
}
.slim-card > .card-body {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
padding-right: 1rem;
}
}
.slim-card > .card-body::-webkit-scrollbar {
width: 6px;
}
.slim-card > .card-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
border-radius: 999px;
}
.slim-card > .card-body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.28);
}