<!DOCTYPE html>
<html lang="et">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Laenukalkulaator – annuiteetgraafik</title>
<meta name="description" content="Ilus ja mugav laenu annuiteetgraafiku kalkulaator. Sisesta laenusumma, aastaintress, periood ning alguskuupäev – saad kuumakse ja täieliku maksegraafiku.">
<style>
:root {
--bg: #0b1020;
--card: #12182b;
--card-2: #0f1530;
--text: #e8ecf6;
--muted: #9fb0d3;
--accent: #6aa6ff;
--accent-2: #7cf7d4;
--border: #263252;
--ring: rgba(106,166,255,.5);
--danger: #ff6a6a;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
color: var(--text);
background: radial-gradient(1250px 650px at 10% -10%, #102046 0, transparent 60%),
radial-gradient(900px 550px at 90% -10%, #093c38 0, transparent 60%),
var(--bg);
line-height: 1.45;
}
.container {
max-width: 1100px;
margin: 40px auto;
padding: 0 16px;
}
header h1 {
font-size: clamp(24px, 4vw, 40px);
margin: 0 0 8px 0;
letter-spacing: .3px;
}
header p {
margin: 0;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 20px;
margin-top: 24px;
}
@media (max-width: 980px) {
.grid { grid-template-columns: 1fr; }
}
.card {
background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,0));
border: 1px solid var(--border);
border-radius: 16px;
padding: 18px;
box-shadow: 0 10px 30px rgba(0,0,0,.25), inset 0 1px 0 rgba(255,255,255,.03);
backdrop-filter: blur(6px);
}
.card h2 {
margin: 0 0 12px 0;
font-size: 18px;
color: #dbe6ff;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.row-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
@media (max-width: 640px) {
.row, .row-3 { grid-template-columns: 1fr; }
}
label {
display: block;
font-size: 12px;
color: var(--muted);
margin: 0 0 6px;
}
input[type="text"], input[type="number"], input[type="date"] {
width: 100%;
padding: 12px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
outline: none;
transition: box-shadow .2s, border-color .2s;
}
input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--ring);
}
.hint { font-size: 12px; color: var(--muted); margin-top: 6px; }
.switch {
display: inline-flex; gap: 8px; align-items: center; font-size: 12px; color: var(--muted);
user-select: none;
}
.btns { display: flex; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
button {
appearance: none;
border: 1px solid var(--border);
background: linear-gradient(180deg, #1b2a4f, #10234a);
color: var(--text);
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
letter-spacing: .2px;
box-shadow: 0 6px 16px rgba(0,0,0,.25);
transition: transform .03s ease, border-color .2s;
}
button:hover { border-color: var(--accent); }
button:active { transform: translateY(1px); }
.btn-secondary {
background: var(--card);
}
.btn-danger {
background: linear-gradient(180deg, #50262e, #3a1a1f);
border-color: #6b2a34;
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
@media (max-width: 820px) { .stats { grid-template-columns: 1fr; } }
.stat {
background: var(--card-2);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
}
.stat .label { color: var(--muted); font-size: 12px; }
.stat .value { font-size: 22px; font-weight: 700; margin-top: 2px; }
.table-wrap {
margin-top: 14px;
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
}
.table-head {
display: grid;
grid-template-columns: 70px 120px 1fr 1fr 1fr 1fr 1fr;
padding: 10px 12px;
background: #0f1630;
border-bottom: 1px solid var(--border);
font-size: 12px;
color: #c9d8ff;
position: sticky; top: 0; z-index: 1;
}
.row-item {
display: grid;
grid-template-columns: 70px 120px 1fr 1fr 1fr 1fr 1fr;
padding: 10px 12px;
border-bottom: 1px solid #1e2a4a;
background: rgba(8, 14, 36, .6);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 13px;
}
.row-item:nth-child(odd) { background: rgba(8, 14, 36, .85); }
.table {
max-height: 420px;
overflow: auto;
scrollbar-width: thin;
}
.footer {
display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 10px 12px; background: #0f1630; border-top: 1px solid var(--border);
}
.badge {
font-size: 12px; color: #a6c1ff;
border: 1px dashed var(--border);
padding: 6px 10px; border-radius: 999px;
background: rgba(106,166,255,.08);
}
.notice { color: var(--muted); font-size: 12px; }
.error { color: var(--danger); font-size: 12px; margin-top: 6px; }
.visually-hidden { position: absolute !important; height: 1px; width: 1px; overflow: hidden; clip: rect(1px, 1px, 1px, 1px); white-space: nowrap; }
.grid-cols-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
@media (max-width: 700px){ .grid-cols-3{ grid-template-columns: 1fr; } }
.kbd { font-family: ui-monospace, Menlo, Consolas, monospace; background: #0c153a; border: 1px solid var(--border); font-size: 12px; padding: 1px 6px; border-radius: 6px; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>Laenukalkulaator <span class="badge">annuiteetgraafik</span></h1>
<p>Arvuta kuumakse ning vaata kogu graafikut. Sisendväljad: <em>Laenusumma (€), Aastaintress (%), Laenu pikkus (aastad / kuudes), Alguskuupäev</em>.</p>
</header>
<div class="grid">
<!-- Vasak: sisendid -->
<section class="card" aria-labelledby="inputs-title">
<h2 id="inputs-title">Sisendid</h2>
<div class="row">
<div>
<label for="amount">Laenusumma (€)</label>
<input id="amount" type="text" inputmode="decimal" placeholder="nt 47 500" value="47 500" autocomplete="off">
<div class="hint">Võid kasutada tühikuid või koma – puhastame automaatselt.</div>
</div>
<div>
<label for="rate">Aastaintress (%)</label>
<input id="rate" type="text" inputmode="decimal" placeholder="nt 9" value="9" autocomplete="off">
</div>
</div>
<div class="row">
<div>
<label for="years">Laenu pikkus (aastad)</label>
<input id="years" type="number" inputmode="numeric" placeholder="nt 5" value="5" min="0" step="1">
</div>
<div>
<label for="months">Laenu pikkus (kuudes) <span class="hint">(jäta tühjaks kui kasutad aastaid)</span></label>
<input id="months" type="number" inputmode="numeric" placeholder="nt 60" min="0" step="1">
</div>
</div>
<div class="row">
<div>
<label for="start">Alguskuupäev</label>
<input id="start" type="date">
<div class="hint">Kui jätad tühjaks, alustame tänasest kuust.</div>
</div>
<div>
<label for="rounding">Summa ümardus</label>
<select id="rounding" style="width:100%; padding:12px; border-radius:12px; border:1px solid var(--border); background: var(--card); color: var(--text);">
<option value="2" selected>Sentid (2 kohta)</option>
<option value="0">Täiseuro</option>
</select>
<div class="hint">Ainult kuvamine – arvutused on täpsed.</div>
</div>
</div>
<div class="btns">
<button id="calcBtn">Arvuta graafik</button>
<button id="resetBtn" class="btn-secondary">Lähtesta</button>
<button id="csvBtn" class="btn-secondary">Ekspordi CSV</button>
<button id="copyLinkBtn" class="btn-secondary">Jaga linki</button>
<span id="error" class="error" role="alert" aria-live="polite"></span>
</div>
</section>
<!-- Parem: kokkuvõte -->
<section class="card" aria-labelledby="summary-title">
<h2 id="summary-title">Kokkuvõte</h2>
<div class="stats">
<div class="stat">
<div class="label">Kuumakse</div>
<div id="outPayment" class="value">–</div>
</div>
<div class="stat">
<div class="label">Kokku intress</div>
<div id="outInterest" class="value">–</div>
</div>
<div class="stat">
<div class="label">Tagasimaksete kogusumma</div>
<div id="outTotal" class="value">–</div>
</div>
</div>
<div class="hint" style="margin-top:10px">
Valem: annuiteet <span class="kbd">M = P·r/(1-(1+r)^-n)</span>, kus <span class="kbd">r</span> on kuuintress <span class="kbd">aastaintress/12</span> ja <span class="kbd">n</span> kuude arv.
</div>
</section>
</div>
<!-- Tabel -->
<section class="card" style="margin-top:20px" aria-labelledby="table-title">
<h2 id="table-title">Maksegraafik</h2>
<div class="table-wrap">
<div class="table-head">
<div>Nr</div>
<div>Kuupäev</div>
<div>Algjääk</div>
<div>Intress</div>
<div>Põhiosa</div>
<div>Makse</div>
<div>Lõppjääk</div>
</div>
<div id="tableBody" class="table" role="table" aria-label="Annuiteetgraafiku read"></div>
<div class="footer">
<span class="notice">NB! See on illustratiivne kalkulaator – tehingutingimused sõltuvad krediidiandjast.</span>
</div>
</div>
</section>
</div>
<script>
// Utiliidid
const € = new Intl.NumberFormat('et-EE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 2 });
const fmt = (x, d) => {
if (!isFinite(x)) return "–";
const n = typeof d === 'number' ? d : 2;
return x.toLocaleString('et-EE', { minimumFractionDigits: n, maximumFractionDigits: n });
};
const parseMoney = (s) => {
if (typeof s === 'number') return s;
if (!s) return NaN;
// Eemalda tühikud, koma -> punkt
s = s.replace(/\s+/g, '').replace(',', '.');
return parseFloat(s);
};
const parsePercent = (s) => parseMoney(s);
const byId = (id) => document.getElementById(id);
function monthsBetween(startDate, n) {
const d = new Date(startDate.getTime());
d.setHours(12,0,0,0); // väldi suve-/talveaja hüppeid
d.setMonth(d.getMonth() + n);
return d;
}
function buildSchedule(P, annualRatePct, nMonths, startDate, roundingDigits) {
const rows = [];
const r = (annualRatePct / 100) / 12;
let payment;
if (r === 0) {
payment = P / nMonths;
} else {
payment = P * r / (1 - Math.pow(1 + r, -nMonths));
}
let balance = P;
let date = new Date(startDate.getFullYear(), startDate.getMonth(), Math.min(startDate.getDate(), 28)); // väldi kuu lõpu eripärasid
let totalInterest = 0;
for (let i = 1; i <= nMonths; i++) {
const interest = r === 0 ? 0 : balance * r;
const principal = payment - interest;
const endBalance = balance - principal;
rows.push({
nr: i,
date: new Date(date.getTime()),
begin: balance,
interest,
principal,
pay: payment,
end: endBalance < 1e-8 ? 0 : endBalance
});
totalInterest += interest;
balance = endBalance;
// Järgmine kuu
date = monthsBetween(date, 1);
}
// Väike korrigeerimine viimases reas (ujukoma)
const last = rows[rows.length - 1];
if (last && Math.abs(last.end) < 0.01) {
last.principal += last.end;
last.end = 0;
}
const totalPay = payment * nMonths;
return { rows, payment, totalInterest, totalPay, roundingDigits };
}
function renderSchedule(model) {
const body = byId('tableBody');
body.innerHTML = '';
const d = model.roundingDigits;
const frag = document.createDocumentFragment();
for (const row of model.rows) {
const div = document.createElement('div');
div.className = 'row-item';
div.innerHTML = `
<div>${row.nr}</div>
<div>${row.date.toLocaleDateString('et-EE')}</div>
<div>${fmt(row.begin, d)}</div>
<div>${fmt(row.interest, d)}</div>
<div>${fmt(row.principal, d)}</div>
<div>${fmt(row.pay, d)}</div>
<div>${fmt(row.end, d)}</div>
`;
frag.appendChild(div);
}
body.appendChild(frag);
byId('outPayment').textContent = €.format(model.payment);
byId('outInterest').textContent = €.format(model.totalInterest);
byId('outTotal').textContent = €.format(model.totalPay);
}
function toCSV(model) {
const d = model.roundingDigits;
const lines = [
['Nr','Kuupäev','Algjääk','Intress','Põhiosa','Makse','Lõppjääk'].join(';')
];
for (const r of model.rows) {
lines.push([
r.nr,
r.date.toLocaleDateString('et-EE'),
fmt(r.begin, d),
fmt(r.interest, d),
fmt(r.principal, d),
fmt(r.pay, d),
fmt(r.end, d)
].join(';'));
}
return lines.join('\n');
}
function readInputs() {
const amount = parseMoney(byId('amount').value);
const rate = parsePercent(byId('rate').value);
const years = byId('years').value ? parseInt(byId('years').value, 10) : 0;
const monthsField = byId('months').value ? parseInt(byId('months').value, 10) : 0;
const rounding = parseInt(byId('rounding').value, 10);
let months = monthsField > 0 ? monthsField : (years > 0 ? years * 12 : 0);
const startStr = byId('start').value;
let start;
if (startStr) {
const [y,m,d] = startStr.split('-').map(Number);
start = new Date(y, m-1, d);
} else {
const now = new Date();
start = new Date(now.getFullYear(), now.getMonth(), 1);
}
return { amount, rate, months, start, rounding };
}
function validate({amount, rate, months}) {
if (!isFinite(amount) || amount <= 0) return 'Palun sisesta korrektne laenusumma.';
if (!isFinite(rate) || rate < 0) return 'Palun sisesta korrektne aastaintress (≥ 0%).';
if (!Number.isInteger(months) || months <= 0) return 'Palun sisesta periood aastates või kuudes.';
return '';
}
function saveToHash(inputs) {
const params = new URLSearchParams();
params.set('a', String(inputs.amount));
params.set('r', String(inputs.rate));
params.set('m', String(inputs.months));
params.set('s', inputs.start.toISOString().slice(0,10));
params.set('d', String(inputs.rounding));
location.hash = params.toString();
}
function readFromHash() {
if (!location.hash) return null;
const q = new URLSearchParams(location.hash.slice(1));
const amount = parseFloat(q.get('a'));
const rate = parseFloat(q.get('r'));
const months = parseInt(q.get('m') || '0', 10);
const dstr = q.get('s');
const rounding = parseInt(q.get('d') || '2', 10);
if (!amount || !months) return null;
const [y,m,d] = (dstr || '2025-09-01').split('-').map(Number);
const start = new Date(y, m-1, d);
return { amount, rate, months, start, rounding };
}
// Sündmused
let currentModel = null;
function computeAndRender() {
const inputs = readInputs();
const msg = validate(inputs);
const errEl = byId('error');
if (msg) { errEl.textContent = msg; return; }
errEl.textContent = '';
const model = buildSchedule(inputs.amount, inputs.rate, inputs.months, inputs.start, inputs.rounding);
currentModel = model;
renderSchedule(model);
saveToHash(inputs);
}
byId('calcBtn').addEventListener('click', computeAndRender);
byId('resetBtn').addEventListener('click', () => {
byId('amount').value = '47 500';
byId('rate').value = '9';
byId('years').value = '5';
byId('months').value = '';
byId('start').value = '';
byId('rounding').value = '2';
byId('error').textContent = '';
currentModel = null;
byId('tableBody').innerHTML = '';
byId('outPayment').textContent = '–';
byId('outInterest').textContent = '–';
byId('outTotal').textContent = '–';
location.hash = '';
});
byId('csvBtn').addEventListener('click', () => {
if (!currentModel) { computeAndRender(); }
if (!currentModel) return;
const blob = new Blob([toCSV(currentModel)], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'laenugraafik.csv';
document.body.appendChild(a);
a.click();
a.remove();
});
byId('copyLinkBtn').addEventListener('click', async () => {
const inputs = readInputs();
const msg = validate(inputs);
if (msg) { byId('error').textContent = msg; return; }
saveToHash(inputs);
const url = location.href;
try {
await navigator.clipboard.writeText(url);
byId('error').textContent = 'Link kopeeritud lõikelauale!';
setTimeout(()=>{ byId('error').textContent = ''; }, 2000);
} catch {
byId('error').textContent = 'Ei saanud linki kopeerida – kopeeri aadressiribalt käsitsi.';
}
});
// Initsialiseeri väljad tänase kuuga või URL hashist
(function initFromHashOrDefault(){
const h = readFromHash();
const startInput = byId('start');
const today = new Date();
const defaultDate = new Date(today.getFullYear(), today.getMonth(), 1);
const iso = defaultDate.toISOString().slice(0,10);
startInput.value = iso;
if (h) {
byId('amount').value = fmt(h.amount, 2);
byId('rate').value = String(h.rate);
byId('years').value = '';
byId('months').value = String(h.months);
byId('start').value = h.start.toISOString().slice(0,10);
byId('rounding').value = String(h.rounding);
const model = buildSchedule(h.amount, h.rate, h.months, h.start, h.rounding);
currentModel = model;
renderSchedule(model);
}
})();
</script>
</body>
</html>