Google Apps Script
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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")