Files
calcserubi/assets/js/app.js
T
2026-10-20 13:16:38 +09:00

431 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Calculator page controller. Results are calculated in the browser (instant,
* also offline) from the folded public model; printing and copying fetch the
* official numbers from calc.php first. A logged-in admin calculates with the
* full parameters to see "Rincian perhitungan".
*/
(function () {
'use strict';
var meta = window.SERUBI_PUBLIC;
var F = window.SerubiFormat;
var PE = window.SerubiPublicEngine;
if (!meta || !meta.model || !F || !PE) return;
var model = meta.model;
var full = meta.full && window.SerubiEngine ? meta.full : null; // admin only
var PRODUCT_KEYS = ['giro', 'simanja', 'simpeda'];
var STALE_HOURS = 24; // older than this: the page came from the offline copy
var MAX_MODEL_AGE_DAYS = 30; // older than this: refuse to calculate with it
var VERIFY_TIMEOUT_MS = 4000;
var $ = function (id) { return document.getElementById(id); };
var form = $('calcForm');
var nominalEl = $('nominal');
var display = $('hasil');
var tape = $('tape');
var MAX_NOMINAL = 1e15;
var DEFAULTS = { nominal: 55000000, tenor: 6, cust: 'perorangan' };
var SHOW_DEPOSITO = false; // perbandingan deposito disembunyikan dari hasil simulasi
var CUST_LABEL = { perorangan: 'Perorangan', badan: 'Badan usaha' };
var lastResult = null;
var feedTimer = null;
var verifiedKey = null; // inputs whose numbers were confirmed by the server
var blocked = false;
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; });
}
// Shrinks an element's font (down to minPx) until its single line fits its box,
// so very large amounts never wrap mid-number or overflow.
function fitText(el, minPx) {
if (!el) return;
el.style.fontSize = '';
var size = parseFloat(window.getComputedStyle(el).fontSize);
for (var i = 0; i < 40 && el.scrollWidth > el.clientWidth + 1 && size > minPx; i++) {
size = Math.max(minPx, size * 0.94);
el.style.fontSize = size + 'px';
}
}
function fitAll() { fitText($('dNet'), 18); fitText(nominalEl, 15); }
function radio(name) { var el = form.querySelector('input[name="' + name + '"]:checked'); return el ? el.value : null; }
function setRadio(name, value) {
var el = form.querySelector('input[name="' + name + '"][value="' + value + '"]');
if (el) el.checked = true;
}
function line(label, value, cls) {
return '<div' + (cls ? ' class="' + cls + '"' : '') + '><dt>' + label + '</dt><dd>' + value + '</dd></div>';
}
function tenorText(t) { return t + ' bulan'; }
function readState() {
return { nominal: F.parseIdr(nominalEl.value), tenor: Number(radio('tenor')), cust: radio('cust') || 'perorangan' };
}
function validate(state) {
var err = $('nominalErr');
var msg = '';
if (state.nominal === null || state.nominal <= 0) msg = 'Masukkan nominal penempatan dana.';
else if (state.nominal > MAX_NOMINAL) msg = 'Nominal terlalu besar. Maksimal Rp1.000 triliun.';
err.textContent = msg;
err.hidden = !msg;
nominalEl.setAttribute('aria-invalid', msg ? 'true' : 'false');
nominalEl.parentNode.classList.toggle('is-invalid', !!msg);
return !msg;
}
function note(text) {
var el = $('dNote');
if (!text) { el.hidden = true; el.innerHTML = ''; return; }
el.innerHTML = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg><span>' + text + '</span>';
el.hidden = false;
}
function ineligibleText(r) {
if (r.reason === 'tenor') {
return 'Jangka waktu ' + r.input.tenor + ' bulan tidak termasuk program cashback. Pilih ' +
model.cashbackTenors.join(', ').replace(/, (\d+)$/, ', atau $1') + ' bulan.';
}
if (r.reason === 'nominal') {
return 'Cashback berlaku mulai ' + F.rp(r.minNominal) + '. Kurang ' + F.rp(r.minNominal - r.input.nominal) + ' lagi.';
}
if (r.reason === 'spread') return 'Spread negatif dengan parameter saat ini, sehingga tidak ada cashback.';
return '';
}
function renderEmpty() {
display.classList.add('is-off');
$('dNet').textContent = 'Rp0';
['dGross', 'dMonthly'].forEach(function (id) { $(id).textContent = '—'; });
$('dEquiv').textContent = '—';
$('dFlag').textContent = '';
note('');
}
function render(r) {
var ok = r.eligible;
var t = r.input.tenor;
display.classList.toggle('is-off', !ok);
// display
$('dNet').textContent = F.rp(r.cashback.net);
fitText($('dNet'), 18);
$('dGross').textContent = F.rp(r.cashback.gross);
$('dEquiv').textContent = F.pct(r.cashback.equiv) + ' p.a.';
$('dMonthly').textContent = F.rp(r.cashback.monthly);
$('dFlag').textContent = !ok ? 'Tanpa cashback' : (typeof r.share === 'number' ? 'Porsi nasabah ' + F.pct(r.share).replace(',00', '') : 'Mendapat cashback');
note(ok ? '' : ineligibleText(r));
$('mNet').textContent = F.rp(r.cashback.net);
$('mEquiv').textContent = ok ? F.pct(r.cashback.equiv) + ' p.a.' : 'tanpa cashback';
// tape: head + totals
$('tDate').textContent = new Intl.DateTimeFormat('id-ID', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date());
$('tNominal').textContent = F.rp(r.input.nominal);
$('tTenor').textContent = tenorText(t);
$('tTenor2').textContent = tenorText(t);
$('tCust').textContent = CUST_LABEL[r.input.customerType];
$('tGross').textContent = F.rp(r.cashback.gross);
$('tTaxRate').textContent = F.pct(r.cashback.taxRate).replace(',00', '');
$('tTax').textContent = '−' + F.rp(r.cashback.tax);
$('tNet').textContent = F.rp(r.cashback.net);
$('tEquiv').textContent = F.pct(r.cashback.equiv);
renderComparison(r);
if ($('calcLines')) renderCalcLines(r); // only present for a logged-in admin
renderPenalty(r);
}
function renderComparison(r) {
var rows = r.comparison.map(function (c) { return { c: c, dep: false }; });
if (SHOW_DEPOSITO) rows.push({ dep: true, c: { key: 'deposito', name: 'Deposito', rate: r.deposito.rate, bungaNet: r.deposito.net, cashbackNet: 0, total: r.deposito.net, totalRate: r.deposito.rate } });
var max = Math.max.apply(null, rows.map(function (x) { return x.c.total; })) || 1;
var bestKey = rows.reduce(function (a, b) { return b.c.total > a.c.total ? b : a; }).c.key;
var html = rows.map(function (x) {
var c = x.c;
var best = c.key === bestKey && c.total > 0;
var cls = 'cmp-row' + (x.dep ? ' is-dep' : '') + (best ? ' is-best' : '');
var tag = x.dep ? '<span class="cmp-tag">Pembanding</span>' : (best ? '<span class="cmp-tag">Tertinggi</span>' : '');
var wBunga = Math.max(0, c.bungaNet) / max * 100;
var wCb = Math.max(0, c.cashbackNet) / max * 100;
var diff = '';
if (SHOW_DEPOSITO && !x.dep) {
var d = c.total - r.deposito.net;
diff = '<p class="cmp-diff ' + (d >= 0 ? 'up' : 'down') + '">' + (d >= 0 ? '+' : '−') + F.rp(Math.abs(d)).replace('−', '') +
(d >= 0 ? ' lebih besar' : ' lebih kecil') + ' dari deposito</p>';
}
return '<li class="' + cls + '">' +
'<div class="cmp-top"><span class="cmp-name">' + esc(c.name) + tag + '</span><span class="cmp-total">' + F.rp(c.total) + '</span></div>' +
'<div class="cmp-bar" role="img" aria-label="Bunga neto ' + F.rp(c.bungaNet) + (x.dep ? '' : ', cashback neto ' + F.rp(c.cashbackNet)) + '">' +
'<i data-w="' + wBunga.toFixed(2) + '"></i>' + (x.dep ? '' : '<i data-w="' + wCb.toFixed(2) + '"></i>') + '</div>' +
'<dl class="cmp-meta">' +
'<div><dt>Rate</dt><dd>' + F.pct(c.rate) + '</dd></div>' +
'<div><dt>Bunga neto</dt><dd>' + F.rp(c.bungaNet) + '</dd></div>' +
(x.dep ? '<div><dt>Cashback</dt><dd>—</dd></div>' : '<div><dt>Cashback</dt><dd>' + F.rp(c.cashbackNet) + '</dd></div>') +
'<div><dt>Total rate</dt><dd>' + F.pct(c.totalRate) + '</dd></div>' +
'</dl>' + diff + '</li>';
}).join('');
$('cmp').innerHTML = html;
// widths set through the CSSOM: allowed by the Content-Security-Policy (inline style attributes are not)
$('cmp').querySelectorAll('.cmp-bar i[data-w]').forEach(function (i) { i.style.width = i.getAttribute('data-w') + '%'; });
if (!document.getElementById('cmpLegend')) {
$('cmp').insertAdjacentHTML('beforebegin', '<p class="legend" id="cmpLegend"><span><i></i>Bunga neto</span><span><i class="cb"></i>Cashback neto</span></p>');
}
}
function renderCalcLines(r) {
var s = full.settings, f = r.fund, t = r.input.tenor;
var html = '<div class="calc-block"><h4>Dana kelolaan</h4><dl class="lines">' +
line('Nominal penempatan', F.rp(r.input.nominal)) +
line('GWM ' + F.pct(s.gwm), '−' + F.rp(f.gwm), 'is-neg') +
line('Kas ' + F.pct(s.kas), '−' + F.rp(f.kas), 'is-neg') +
line('Dana kelolaan', F.rp(f.managed), 'is-strong') + '</dl></div>';
html += '<div class="calc-block"><h4>Pendapatan bank selama ' + t + ' bulan</h4><dl class="lines">' +
line('PUAB<span class="formula">' + F.pct(s.porsi_puab).replace(',00', '') + ' dana × ' + F.pct(s.puab) + ' × ' + s.hari_sebulan + '/' + s.hari_setahun + ' × ' + t + '</span>', F.rp(f.puabIncome)) +
line('Kredit<span class="formula">' + F.pct(s.porsi_kredit).replace(',00', '') + ' dana × ' + F.pct(s.kredit) + ' × ' + t + '/12</span>', F.rp(f.creditIncome)) +
line('Total pendapatan', F.rp(f.income), 'is-strong') + '</dl></div>';
PRODUCT_KEYS.forEach(function (key) {
var pr = r.products[key];
html += '<div class="calc-block"><h4>Spread ' + esc(pr.name) + ' (' + F.pct(pr.rate) + ')</h4><dl class="lines">' +
line('Bunga kotor produk', '−' + F.rp(pr.bungaGross), 'is-neg') +
(pr.program ? line('Biaya program', '−' + F.rp(pr.program), 'is-neg') : '') +
line('Overhead cost ' + F.pct(s.ohc) + ' × PUAB', '−' + F.rp(pr.ohc), 'is-neg') +
line('Premi LPS ' + F.pct(s.premi_lps), '−' + F.rp(pr.lps), 'is-neg') +
line('Spread', F.rp(pr.spread), 'is-strong') +
line('Porsi nasabah ' + F.pct(r.share).replace(',00', ''), F.rp(pr.cashbackGross)) + '</dl></div>';
});
html += '<div class="calc-block"><h4>Cashback</h4><dl class="lines">' +
line('Rata-rata porsi nasabah 3 produk', F.rp(r.cashback.gross)) +
line('Pajak cashback ' + F.pct(r.cashback.taxRate), '−' + F.rp(r.cashback.tax), 'is-neg') +
line('Cashback neto', F.rp(r.cashback.net), 'is-strong') +
line('Cashback per bulan', F.rp(r.cashback.monthly)) +
line('Porsi bank', F.pct(r.shareBank).replace(',00', '')) + '</dl></div>';
html += '<div class="calc-block"><h4>Deposito pembanding</h4><dl class="lines">' +
line('Rate deposito ' + t + ' bulan', F.pct(r.deposito.rate)) +
line('Bunga kotor', F.rp(r.deposito.gross)) +
line('Pajak ' + F.pct(s.pajak_deposito), '−' + F.rp(r.deposito.tax), 'is-neg') +
line('Bunga neto', F.rp(r.deposito.net), 'is-strong') +
line('Bunga neto per bulan', F.rp(r.deposito.monthly)) + '</dl></div>';
$('calcLines').innerHTML = html;
}
function renderPenalty(r) {
var p = r.penalty;
var pinalti = typeof p.rate === 'number' ? p.rate : model.pinalti;
$('penaltyLines').innerHTML = r.eligible ?
line('Pengembalian cashback neto', F.rp(p.refund)) +
line('Pajak cashback', F.rp(p.tax)) +
line('Pinalti ' + F.pct(pinalti).replace(',00', '') + ' dari cashback neto', F.rp(p.fee)) +
line('Total kewajiban nasabah', F.rp(p.total), 'is-strong') :
line('Tidak ada cashback, tidak ada pinalti', 'Rp0');
}
function feed() {
clearTimeout(feedTimer);
feedTimer = setTimeout(function () {
tape.classList.remove('is-feeding');
void tape.offsetWidth; // restart animation
tape.classList.add('is-feeding');
}, 220);
}
function syncUrl(state) {
try {
var u = new URL(location.href);
u.searchParams.set('n', state.nominal || '');
u.searchParams.set('t', state.tenor);
u.searchParams.set('c', state.cust);
history.replaceState(null, '', u);
} catch (e) { /* ignore */ }
}
function syncChips(n) {
form.querySelectorAll('.chip').forEach(function (b) { b.setAttribute('aria-pressed', String(Number(b.dataset.amount) === n)); });
}
function stateKey(st) { return st.nominal + '|' + st.tenor + '|' + st.cust; }
function localResult(st) {
var input = { nominal: st.nominal, tenor: st.tenor, customerType: st.cust };
return full ? window.SerubiEngine.calculate(full, input) : PE.calculate(model, input);
}
function setVerify(ok, when) {
var el = $('tVerify');
if (ok === null) { el.hidden = true; el.textContent = ''; return; }
el.hidden = false;
el.textContent = ok
? 'Angka terverifikasi server Bank Papua, ' + new Intl.DateTimeFormat('id-ID', { dateStyle: 'medium', timeStyle: 'short' }).format(when) + '.'
: 'Simulasi lokal (tanpa koneksi): angka belum terverifikasi server.';
}
function update(animate) {
var state = readState();
fitText(nominalEl, 15);
syncChips(state.nominal);
if (blocked || !validate(state)) { lastResult = null; renderEmpty(); return; }
lastResult = localResult(state);
verifiedKey = null;
setVerify(null);
render(lastResult);
syncUrl(state);
if (animate) feed();
}
/*
* Official numbers for printing/copying: asks calc.php (server engine, live
* parameters). Without a connection the local simulation is used and marked
* as not verified. Calls done(verified) in both cases.
*/
function verifyThen(done) {
var state = readState();
if (blocked || !lastResult || !validate(state)) { toast('Isi nominal terlebih dahulu.'); return; }
if (verifiedKey === stateKey(state)) { done(true); return; }
var url = meta.calcUrl + '?n=' + encodeURIComponent(state.nominal) + '&t=' + encodeURIComponent(state.tenor) + '&c=' + encodeURIComponent(state.cust);
var timer;
var timeout = new Promise(function (_, reject) { timer = setTimeout(function () { reject(new Error('timeout')); }, VERIFY_TIMEOUT_MS); });
toast('Memverifikasi ke server…');
Promise.race([
fetch(url, { credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json' } }).then(function (res) {
return res.json().then(function (body) { if (!res.ok) throw new Error(body.error || 'HTTP ' + res.status); return body; });
}),
timeout
]).then(function (r) {
clearTimeout(timer);
if (stateKey(readState()) !== stateKey(state)) return; // inputs changed meanwhile
lastResult = r;
verifiedKey = stateKey(state);
render(r);
setVerify(true, new Date());
toast('');
done(true);
}, function () {
clearTimeout(timer);
setVerify(false);
toast('');
done(false);
});
}
// Age of the model: an old one means this page came from the offline copy.
function checkModelAge() {
var made = Date.parse(model.generatedAt);
if (isNaN(made)) return;
var hours = (Date.now() - made) / 3600000;
var note = $('modelNote');
var at = Date.parse(String(model.paramsAt || '').replace(' ', 'T'));
var per = isNaN(at) ? '' : new Intl.DateTimeFormat('id-ID', { dateStyle: 'medium', timeStyle: 'short' }).format(at);
if (hours > MAX_MODEL_AGE_DAYS * 24) {
blocked = true;
note.textContent = 'Parameter tersimpan di perangkat ini sudah lebih dari ' + MAX_MODEL_AGE_DAYS + ' hari. Sambungkan ke internet lalu muat ulang halaman.';
note.hidden = false;
} else if (hours > STALE_HOURS || navigator.onLine === false) {
note.textContent = 'Mode offline: memakai parameter yang tersimpan di perangkat' + (per ? ' (berlaku per ' + per + ')' : '') + '. Cetak struk dan salin ringkasan akan diverifikasi ke server saat online.';
note.hidden = false;
}
}
function setState(s) {
nominalEl.value = F.num(s.nominal);
setRadio('tenor', s.tenor);
setRadio('cust', s.cust);
}
function summaryText(r, verified) {
var lines = [
'Simulasi Cashback SERUBI – Bank Papua',
'Nominal ' + F.rp(r.input.nominal) + ' · ' + tenorText(r.input.tenor) + ' · ' + CUST_LABEL[r.input.customerType],
r.eligible ? 'Cashback neto: ' + F.rp(r.cashback.net) + ' (setara ' + F.pct(r.cashback.equiv) + ' per tahun)' : 'Tanpa cashback: ' + ineligibleText(r),
'',
'Total diterima selama ' + tenorText(r.input.tenor) + ':'
];
r.comparison.forEach(function (c) { lines.push('• ' + c.name + ': ' + F.rp(c.total) + ' (' + F.pct(c.totalRate) + ' p.a.)'); });
if (SHOW_DEPOSITO) lines.push('• Deposito: ' + F.rp(r.deposito.net) + ' (' + F.pct(r.deposito.rate) + ' p.a.)');
if (!verified) lines.push('', '(Simulasi lokal tanpa koneksi, belum terverifikasi server.)');
lines.push('', '*Hasil simulasi (estimasi), bukan dokumen resmi atau penawaran yang mengikat; dapat berubah sesuai ketentuan Bank Papua.');
return lines.join('\n');
}
function toast(msg) {
var el = $('toast');
el.textContent = msg;
clearTimeout(toast.t);
toast.t = setTimeout(function () { el.textContent = ''; }, 2600);
}
// ---- events ----
nominalEl.addEventListener('input', function () { F.liveFormatIdr(nominalEl); update(true); });
var resizeTimer = null;
window.addEventListener('resize', function () { clearTimeout(resizeTimer); resizeTimer = setTimeout(fitAll, 120); });
if (document.fonts && document.fonts.ready) document.fonts.ready.then(fitAll);
nominalEl.addEventListener('keydown', function (ev) { if (ev.key === 'Enter') { ev.preventDefault(); nominalEl.blur(); display.focus({ preventScroll: true }); } });
form.addEventListener('change', function (ev) { if (ev.target.name === 'tenor' || ev.target.name === 'cust') update(true); });
form.addEventListener('submit', function (ev) { ev.preventDefault(); });
form.querySelectorAll('.chip').forEach(function (b) {
b.addEventListener('click', function () { nominalEl.value = F.num(Number(b.dataset.amount)); update(true); });
});
$('btnReset').addEventListener('click', function () { setState(DEFAULTS); update(true); nominalEl.focus(); });
$('btnPrint').addEventListener('click', function () {
verifyThen(function () {
var d = document.querySelectorAll('.tape-details');
var was = Array.prototype.map.call(d, function (x) { return x.open; });
d.forEach(function (x) { x.open = true; });
window.print();
d.forEach(function (x, i) { x.open = was[i]; });
});
});
$('btnCopy').addEventListener('click', function () {
verifyThen(function (verified) {
var text = summaryText(lastResult, verified);
var done = function () { toast('Ringkasan disalin. Tempel di WhatsApp atau email.'); };
// the browser may refuse a late clipboard write; a second tap copies at once (already verified)
var failed = function () { toast('Belum tersalin. Tekan "Salin ringkasan" sekali lagi.'); };
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done, function () { fallbackCopy(text) ? done() : failed(); });
} else if (fallbackCopy(text)) done(); else failed();
});
});
function fallbackCopy(text) {
var ta = document.createElement('textarea');
ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
return ok;
}
// mini display on phones once the main display is out of view
if ('IntersectionObserver' in window) {
var mini = $('miniDisplay');
new IntersectionObserver(function (entries) {
var hidden = !entries[0].isIntersecting && entries[0].boundingClientRect.top < 0;
mini.classList.toggle('is-shown', hidden && !!lastResult);
}).observe(display);
}
// details open by default on wide screens
if ($('detailCalc') && window.matchMedia('(min-width: 900px)').matches) $('detailCalc').open = true;
// initial state: deep link ?n=&t=&c=
var q = new URLSearchParams(location.search);
var init = {
nominal: F.parseIdr(q.get('n')) || DEFAULTS.nominal,
tenor: model.tenors.indexOf(Number(q.get('t'))) >= 0 ? Number(q.get('t')) : DEFAULTS.tenor,
cust: q.get('c') === 'badan' ? 'badan' : DEFAULTS.cust
};
$('minNominal').textContent = F.rp(model.minNominal);
checkModelAge();
setState(init);
update(false);
fitAll();
// Offline copy of the page and its model (sw.js): the calculator keeps working without signal.
if ('serviceWorker' in navigator && meta.swUrl && window.isSecureContext) {
window.addEventListener('load', function () { navigator.serviceWorker.register(meta.swUrl).catch(function () { /* optional */ }); });
}
})();