
Exception: Malformed HTML content: window.__JS_OK = true;
const S = { me:'', clients:[], admins:[], stats:null, view:'dash', filter:'All', q:'', cal:new Date(), chart:null };
const $ = s => document.querySelector(s);
const esc = v => String(v == null ? '' : v).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const iso = d => d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');
const TODAY = iso(new Date());
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const ICON = {
edit:'<svg viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z"/></svg>',
del:'<svg viewBox="0 0 24 24"><path d="M5 7h14M10 11v6M14 11v6M7 7l1 13h8l1-13M9 7V4h6v3"/></svg>',
plus:'<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
user:'<svg viewBox="0 0 24 24"><circle cx="12" cy="9" r="3.2"/><path d="M5.5 19c.6-3 3-4.5 6.5-4.5s5.9 1.5 6.5 4.5"/></svg>',
spark:'<svg viewBox="0 0 24 24"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/></svg>',
ops:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/></svg>',
alert:'<svg viewBox="0 0 24 24"><path d="M12 4l9 16H3z"/><path d="M12 10v4M12 17.5v.01"/></svg>',
stop:'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><path d="M6.5 6.5l11 11"/></svg>',
chev:(d)=>`<svg viewBox="0 0 24 24"><path d="${d}"/></svg>`
};
/* ---------- server calls ---------- */
function call(fn, ...args) {
document.body.classList.add('loading');
const a = fn === 'login' ? args : [S.token, ...args]; // semua API selain login membawa token sesi
return new Promise((res, rej) => google.script.run
.withSuccessHandler(r => { document.body.classList.remove('loading'); res(r); })
.withFailureHandler(e => {
document.body.classList.remove('loading');
const m = String(e.message || e).replace(/^Error:\s*/, '');
if (m.includes('SESSION_EXPIRED')) showLogin('Sesi berakhir, silakan masuk lagi.'); else toast(m, true);
rej(e);
})[fn](...a));
}
function apply(d) { Object.assign(S, d); renderAll(); }
function toast(msg, err) {
const t = $('#toast'); t.textContent = msg; t.className = 'toast show' + (err ? ' err' : '');
clearTimeout(toast.t); toast.t = setTimeout(() => t.className = 'toast', 2800);
}
/* ---------- derived ---------- */
const filtered = () => S.clients.filter(c => S.filter === 'All' || c.status === S.filter);
const countBy = st => S.clients.filter(c => c.status === st).length;
const dueMap = () => { // yyyy-mm-dd -> clients (respects pill filter, hides terminated under "All")
const m = {};
filtered().filter(c => c.status !== 'Terminated' || S.filter === 'Terminated').forEach(c => { if (c.nextDue) (m[c.nextDue] = m[c.nextDue] || []).push(c); });
return m;
};
/* ---------- render ---------- */
function renderAll() {
const t = S.me.split('@')[0] || '?';
$('#avatar').textContent = t[0].toUpperCase(); $('#avatar').title = S.me;
renderHead(); renderPills();
if (S.view === 'dash') { renderCards(); renderCalendar(); renderUpcoming(); renderChart(); } // only render what is visible
else if (S.view === 'clients') renderClients(); else renderAdmins();
}
function renderHead() {
const v = S.view, h = $('#title'), b = $('#btnAdd');
h.innerHTML = v === 'dash' ? `Managing <span class="ico">${ICON.user}</span> Your Clients and <span class="ico l">${ICON.spark}</span> Billing`
: v === 'clients' ? 'All Clients' : 'Admin Team';
b.innerHTML = ICON.plus + (v === 'admins' ? 'Add Admin' : 'Add Client');
$('#pills').hidden = v === 'admins';
['dash','clients','admins'].forEach(x => $('#v-' + x).hidden = x !== v);
document.querySelectorAll('.nav').forEach(n => n.classList.toggle('on', n.dataset.view === v));
}
function renderPills() {
$('#pills').innerHTML = ['All','Active','Unpaid','Terminated'].map(f =>
`<button class="pill ${S.filter===f?'on':''}" role="tab" data-f="${f}">${f}<small>${f==='All'?S.clients.length:countBy(f)}</small></button>`).join('');
}
function segs(ratio) {
const on = Math.round(ratio * 8);
return '<div class="segs">' + Array.from({length:8}, (_, i) => `<i class="${i < on ? '' : 'e'}"></i>`).join('') + '</div>';
}
function renderCards() {
const n = S.clients.length || 1, a = countBy('Active'), u = countBy('Unpaid'), t = countBy('Terminated');
const pct = x => Math.round(x / n * 100);
const card = (cls, ico, label, val, pc) => `<div class="card ${cls}"> <div class="card-top"><span class="chip-ico">${ico}</span>${label}</div> <div class="big"><b>${val}</b><span>/ ${S.clients.length}</span><em class="badge" style="font-style:normal">${pc}%</em></div>${segs(val / n)}</div>`;
const dark = `<div class="card k"> <div class="card-top"><span class="chip-ico">${ICON.stop}</span>Terminated Clients</div> <div class="big"><b>${t}</b><span>/ ${S.clients.length}</span><em class="badge" style="font-style:normal">${pct(t)}%</em></div> <button class="btn" data-f="Terminated">View list</button></div>`;
$('#cards').innerHTML = card('w', ICON.ops, 'Active Clients', a, pct(a)) + card('y', ICON.alert, 'Unpaid Clients', u, pct(u)) + dark;
}
/* calendar */
function renderCalendar() {
const y = S.cal.getFullYear(), m = S.cal.getMonth(), map = dueMap();
const first = new Date(y, m, 1).getDay(), days = new Date(y, m + 1, 0).getDate();
let h = `<div class="cal-head"><button data-cal="-1" aria-label="Previous month">${ICON.chev('M15 5l-7 7 7 7')}</button> <b>${S.cal.toLocaleString('en',{month:'long'})} ${y}</b><button data-cal="1" aria-label="Next month">${ICON.chev('M9 5l7 7-7 7')}</button></div><div class="grid7">`;
h += ['S','M','T','W','T','F','S'].map(d => `<div class="dn">${d}</div>`).join('') + '<span></span>'.repeat(first);
for (let d = 1; d <= days; d++) {
const k = y + '-' + String(m+1).padStart(2,'0') + '-' + String(d).padStart(2,'0'), list = map[k];
h += list ? `<button class="day has ${k<TODAY?'late':''} ${k===TODAY?'today':''}" data-day="${k}" data-n="${list.length}">${d}</button>`
: `<button class="day ${k===TODAY?'today':''}" disabled>${d}</button>`;
}
$('#cal').innerHTML = h + '</div>';
}
function renderUpcoming() {
const rows = Object.entries(dueMap()).filter(([k]) => k >= TODAY).sort().slice(0, 5);
$('#upcoming').innerHTML = '<div class="up-title">Upcoming due dates</div>' + (rows.length ? rows.map(([k, l]) => {
const d = new Date(k + 'T00:00:00');
return `<div class="up"><div class="d"><b>${d.getDate()}</b>${MONTHS[d.getMonth()]}</div><div><strong>${esc(l[0].name)}${l.length>1 ? ' +' + (l.length-1) : ''}</strong><span>${esc(l[0].service||'—')}</span></div></div>`;
}).join('') : '<div class="empty">No upcoming payments for this filter.</div>');
}
/* chart – overlapping rounded pills: black = expected, lime = paid */
const pillDots = { id:'pillDots', afterDatasetsDraw(c) {
const x = c.ctx; [0, 1].forEach(di => c.getDatasetMeta(di).data.forEach((b, i) => {
if (!c.data.datasets[di].data[i]) return;
x.save(); x.beginPath(); x.fillStyle = di ? '#16181A' : '#fff';
x.arc(b.x, b.y + 16, 5, 0, 7); x.fill(); x.restore();
}));
}};
function loadChart() { // Chart.js loads lazily, after first paint
return window.Chart ? Promise.resolve() : new Promise((ok, no) => { const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js'; s.onload = ok; s.onerror = no; document.head.appendChild(s); });
}
function renderChart() {
if (!S.stats || S.view !== 'dash') return;
if (!window.Chart) { loadChart().then(renderChart).catch(() => { $('.chart-wrap').innerHTML = '<div class="empty">Grafik gagal dimuat.</div>'; }); return; }
const sel = $('#yearSel'), y0 = new Date().getFullYear();
if (!sel.options.length) { for (let y = y0 - 3; y <= y0 + 1; y++) sel.add(new Option(y, y)); }
sel.value = S.stats.year;
const paid = S.stats.months.map(m => m.paid), exp = S.stats.months.map(m => m.paid + m.due);
const ds = (data, color) => ({ data, backgroundColor: color, borderRadius: 999, borderSkipped: false, grouped: false, barPercentage: .5, categoryPercentage: .9 });
const cfg = { type:'bar', data:{ labels: MONTHS, datasets:[ ds(exp,'#16181A'), ds(paid,'#EBF98F') ] }, plugins:[pillDots],
options:{ responsive:true, maintainAspectRatio:false, animation:{duration:600},
plugins:{ legend:{display:false}, tooltip:{ backgroundColor:'#16181A', cornerRadius:12, padding:10,
callbacks:{ label:c => (c.datasetIndex ? 'Paid: ' : 'Expected: ') + c.parsed.y } } },
scales:{ x:{ grid:{display:false}, border:{display:false}, ticks:{color:'#8A8F8A'} },
y:{ beginAtZero:true, grid:{color:'#DADDD7'}, border:{display:false}, ticks:{color:'#8A8F8A', precision:0} } } } };
if (S.chart) { S.chart.data = cfg.data; S.chart.update(); } else S.chart = new Chart($('#chart'), cfg);
}
/* tables */
function renderClients() {
const q = S.q.toLowerCase(), list = filtered().filter(c => !q || [c.name,c.phone,c.email,c.service].join(' ').toLowerCase().includes(q));
$('#count').textContent = list.length + ' clients';
$('#tbl').innerHTML = '<thead><tr><th>Client</th><th>Service</th><th>Cycle</th><th>Last paid</th><th>Next due</th><th>Status</th><th></th></tr></thead><tbody>' +
(list.map(c => `<tr><td>${esc(c.name)}<small>${esc(c.phone)} ${c.email?'· '+esc(c.email):''}</small></td><td>${esc(c.service)}</td> <td>${c.cycle} mo</td><td>${esc(c.lastPaid)||'—'}</td><td>${esc(c.nextDue)||'—'}</td><td><span class="st ${c.status}">${c.status}</span></td> <td><div class="act"><button data-edit="${esc(c.id)}" aria-label="Edit">${ICON.edit}</button><button data-del="${esc(c.id)}" aria-label="Delete">${ICON.del}</button></div></td></tr>`).join('')
|| '<tr><td colspan="7" class="empty">No clients match. Use “Add Client” to create one.</td></tr>') + '</tbody>';
}
function renderAdmins() {
$('#adm').innerHTML = '<thead><tr><th>Username</th><th>Email</th><th>Role</th><th></th></tr></thead><tbody>' + S.admins.map(a => {
const self = a.username.toLowerCase() === S.me.toLowerCase();
const delBtn = self ? '' : '<button data-adel="' + esc(a.username) + '" aria-label="Delete">' + ICON.del + '</button>';
return `<tr><td>${esc(a.username)}${self?'<small>You</small>':''}</td><td>${esc(a.email)||'—'}</td><td>${esc(a.role)}</td><td><div class="act"> <button data-aedit="${esc(a.username)}" aria-label="Edit">${ICON.edit}</button> ${delBtn}</div></td></tr>`;
}).join('') + '</tbody>';
}
/* ---------- modal ---------- */
function openModal(html) { $('#modalBody').innerHTML = html; $('#overlay').classList.add('open'); $('#overlay').setAttribute('aria-hidden','false');
const f = $('#modalBody input,#modalBody button'); f && f.focus(); }
function closeModal() { $('#overlay').classList.remove('open'); $('#overlay').setAttribute('aria-hidden','true'); }
function dayModal(k) {
const l = dueMap()[k] || [], d = new Date(k + 'T00:00:00');
openModal(`<h3>Due ${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}</h3>` + l.map(c =>
`<div class="up"><div class="d">${ICON.user.replace('<svg','<svg width="20" height="20"')}</div><div style="flex:1"><strong>${esc(c.name)}</strong> <span>${esc(c.service||'—')} · ${esc(c.phone)}</span></div><span class="st ${c.status}">${c.status}</span></div>`).join('') +
`<div class="m-actions"><button class="btn ghost" data-close>Close</button></div>`);
}
function clientModal(c) {
c = c || { cycle:1, status:'Active', lastPaid:TODAY };
const opt = (arr, v, fmt) => arr.map(x => `<option value="${x}" ${x==v?'selected':''}>${fmt?fmt(x):x}</option>`).join('');
openModal(`<h3>${c.id?'Edit client':'Add client'}</h3><div class="f" id="cf"> <label class="full">Name<input id="f-name" value="${esc(c.name)}" required></label> <label>Phone<input id="f-phone" inputmode="tel" value="${esc(c.phone)}"></label> <label>Email<input id="f-email" type="email" value="${esc(c.email)}"></label> <label class="full">Service type<input id="f-service" value="${esc(c.service)}"></label> <label>Billing cycle<select id="f-cycle">${opt([1,2,3,6,12], c.cycle, x => x + (x===1?' month':' months'))}</select></label> <label>Status<select id="f-status">${opt(['Active','Unpaid','Terminated'], c.status)}</select></label> <label>Last paid<input id="f-last" type="date" value="${esc(c.lastPaid)}"></label> <label>Next due (auto)<input id="f-next" readonly value="${esc(c.nextDue)}"></label></div> <div class="m-actions"><button class="btn ghost" data-close>Cancel</button><button class="btn lime" id="saveClient" data-id="${esc(c.id||'')}">Save client</button></div>`);
const calc = () => { // same clamping rule as Code.gs addMonths_
const v = $('#f-last').value; if (!v) return $('#f-next').value = '';
const [y, m, d] = v.split('-').map(Number), n = +$('#f-cycle').value;
const r = new Date(y, m - 1 + n, 1); r.setDate(Math.min(d, new Date(r.getFullYear(), r.getMonth() + 1, 0).getDate()));
$('#f-next').value = iso(r);
};
$('#f-last').oninput = $('#f-cycle').onchange = calc; calc();
}
function adminModal(username) {
const a = username ? S.admins.find(x => x.username === username) : null;
openModal(`<h3>${a?'Edit admin':'Add admin'}</h3><div class="f"> <label class="full">Username<input id="f-au" autocomplete="off" value="${esc(a?a.username:'')}" ${a?'readonly':''}></label> <label class="full">Email (opsional)<input id="f-ae" type="email" value="${esc(a?a.email:'')}"></label> <label class="full">${a?'Password baru (kosongkan jika tidak diganti)':'Password (min. 8 karakter)'}<input id="f-ap" type="password" autocomplete="new-password"></label></div> <div class="m-actions"><button class="btn ghost" data-close>Cancel</button><button class="btn lime" id="saveAdmin" data-new="${a?'':'1'}">Save admin</button></div>`);
}
/* ---------- events (delegated) ---------- */
document.addEventListener('click', async e => {
const t = e.target.closest('button'); if (!t) { if (e.target.id === 'overlay') closeModal(); return; }
const D = t.dataset;
try {
if (D.view) { S.view = D.view; renderAll(); window.scrollTo(0, 0); }
else if (D.f) { S.filter = D.f; if (D.f==='Terminated' && t.classList.contains('btn')) S.view = 'clients'; renderAll(); }
else if (D.cal) { S.cal = new Date(S.cal.getFullYear(), S.cal.getMonth() + +D.cal, 1); renderCalendar(); }
else if (D.day) dayModal(D.day);
else if (D.edit) clientModal(S.clients.find(c => c.id === D.edit));
else if (D.del) { if (confirm('Delete this client permanently?')) { apply(await call('deleteClient', D.del)); toast('Client deleted'); } }
else if (D.aedit) adminModal(D.aedit);
else if (D.adel) { if (confirm('Remove this admin?')) { apply(await call('deleteAdmin', D.adel)); toast('Admin removed'); } }
else if (D.close !== undefined) closeModal();
else if (t.id === 'btnAdd') S.view === 'admins' ? adminModal() : clientModal();
else if (t.id === 'btnLogout') { google.script.run.logout(S.token); showLogin(); }
else if (t.id === 'btnRefresh') { apply(await call('getData')); toast('Refreshed'); }
else if (t.id === 'saveClient') {
const name = $('#f-name').value.trim(); if (!name) return toast('Name is required', true);
apply(await call('saveClient', { id:D.id, name, phone:$('#f-phone').value, email:$('#f-email').value, service:$('#f-service').value,
cycle:+$('#f-cycle').value, status:$('#f-status').value, lastPaid:$('#f-last').value }));
closeModal(); toast('Client saved');
}
else if (t.id === 'saveAdmin') { apply(await call('saveAdmin', { username:$('#f-au').value, email:$('#f-ae').value, password:$('#f-ap').value, isNew:!!D.new })); closeModal(); toast('Admin saved'); }
} catch (_) { /* toast already shown by call() */ }
});
let qt; $('#q').addEventListener('input', e => { clearTimeout(qt); const v = e.target.value; qt = setTimeout(() => { S.q = v; renderClients(); }, 150); });
$('#yearSel').addEventListener('change', async e => { const s = await call('getStats', +e.target.value); S.stats = s; renderChart(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
/* ---------- boot ---------- */
['gesturestart', 'gesturechange', 'gestureend'].forEach(g => document.addEventListener(g, e => e.preventDefault())); // iOS pinch-zoom
const store = {
get: () => { try { return localStorage.getItem('cbm_t') || ''; } catch (_) { return ''; } },
set: v => { try { v ? localStorage.setItem('cbm_t', v) : localStorage.removeItem('cbm_t'); } catch (_) {} }
};
const bootFail = m => { $('#boot').hidden = false; $('#boot').innerHTML = esc(typeof m === 'string' ? m : 'Data gagal dimuat.') + ' <button class="btn dark" onclick="location.reload()">Coba lagi</button>'; };
function showLogin(msg) {
S.token = ''; store.set(''); $('.shell').hidden = true; $('#login').hidden = false;
$('#lg-err').textContent = msg || ''; $('#btnLogin').disabled = false; setTimeout(() => $('#lg-u').focus(), 50);
}
function showApp() { $('#login').hidden = true; $('.shell').hidden = false; }
function loadApp() {
showApp(); S.loaded = false; $('#boot').hidden = false; $('#boot').textContent = 'Mengambil data dari server… (v4)';
if (typeof google === 'undefined' || !google.script) return bootFail('google.script.run tidak tersedia di halaman ini.');
call('getData').then(d => { S.loaded = true; $('#boot').hidden = true; apply(d); }).catch(() => { if (!$('.shell').hidden) bootFail(); });
setTimeout(() => { if (!S.loaded && !$('.shell').hidden) bootFail('Server tidak merespons.'); }, 15000);
}
function doLogin() {
const u = $('#lg-u').value.trim(), p = $('#lg-p').value;
if (!u || !p) { $('#lg-err').textContent = 'Isi username dan password.'; return; }
$('#btnLogin').disabled = true; $('#lg-err').textContent = '';
google.script.run
.withSuccessHandler(r => { S.token = r.token; store.set(r.token); $('#lg-p').value = ''; loadApp(); })
.withFailureHandler(e => { $('#btnLogin').disabled = false; $('#lg-err').textContent = String(e.message || e).replace(/^Error:\s*/, ''); })
.login(u, p);
}
$('#btnLogin').addEventListener('click', doLogin);
['#lg-u', '#lg-p'].forEach(q => $(q).addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); }));
S.token = store.get();
S.token ? loadApp() : showLogin();. (line 18, file "Code")