/* * 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 '
' + label + '
' + value + '
'; } 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 = '' + text + ''; 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 ? 'Pembanding' : (best ? 'Tertinggi' : ''); 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 = '

' + (d >= 0 ? '+' : '−') + F.rp(Math.abs(d)).replace('−', '') + (d >= 0 ? ' lebih besar' : ' lebih kecil') + ' dari deposito

'; } return '
  • ' + '
    ' + esc(c.name) + tag + '' + F.rp(c.total) + '
    ' + '' + '
    ' + '
    Rate
    ' + F.pct(c.rate) + '
    ' + '
    Bunga neto
    ' + F.rp(c.bungaNet) + '
    ' + (x.dep ? '
    Cashback
    —
    ' : '
    Cashback
    ' + F.rp(c.cashbackNet) + '
    ') + '
    Total rate
    ' + F.pct(c.totalRate) + '
    ' + '
    ' + diff + '
  • '; }).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', '

    Bunga netoCashback neto

    '); } } function renderCalcLines(r) { var s = full.settings, f = r.fund, t = r.input.tenor; var html = '

    Dana kelolaan

    ' + 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') + '
    '; html += '

    Pendapatan bank selama ' + t + ' bulan

    ' + line('PUAB' + F.pct(s.porsi_puab).replace(',00', '') + ' dana × ' + F.pct(s.puab) + ' × ' + s.hari_sebulan + '/' + s.hari_setahun + ' × ' + t + '', F.rp(f.puabIncome)) + line('Kredit' + F.pct(s.porsi_kredit).replace(',00', '') + ' dana × ' + F.pct(s.kredit) + ' × ' + t + '/12', F.rp(f.creditIncome)) + line('Total pendapatan', F.rp(f.income), 'is-strong') + '
    '; PRODUCT_KEYS.forEach(function (key) { var pr = r.products[key]; html += '

    Spread ' + esc(pr.name) + ' (' + F.pct(pr.rate) + ')

    ' + 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)) + '
    '; }); html += '

    Cashback

    ' + 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', '')) + '
    '; html += '

    Deposito pembanding

    ' + 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)) + '
    '; $('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 */ }); }); } })();