/*
* 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) + '
' +
'
' +
'' + (x.dep ? '' : '') + '
' +
'
' +
'
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 = '