asd
This commit is contained in:
commit
3e4a056b14
67 files changed
+13926
No files matched your search
@@ -0,0 +1,345 @@
|
||||
/* Admin: navy machine side panel, paper work surface. */
|
||||
:root {
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f5f7fb;
|
||||
--line: #d6dde8;
|
||||
--ink: #14223b;
|
||||
--muted: #536079;
|
||||
--primary: #0b2e66;
|
||||
--primary-hi: #154290;
|
||||
--danger: #b42318;
|
||||
--ok-bg: #e5f5ec;
|
||||
--ok-ink: #0a5a33;
|
||||
--err-bg: #fde9e7;
|
||||
--err-ink: #8f1d13;
|
||||
--info-bg: #e8effb;
|
||||
--info-ink: #173d78;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--surface: #0f1829; --surface-2: #0b1322; --line: #24314a; --ink: #e4e9f3; --muted: #9ba7bc;
|
||||
--primary: #f2c14e; --primary-hi: #f8d377;
|
||||
--ok-bg: #0d2c1d; --ok-ink: #8fe0b2; --err-bg: #3a1411; --err-ink: #ffb4a8; --info-bg: #13233f; --info-ink: #b9cdf0;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--surface: #0f1829; --surface-2: #0b1322; --line: #24314a; --ink: #e4e9f3; --muted: #9ba7bc;
|
||||
--primary: #f2c14e; --primary-hi: #f8d377;
|
||||
--ok-bg: #0d2c1d; --ok-ink: #8fe0b2; --err-bg: #3a1411; --err-ink: #ffb4a8; --info-bg: #13233f; --info-ink: #b9cdf0;
|
||||
}
|
||||
:root[data-theme="dark"] .btn-primary, :root[data-theme="dark"] .btn-primary:hover { color: var(--gold-ink); }
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .btn-primary { color: var(--gold-ink); } }
|
||||
|
||||
body.page-admin, body.page-login { color: var(--ink); }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
/* ---- buttons & fields ---- */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
min-height: 44px; padding: 0 16px; border-radius: 10px; border: 1px solid transparent;
|
||||
font-weight: 650; font-size: .95rem; text-decoration: none; white-space: nowrap;
|
||||
transition: background-color .15s, border-color .15s, color .15s;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-hi); }
|
||||
.btn-ghost { background: transparent; border-color: var(--line); color: var(--ink); }
|
||||
.btn-ghost:hover { background: var(--surface-2); border-color: var(--muted); }
|
||||
.btn-danger { background: var(--danger); color: #fff; }
|
||||
.btn-danger:hover { background: #8f1d13; }
|
||||
.btn-sm { min-height: 38px; padding: 0 12px; font-size: .88rem; }
|
||||
@media (pointer: coarse) { .btn-sm { min-height: 44px; } }
|
||||
.btn-block { width: 100%; }
|
||||
.btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.icon-btn:disabled { opacity: .35; cursor: not-allowed; }
|
||||
|
||||
.form-field { display: grid; gap: 6px; min-width: 0; }
|
||||
.form-field > label { font-weight: 650; font-size: .9rem; }
|
||||
.hint { font-size: .82rem; color: var(--muted); }
|
||||
input:not([type=checkbox]):not([type=radio]), select {
|
||||
width: 100%; min-height: 44px; padding: 0 12px; border-radius: 9px;
|
||||
border: 1px solid var(--line); background: var(--surface); color: var(--ink); font-size: 1rem;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input:hover, select:hover { border-color: var(--muted); }
|
||||
input:focus, select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent); }
|
||||
input[aria-invalid="true"], .is-invalid input { border-color: var(--danger); }
|
||||
|
||||
.input-affix { display: flex; align-items: center; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); min-width: 0; transition: border-color .15s, box-shadow .15s; }
|
||||
.input-affix:hover { border-color: var(--muted); }
|
||||
.input-affix:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent); }
|
||||
.input-affix > span { padding: 0 10px; color: var(--muted); font-weight: 600; font-size: .9rem; white-space: nowrap; }
|
||||
.input-affix > input { border: 0 !important; box-shadow: none !important; background: transparent; text-align: right; padding: 0 4px; min-width: 0; }
|
||||
.input-affix > input:first-child { padding-left: 12px; }
|
||||
.input-affix > input:last-child { padding-right: 12px; }
|
||||
.is-invalid .input-affix { border-color: var(--danger); }
|
||||
|
||||
.alert { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; margin-bottom: 16px; font-weight: 550; }
|
||||
.alert .icon { margin-top: 3px; }
|
||||
.alert-ok { background: var(--ok-bg); color: var(--ok-ink); }
|
||||
.alert-error { background: var(--err-bg); color: var(--err-ink); }
|
||||
.alert-info { background: var(--info-bg); color: var(--info-ink); }
|
||||
.field-msg { display: flex; gap: 6px; align-items: flex-start; color: var(--danger); font-size: .86rem; font-weight: 600; margin: 6px 0; }
|
||||
:root[data-theme="dark"] .field-msg { color: var(--err-ink); }
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .field-msg { color: var(--err-ink); } }
|
||||
.field-msg .icon { margin-top: 2px; }
|
||||
.hint-block { display: flex; gap: 10px; align-items: flex-start; font-size: .9rem; color: var(--info-ink); background: var(--info-bg); padding: 11px 14px; border-radius: 10px; margin-bottom: 16px; }
|
||||
.hint-block .icon { margin-top: 3px; }
|
||||
|
||||
/* ---- login ---- */
|
||||
.login { min-height: 100vh; min-height: 100dvh; display: grid; place-content: center; gap: 18px; padding: 24px 16px; justify-items: center; }
|
||||
.login-card {
|
||||
width: min(400px, calc(100vw - 32px)); display: grid; gap: 16px;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 18px; padding: 26px 22px;
|
||||
box-shadow: 0 24px 40px -28px rgba(10, 30, 70, .45);
|
||||
}
|
||||
.login-card h1 { font-size: 1.35rem; font-weight: 800; font-stretch: 92%; }
|
||||
.back-link { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); min-height: 44px; font-weight: 600; }
|
||||
.captcha-img { display: block; width: 200px; max-width: 100%; height: auto; border: 1px solid var(--line); border-radius: 9px; margin-bottom: 6px; }
|
||||
.otp-steps { display: grid; gap: 12px; padding-left: 1.2em; font-size: .92rem; }
|
||||
.otp-qr { width: 196px; max-width: 100%; margin-top: 8px; background: #fff; border-radius: 10px; overflow: hidden; }
|
||||
.otp-qr svg { display: block; width: 100%; height: auto; }
|
||||
.otp-secret { display: block; margin-top: 6px; padding: 8px 10px; border-radius: 8px; background: var(--surface-2); border: 1px solid var(--line); font-size: .95rem; letter-spacing: .04em; word-break: break-all; }
|
||||
.recovery-codes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; padding-left: 1.4em; font-size: 1rem; }
|
||||
.recovery-codes code { letter-spacing: .06em; }
|
||||
|
||||
/* ---- layout ---- */
|
||||
.admin { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 100vh; }
|
||||
.side {
|
||||
background: linear-gradient(180deg, var(--machine-hi), var(--machine) 160px, var(--machine-lo));
|
||||
color: var(--machine-ink); padding: 14px 16px; display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.side :focus-visible { outline-color: var(--focus); }
|
||||
.side-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
||||
.side .logo { height: 30px; }
|
||||
.side .brand { min-height: 0; }
|
||||
.side-tag { display: flex; align-items: center; gap: 10px; margin: 0; }
|
||||
@media (min-width: 960px) { .side .logo { height: 38px; } .side-tag { padding-left: 2px; } }
|
||||
.login .logo-lg .logo { height: 48px; }
|
||||
.login .brand { justify-content: center; }
|
||||
.side-sub { font-size: .82rem; color: var(--machine-muted); font-weight: 600; }
|
||||
@media (max-width: 459px) { .side-sub { display: none; } }
|
||||
@media (max-width: 959px) { .side-top { gap: 6px 12px; } }
|
||||
.side-nav { display: flex; gap: 4px; min-width: 0; overflow-x: auto; scrollbar-width: thin; margin: 0 -16px; padding: 0 16px 4px; scroll-snap-type: x proximity; }
|
||||
.side-link {
|
||||
display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 12px; border-radius: 10px;
|
||||
color: var(--machine-ink); text-decoration: none; font-weight: 600; font-size: .93rem; white-space: nowrap; scroll-snap-align: start;
|
||||
transition: background-color .15s;
|
||||
}
|
||||
.side-link .icon { color: var(--machine-muted); }
|
||||
.side-link:hover { background: rgba(255,255,255,.08); }
|
||||
.side-link.is-active { background: var(--gold); color: var(--gold-ink); }
|
||||
.side-link.is-active .icon { color: var(--gold-ink); }
|
||||
.side-foot { display: none; }
|
||||
.side .icon-btn, .side .text-btn { color: var(--machine-ink); border-color: var(--machine-line); }
|
||||
.side .icon-btn:hover, .side .text-btn:hover { background: rgba(255,255,255,.08); }
|
||||
|
||||
.admin-main { padding: 22px 16px 120px; min-width: 0; outline: none; }
|
||||
.preview { padding: 0 16px 120px; min-width: 0; }
|
||||
.has-preview .admin-main { padding-bottom: 8px; }
|
||||
@media (min-width: 1320px) { .has-preview .admin-main { padding-bottom: 120px; } }
|
||||
|
||||
/* mobile: user controls live at the end of the nav strip */
|
||||
@media (max-width: 959px) {
|
||||
.side { position: sticky; top: 0; z-index: 10; }
|
||||
.side-foot { display: flex; align-items: center; gap: 8px; }
|
||||
.side-foot .side-link, .side-foot .side-user { display: none; }
|
||||
.side-top { flex-wrap: nowrap; }
|
||||
.side { flex-direction: row; flex-wrap: wrap; align-items: center; }
|
||||
.side-top { flex: 1; }
|
||||
.side > * { min-width: 0; }
|
||||
.side-nav { order: 3; flex: 1 1 100%; width: calc(100% + 32px); max-width: calc(100% + 32px); }
|
||||
.side-actions { display: flex; gap: 6px; }
|
||||
.side-actions .text-btn span { display: none; }
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
.admin { grid-template-columns: 252px minmax(0, 1fr); }
|
||||
.side { position: sticky; top: 0; height: 100vh; height: 100dvh; padding: 22px 16px; }
|
||||
.side-top { flex-direction: column; align-items: flex-start; gap: 12px; }
|
||||
.side-nav { flex-direction: column; overflow: visible; margin: 12px 0 0; padding: 0; }
|
||||
.side-foot { display: grid; gap: 10px; margin-top: auto; padding-top: 14px; border-top: 1px solid var(--machine-line); }
|
||||
.side-user { display: grid; padding: 0 12px; }
|
||||
.side-user-name { font-weight: 700; }
|
||||
.side-user-id { font-size: .84rem; color: var(--machine-muted); }
|
||||
.side-actions { display: flex; gap: 8px; padding: 0 4px; }
|
||||
.admin-main { padding: 34px 32px 120px; }
|
||||
.preview { grid-column: 2; padding: 0 32px 120px; }
|
||||
}
|
||||
@media (min-width: 1320px) {
|
||||
/* form and preview stay side by side; on very wide screens the spare room goes to the right, not between them */
|
||||
.admin.has-preview { grid-template-columns: 252px minmax(0, 924px) 360px; }
|
||||
.has-preview .preview { grid-column: 3; padding: 34px 24px 40px 0; }
|
||||
.preview-card { position: sticky; top: 24px; }
|
||||
}
|
||||
.admin-main > * { max-width: 860px; }
|
||||
|
||||
.page-head { margin-bottom: 22px; }
|
||||
.page-head h1 { font-size: 1.6rem; font-weight: 800; font-stretch: 92%; letter-spacing: -.005em; }
|
||||
.page-head p { color: var(--muted); margin-top: 4px; max-width: 65ch; }
|
||||
.page-head .page-meta { font-size: .85rem; margin-top: 8px; }
|
||||
|
||||
/* ---- panels ---- */
|
||||
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
|
||||
@media (min-width: 640px) { .panel { padding: 22px 24px; } }
|
||||
.panel-head { margin-bottom: 14px; }
|
||||
.panel-head h2 { font-size: 1.08rem; font-weight: 750; }
|
||||
.panel-head p { color: var(--muted); font-size: .9rem; margin-top: 3px; max-width: 70ch; }
|
||||
|
||||
.setting-list { display: grid; }
|
||||
.setting {
|
||||
display: grid; grid-template-columns: 1fr; gap: 8px 20px; align-items: center;
|
||||
padding: 14px 0; border-top: 1px solid var(--line);
|
||||
}
|
||||
.setting:first-child { border-top: 0; padding-top: 0; }
|
||||
.setting label { font-weight: 650; }
|
||||
.setting-text p { font-size: .85rem; color: var(--muted); margin-top: 2px; }
|
||||
.setting .input-affix { max-width: 260px; }
|
||||
@media (min-width: 640px) {
|
||||
.setting { grid-template-columns: minmax(0, 1fr) 220px; }
|
||||
.setting .input-affix { max-width: none; }
|
||||
}
|
||||
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin: 6px 0 22px; }
|
||||
|
||||
/* tier tables */
|
||||
.tier-table, .matrix { width: 100%; border-collapse: collapse; }
|
||||
.tier-table th, .matrix th { text-align: left; font-size: .78rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 0 8px 8px 0; }
|
||||
.tier-table td, .matrix td, .matrix tbody th { padding: 6px 8px 6px 0; vertical-align: middle; border-top: 1px solid var(--line); }
|
||||
.matrix tbody th { text-transform: none; letter-spacing: 0; font-size: .9rem; color: var(--ink); font-weight: 600; }
|
||||
.tier-range { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.tier-from { font-size: .88rem; color: var(--muted); font-weight: 600; white-space: nowrap; min-width: 0; }
|
||||
@media (min-width: 640px) { .tier-from { flex: 0 0 168px; } }
|
||||
.tier-max-wrap { flex: 1 1 170px; }
|
||||
.tier-max-wrap[hidden] { display: none; }
|
||||
.tier-rate { max-width: 130px; }
|
||||
.tier-del-cell { width: 52px; text-align: right; padding-right: 0 !important; }
|
||||
.tier-del { border-color: transparent; color: var(--muted); }
|
||||
.tier-del:hover:not(:disabled) { color: var(--danger); background: var(--err-bg); }
|
||||
.tier-add, .cb-add { margin-top: 12px; }
|
||||
.cb-bank { color: var(--muted); font-weight: 600; white-space: nowrap; }
|
||||
|
||||
.matrix-scroll { overflow-x: auto; margin: 0 -4px; padding: 0 4px; }
|
||||
.matrix { min-width: 760px; }
|
||||
.matrix .tier-rate { max-width: 104px; min-width: 88px; }
|
||||
.matrix tbody th { min-width: 230px; }
|
||||
|
||||
@media (max-width: 639px) {
|
||||
/* stack tier rows into cards */
|
||||
.tier-table thead { display: none; }
|
||||
.tier-table, .tier-table tbody, .tier-table tr, .tier-table td { display: block; width: 100%; }
|
||||
.tier-table tr { position: relative; border-top: 1px solid var(--line); padding: 10px 52px 10px 0; }
|
||||
.tier-table td { border: 0; padding: 4px 0; }
|
||||
.tier-table td[data-label]::before { content: attr(data-label); display: block; font-size: .76rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 4px; }
|
||||
.tier-table .tier-del-cell { position: absolute; right: 0; top: 10px; width: auto; }
|
||||
.tier-rate { max-width: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
/* deposito matrix: one card per nominal band, tenor rates in a small grid */
|
||||
.matrix-scroll { overflow: visible; margin: 0; padding: 0; }
|
||||
.matrix { min-width: 0; }
|
||||
.matrix thead { display: none; }
|
||||
.matrix, .matrix tbody { display: block; }
|
||||
.matrix tr { position: relative; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding: 12px 0; border-top: 1px solid var(--line); }
|
||||
.matrix tbody th { grid-column: 1 / -1; min-width: 0; padding: 0 48px 0 0; border: 0; }
|
||||
.matrix td { display: block; padding: 0; border: 0; }
|
||||
.matrix td[data-label]::before { content: attr(data-label); display: block; font-size: .74rem; font-weight: 700; color: var(--muted); margin-bottom: 4px; }
|
||||
.matrix .tier-rate { max-width: none; min-width: 0; }
|
||||
.matrix .tier-del-cell { position: absolute; top: 8px; right: 0; width: auto; }
|
||||
}
|
||||
.check-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.check { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 14px; border: 1px solid var(--line); border-radius: 10px; cursor: pointer; font-weight: 600; }
|
||||
.check:has(input:checked) { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 8%, transparent); }
|
||||
.check input { width: 18px; height: 18px; accent-color: var(--primary); }
|
||||
|
||||
.panel-danger { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); }
|
||||
.panel-danger summary { list-style: none; display: flex; justify-content: space-between; align-items: center; cursor: pointer; min-height: 44px; margin: -10px 0; }
|
||||
.panel-danger summary::-webkit-details-marker { display: none; }
|
||||
.panel-danger summary h2 { font-size: 1.02rem; font-weight: 700; color: var(--danger); }
|
||||
:root[data-theme="dark"] .panel-danger summary h2 { color: var(--err-ink); }
|
||||
.panel-danger[open] summary { margin-bottom: 12px; }
|
||||
.panel-danger[open] .chev { transform: rotate(180deg); }
|
||||
.danger-body { display: grid; gap: 14px; max-width: 520px; }
|
||||
.danger-body p { color: var(--muted); font-size: .92rem; }
|
||||
.danger-body .btn { justify-self: start; }
|
||||
|
||||
/* users */
|
||||
.user-list { list-style: none; margin: 0; padding: 0; }
|
||||
.user { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: flex-start; justify-content: space-between; padding: 14px 0; border-top: 1px solid var(--line); }
|
||||
.user:first-child { border-top: 0; padding-top: 0; }
|
||||
.user.is-off .user-name { color: var(--muted); }
|
||||
.user-main { display: grid; min-width: 0; }
|
||||
.user-name { font-weight: 700; }
|
||||
.user-meta { font-size: .86rem; color: var(--muted); }
|
||||
.user-actions { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.badge { font-size: .72rem; font-weight: 700; padding: 1px 8px; border-radius: 999px; background: var(--info-bg); color: var(--info-ink); vertical-align: 2px; }
|
||||
.badge-off { background: var(--surface-2); color: var(--muted); border: 1px solid var(--line); }
|
||||
.inline-details > summary { list-style: none; }
|
||||
.inline-details > summary::-webkit-details-marker { display: none; }
|
||||
.inline-form { display: grid; gap: 10px; margin-top: 10px; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); min-width: min(300px, 80vw); }
|
||||
.two-col { display: grid; gap: 16px; }
|
||||
@media (min-width: 900px) { .two-col { grid-template-columns: 1fr 1fr; } }
|
||||
.two-col .panel { margin: 0; }
|
||||
.inline-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.stack-form { display: grid; gap: 14px; }
|
||||
.stack-form .btn { justify-self: start; }
|
||||
|
||||
/* log */
|
||||
.log-list { list-style: none; margin: 0; padding: 0; }
|
||||
.log { padding: 14px 0; border-top: 1px solid var(--line); }
|
||||
.log:first-child { border-top: 0; padding-top: 0; }
|
||||
.log-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 16px; }
|
||||
.log-what { font-weight: 650; }
|
||||
.log-meta { color: var(--muted); font-size: .86rem; }
|
||||
.log-diff { margin: 8px 0 0; padding: 10px 12px; list-style: none; background: var(--surface-2); border-radius: 8px; font-size: .86rem; display: grid; gap: 6px; font-family: var(--font-tape); overflow-wrap: anywhere; }
|
||||
.log-diff del { color: var(--danger); }
|
||||
.log-diff ins { color: var(--good); text-decoration: none; }
|
||||
:root[data-theme="dark"] .log-diff del { color: var(--err-ink); }
|
||||
:root[data-theme="dark"] .log-diff ins { color: var(--ok-ink); }
|
||||
.pager { display: flex; justify-content: center; align-items: center; gap: 14px; margin-top: 16px; color: var(--muted); font-size: .9rem; }
|
||||
.empty { color: var(--muted); }
|
||||
|
||||
/* preview */
|
||||
.preview-card { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 18px; }
|
||||
.preview-card h2 { font-size: 1.05rem; font-weight: 750; }
|
||||
.preview-card > .muted { font-size: .86rem; margin-top: 2px; }
|
||||
.pv-inputs { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; margin: 14px 0; }
|
||||
@media (min-width: 560px) and (max-width: 1319px) { .pv-inputs { grid-template-columns: 1fr 1fr; } }
|
||||
.pv-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
.pv-table th, .pv-table td { padding: 7px 0; border-top: 1px solid var(--line); text-align: right; white-space: nowrap; }
|
||||
.pv-table th[scope=row] { text-align: left; font-weight: 600; color: var(--muted); white-space: normal; }
|
||||
.pv-table thead th { border-top: 0; font-size: .74rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: 700; }
|
||||
.pv-table td { padding-left: 10px; font-variant-numeric: tabular-nums; }
|
||||
.pv-table tr.is-changed th { color: var(--ink); }
|
||||
.pv-table td.up { color: var(--good); font-weight: 700; }
|
||||
/* each row: label on top, saved | form values below at half width each, so large amounts fit the narrow card */
|
||||
.pv-table thead tr, .pv-table tbody tr { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 10px; border-top: 1px solid var(--line); padding: 7px 0; }
|
||||
.pv-table thead tr { border-top: 0; padding-top: 0; }
|
||||
.pv-table th, .pv-table td { border-top: 0; padding: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.pv-table thead th:first-child { display: none; }
|
||||
.pv-table th[scope=row] { grid-column: 1 / -1; margin-bottom: 2px; }
|
||||
.pv-table td { font-size: .84rem; }
|
||||
.pv-table td.down { color: var(--danger); font-weight: 700; }
|
||||
:root[data-theme="dark"] .pv-table td.up { color: var(--ok-ink); }
|
||||
:root[data-theme="dark"] .pv-table td.down { color: var(--err-ink); }
|
||||
|
||||
/* save bar */
|
||||
.savebar {
|
||||
position: fixed; left: 50%; bottom: max(14px, env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 30;
|
||||
width: min(720px, calc(100vw - 24px));
|
||||
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px;
|
||||
padding: 10px 12px 10px 16px; border-radius: 14px;
|
||||
background: var(--machine); color: var(--machine-ink);
|
||||
box-shadow: 0 18px 40px -14px rgba(4, 18, 48, .6);
|
||||
animation: rise .25s var(--ease-out);
|
||||
}
|
||||
.savebar[hidden] { display: none; }
|
||||
.savebar > span { display: inline-flex; gap: 8px; align-items: center; font-weight: 600; font-size: .92rem; }
|
||||
.savebar > div { display: flex; gap: 8px; margin-left: auto; }
|
||||
.savebar .btn-ghost { color: var(--machine-ink); border-color: var(--machine-line); }
|
||||
.savebar .btn-ghost:hover { background: rgba(255,255,255,.08); }
|
||||
.savebar .btn-primary { background: var(--gold); color: var(--gold-ink); }
|
||||
.savebar .btn-primary:hover { background: var(--gold-hi); }
|
||||
.savebar :focus-visible { outline-color: var(--focus); }
|
||||
@media (min-width: 960px) { .savebar { left: calc(50% + 126px); width: min(720px, calc(100vw - 300px)); } }
|
||||
@keyframes rise { from { transform: translate(-50%, 20px); opacity: 0; } }
|
||||
@media (min-width: 1320px) { .has-preview ~ .savebar { left: calc(252px + 462px); width: 720px; } }
|
||||
@@ -0,0 +1,302 @@
|
||||
/* Calculator page: the machine (inputs + display) and the paper tape (results). */
|
||||
|
||||
.desk {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 4px max(16px, env(safe-area-inset-right)) 48px max(16px, env(safe-area-inset-left));
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.desk { grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); gap: 28px; align-items: start; }
|
||||
.machine { position: sticky; top: 16px; }
|
||||
}
|
||||
@media (min-width: 1180px) {
|
||||
.desk { grid-template-columns: 460px minmax(0, 1fr); gap: 40px; }
|
||||
}
|
||||
|
||||
/* ---- machine ---- */
|
||||
.machine {
|
||||
background: linear-gradient(180deg, var(--machine-hi) 0%, var(--machine) 120px, var(--machine-lo) 100%);
|
||||
color: var(--machine-ink);
|
||||
border-radius: var(--radius-l);
|
||||
box-shadow: var(--shadow-machine);
|
||||
padding: 18px 18px 20px;
|
||||
}
|
||||
.machine :focus-visible { outline-color: var(--focus); }
|
||||
@media (min-width: 480px) { .machine { padding: 22px 24px 24px; } }
|
||||
@media (max-width: 299px) { .machine { padding: 14px 12px 16px; } .tape { padding-left: 12px; padding-right: 12px; } }
|
||||
|
||||
.machine-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px 12px; margin-bottom: 14px; }
|
||||
.machine-head h1 { font-size: 1.15rem; font-weight: 750; font-stretch: 92%; letter-spacing: .005em; }
|
||||
.machine-head p { font-size: .85rem; color: var(--machine-muted); }
|
||||
|
||||
/* display */
|
||||
.display {
|
||||
background: var(--display);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px 14px;
|
||||
box-shadow: inset 0 3px 10px rgba(0,0,0,.55), inset 0 0 0 1px rgba(255,255,255,.04), 0 1px 0 rgba(255,255,255,.1);
|
||||
color: var(--display-ink);
|
||||
outline: none;
|
||||
}
|
||||
.display-top { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 4px 10px; min-height: 24px; }
|
||||
.display-label { font-size: .8rem; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--display-dim); }
|
||||
.display-flag {
|
||||
font-size: .75rem; font-weight: 650; padding: 2px 8px; border-radius: 999px;
|
||||
border: 1px solid var(--display-line); color: var(--display-dim); white-space: nowrap;
|
||||
}
|
||||
.display-flag:empty { display: none; }
|
||||
.display-value {
|
||||
display: block;
|
||||
font-size: clamp(2.1rem, 9vw, 2.9rem);
|
||||
font-weight: 800;
|
||||
font-stretch: 78%;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -.01em;
|
||||
text-align: right;
|
||||
margin: 6px 0 10px;
|
||||
white-space: nowrap; /* never split a number; app.js shrinks the size to fit */
|
||||
overflow: hidden;
|
||||
transition: color .2s;
|
||||
}
|
||||
.display.is-off .display-value { color: var(--display-dim); opacity: .7; }
|
||||
.display-sub {
|
||||
display: flex; flex-wrap: wrap; gap: 8px 18px;
|
||||
margin: 0; padding-top: 10px; border-top: 1px dashed var(--display-line);
|
||||
}
|
||||
.display-sub div { flex: 1 0 auto; min-width: 0; } /* grow, never shrink below the number */
|
||||
.display-sub dt { font-size: .72rem; color: var(--display-dim); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; }
|
||||
.display-sub dd { margin: 2px 0 0; font-weight: 700; font-size: .98rem; font-stretch: 88%; white-space: nowrap; }
|
||||
.display-note {
|
||||
display: flex; gap: 8px; align-items: flex-start;
|
||||
margin-top: 12px; padding: 9px 11px; border-radius: 9px;
|
||||
background: rgba(255, 230, 160, .1); color: var(--display-ink); font-size: .86rem; line-height: 1.45;
|
||||
}
|
||||
.display-note[hidden] { display: none; }
|
||||
.display-note .icon { margin-top: 2px; color: var(--gold); }
|
||||
|
||||
/* inputs */
|
||||
.keys { margin-top: 18px; display: grid; gap: 18px; }
|
||||
.field { margin: 0; padding: 0; border: 0; min-width: 0; }
|
||||
.field > label, .field > legend {
|
||||
display: block; padding: 0; margin-bottom: 8px;
|
||||
font-size: .82rem; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; color: var(--machine-muted);
|
||||
}
|
||||
.entry {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--display); border-radius: 12px; padding: 0 14px;
|
||||
box-shadow: inset 0 2px 6px rgba(0,0,0,.5), 0 1px 0 rgba(255,255,255,.08);
|
||||
border: 2px solid transparent; transition: border-color .15s;
|
||||
}
|
||||
.entry:focus-within { border-color: var(--gold); }
|
||||
.entry.is-invalid { border-color: #ff8a7a; }
|
||||
.entry-prefix { font-weight: 700; color: var(--display-dim); font-size: 1.1rem; }
|
||||
.entry input {
|
||||
flex: 1; min-width: 0; height: 56px; border: 0; background: transparent; outline: none;
|
||||
color: var(--display-ink); caret-color: var(--gold);
|
||||
font-size: 1.55rem; font-weight: 750; font-stretch: 85%; text-align: right; letter-spacing: .01em;
|
||||
}
|
||||
.help { margin-top: 7px; font-size: .84rem; color: var(--machine-muted); }
|
||||
.field-error { margin-top: 7px; font-size: .86rem; color: #ffb4a8; font-weight: 600; }
|
||||
.field-error[hidden] { display: none; }
|
||||
|
||||
.quick { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 10px; }
|
||||
@media (min-width: 360px) { .quick { grid-template-columns: repeat(6, 1fr); } }
|
||||
.chip {
|
||||
min-height: 36px; padding: 0 4px; border-radius: 8px; white-space: nowrap;
|
||||
border: 1px solid var(--machine-line); background: rgba(255,255,255,.04); color: var(--machine-ink);
|
||||
font-size: .86rem; font-weight: 600; transition: background-color .15s, border-color .15s;
|
||||
}
|
||||
.chip:hover { background: rgba(255,255,255,.1); border-color: var(--machine-muted); }
|
||||
.chip[aria-pressed="true"] { background: rgba(242,193,78,.16); border-color: var(--gold); color: var(--gold-hi); }
|
||||
@media (pointer: coarse) { .chip { min-height: 44px; } }
|
||||
|
||||
/* segmented keys */
|
||||
.segmented { display: grid; gap: 6px; }
|
||||
.seg-tenor { grid-template-columns: repeat(auto-fit, minmax(48px, 1fr)); }
|
||||
.seg-cust { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); }
|
||||
.seg { position: relative; display: grid; }
|
||||
.seg input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
|
||||
.seg-face {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
min-height: 52px; padding: 6px 4px 5px; border-radius: 10px;
|
||||
background: var(--key); color: var(--key-ink); box-shadow: inset 0 -3px 0 var(--key-edge);
|
||||
font-weight: 650; text-align: center; line-height: 1.15; user-select: none;
|
||||
transition: background-color .15s, color .15s, transform .08s, box-shadow .08s;
|
||||
}
|
||||
.seg-cust .seg-face { flex-direction: row; min-height: 48px; font-size: .95rem; }
|
||||
.seg-num { font-size: 1.2rem; font-weight: 800; font-stretch: 85%; }
|
||||
.seg-unit { font-size: .7rem; font-weight: 600; opacity: .8; }
|
||||
.seg-tag { font-size: .62rem; font-weight: 600; opacity: .75; margin-top: 1px; }
|
||||
.seg:hover .seg-face { background: var(--key-hi); }
|
||||
.seg input:checked + .seg-face {
|
||||
background: var(--gold); color: var(--gold-ink); box-shadow: inset 0 -2px 0 var(--gold-edge);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.seg input:focus-visible + .seg-face { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.seg input:active + .seg-face { transform: translateY(2px); box-shadow: inset 0 -1px 0 var(--key-edge); }
|
||||
|
||||
.machine-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; padding-top: 4px; }
|
||||
.machine-actions .key { min-width: 0; padding: 0 10px; text-align: center; line-height: 1.2; }
|
||||
@media (max-width: 279px) { .machine-actions { grid-template-columns: minmax(0, 1fr); } }
|
||||
.machine-actions .key-gold { grid-column: 1 / -1; min-height: 52px; font-size: 1rem; }
|
||||
.toast { min-height: 1.2em; font-size: .86rem; color: var(--gold-hi); text-align: right; margin-top: -6px; }
|
||||
.model-note { font-size: .82rem; line-height: 1.4; padding: 8px 10px; border-radius: 8px; background: var(--warn-bg); color: #3b2600; border: 1px solid #e7c271; }
|
||||
.tape-verify { font-weight: 600; color: var(--paper-ink, inherit); }
|
||||
|
||||
/* ---- tape ---- */
|
||||
.tape-wrap { position: relative; min-width: 0; max-width: 720px; width: 100%; justify-self: center; }
|
||||
@media (min-width: 900px) { .tape-wrap { justify-self: start; } }
|
||||
.tape-slot {
|
||||
height: 14px; margin: 0 -6px; border-radius: 8px 8px 4px 4px;
|
||||
background: linear-gradient(180deg, var(--machine-lo), var(--machine));
|
||||
box-shadow: inset 0 -4px 6px rgba(0,0,0,.45);
|
||||
}
|
||||
.tape {
|
||||
--zig: 9px;
|
||||
position: relative;
|
||||
margin: -6px 6px 0;
|
||||
background: var(--paper);
|
||||
color: var(--paper-ink);
|
||||
padding: 22px clamp(16px, 4vw, 30px) calc(26px + var(--zig));
|
||||
box-shadow: var(--shadow-paper);
|
||||
/* torn zig-zag bottom edge */
|
||||
-webkit-mask: linear-gradient(#000 0 0) top / 100% calc(100% - var(--zig)) no-repeat,
|
||||
conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) bottom / calc(var(--zig) * 2) var(--zig) repeat-x;
|
||||
mask: linear-gradient(#000 0 0) top / 100% calc(100% - var(--zig)) no-repeat,
|
||||
conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg) bottom / calc(var(--zig) * 2) var(--zig) repeat-x;
|
||||
font-family: var(--font-tape);
|
||||
font-size: .86rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.tape ::selection { background: var(--paper-hilite); color: var(--paper-ink); }
|
||||
.tape :focus-visible { outline-color: var(--focus-on-light); }
|
||||
.tape.is-feeding { animation: feed .32s var(--ease-out); }
|
||||
@keyframes feed { from { transform: translateY(-10px); } to { transform: none; } }
|
||||
|
||||
.tape h2, .tape h3 { font-family: var(--font-ui); }
|
||||
.tape-head { text-align: center; padding-bottom: 14px; border-bottom: 1px dashed var(--paper-rule); }
|
||||
.tape-logo { justify-content: center; margin-bottom: 12px; }
|
||||
.tape-logo .logo { height: 34px; }
|
||||
.tape-head h2 { font-size: 1.08rem; font-weight: 800; font-stretch: 90%; letter-spacing: .04em; text-transform: uppercase; color: var(--paper-accent); }
|
||||
.tape-meta { color: var(--paper-muted); font-size: .8rem; margin-top: 2px; }
|
||||
.lines-input { margin-top: 12px; text-align: left; }
|
||||
|
||||
.tape-sec { padding: 16px 0; border-bottom: 1px dashed var(--paper-rule); }
|
||||
.tape-sec h3 { font-size: .95rem; font-weight: 750; color: var(--paper-ink); margin-bottom: 10px; }
|
||||
|
||||
/* label .......... value lines */
|
||||
.lines { margin: 0; display: grid; gap: 4px; }
|
||||
/* receipt row: the value always stays in the right column on the same row;
|
||||
when space runs out the label wraps and the value aligns with its last line */
|
||||
.lines div { display: flex; flex-wrap: nowrap; align-items: flex-end; column-gap: 8px; min-width: 0; }
|
||||
.lines dt { color: var(--paper-muted); flex: 0 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.4; order: 0; }
|
||||
.lines dd { order: 2; flex: none; margin: 0 0 0 auto; text-align: right; white-space: nowrap; font-weight: 600; color: var(--paper-ink); line-height: 1.4; }
|
||||
/* dotted leader between label and value, like a printed receipt */
|
||||
.lines div::after {
|
||||
content: ""; order: 1; flex: 1 1 10px; min-width: 10px; align-self: flex-end;
|
||||
border-bottom: 1px dotted var(--paper-rule); margin-bottom: .45em;
|
||||
}
|
||||
.lines .is-strong::after, .lines .is-group::after { border-bottom-color: transparent; }
|
||||
.lines .is-strong dt, .lines .is-strong dd { color: var(--paper-ink); font-weight: 800; }
|
||||
.lines .is-strong { padding: 6px 0; border-top: 1px solid var(--paper-ink); border-bottom: 1px solid var(--paper-ink); margin: 4px 0; font-size: 1.02rem; }
|
||||
.lines .is-sub dt { padding-left: 14px; }
|
||||
.lines .is-neg dd { color: var(--bad); }
|
||||
.lines .is-group { margin-top: 10px; }
|
||||
.lines .is-group dt { color: var(--paper-ink); font-weight: 700; font-family: var(--font-ui); }
|
||||
.lines-total { font-size: .92rem; }
|
||||
@media (max-width: 420px) {
|
||||
.tape { font-size: .8rem; }
|
||||
.lines-total { font-size: .86rem; }
|
||||
.lines .is-strong { font-size: .94rem; }
|
||||
}
|
||||
.lines dt .formula { display: block; font-size: .74rem; color: var(--paper-muted); opacity: .9; }
|
||||
|
||||
/* comparison rows */
|
||||
.cmp { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
||||
.cmp-row {
|
||||
padding: 11px 12px 12px; border-radius: 10px; border: 1px solid var(--paper-rule);
|
||||
background: color-mix(in srgb, var(--paper) 92%, var(--paper-rule));
|
||||
}
|
||||
.cmp-row.is-best { border-color: var(--paper-accent); background: var(--paper); box-shadow: inset 0 0 0 1px var(--paper-accent); }
|
||||
.cmp-row.is-dep { border-style: dashed; background: transparent; }
|
||||
.cmp-top { display: flex; align-items: baseline; justify-content: space-between; gap: 6px 12px; flex-wrap: wrap; }
|
||||
.cmp-name { font-family: var(--font-ui); font-weight: 750; font-size: .98rem; display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.cmp-tag {
|
||||
font-family: var(--font-ui); font-size: .7rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
|
||||
padding: 1px 7px; border-radius: 999px; background: var(--paper-accent); color: #fff;
|
||||
}
|
||||
.cmp-row.is-dep .cmp-tag { background: transparent; color: var(--paper-muted); border: 1px solid var(--paper-rule); }
|
||||
.cmp-total { font-weight: 800; font-size: clamp(.86rem, 4.2vw, 1.08rem); margin-left: auto; white-space: nowrap; }
|
||||
.cmp-bar { height: 8px; border-radius: 4px; background: color-mix(in srgb, var(--paper-rule) 45%, transparent); margin: 8px 0 9px; overflow: hidden; display: flex; }
|
||||
.cmp-bar i { display: block; height: 100%; background: var(--bar); }
|
||||
.cmp-bar i + i { background: var(--gold); }
|
||||
.cmp-row.is-dep .cmp-bar i { background: var(--bar-dep); }
|
||||
.cmp-meta { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 0; font-size: .82rem; }
|
||||
.cmp-meta div { display: flex; flex-direction: column; gap: 0; flex: 1 0 auto; min-width: 84px; }
|
||||
.cmp-meta dt { color: var(--paper-muted); font-size: .72rem; }
|
||||
.cmp-meta dd { margin: 0; font-weight: 600; white-space: nowrap; }
|
||||
.cmp-diff { margin-top: 6px; font-size: .8rem; font-weight: 700; }
|
||||
.cmp-diff.up { color: var(--good); }
|
||||
.cmp-diff.down { color: var(--bad); }
|
||||
.legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: .76rem; color: var(--paper-muted); margin: -2px 0 10px; }
|
||||
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.legend i { width: 10px; height: 10px; border-radius: 2px; background: var(--bar); }
|
||||
.legend i.cb { background: var(--gold); }
|
||||
|
||||
.tape-note { font-size: .76rem; color: var(--paper-muted); margin-top: 10px; line-height: 1.5; }
|
||||
|
||||
.tape-details summary {
|
||||
list-style: none; display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
cursor: pointer; min-height: 44px; margin: -8px 0;
|
||||
}
|
||||
.tape-details summary::-webkit-details-marker { display: none; }
|
||||
.tape-details summary h3 { margin: 0; display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.tape-details summary .chev { transition: transform .2s var(--ease-out); color: var(--paper-muted); }
|
||||
.tape-details[open] summary .chev { transform: rotate(180deg); }
|
||||
.tape-details[open] summary { margin-bottom: 8px; }
|
||||
.tape-details summary:hover h3 { text-decoration: underline; text-underline-offset: .25em; }
|
||||
|
||||
.calc-block + .calc-block { margin-top: 14px; }
|
||||
.calc-block h4 { font-family: var(--font-ui); font-size: .85rem; font-weight: 750; margin: 0 0 4px; color: var(--paper-accent); }
|
||||
|
||||
.tape-foot { padding-top: 14px; text-align: center; color: var(--paper-muted); font-size: .76rem; display: grid; gap: 2px; }
|
||||
|
||||
/* mini display (phones, when the main display scrolled away) */
|
||||
.mini-display {
|
||||
position: fixed; left: 12px; right: 12px; bottom: max(12px, env(safe-area-inset-bottom)); z-index: 20;
|
||||
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
|
||||
padding: 10px 14px; border-radius: 14px; background: var(--display); color: var(--display-ink);
|
||||
box-shadow: 0 12px 30px -10px rgba(0,0,0,.55);
|
||||
transform: translateY(140%); transition: transform .25s var(--ease-out);
|
||||
}
|
||||
.mini-display.is-shown { transform: none; }
|
||||
.mini-label { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--display-dim); font-weight: 650; }
|
||||
.mini-value { font-weight: 800; font-size: 1.2rem; font-stretch: 82%; }
|
||||
.mini-equiv { margin-left: auto; font-size: .85rem; color: var(--display-dim); font-weight: 600; }
|
||||
@media (min-width: 900px) { .mini-display { display: none; } }
|
||||
@media (max-width: 899px) { .page-calc { padding-bottom: 76px; } }
|
||||
|
||||
/* error page */
|
||||
.desk-error { display: block; }
|
||||
.panel-error {
|
||||
display: flex; gap: 14px; max-width: 640px; margin: 40px auto; padding: 20px; border-radius: var(--radius-m);
|
||||
background: var(--warn-bg); color: #3b2600; border: 1px solid #e7c271;
|
||||
}
|
||||
.panel-error .icon { width: 24px; height: 24px; color: var(--warn); margin-top: 2px; }
|
||||
.panel-error h1 { font-size: 1.15rem; margin-bottom: 6px; }
|
||||
|
||||
/* ---- print: only the tape ---- */
|
||||
@media print {
|
||||
@page { margin: 12mm; }
|
||||
body { background: #fff; }
|
||||
.topbar, .machine, .tape-slot, .mini-display, .skip-link { display: none !important; }
|
||||
.desk { display: block; padding: 0; }
|
||||
.tape-wrap { max-width: none; }
|
||||
.tape { margin: 0; box-shadow: none; -webkit-mask: none; mask: none; background: #fff; color: #000; padding: 0; font-size: 10.5pt; }
|
||||
.tape-details summary .chev { display: none; }
|
||||
.tape-logo .logo { height: 12mm; }
|
||||
.cmp-row { break-inside: avoid; }
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/* SERUBI base: tokens, fonts, reset, shared controls. */
|
||||
@font-face {
|
||||
font-family: "Archivo";
|
||||
src: url("../fonts/archivo-var.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
font-stretch: 62% 125%;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
src: url("../fonts/jetbrains-mono-var.woff2") format("woff2");
|
||||
font-weight: 400 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--font-ui: "Archivo", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-tape: "JetBrains Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
|
||||
/* desk (page ground) */
|
||||
--desk: #e7ebf2;
|
||||
--desk-2: #dde3ec;
|
||||
--desk-ink: #14223b;
|
||||
--desk-muted: #4b576c;
|
||||
--desk-line: #c6cfdc;
|
||||
|
||||
/* machine body */
|
||||
--machine: #0b2e66;
|
||||
--machine-hi: #154290;
|
||||
--machine-lo: #072153;
|
||||
--machine-ink: #eef3fb;
|
||||
--machine-muted: #aebfdb;
|
||||
--machine-line: rgba(174, 191, 219, .22);
|
||||
--key: #143c7c;
|
||||
--key-hi: #1c4a93;
|
||||
--key-edge: #061a40;
|
||||
--key-ink: #e9f0fc;
|
||||
|
||||
/* gold (Bank Papua accent) */
|
||||
--gold: #f2c14e;
|
||||
--gold-hi: #f8d377;
|
||||
--gold-edge: #a87b14;
|
||||
--gold-ink: #241800;
|
||||
|
||||
/* display */
|
||||
--display: #04132f;
|
||||
--display-ink: #ffe6a0;
|
||||
--display-dim: #c9b98c;
|
||||
--display-line: rgba(255, 230, 160, .16);
|
||||
|
||||
/* paper */
|
||||
--paper: #fffef8;
|
||||
--paper-ink: #1a2130;
|
||||
--paper-muted: #545d70;
|
||||
--paper-rule: #c3c7cf;
|
||||
--paper-accent: #0b2e66;
|
||||
--paper-hilite: #fff2c4;
|
||||
--bar: #0b2e66;
|
||||
--bar-dep: #9aa3b3;
|
||||
|
||||
/* states */
|
||||
--good: #0a7140;
|
||||
--bad: #b42318;
|
||||
--warn: #7a5000;
|
||||
--warn-bg: #fff3d6;
|
||||
--focus: #f2c14e;
|
||||
--focus-on-light: #0b57d0;
|
||||
|
||||
--radius-s: 8px;
|
||||
--radius-m: 12px;
|
||||
--radius-l: 22px;
|
||||
--ease-out: cubic-bezier(.16, 1, .3, 1);
|
||||
--shadow-machine: 0 1px 0 rgba(255,255,255,.14) inset, 0 -2px 0 rgba(0,0,0,.25) inset, 0 28px 50px -24px rgba(4, 18, 48, .6), 0 3px 8px rgba(4, 18, 48, .18);
|
||||
--shadow-paper: 0 18px 30px -18px rgba(20, 34, 59, .45), 0 2px 4px rgba(20, 34, 59, .08);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--desk: #070c17;
|
||||
--desk-2: #0c1424;
|
||||
--desk-ink: #e4e9f3;
|
||||
--desk-muted: #9ba7bc;
|
||||
--desk-line: #1f2a40;
|
||||
--machine: #0c2a5c;
|
||||
--machine-hi: #12387a;
|
||||
--machine-lo: #071a40;
|
||||
--key: #11346d;
|
||||
--key-hi: #184083;
|
||||
--display: #020a1c;
|
||||
--paper: #ebe7db;
|
||||
--paper-ink: #191f2b;
|
||||
--paper-muted: #4c5464;
|
||||
--paper-rule: #b4b1a6;
|
||||
--paper-hilite: #f4e3ad;
|
||||
--shadow-machine: 0 1px 0 rgba(255,255,255,.08) inset, 0 -2px 0 rgba(0,0,0,.3) inset, 0 28px 50px -24px rgba(0,0,0,.8), 0 3px 8px rgba(0,0,0,.4);
|
||||
--shadow-paper: 0 18px 30px -18px rgba(0,0,0,.7), 0 2px 4px rgba(0,0,0,.3);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--desk: #070c17;
|
||||
--desk-2: #0c1424;
|
||||
--desk-ink: #e4e9f3;
|
||||
--desk-muted: #9ba7bc;
|
||||
--desk-line: #1f2a40;
|
||||
--machine: #0c2a5c;
|
||||
--machine-hi: #12387a;
|
||||
--machine-lo: #071a40;
|
||||
--key: #11346d;
|
||||
--key-hi: #184083;
|
||||
--display: #020a1c;
|
||||
--paper: #ebe7db;
|
||||
--paper-ink: #191f2b;
|
||||
--paper-muted: #4c5464;
|
||||
--paper-rule: #b4b1a6;
|
||||
--paper-hilite: #f4e3ad;
|
||||
--shadow-machine: 0 1px 0 rgba(255,255,255,.08) inset, 0 -2px 0 rgba(0,0,0,.3) inset, 0 28px 50px -24px rgba(0,0,0,.8), 0 3px 8px rgba(0,0,0,.4);
|
||||
--shadow-paper: 0 18px 30px -18px rgba(0,0,0,.7), 0 2px 4px rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: var(--desk);
|
||||
color: var(--desk-ink);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
font-variant-numeric: tabular-nums;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
h1, h2, h3 { line-height: 1.2; text-wrap: balance; margin: 0; }
|
||||
p { margin: 0; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; }
|
||||
a { color: inherit; text-underline-offset: .2em; }
|
||||
::selection { background: var(--gold); color: var(--gold-ink); }
|
||||
:focus-visible { outline: 3px solid var(--focus-on-light); outline-offset: 2px; border-radius: 4px; }
|
||||
code { font-family: var(--font-tape); font-size: .9em; }
|
||||
|
||||
.icon { width: 1.15em; height: 1.15em; flex: none; }
|
||||
|
||||
.skip-link {
|
||||
position: absolute; left: 12px; top: -60px; z-index: 50;
|
||||
background: var(--gold); color: var(--gold-ink); padding: 10px 14px; border-radius: var(--radius-s);
|
||||
font-weight: 700; text-decoration: none;
|
||||
}
|
||||
.skip-link:focus { top: 12px; }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* ---- Bank Papua logo ---- */
|
||||
.logo-wrap { display: inline-flex; line-height: 0; }
|
||||
.logo { display: block; height: 36px; width: auto; max-width: 100%; }
|
||||
.logo-auto .logo-white { display: none; }
|
||||
:root[data-theme="dark"] .logo-auto .logo-color { display: none; }
|
||||
:root[data-theme="dark"] .logo-auto .logo-white { display: block; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .logo-auto .logo-color { display: none; }
|
||||
:root:not([data-theme="light"]) .logo-auto .logo-white { display: block; }
|
||||
}
|
||||
@media (max-width: 419px) { .topbar .logo { height: 29px; } .topbar .brand { gap: 4px 8px; } }
|
||||
|
||||
/* ---- top bar ---- */
|
||||
.topbar {
|
||||
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px;
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
padding: max(14px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) 14px max(16px, env(safe-area-inset-left));
|
||||
}
|
||||
.brand { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; text-decoration: none; min-height: 44px; align-items: center; }
|
||||
.brand-prog {
|
||||
font-weight: 700; font-size: .78rem; letter-spacing: .14em;
|
||||
padding: 3px 8px 2px; border-radius: 5px; background: var(--gold); color: var(--gold-ink);
|
||||
}
|
||||
.topbar-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||||
@media (max-width: 479px) { .topbar-actions .text-btn span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .topbar-actions .text-btn { padding: 0; } }
|
||||
.icon-btn, .text-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
min-width: 44px; min-height: 44px; padding: 0 12px;
|
||||
border: 1px solid var(--desk-line); background: transparent; color: var(--desk-ink);
|
||||
border-radius: 10px; text-decoration: none; font-weight: 600; font-size: .92rem;
|
||||
transition: background-color .18s, border-color .18s;
|
||||
}
|
||||
.icon-btn { padding: 0; }
|
||||
.icon-btn:hover, .text-btn:hover { background: var(--desk-2); border-color: var(--desk-muted); }
|
||||
.icon-sun { display: none; }
|
||||
:root[data-theme="dark"] .icon-sun { display: block; }
|
||||
:root[data-theme="dark"] .icon-moon { display: none; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .icon-sun { display: block; }
|
||||
:root:not([data-theme="light"]) .icon-moon { display: none; }
|
||||
}
|
||||
|
||||
/* ---- keys (machine buttons) ---- */
|
||||
.key {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
min-height: 48px; padding: 0 16px; border-radius: 10px;
|
||||
border: 0; box-shadow: inset 0 -3px 0 var(--key-edge);
|
||||
background: var(--key); color: var(--key-ink); font-weight: 650; font-size: .95rem;
|
||||
transition: background-color .15s, transform .08s, box-shadow .08s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.key:hover { background: var(--key-hi); }
|
||||
.key:active { transform: translateY(2px); box-shadow: inset 0 -1px 0 var(--key-edge); }
|
||||
.key-gold { background: var(--gold); color: var(--gold-ink); box-shadow: inset 0 -3px 0 var(--gold-edge); }
|
||||
.key-gold:active { box-shadow: inset 0 -1px 0 var(--gold-edge); }
|
||||
.key-gold:hover { background: var(--gold-hi); }
|
||||
.key:disabled { opacity: .55; cursor: not-allowed; transform: none; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#0b2e66"/><rect x="8" y="6" width="16" height="20" rx="2.5" fill="none" stroke="#f2c14e" stroke-width="2"/><rect x="11" y="9" width="10" height="4" rx="1" fill="#f2c14e"/><g fill="#eef3fb"><circle cx="12" cy="17" r="1.3"/><circle cx="16" cy="17" r="1.3"/><circle cx="20" cy="17" r="1.3"/><circle cx="12" cy="21.5" r="1.3"/><circle cx="16" cy="21.5" r="1.3"/><circle cx="20" cy="21.5" r="1.3"/></g></svg>
|
||||
|
After Width: | Height: | Size: 506 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 */ }); });
|
||||
}
|
||||
})();
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
@@ -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
@@ -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 });
|
||||
})();
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in new issue
Block a user