first commit
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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 socket;
|
||||
let debounceTimer;
|
||||
let lastRequest = null;
|
||||
window.lastSimulationData = null;
|
||||
|
||||
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
|
||||
})).filter(x => x.month > 0 && x.amount > 0);
|
||||
|
||||
return {
|
||||
principal: num('principal'),
|
||||
years: num('years'),
|
||||
margin: num('margin'),
|
||||
base_rate: num('baseRate'),
|
||||
installment_type: $('installmentType').value,
|
||||
overpayment_effect: $('overpaymentEffect').value,
|
||||
rate_changes: rateChanges,
|
||||
overpayments: overpayments
|
||||
};
|
||||
}
|
||||
|
||||
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';
|
||||
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 addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', 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><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">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>
|
||||
<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(); };
|
||||
div.querySelectorAll('input,select').forEach(x => x.addEventListener('input', recalc));
|
||||
$('overpayments').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)],
|
||||
['Nadpłaty', money(s.total_overpayment)],
|
||||
['Okres', `${s.months} mies. / ${Math.ceil(s.months / 12)} lat`],
|
||||
['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('');
|
||||
|
||||
$('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)}.`;
|
||||
|
||||
$('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('');
|
||||
|
||||
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 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'], datasets: [{ data: [lastRequest.principal, data.summary.total_interest, data.summary.total_overpayment] }] },
|
||||
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 } } }
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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','years','baseRate','margin','installmentType','overpaymentEffect'].forEach(id => $(id).addEventListener('input', recalc));
|
||||
$('addRate').onclick = () => addRateRow();
|
||||
$('addOverpayment').onclick = () => addOverpaymentRow();
|
||||
$('exportCsv').onclick = () => download('/api/export/csv', 'symulacja-kredytu.csv');
|
||||
$('exportPdf').onclick = () => download('/api/export/pdf', 'symulacja-kredytu.pdf');
|
||||
$('loadNbp').onclick = loadNbp;
|
||||
initTheme();
|
||||
|
||||
addOverpaymentRow(24, 20000, 'once', '');
|
||||
addOverpaymentRow(36, 500, 'monthly', 120);
|
||||
connectWs();
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,136 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Symulator kredytu hipotecznego</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/static/styles.css" rel="stylesheet">
|
||||
</head>
|
||||
<body data-theme="dark">
|
||||
<main class="container-fluid app-shell py-3">
|
||||
<header class="hero card border-0 shadow-sm mb-3">
|
||||
<div class="card-body d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">Symulator kredytu hipotecznego</h1>
|
||||
<p class="text-muted mb-0">@linuiarz.pl</p>
|
||||
</div>
|
||||
<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="exportCsv" class="btn btn-outline-secondary btn-sm">CSV</button>
|
||||
<button id="exportPdf" class="btn btn-primary btn-sm">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="row g-3">
|
||||
<section class="col-12 col-xl-4">
|
||||
<div class="card shadow-sm border-0 sticky-xl-top slim-card">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 mb-3">Parametry</h2>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Kwota kredytu</label>
|
||||
<input id="principal" type="number" class="form-control form-control-sm" value="600000" min="1">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Okres lat</label>
|
||||
<input id="years" type="number" class="form-control form-control-sm" value="25" min="1" max="50">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Stopa bazowa %</label>
|
||||
<input id="baseRate" type="number" step="0.01" class="form-control form-control-sm" value="5.75">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Marża %</label>
|
||||
<input id="margin" type="number" step="0.01" class="form-control form-control-sm" value="2.00">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Typ rat</label>
|
||||
<select id="installmentType" class="form-select form-select-sm">
|
||||
<option value="equal">Równe</option>
|
||||
<option value="decreasing">Malejące</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Efekt nadpłat</label>
|
||||
<select id="overpaymentEffect" class="form-select form-select-sm">
|
||||
<option value="shorten">Skrócenie okresu</option>
|
||||
<option value="lower_payment">Zmniejszenie raty</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h3 class="h6 mb-0">Zmiany oprocentowania</h3>
|
||||
<button id="addRate" class="btn btn-sm btn-outline-primary">+</button>
|
||||
</div>
|
||||
<div id="rateChanges" class="stack"></div>
|
||||
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h3 class="h6 mb-0">Nadpłaty</h3>
|
||||
<button id="addOverpayment" class="btn btn-sm btn-outline-primary">+</button>
|
||||
</div>
|
||||
<div id="overpayments" class="stack"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="col-12 col-xl-8">
|
||||
<div class="row g-3 mb-3" id="summaryCards"></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card shadow-sm border-0 chart-card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Saldo i rata w czasie</h2>
|
||||
<canvas id="lineChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card shadow-sm border-0 chart-card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Struktura kosztów</h2>
|
||||
<canvas id="pieChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0 chart-card-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Odsetki rocznie</h2>
|
||||
<canvas id="barChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Opis wyniku</h2>
|
||||
<p id="resultText" class="mb-0 text-muted"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<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>
|
||||
<tbody id="scheduleTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
@font-face {
|
||||
font-family: "AppSans";
|
||||
src: url("/static/fonts/DejaVuSans.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "AppSans";
|
||||
src: url("/static/fonts/DejaVuSans-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #f4f7fb;
|
||||
--text: #1e2a36;
|
||||
--card: #ffffff;
|
||||
--muted: #6c7684;
|
||||
--border: #edf1f7;
|
||||
--row: #f8fafc;
|
||||
--input-bg: #ffffff;
|
||||
--input-text: #1e2a36;
|
||||
--table-border: #e7edf5;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] {
|
||||
--bg: #05070d;
|
||||
--text: #e5e7eb;
|
||||
--card: #0d1422;
|
||||
--muted: #9aa7bb;
|
||||
--border: #10192a;
|
||||
--row: #111a2b;
|
||||
--input-bg: #070b13;
|
||||
--input-text: #e5e7eb;
|
||||
--table-border: #3b4658;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "AppSans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.card,
|
||||
.hero,
|
||||
.stat-card {
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.text-muted,
|
||||
.form-label,
|
||||
.stat-label,
|
||||
#resultText {
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
background-color: var(--input-bg);
|
||||
color: var(--input-text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
background-color: var(--input-bg);
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
.form-control::placeholder { color: var(--muted); }
|
||||
|
||||
.table {
|
||||
color: var(--text);
|
||||
--bs-table-color: var(--text);
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-border-color: var(--table-border);
|
||||
}
|
||||
|
||||
.app-shell { max-width: 1680px; }
|
||||
.hero { border-radius: 20px; }
|
||||
.slim-card { top: 1rem; border-radius: 18px; }
|
||||
.chart-card canvas { max-height: 330px; }
|
||||
.chart-card-sm canvas { max-height: 230px; }
|
||||
.form-label { font-size: .78rem; margin-bottom: .2rem; }
|
||||
.form-control, .form-select { border-radius: 10px; }
|
||||
.stack { display: grid; gap: .45rem; }
|
||||
.dynamic-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: .4rem;
|
||||
align-items: end;
|
||||
background: var(--row);
|
||||
border: 1px solid var(--border);
|
||||
padding: .55rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.dynamic-row.overpay { grid-template-columns: .75fr 1fr .9fr .75fr 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 button { grid-column: span 2; }
|
||||
}
|
||||
|
||||
.card { box-shadow: 0 12px 34px rgba(0, 0, 0, .16) !important; }
|
||||
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; }
|
||||
Reference in New Issue
Block a user