This commit is contained in:
DIGIDEV\corer committed 2026-10-20 13:16:38 +09:00
commit 3e4a056b14
67 files changed
+13926

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
/* Admin page: tier editors, unsaved-change bar, and live impact preview. */
(function () {
'use strict';
var F = window.SerubiFormat;
var E = window.SerubiEngine;
var saved = window.SERUBI_PARAMS;
var form = document.getElementById('paramForm');
var $ = function (id) { return document.getElementById(id); };
var clone = function (o) { return JSON.parse(JSON.stringify(o)); };
// ---- thousands formatting for every Rupiah input ----
document.addEventListener('input', function (ev) {
var el = ev.target;
if (el.matches && el.matches('.is-idr input, #pvNominal')) F.liveFormatIdr(el);
});
// ---- tier editors (product tiers and deposito matrix share the row logic) ----
function tierRows(editor) { return Array.prototype.slice.call(editor.querySelectorAll('tbody .tier-row')); }
function refreshTiers(editor) {
var rows = tierRows(editor);
var prev = 0;
rows.forEach(function (row, i) {
var last = i === rows.length - 1;
var maxInput = row.querySelector('.t-max');
var wrap = row.querySelector('.tier-max-wrap');
var from = row.querySelector('.tier-from');
if (last) {
from.textContent = rows.length === 1 ? 'Semua nominal' : 'Di atas Rp' + F.num(prev);
wrap.hidden = true;
maxInput.value = '';
} else {
from.textContent = i === 0 ? 'Sampai dengan' : 'Rp' + F.num(prev + 1) + ' s.d.';
wrap.hidden = false;
prev = F.parseIdr(maxInput.value) || prev;
}
// renumber names: prefix[i][field] or bands[i] / rates[i][tenor]
row.querySelectorAll('input[name]').forEach(function (inp) {
inp.name = inp.name.replace(/^(\w+(?:\[[a-z_]\w*\])?)\[\d+\]/, '$1[' + i + ']');
});
var del = row.querySelector('.tier-del');
if (del) { del.disabled = rows.length === 1; del.setAttribute('aria-label', 'Hapus baris ' + (i + 1)); }
});
}
document.querySelectorAll('[data-tiers], #depoMatrix').forEach(function (ed) {
var editor = ed.id === 'depoMatrix' ? ed.closest('.panel') : ed;
refreshTiers(editor);
editor.addEventListener('input', function (ev) { if (ev.target.classList.contains('t-max')) refreshTiers(editor); });
editor.addEventListener('click', function (ev) {
var del = ev.target.closest('.tier-del');
var add = ev.target.closest('.tier-add');
if (del) {
var rows = tierRows(editor);
if (rows.length > 1) { del.closest('.tier-row').remove(); refreshTiers(editor); markDirty(); }
} else if (add) {
var rowsA = tierRows(editor);
var lastRow = rowsA[rowsA.length - 1];
var copy = lastRow.cloneNode(true);
copy.querySelectorAll('input').forEach(function (i) { i.value = ''; i.removeAttribute('aria-invalid'); });
// the new row goes before the open-ended last row, keeping "di atas" at the bottom
lastRow.parentNode.insertBefore(copy, lastRow);
copy.querySelectorAll('.t-rate').forEach(function (inp, k) {
var src = lastRow.querySelectorAll('.t-rate')[k];
inp.value = src ? src.value : '';
});
refreshTiers(editor);
var first = copy.querySelector('.t-max');
if (first) first.focus();
markDirty();
}
});
});
// ---- cashback tiers ----
var cbTable = $('cbTable');
function refreshCb() {
var rows = cbTable.querySelectorAll('tbody .cb-row');
rows.forEach(function (row, i) {
var share = F.parsePct(row.querySelector('.cb-share').value);
row.querySelector('.cb-bank').textContent = share === null || isNaN(share) ? '—' : F.pctInput(1 - share) + '%';
row.querySelectorAll('input[name]').forEach(function (inp) { inp.name = inp.name.replace(/^cb\[\d+\]/, 'cb[' + i + ']'); });
row.querySelector('.cb-del').disabled = rows.length === 1;
});
}
if (cbTable) {
refreshCb();
cbTable.addEventListener('input', refreshCb);
cbTable.addEventListener('click', function (ev) {
var del = ev.target.closest('.cb-del');
if (del && cbTable.querySelectorAll('tbody .cb-row').length > 1) { del.closest('.cb-row').remove(); refreshCb(); markDirty(); }
});
document.querySelector('.cb-add').addEventListener('click', function () {
var rows = cbTable.querySelectorAll('tbody .cb-row');
var copy = rows[rows.length - 1].cloneNode(true);
copy.querySelectorAll('input').forEach(function (i) { i.value = ''; });
cbTable.querySelector('tbody').appendChild(copy);
refreshCb();
copy.querySelector('.cb-min').focus();
markDirty();
});
}
// ---- unsaved changes ----
var dirty = false;
var savebar = $('savebar');
function markDirty() {
if (!form) return;
dirty = true;
savebar.hidden = false;
document.body.classList.add('has-savebar');
preview();
}
if (form) {
form.addEventListener('input', markDirty);
form.addEventListener('change', markDirty);
form.addEventListener('submit', function () {
dirty = false;
form.querySelectorAll('button[type=submit]').forEach(function (b) { b.disabled = true; b.setAttribute('aria-busy', 'true'); });
$('btnSave').disabled = true;
});
$('btnSave').addEventListener('click', function () { form.requestSubmit ? form.requestSubmit() : form.submit(); });
$('btnDiscard').addEventListener('click', function () { dirty = false; location.href = location.pathname + location.search; });
window.addEventListener('beforeunload', function (ev) { if (dirty) { ev.preventDefault(); ev.returnValue = ''; } });
document.addEventListener('serubi:dirty', markDirty);
}
// ---- draft params from the form ----
function readTierEditor(editor, rateSelector) {
return tierRows(editor).map(function (row, i, all) {
return { max: i === all.length - 1 ? null : F.parseIdr(row.querySelector('.t-max').value), rate: F.parsePct(row.querySelector(rateSelector).value) };
});
}
function draftParams() {
var p = clone(saved);
if (!form) return p;
var kind = form.dataset.kind;
var bad = false;
if (kind === 'settings') {
form.querySelectorAll('[data-setting]').forEach(function (inp) {
var u = inp.dataset.unit, v;
if (u === 'pct') v = F.parsePct(inp.value);
else if (u === 'idr') v = F.parseIdr(inp.value);
else v = parseFloat(String(inp.value).replace(',', '.'));
if (v === null || isNaN(v)) bad = true; else p.settings[inp.dataset.setting] = v;
});
} else if (kind === 'products') {
form.querySelectorAll('[data-tiers]').forEach(function (ed) { p.productRates[ed.dataset.tiers] = readTierEditor(ed, '.t-rate'); });
} else if (kind === 'deposito') {
var panel = $('depoMatrix').closest('.panel');
p.tenors.forEach(function (t) { p.depositoRates[t] = readTierEditor(panel, '.t-rate[data-tenor="' + t + '"]'); });
} else if (kind === 'cashback') {
p.cashbackTiers = Array.prototype.map.call(cbTable.querySelectorAll('tbody .cb-row'), function (row) {
return { min: F.parseIdr(row.querySelector('.cb-min').value), nasabah: F.parsePct(row.querySelector('.cb-share').value) };
}).filter(function (t) { return t.min !== null && t.nasabah !== null && !isNaN(t.nasabah); });
p.cashbackTenors = Array.prototype.filter.call(form.querySelectorAll('[data-cbtenor]'), function (c) { return c.checked; })
.map(function (c) { return Number(c.dataset.cbtenor); });
}
return bad ? null : p;
}
// ---- warning: a higher nominal tier with a lower rate is usually a typing mistake ----
var PRODUCT_NAMES = { giro: 'Giro Umum', simanja: 'SIMANJA', simpeda: 'SIMPEDA' };
function descending(tiers) {
for (var i = 1; i < (tiers || []).length; i++) {
if (Number(tiers[i].rate) < Number(tiers[i - 1].rate)) return i;
}
return 0;
}
function tierWarnings(p) {
var out = [];
Object.keys(PRODUCT_NAMES).forEach(function (k) {
var i = descending(p.productRates[k]);
if (i) out.push('Rate ' + PRODUCT_NAMES[k] + ' baris ' + (i + 1) + ' lebih kecil dari baris ' + i + '.');
});
p.tenors.forEach(function (t) {
var i = descending(p.depositoRates[t]);
if (i) out.push('Rate deposito ' + t + ' bulan baris ' + (i + 1) + ' lebih kecil dari baris ' + i + '.');
});
return out;
}
// ---- preview ----
var pvRows = $('pvRows');
function preview() {
if (!pvRows) return;
var n = F.parseIdr($('pvNominal').value) || 0;
var t = Number($('pvTenor').value);
var input = { nominal: n, tenor: t, customerType: 'perorangan' };
var a = E.calculate(saved, input);
var dp = draftParams();
var b = dp ? E.calculate(dp, input) : null;
var by = function (r, k) { return r.comparison.filter(function (c) { return c.key === k; })[0]; };
var rows = [
['Cashback neto', function (r) { return r.cashback.net; }, F.rp],
['Setara p.a.', function (r) { return r.cashback.equiv; }, F.pct],
['Total Giro', function (r) { return by(r, 'giro').total; }, F.rp],
['Total SIMANJA', function (r) { return by(r, 'simanja').total; }, F.rp],
['Total SIMPEDA', function (r) { return by(r, 'simpeda').total; }, F.rp],
['Deposito neto', function (r) { return r.deposito.net; }, F.rp],
['Rate deposito', function (r) { return r.deposito.rate; }, F.pct]
];
pvRows.innerHTML = rows.map(function (row) {
var va = row[1](a), vb = b ? row[1](b) : null;
var changed = b && Math.abs(va - vb) > 1e-9;
var cls = changed ? (vb > va ? ' class="up"' : ' class="down"') : '';
return '<tr' + (changed ? ' class="is-changed"' : '') + '><th scope="row">' + row[0] + '</th><td>' + row[2](va) + '</td><td' + cls + '>' +
(b ? row[2](vb) : '<span class="muted">isian belum valid</span>') + '</td></tr>';
}).join('');
var warn = $('pvWarn');
var list = dp ? tierWarnings(dp) : [];
warn.hidden = !list.length;
warn.querySelector('span').textContent = list.length ? 'Periksa kembali: ' + list.join(' ') + ' Simpan tetap diizinkan bila memang disengaja.' : '';
}
if (pvRows) {
$('pvNominal').addEventListener('input', preview);
$('pvTenor').addEventListener('change', preview);
preview();
}
})();
+430
View File
@@ -0,0 +1,430 @@
/*
* 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 */ }); });
}
})();
+99
View File
@@ -0,0 +1,99 @@
/*
* SERUBI public engine: the customer-facing result, calculated in the browser
* from the folded model of engine_client_model() (includes/engine.php). The
* page never receives the bank's internal parameters, only one margin per
* tenor. Same numbers as assets/js/engine.js and includes/engine.php, checked
* against the reference workbook by tests/validate_public.js.
* Works in the browser (window.SerubiPublicEngine) and in Node (module.exports).
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.SerubiPublicEngine = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
var PRODUCTS = [
{ key: 'giro', name: 'Giro Umum' },
{ key: 'simanja', name: 'Tabungan SIMANJA' },
{ key: 'simpeda', name: 'Tabungan SIMPEDA' }
];
// Excel ROUNDDOWN(x, 0) on 15 significant digits (see engine.js).
function rounddown(x) {
var v = Number(x.toPrecision(15));
return v < 0 ? Math.ceil(v) : Math.floor(v);
}
function tierRate(tiers, n) {
if (!tiers || !tiers.length) return 0;
for (var i = 0; i < tiers.length; i++) {
var t = tiers[i];
if (t.max === null || t.max === undefined || n <= t.max) return Number(t.rate) || 0;
}
return 0;
}
function shareFor(m, n, tenor) {
if ((m.cashbackTenors || []).indexOf(tenor) === -1) return { share: null, reason: 'tenor' };
var tiers = (m.cashbackTiers || []).slice().sort(function (a, b) { return b.min - a.min; });
for (var i = 0; i < tiers.length; i++) {
if (n >= tiers[i].min) return { share: Number(tiers[i].nasabah), reason: null };
}
return { share: null, reason: 'nominal' };
}
function calculate(m, input) {
var n = Math.max(0, Number(input.nominal) || 0);
var t = Number(input.tenor) || 0;
var customerType = input.customerType === 'badan' ? 'badan' : 'perorangan';
var year = m.days.year, month = m.days.month;
var sh = shareFor(m, n, t);
var eligible = sh.share !== null;
var share = eligible ? sh.share : 0;
// Bunga per produk (COB!J24/N24/R24), identical to the full engine
var bunga = {}, bungaSum = 0;
PRODUCTS.forEach(function (prod) {
var rate = tierRate((m.productRates || {})[prod.key], n);
var b = rounddown(((n * rate * month) / year) * t);
bunga[prod.key] = { rate: rate, gross: b };
bungaSum += b;
});
// Rata-rata spread 3 produk = margin × nominal − rata-rata bunga − biaya program/3 − premi LPS
var lps = Math.min(n, m.lps.cap) * m.lps.rate;
var avgSpread = n * (m.margin[t] || 0) - bungaSum / 3 - m.programAvg - lps;
var gross = eligible ? avgSpread * share : 0;
var negative = gross < 0;
if (negative) gross = 0;
var taxRate = m.tax[customerType];
var tax = gross * taxRate;
var net = gross - tax;
var equiv = n > 0 && t > 0 ? (net / n) * (year / (month * t)) : 0;
var comparison = PRODUCTS.map(function (prod) {
var b = bunga[prod.key];
var bungaNet = b.gross - b.gross * m.tax.bunga;
var total = bungaNet + net;
return { key: prod.key, name: prod.name, rate: b.rate, bungaNet: bungaNet, cashbackNet: net, total: total, totalRate: b.rate + equiv };
});
var penalty = { refund: net, tax: gross * taxRate, fee: net * m.pinalti, rate: m.pinalti };
penalty.total = penalty.refund + penalty.tax + penalty.fee;
var mins = (m.cashbackTiers || []).map(function (x) { return x.min; });
return {
input: { nominal: n, tenor: t, customerType: customerType },
eligible: eligible && !negative,
reason: negative ? 'spread' : sh.reason,
minNominal: mins.length ? Math.min.apply(null, mins) : 0,
share: share,
cashback: { gross: gross, taxRate: taxRate, tax: tax, net: net, monthly: t ? net / t : 0, equiv: equiv },
comparison: comparison,
penalty: penalty
};
}
return { calculate: calculate, PRODUCTS: PRODUCTS };
});
+157
View File
@@ -0,0 +1,157 @@
/*
* SERUBI cashback engine.
* Faithful port of sheet "COB" and "CALCULATOR CASHBACK SERUBI" from
* CALCULATOR CASHBACK SERUB KANTOR CABANG 22092026.xlsx. Cell references are
* noted next to each step so the numbers can be audited against the workbook.
* Works in the browser (window.SerubiEngine) and in Node (module.exports).
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.SerubiEngine = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
var PRODUCTS = [
{ key: 'giro', name: 'Giro Umum' },
{ key: 'simanja', name: 'Tabungan SIMANJA' },
{ key: 'simpeda', name: 'Tabungan SIMPEDA' }
];
// Excel ROUNDDOWN(x, 0) works on 15 significant digits; mirror that so
// values like 203424.99999999997 do not lose a rupiah.
function rounddown(x) {
var v = Number(x.toPrecision(15));
return v < 0 ? Math.ceil(v) : Math.floor(v);
}
// Tiers are [{max: number|null, rate}], ascending; first tier with n <= max
// wins, a null max means "di atas" (everything above the previous tier).
function tierRate(tiers, n) {
if (!tiers || !tiers.length) return 0;
for (var i = 0; i < tiers.length; i++) {
var t = tiers[i];
if (t.max === null || t.max === undefined || n <= t.max) return Number(t.rate) || 0;
}
return 0;
}
// Cashback sharing: [{min, nasabah}] — highest min that n reaches wins (COB!I34:I35).
function shareFor(params, n, tenor) {
if ((params.cashbackTenors || []).indexOf(tenor) === -1) return { share: null, reason: 'tenor' };
var tiers = (params.cashbackTiers || []).slice().sort(function (a, b) { return b.min - a.min; });
for (var i = 0; i < tiers.length; i++) {
if (n >= tiers[i].min) return { share: Number(tiers[i].nasabah), reason: null };
}
return { share: null, reason: 'nominal' };
}
function minCashbackNominal(params) {
var mins = (params.cashbackTiers || []).map(function (t) { return t.min; });
return mins.length ? Math.min.apply(null, mins) : 0;
}
function depositoRate(params, n, tenor) {
if (n < params.settings.deposito_min) return 0; // COB!K14: F4<1000000 -> 0
return tierRate((params.depositoRates || {})[tenor], n);
}
function calculate(params, input) {
var p = params.settings;
var n = Math.max(0, Number(input.nominal) || 0);
var t = Number(input.tenor) || 0;
var customerType = input.customerType === 'badan' ? 'badan' : 'perorangan';
var year = p.hari_setahun, month = p.hari_sebulan;
// Pengelolaan dana (COB!F23:F38)
var gwm = n * p.gwm; // F24
var kas = n * p.kas; // F25
var managed = n - (gwm + kas); // F26
var puabBase = managed * p.porsi_puab; // F28
var puabIncome = puabBase * p.puab * (month / year) * t; // F31 (A)
var creditBase = managed * p.porsi_kredit; // F34
var creditIncome = creditBase * p.kredit * (t / 12); // F36 (B)
var income = puabIncome + creditIncome; // F38
var sh = shareFor(params, n, t);
var eligible = sh.share !== null;
var share = eligible ? sh.share : 0;
// Biaya & spread per produk (COB!H22:R37)
var lps = Math.min(n, p.lps_batas) * p.premi_lps; // J27 (charged once, not prorated)
var ohc = puabIncome * p.ohc; // J26 / N25 / R25
var products = {};
PRODUCTS.forEach(function (prod) {
var rate = tierRate((params.productRates || {})[prod.key], n); // I24 / M24 / Q24
var bunga = rounddown(((n * rate * month) / year) * t); // J24 / N24 / R24
var program = prod.key === 'simanja' ? p.biaya_program : 0; // J25 (SIMANJA only)
var cost = bunga + program + ohc + lps; // J28 / N27 / R27
var spread = income - cost; // J29 / N28 / R28
var bungaTax = bunga * p.pajak_tabungan; // F7 / F10 / F13
products[prod.key] = {
key: prod.key, name: prod.name, rate: rate,
bungaGross: bunga, bungaTax: bungaTax, bungaNet: bunga - bungaTax,
program: program, ohc: ohc, lps: lps, cost: cost, spread: spread,
cashbackGross: spread * share, // J35 / N34 / R34
costOfFund: managed > 0 ? (cost + spread * share) / managed : 0 // J30 / N29 / R29
};
});
// Cashback = rata-rata 3 produk (COB!H7:H10)
var gross = eligible ? (products.giro.cashbackGross + products.simanja.cashbackGross + products.simpeda.cashbackGross) / 3 : 0;
var negative = gross < 0;
if (negative) gross = 0;
var taxRate = customerType === 'badan' ? p.pajak_cashback_badan : p.pajak_cashback_perorangan;
var tax = gross * taxRate; // H9
var net = gross - tax; // H10
var equiv = n > 0 && t > 0 ? (net / n) * (year / (month * t)) : 0; // CALC!D10
// Deposito pembanding (COB!K14:L17)
var dRate = depositoRate(params, n, t);
var dGross = rounddown(((n * dRate * month) / year) * t);
var dTax = dGross * p.pajak_deposito;
var dNet = dGross - dTax;
var deposito = { rate: dRate, gross: dGross, tax: dTax, net: dNet, monthly: t ? dNet / t : 0 };
// Perbandingan (CALC!A10:F13)
var comparison = PRODUCTS.map(function (prod) {
var pr = products[prod.key];
var total = pr.bungaNet + net;
return {
key: prod.key, name: prod.name, rate: pr.rate, bungaNet: pr.bungaNet,
cashbackNet: net, total: total, totalRate: pr.rate + equiv, diff: total - dNet
};
});
// Pinalti pencairan sebelum jatuh tempo (CALC!A17:B22)
var penalty = {
refund: net,
tax: gross * taxRate,
fee: net * p.pinalti,
total: 0
};
penalty.total = penalty.refund + penalty.tax + penalty.fee;
return {
input: { nominal: n, tenor: t, customerType: customerType },
eligible: eligible && !negative,
reason: negative ? 'spread' : sh.reason,
minNominal: minCashbackNominal(params),
share: share, shareBank: eligible ? 1 - share : 0,
fund: {
gwm: gwm, kas: kas, managed: managed, puabBase: puabBase, puabIncome: puabIncome,
creditBase: creditBase, creditIncome: creditIncome, income: income
},
products: products,
cashback: { gross: gross, taxRate: taxRate, tax: tax, net: net, monthly: t ? net / t : 0, equiv: equiv },
deposito: deposito,
comparison: comparison,
best: comparison.reduce(function (a, b) { return b.total > a.total ? b : a; }),
penalty: penalty
};
}
return {
calculate: calculate, tierRate: tierRate, depositoRate: depositoRate,
rounddown: rounddown, PRODUCTS: PRODUCTS
};
});
+54
View File
@@ -0,0 +1,54 @@
/* Indonesian number formatting shared by the calculator and admin pages. */
(function (root) {
'use strict';
var nf0 = new Intl.NumberFormat('id-ID', { maximumFractionDigits: 0 });
var nf2 = new Intl.NumberFormat('id-ID', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
var nfp = new Intl.NumberFormat('id-ID', { minimumFractionDigits: 0, maximumFractionDigits: 4 });
var F = {
/** 1234567.8 -> "Rp1.234.568" */
rp: function (x) {
var v = Math.round(Number(x) || 0);
return (v < 0 ? '−Rp' : 'Rp') + nf0.format(Math.abs(v));
},
/** 0.027 -> "2,70%" */
pct: function (x) { return nf2.format((Number(x) || 0) * 100) + '%'; },
/** 0.0075 -> "0,75" (for inputs) */
pctInput: function (x) { return nfp.format((Number(x) || 0) * 100); },
num: function (x) { return nf0.format(Math.round(Number(x) || 0)); },
/** "55.000.000" -> 55000000 */
parseIdr: function (s) {
var d = String(s == null ? '' : s).replace(/[^0-9]/g, '');
return d === '' ? null : Number(d);
},
/** "0,75" / "0.75" -> 0.0075; "" -> null; invalid -> NaN */
parsePct: function (s) {
var t = String(s == null ? '' : s).replace(/[\s%]/g, '').replace(',', '.');
if (t === '') return null;
if (!/^-?\d+(\.\d+)?$/.test(t)) return NaN;
return Math.round(parseFloat(t) * 1e6) / 1e8;
},
/** 50000000 -> "50 jt", 2e9 -> "2 M" */
short: function (x) {
var v = Number(x) || 0;
if (v >= 1e12) return nfp.format(v / 1e12) + ' T';
if (v >= 1e9) return nfp.format(v / 1e9) + ' M';
if (v >= 1e6) return nfp.format(v / 1e6) + ' jt';
return nf0.format(v);
},
/** Keeps the caret position stable while re-formatting a thousands-separated input. */
liveFormatIdr: function (input) {
var before = input.value;
var caret = input.selectionStart == null ? before.length : input.selectionStart;
var digitsBefore = before.slice(0, caret).replace(/[^0-9]/g, '').length;
var n = F.parseIdr(before);
var after = n === null ? '' : nf0.format(n);
input.value = after;
var pos = 0, seen = 0;
while (pos < after.length && seen < digitsBefore) { if (/[0-9]/.test(after[pos])) seen++; pos++; }
try { input.setSelectionRange(pos, pos); } catch (e) { /* not focusable */ }
return n;
}
};
root.SerubiFormat = F;
})(window);
File diff suppressed because it is too large. Load diff
+10
View File
@@ -0,0 +1,10 @@
/* 2FA activation: draws the otpauth:// QR code locally (no external service). */
(function () {
'use strict';
var box = document.querySelector('[data-otpauth]');
if (!box || typeof window.qrcode !== 'function') return;
var qr = window.qrcode(0, 'M');
qr.addData(box.getAttribute('data-otpauth'));
qr.make();
box.innerHTML = qr.createSvgTag({ cellSize: 5, margin: 4, scalable: true });
})();
+17
View File
@@ -0,0 +1,17 @@
/* Light/dark toggle; remembers the choice per browser. */
(function () {
'use strict';
var root = document.documentElement;
function current() {
if (root.dataset.theme) return root.dataset.theme;
return window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.querySelectorAll('[data-theme-toggle]').forEach(function (btn) {
btn.addEventListener('click', function () {
var next = current() === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
try { localStorage.setItem('serubi-theme', next); } catch (e) { /* storage unavailable */ }
btn.setAttribute('aria-label', next === 'dark' ? 'Ganti ke tema terang' : 'Ganti ke tema gelap');
});
});
})();