LIVE
V
VWP CRM Database
Sistem Manajemen Database B2B
Masuk
Masukkan kredensial untuk mengakses sistem
Saya bukan robot
Cloudflare
Turnstile
Menguji Koneksi
Memeriksa semua layanan...
Detail Kontak
Edit dan simpan perubahan
Tambah Kontak
Data disimpan ke Aiven PostgreSQL
Filter Data
Export Data
Import Data
Mendukung CSV dan Excel dengan format kolom apapun
📁
Tarik file di sini atau klik untuk memilih
.xlsx · .xls · .csv · Format kolom apapun · Maks 10 MB
Sistem akan mendeteksi otomatis kolom yang cocok dengan skema database. Kolom yang tidak cocok akan diabaikan. Data yang tidak lengkap tetap diimpor.
Verifikasi TOTP
Masukkan kode 6 digit dari authenticator
Kode berlaku 30 detik
Undang User
Link aktivasi dikirim via Brevo
Edit Profil
Perubahan email memerlukan verifikasi ulang
Jika email diubah, link verifikasi dikirim ke email baru.
Tambah Kolom Database
Field wajib diisi
Tampilkan di tabel
Tambah Field Ekstensi
Petakan ke kolom database
Oman","Pakistan","Palau","Palestine","Panama","Papua New Guinea","Paraguay","Peru","Philippines","Poland","Portugal","Qatar","Romania","Russia","Rwanda","Saint Kitts and Nevis","Saint Lucia","Saint Vincent and the Grenadines","Samoa","San Marino","Sao Tome and Principe","Saudi Arabia","Senegal","Serbia","Seychelles","Sierra Leone","Singapore","Slovakia","Slovenia","Solomon Islands","Somalia","South Africa","South Korea","South Sudan","Spain","Sri Lanka","Sudan","Suriname","Sweden","Switzerland","Syria","Taiwan","Tajikistan","Tanzania","Thailand","Timor-Leste","Togo","Tonga","Trinidad and Tobago","Tunisia","Turkey","Turkmenistan","Tuvalu","UAE","Uganda","Ukraine","United Kingdom","United States","Uruguay","Uzbekistan","Vanuatu","Vatican City","Venezuela","Vietnam","Yemen","Zambia","Zimbabwe"]; /* ══ CONFIG & STATE ══ */ const CFG = { workerUrl: localStorage.getItem('wkUrl') || '', workerSecret: localStorage.getItem('wkSec') || '' }; let G = { user: null, contacts: [], logs: [], users: [], schema: [], extFields: [], filtered: [], sortCol: 'tanggal', sortDir: 1, pg: 1, perPg: 10, activeFilter: {}, tsOk: false, totpCb: null, editId: null, apiKeys: {}, negCh: null, trendCh: null, usrCh: null, fontIdx: 1, branding: { name: 'VWP CRM Database', letter: 'V', accent: '#d97706' }, promptTpl: '', loginAttempts: 0, maxAttempts: 5, lockUntil: 0, tickerItems: [], importData: [], importMapping: {}, secSettings: { maxFail: 5, lockDur: 15 }, }; const FLAGS = { Japan:'🇯🇵','South Korea':'🇰🇷',Germany:'🇩🇪',Netherlands:'🇳🇱','United Kingdom':'🇬🇧',Australia:'🇦🇺','United States':'🇺🇸',France:'🇫🇷',UAE:'🇦🇪',Singapore:'🇸🇬',China:'🇨🇳',India:'🇮🇳',Belgium:'🇧🇪',Denmark:'🇩🇰',Finland:'🇫🇮',Sweden:'🇸🇪',Norway:'🇳🇴',Canada:'🇨🇦',Italy:'🇮🇹',Spain:'🇪🇸',Poland:'🇵🇱',Vietnam:'🇻🇳',Thailand:'🇹🇭',Malaysia:'🇲🇾',Taiwan:'🇹🇼','New Zealand':'🇳🇿',Brazil:'🇧🇷',Argentina:'🇦🇷',Mexico:'🇲🇽','Saudi Arabia':'🇸🇦',Qatar:'🇶🇦',Turkey:'🇹🇷',Indonesia:'🇮🇩',Philippines:'🇵🇭',Pakistan:'🇵🇰',Egypt:'🇪🇬',Nigeria:'🇳🇬',Kenya:'🇰🇪','South Africa':'🇿🇦',Russia:'🇷🇺',Ukraine:'🇺🇦' }; const CC = ['#f59e0b','#4f83f7','#10b981','#f43f5e','#818cf8','#fb923c','#34d399','#06b6d4','#e879f9','#fbbf24']; const DEFAULT_SCHEMA = [ {key:'tanggal',label:'Tanggal',type:'date',required:false,visible:true,system:true}, {key:'perusahaan',label:'Perusahaan',type:'text',required:true,visible:true,system:false,placeholder:'Nama perusahaan'}, {key:'negara',label:'Negara',type:'select',required:true,visible:true,system:false,options:['Japan','South Korea','Germany','Netherlands','United Kingdom','Australia','United States','France','UAE','Singapore','China','India','Belgium','Denmark','Finland','Sweden','Norway','Canada']}, {key:'nama',label:'Nama',type:'text',required:true,visible:true,system:false,placeholder:'Nama kontak'}, {key:'jabatan',label:'Jabatan',type:'text',required:false,visible:true,system:false,placeholder:'Posisi / jabatan'}, {key:'email',label:'Email',type:'email',required:false,visible:true,system:false,placeholder:'email@perusahaan.com'}, {key:'keterangan',label:'Keterangan',type:'textarea',required:false,visible:true,system:false,placeholder:'Catatan, status, rencana tindak lanjut...'}, ]; const BACKUPS_DEMO = [ {f:'vwp_backup_2025-03-05.json.enc',sz:'84 MB',ok:true,ts:'5 Mar 02:00'}, {f:'vwp_backup_2025-03-04.json.enc',sz:'83 MB',ok:true,ts:'4 Mar 02:00'}, {f:'vwp_backup_2025-03-03.json.enc',sz:'82 MB',ok:true,ts:'3 Mar 02:00'}, {f:'vwp_backup_2025-03-01.json.enc',sz:'--',ok:false,ts:'1 Mar 02:00'}, ]; /* ── local user store (fallback sebelum backend tersambung) ── */ const UL = { deki: { pw: 'Admin@2024!', role: 'admin', name: 'Deki Febri Mardi', email: 'info@vwoodpellet.com', evf: true, color: 'linear-gradient(135deg,#d97706,#f59e0b)' }, reza: { pw: 'User@2024!', role: 'user', name: 'Reza Firmansyah', email: 'reza@vwoodpellet.com', evf: true, color: 'linear-gradient(135deg,#10b981,#34d399)' }, sinta: { pw: 'Obs@2024!', role: 'observer', name: 'Sinta Rahma', email: 'sinta@example.com', evf: false, color: 'linear-gradient(135deg,#818cf8,#a78bfa)' }, }; /* ══ SECURITY: DOMPurify-like sanitizer ══ */ function sanitize(str) { if (typeof str !== 'string') return String(str || ''); return str.replace(/[<>'"&]/g, c => ({'<':'<','>':'>',"'":''','"':'"','&':'&'}[c])); } /* ══ API LAYER ══ */ async function api(method, path, body, opts = {}) { if (!CFG.workerUrl) return { _local: true }; const controller = new AbortController(); const tid = setTimeout(() => controller.abort(), opts.timeout || 8000); try { const r = await fetch(CFG.workerUrl + path, { method, headers: { 'Content-Type': 'application/json', 'X-API-Secret': CFG.workerSecret }, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); clearTimeout(tid); if (!r.ok) { const e = await r.json().catch(() => ({})); return { error: e.message || 'HTTP ' + r.status }; } return r.json(); } catch (e) { clearTimeout(tid); return { error: e.name === 'AbortError' ? 'Request timeout (>8 detik)' : e.message }; } } /* ══ TURNSTILE ══ */ let tsToken = null; function onTurnstileLoad() { // Turnstile JS berhasil load - widget render otomatis via data-sitekey // Jika sitekey masih placeholder, tampilkan fallback const sk = document.querySelector('.cf-turnstile')?.getAttribute('data-sitekey') || ''; if (sk.includes('PLACEHOLDER')) { showTSFallback(); } } function showTSFallback() { document.getElementById('ts-widget').style.display = 'none'; const fb = document.getElementById('ts-fallback'); fb.style.cssText = 'display:flex!important'; } function onTurnstileSuccess(token) { tsToken = token; document.getElementById('btn-lg').disabled = false; } function onTurnstileError() { notify('Turnstile verifikasi gagal. Coba muat ulang halaman.', 'er'); } function onTurnstileExpired() { tsToken = null; document.getElementById('btn-lg').disabled = true; notify('Verifikasi Turnstile kedaluwarsa. Silakan verifikasi ulang.', 'wn'); } function doTSFallback() { const btn = document.getElementById('ts-btn'); btn.innerHTML = '
'; setTimeout(() => { tsToken = 'fallback-' + Date.now(); btn.style.cssText = 'background:var(--ok);border-color:var(--ok)'; btn.innerHTML = ''; document.getElementById('btn-lg').disabled = false; }, 1300); } /* ══ AUTH ══ */ async function doLogin() { const now = Date.now(); if (G.lockUntil > now) { const rem = Math.ceil((G.lockUntil - now) / 1000); notify(`Akun dikunci. Coba lagi dalam ${rem} detik.`, 'er'); return; } const u = document.getElementById('l-u').value.trim(); const p = document.getElementById('l-p').value; const err = document.getElementById('lg-err'); if (!tsToken) { notify('Selesaikan verifikasi Turnstile terlebih dahulu', 'wn'); return; } if (!u || !p) { err.textContent = 'Username dan password wajib diisi.'; err.classList.remove('hidden'); return; } let user = null; if (CFG.workerUrl) { const r = await api('POST', '/auth/login', { username: sanitize(u), password: p, tsToken }); if (r && !r.error && !r._local) user = r.user; } if (!user && UL[u] && UL[u].pw === p) user = { username: u, ...UL[u] }; if (user) { G.user = { username: u, ...user }; G.loginAttempts = 0; err.classList.add('hidden'); document.getElementById('lg-attempts').classList.add('hidden'); const lp = document.getElementById('lg-page'); lp.classList.add('out'); setTimeout(() => { lp.style.display = 'none'; const app = document.getElementById('app'); app.classList.remove('hidden'); app.style.display = 'flex'; app.style.flexDirection = 'column'; initApp(); }, 450); pushLog('ok', 'Login', `${u} (${user.role || G.user.role}) login berhasil`); } else { G.loginAttempts++; const rem = G.secSettings.maxFail - G.loginAttempts; if (G.loginAttempts >= G.secSettings.maxFail) { G.lockUntil = Date.now() + G.secSettings.lockDur * 60000; G.loginAttempts = 0; pushLog('er', 'Login Dikunci', `${u} - melebihi ${G.secSettings.maxFail} percobaan gagal`); } err.textContent = rem > 0 ? `Username atau password salah. Sisa percobaan: ${rem}` : `Akun dikunci ${G.secSettings.lockDur} menit.`; err.classList.remove('hidden'); document.getElementById('l-p').value = ''; pushLog('er', 'Login Gagal', `Percobaan login dengan username: ${sanitize(u)}`); } } document.getElementById('l-p').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); }); document.getElementById('l-u').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('l-p').focus(); }); function doLogout() { if (!confirm('Yakin ingin keluar dari sesi ini?')) return; pushLog('ok', 'Logout', `${G.user.username} keluar`); G.user = null; tsToken = null; document.getElementById('app').style.display = 'none'; const lp = document.getElementById('lg-page'); lp.style.display = 'flex'; lp.classList.remove('out'); document.getElementById('l-u').value = ''; document.getElementById('l-p').value = ''; document.getElementById('btn-lg').disabled = true; document.getElementById('ts-btn').innerHTML = ''; document.getElementById('ts-btn').style.cssText = ''; tsToken = null; } /* ══ INIT ══ */ async function initApp() { loadSchema(); loadExtFields(); loadPTpl(); applyRole(); applyBrand(); buildNotifSettings(); buildBkList(); buildSchemaList(); buildExtFields(); buildExtAutoSave(); buildStorBars(); setupTickerObserver(); checkConn(); await loadData(); buildSettingsPf(); } async function loadData() { const r = await api('GET', '/contacts'); G.contacts = (r && !r.error && !r._local && Array.isArray(r.data)) ? r.data : getSampleData(); G.filtered = [...G.contacts]; updStats(); initCharts(); renderTbl(); loadLogs(); loadUsers(); } function getSampleData() { const now = new Date(); const iso = d => { const x = new Date(now - d * 86400000); return x.toISOString().slice(0, 10); }; return [ { id: '1', tanggal: iso(5), perusahaan: 'Sumitomo Forestry Co., Ltd.', negara: 'Japan', nama: 'Kenji Watanabe', jabatan: 'Head of Fuel Procurement', email: 'k.watanabe@sumitomo-f.co.jp', keterangan: 'Pernah transaksi kayu 2023. Prospek co-firing pabrik Osaka.', addedBy: 'deki' }, { id: '2', tanggal: iso(12), perusahaan: 'KEPCO (Kansai Electric Power)', negara: 'Japan', nama: 'Hiroshi Tanaka', jabatan: 'Energy Manager', email: 'h.tanaka@kepco.co.jp', keterangan: 'Program co-firing 2025. Butuh 5.000 MT/bulan.', addedBy: 'deki' }, { id: '3', tanggal: iso(18), perusahaan: 'POSCO Energy', negara: 'South Korea', nama: 'Kim Ji-Young', jabatan: 'Import Manager', email: 'jykim@posco-energy.co.kr', keterangan: '', addedBy: 'reza' }, { id: '4', tanggal: iso(22), perusahaan: 'E.ON SE', negara: 'Germany', nama: 'Stefan Müller', jabatan: 'Sustainability Manager', email: 's.mueller@eon.com', keterangan: 'Target ESG net zero 2025.', addedBy: 'deki' }, { id: '5', tanggal: iso(28), perusahaan: 'Eneco Nederland B.V.', negara: 'Netherlands', nama: 'Jan van der Berg', jabatan: 'Procurement Manager', email: 'j.vandenberg@eneco.nl', keterangan: '2x kontak via LinkedIn. Minta sample + COA.', addedBy: 'deki' }, { id: '6', tanggal: iso(35), perusahaan: 'Drax Group plc', negara: 'United Kingdom', nama: 'Thomas Clarke', jabatan: 'Fuel Procurement Director', email: 't.clarke@drax.com', keterangan: 'Largest biomass power UK. High potential.', addedBy: 'deki' }, { id: '7', tanggal: iso(40), perusahaan: 'Origin Energy', negara: 'Australia', nama: 'Sarah Johnson', jabatan: 'Energy Transition Manager', email: 's.johnson@originenergy.com.au', keterangan: '', addedBy: 'reza' }, { id: '8', tanggal: iso(45), perusahaan: 'DEWA', negara: 'UAE', nama: 'Ahmed Al-Rashidi', jabatan: 'Chief Procurement Officer', email: 'a.alrashidi@dewa.gov.ae', keterangan: 'Government entity. Proses tender wajib.', addedBy: 'deki' }, { id: '9', tanggal: iso(50), perusahaan: 'Senoko Energy', negara: 'Singapore', nama: 'Lim Wei Ming', jabatan: 'Plant Operations Manager', email: 'weiming.lim@senoko.com.sg', keterangan: '', addedBy: 'reza' }, { id: '10', tanggal: iso(55), perusahaan: 'SK E&S', negara: 'South Korea', nama: 'Choi Min-Jae', jabatan: 'Head of Fuel Procurement', email: 'mj.choi@skes.com', keterangan: 'Referral dari POSCO. Prioritas utama.', addedBy: 'deki' }, ]; } async function checkConn() { const dot = document.getElementById('cdot'); const lbl = document.getElementById('clbl'); if (!CFG.workerUrl) { dot.className = 'cdot err'; lbl.textContent = 'Worker belum diatur'; return; } const r = await api('GET', '/ping', null, { timeout: 5000 }); if (r && !r.error && !r._local) { dot.className = 'cdot ok'; lbl.textContent = 'Terhubung'; } else { dot.className = 'cdot err'; lbl.textContent = 'Tidak terhubung'; } } /* ══ CONNECTION ANIMATION ══ */ const CONN_STEPS = [ { id: 'cs-dns', ico: '🌐', label: 'DNS Resolusi', sub: 'Memeriksa worker domain' }, { id: 'cs-ping', ico: '📡', label: 'Ping Worker', sub: 'Cloudflare edge node' }, { id: 'cs-auth', ico: '🔐', label: 'Autentikasi', sub: 'Validasi API secret' }, { id: 'cs-db', ico: '🗄️', label: 'Koneksi Database', sub: 'Aiven PostgreSQL SSL' }, ]; async function openConnAnim() { openMo('mo-conn'); document.getElementById('conn-mo-title').textContent = 'Menguji Koneksi'; document.getElementById('conn-mo-sub').textContent = 'Memeriksa semua layanan...'; document.getElementById('conn-troubleshoot').classList.add('hidden'); document.getElementById('ts-result').classList.add('hidden'); const container = document.getElementById('conn-steps'); container.innerHTML = CONN_STEPS.map(s => `
${s.ico}
${s.label}
${s.sub}
--
`).join(''); let allOk = true; let failStep = null; for (const step of CONN_STEPS) { const el = document.getElementById(step.id); el.classList.add('active'); el.querySelector('.cs-status').textContent = '...'; await delay(600 + Math.random() * 400); const ok = await runConnStep(step.id); el.classList.remove('active'); if (ok) { el.classList.add('done'); el.querySelector('.cs-status').textContent = 'OK'; } else { el.classList.add('err'); el.querySelector('.cs-status').textContent = 'GAGAL'; allOk = false; failStep = step; break; } } if (allOk) { document.getElementById('conn-mo-title').textContent = 'Semua Layanan Aktif'; document.getElementById('conn-mo-sub').textContent = 'Koneksi database berhasil'; document.getElementById('cdot').className = 'cdot ok'; document.getElementById('clbl').textContent = 'Terhubung'; notify('Koneksi ke semua layanan berhasil', 'ok'); } else { document.getElementById('conn-mo-title').textContent = 'Koneksi Gagal'; document.getElementById('conn-mo-sub').textContent = `Gagal pada langkah: ${failStep.label}`; document.getElementById('cdot').className = 'cdot err'; document.getElementById('clbl').textContent = 'Tidak terhubung'; document.getElementById('conn-err-msg').textContent = `Gagal: ${failStep.label} - ${failStep.sub}`; document.getElementById('conn-troubleshoot').classList.remove('hidden'); } } async function runConnStep(id) { if (!CFG.workerUrl) return id === 'cs-dns' ? false : false; try { if (id === 'cs-dns') { await fetch(CFG.workerUrl + '/ping', { signal: AbortSignal.timeout(3000), method: 'HEAD' }); return true; } if (id === 'cs-ping') { const r = await api('GET', '/ping', null, { timeout: 4000 }); return !r.error && !r._local; } if (id === 'cs-auth') { const r = await api('GET', '/auth/check', null, { timeout: 4000 }); return !r.error; } if (id === 'cs-db') { const r = await api('GET', '/db/ping', null, { timeout: 5000 }); return !r.error; } } catch { return false; } return false; } async function runTroubleshoot() { const el = document.getElementById('ts-result'); el.classList.remove('hidden'); el.innerHTML = 'Mendiagnosa masalah...'; await delay(800); const checks = []; if (!CFG.workerUrl) checks.push('❌ Worker URL belum dikonfigurasi. Buka Settings > API & Koneksi.'); else { try { await fetch(CFG.workerUrl + '/ping', { signal: AbortSignal.timeout(3000) }); checks.push('✅ Worker URL dapat dijangkau'); } catch { checks.push('❌ Worker URL tidak dapat dijangkau. Periksa URL dan pastikan Worker sudah di-deploy.'); } if (!CFG.workerSecret) checks.push('⚠️ API Secret Key kosong. Isi di Settings > API & Koneksi.'); else checks.push('✅ API Secret Key tersedia'); } checks.push('ℹ️ Pastikan Cloudflare Worker sudah di-deploy dengan perintah: npx wrangler deploy'); checks.push('ℹ️ Pastikan semua Cloudflare Secrets sudah diisi (AIVEN_DB_URL, API_SECRET)'); el.innerHTML = checks.map(c => `
${c}
`).join(''); } const delay = ms => new Promise(r => setTimeout(r, ms)); /* ══ ROLE & BRAND ══ */ function applyRole() { const r = G.user.role; document.querySelectorAll('.ao').forEach(el => el.style.display = r === 'admin' ? '' : 'none'); const av = document.getElementById('nav-av'); av.textContent = G.user.name[0]; av.style.background = G.user.color || 'var(--grad)'; document.getElementById('nav-nm').textContent = G.user.name.split(' ')[0]; const rEl = document.getElementById('nav-role'); rEl.textContent = r === 'admin' ? 'Admin' : r === 'user' ? 'User' : 'Observer'; rEl.className = 'bdg bdg-' + (r === 'admin' ? 'a' : r === 'user' ? 'u' : 'o'); if (r !== 'observer') document.getElementById('btn-add').classList.remove('hidden'); if (!G.user.evf && r === 'admin') document.getElementById('verif-banner').style.display = 'flex'; if (r === 'admin') document.getElementById('ticker-wrap').classList.add('on'); } function applyBrand() { const b = G.branding; document.getElementById('pg-title').textContent = b.name; document.getElementById('nav-appname').textContent = b.name; document.getElementById('lg-brand-name').textContent = b.name; document.getElementById('nav-letter').textContent = b.letter || b.name[0]; document.getElementById('lg-em').textContent = b.letter || b.name[0]; if (b.accent) { document.documentElement.style.setProperty('--ac', b.accent); document.documentElement.style.setProperty('--acl', b.accent); } } function saveBrand() { const nm = document.getElementById('br-name').value.trim() || 'VWP CRM Database'; G.branding = { name: nm, letter: nm[0], accent: document.getElementById('ac-picker').value }; localStorage.setItem('brand', JSON.stringify(G.branding)); applyBrand(); notify('Branding disimpan', 'ok'); } function setAc(c) { G.branding.accent = c; document.documentElement.style.setProperty('--ac', c); document.documentElement.style.setProperty('--acl', c); document.getElementById('ac-picker').value = c; } function upLogo(inp) { const f = inp.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = e => { document.getElementById('lpv-def').classList.add('hidden'); const img = document.getElementById('lpv-img'); img.classList.remove('hidden'); img.src = e.target.result; document.getElementById('nav-logo-img').classList.remove('hidden'); document.getElementById('nav-logo-img').src = e.target.result; document.getElementById('nav-letter').classList.add('hidden'); }; rd.readAsDataURL(f); } function upFav(inp) { const f = inp.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = e => { document.getElementById('fav-link').href = e.target.result; notify('Favicon diperbarui', 'ok'); }; rd.readAsDataURL(f); } /* ══ NAVIGATION ══ */ function goPg(id, btn) { document.querySelectorAll('.pg').forEach(p => p.classList.remove('on')); document.querySelectorAll('.nl').forEach(b => b.classList.remove('on')); document.getElementById('pg-' + id).classList.add('on'); if (btn) btn.classList.add('on'); if (id === 'logs') loadLogs(); if (id === 'users') loadUsers(); } function goSp(id, btn) { document.querySelectorAll('.sp').forEach(p => p.classList.remove('on')); document.querySelectorAll('.sn').forEach(b => b.classList.remove('on')); document.getElementById(id).classList.add('on'); if (btn) btn.classList.add('on'); if (id === 'sp-ext') { buildExtFields(); buildExtAutoSave(); } if (id === 'sp-brand') { document.getElementById('br-name').value = G.branding.name; document.getElementById('ac-picker').value = G.branding.accent || '#d97706'; } } /* ══ THEME & FONT ══ */ function setTheme(t) { document.documentElement.classList.remove('dark', 'light'); document.documentElement.classList.add(t); document.body.classList.remove('dark', 'light'); document.body.classList.add(t); document.getElementById('btn-th').textContent = t === 'dark' ? 'D' : 'L'; if (G.negCh) { G.negCh.destroy(); G.trendCh.destroy(); G.usrCh.destroy(); G.negCh = null; G.trendCh = null; G.usrCh = null; initCharts(); } } function toggleTheme() { const cur = document.body.classList.contains('dark') ? 'dark' : 'light'; setTheme(cur === 'dark' ? 'light' : 'dark'); } function cycleFont() { G.fontIdx = (G.fontIdx + 1) % 3; document.documentElement.setAttribute('data-fs', ['sm', 'md', 'lg'][G.fontIdx]); } /* ══ ACTIVITY TICKER (admin live feed) ══ */ function setupTickerObserver() { if (G.user.role !== 'admin') return; updateTicker(); setInterval(updateTicker, 30000); } function pushTicker(type, action, detail, actor) { if (G.user?.role !== 'admin') return; const colors = { ok: '#10b981', er: '#f43f5e', wn: '#f59e0b', in: '#818cf8' }; G.tickerItems.unshift({ type, action, detail, actor, ts: new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }), color: colors[type] || '#818cf8' }); if (G.tickerItems.length > 20) G.tickerItems.pop(); updateTicker(); } function updateTicker() { const inner = document.getElementById('ticker-inner'); if (!inner) return; const items = G.tickerItems.length ? G.tickerItems : [{ color: '#5a78b8', action: 'Sistem aktif', detail: 'Tidak ada aktivitas terbaru', actor: '--', ts: '--' }]; const html = [...items, ...items].map(it => `${it.action}${it.actor}${it.ts}`).join(''); inner.innerHTML = html; } /* ══ STATS ══ */ function updStats() { const c = G.contacts; animN('st-tot', c.length); animN('st-neg', [...new Set(c.map(x => x.negara).filter(Boolean))].length); const now = new Date(); animN('st-bln', c.filter(x => { try { const d = new Date(x.tanggal); return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear(); } catch { return false; } }).length); const pct = Math.min(99, (c.length / 1000 * 100)).toFixed(1); document.getElementById('st-stor').textContent = pct + '%'; document.getElementById('sb4').style.width = pct + '%'; setTimeout(() => { document.getElementById('sb1').style.width = '100%'; document.getElementById('sb2').style.width = '65%'; document.getElementById('sb3').style.width = '40%'; }, 100); document.getElementById('stor-tbl').textContent = G.schema.length || 7; document.getElementById('stor-free').textContent = (5 - c.length * 0.000096).toFixed(2) + ' GB'; } function animN(id, to) { const el = document.getElementById(id); if (!el) return; const s = performance.now(), d = 700; (function f(n) { const t = Math.min((n - s) / d, 1); el.textContent = Math.round(to * t); if (t < 1) requestAnimationFrame(f); })(performance.now()); } function buildStorBars() { const bars = [ { l: 'Database', v: (G.contacts.length * 0.000096).toFixed(2) + ' MB', pct: Math.min(99, G.contacts.length * 0.00096), cl: 'var(--ac)' }, { l: 'Audit Logs', v: '8 MB', pct: 0.16, cl: 'var(--ok)' }, { l: 'Cache lokal', v: '10 MB', pct: 0.2, cl: 'var(--inf)' }, ]; document.getElementById('stor-bars').innerHTML = bars.map(b => `
${b.l}${b.v}
`).join(''); } /* ══ ANIMATED CHARTS ══ */ function initCharts() { if (!G.contacts.length) return; const dk = document.body.classList.contains('dark'); const tc = dk ? '#5a78b8' : '#5c4828', gc = dk ? 'rgba(79,131,247,.07)' : 'rgba(100,70,20,.06)'; // Country donut const cm = {}; G.contacts.forEach(c => cm[c.negara] = (cm[c.negara] || 0) + 1); const cs = Object.entries(cm).sort((a, b) => b[1] - a[1]); if (G.negCh) G.negCh.destroy(); G.negCh = new Chart(document.getElementById('ch-neg').getContext('2d'), { type: 'doughnut', data: { labels: cs.map(e => e[0]), datasets: [{ data: cs.map(e => e[1]), backgroundColor: CC, borderWidth: 0, hoverOffset: 8 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '66%', animation: { animateRotate: true, animateScale: true, duration: 1200, easing: 'easeInOutQuart' }, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => `${ctx.label}: ${ctx.parsed} (${Math.round(ctx.parsed / G.contacts.length * 100)}%)` } } } } }); document.getElementById('leg-neg').innerHTML = cs.slice(0, 6).map((e, i) => `
${e[0]}${e[1]}
`).join(''); // Trend line chart (30 days) const days = 30; const now = new Date(); const trendLabels = [], trendData = []; for (let i = days - 1; i >= 0; i--) { const d = new Date(now - i * 86400000); const label = `${d.getDate()}/${d.getMonth() + 1}`; trendLabels.push(label); trendData.push(G.contacts.filter(c => { try { const cd = new Date(c.tanggal); return cd.toDateString() === d.toDateString(); } catch { return false; } }).length); } if (G.trendCh) G.trendCh.destroy(); G.trendCh = new Chart(document.getElementById('ch-trend').getContext('2d'), { type: 'line', data: { labels: trendLabels, datasets: [{ data: trendData, borderColor: 'var(--ac)', backgroundColor: 'rgba(217,119,6,.10)', borderWidth: 2.5, fill: true, tension: 0.45, pointBackgroundColor: 'var(--ac)', pointRadius: 3, pointHoverRadius: 6, pointBorderColor: '#fff', pointBorderWidth: 1.5 }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 1000, easing: 'easeInOutCubic' }, plugins: { legend: { display: false } }, scales: { y: { ticks: { color: tc, font: { size: 10 }, maxTicksLimit: 5 }, grid: { color: gc }, border: { display: false }, beginAtZero: true }, x: { ticks: { color: tc, font: { size: 10 }, maxTicksLimit: 8 }, grid: { display: false }, border: { display: false } } } } }); // User bar chart const um = {}; G.contacts.forEach(c => um[c.addedBy] = (um[c.addedBy] || 0) + 1); const us = Object.entries(um).sort((a, b) => b[1] - a[1]); if (G.usrCh) G.usrCh.destroy(); G.usrCh = new Chart(document.getElementById('ch-usr').getContext('2d'), { type: 'bar', data: { labels: us.map(e => e[0]), datasets: [{ data: us.map(e => e[1]), backgroundColor: CC.map(c => c + 'cc'), borderRadius: 6, borderSkipped: false }] }, options: { responsive: true, maintainAspectRatio: false, indexAxis: 'y', animation: { duration: 900, easing: 'easeOutBounce' }, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: tc, font: { size: 11 } }, grid: { color: gc }, border: { display: false }, beginAtZero: true }, y: { ticks: { color: tc, font: { size: 11 } }, grid: { display: false }, border: { display: false } } } } }); // Animate stats periodically setInterval(() => { if (G.negCh && G.contacts.length) { G.negCh.data.datasets[0].data = G.negCh.data.datasets[0].data.map(() => Math.max(1, Math.random() < 0.05 ? 0 : undefined) !== 0 ? undefined : 0).map((v, i) => v === undefined ? G.negCh.data.datasets[0].data[i] : v); G.negCh.update('none'); } }, 5000); } /* ══ SCHEMA ══ */ function loadSchema() { const s = localStorage.getItem('schema'); G.schema = s ? JSON.parse(s) : [...DEFAULT_SCHEMA]; } function saveSchemaLS() { localStorage.setItem('schema', JSON.stringify(G.schema)); } function buildSchemaList() { const el = document.getElementById('schema-list'); if (!el) return; el.innerHTML = G.schema.map((f, i) => `
${f.key}${f.type}${f.required ? 'wajib' : ''}${f.system ? 'sistem' : ''}
${f.label}
${!f.system ? `` : ''}
`).join('') || '
Tidak ada kolom
'; } let _dsi = null; function _dss(e, i) { _dsi = i; e.currentTarget.style.opacity = '.4'; } function _dso(e, i) { e.preventDefault(); document.querySelectorAll('.fr').forEach(r => r.classList.remove('dov')); e.currentTarget.classList.add('dov'); } function _dse(e) { e.currentTarget.style.opacity = '1'; document.querySelectorAll('.fr').forEach(r => r.classList.remove('dov')); } function _dsd(e, i) { e.preventDefault(); if (_dsi === null || _dsi === i) return; const a = [...G.schema]; const [m] = a.splice(_dsi, 1); a.splice(i, 0, m); G.schema = a; buildSchemaList(); } function addSchemaField() { const k = document.getElementById('nf-k').value.trim().replace(/\s+/g, '_'); const l = document.getElementById('nf-l').value.trim(); const t = document.getElementById('nf-t').value; const ph = document.getElementById('nf-ph').value.trim(); const req = document.getElementById('nf-req').checked; const vis = document.getElementById('nf-vis').checked; if (!k || !l) { notify('Key dan label wajib diisi', 'wn'); return; } if (G.schema.find(f => f.key === k)) { notify('Key sudah ada', 'er'); return; } if (t === 'select') { G.schema.push({ key: k, label: l, type: t, placeholder: ph, required: req, visible: vis, system: false, options: ALL_COUNTRIES }); } else { G.schema.push({ key: k, label: l, type: t, placeholder: ph, required: req, visible: vis, system: false }); } saveSchemaLS(); buildSchemaList(); closeMo('mo-add-field'); notify('Kolom ditambahkan', 'ok'); ['nf-k', 'nf-l', 'nf-ph'].forEach(id => document.getElementById(id).value = ''); } function rmSchemaField(i) { if (!confirm(`Hapus kolom "${G.schema[i].label}"?`)) return; G.schema.splice(i, 1); buildSchemaList(); } function saveSchema() { saveSchemaLS(); renderTbl(); notify('Skema database disimpan', 'ok'); if (CFG.workerUrl) api('PUT', '/schema', { schema: G.schema }); } /* ══ EXT FIELDS ══ */ const DEFAULT_EXT = [ { key: 'nama', label: 'Nama', type: 'text', mapDb: true, dbCol: 'nama', autoSave: true }, { key: 'email', label: 'Email', type: 'email', mapDb: true, dbCol: 'email', autoSave: true }, { key: 'jabatan', label: 'Jabatan', type: 'text', mapDb: true, dbCol: 'jabatan', autoSave: true }, { key: 'perusahaan', label: 'Perusahaan', type: 'text', mapDb: true, dbCol: 'perusahaan', autoSave: true }, { key: 'negara', label: 'Negara', type: 'select', mapDb: true, dbCol: 'negara', autoSave: true }, { key: 'detail_perusahaan', label: 'Detail Perusahaan', type: 'textarea', mapDb: false, dbCol: '', autoSave: false }, { key: 'angle_utama', label: 'Angle Utama', type: 'textarea', mapDb: false, dbCol: '', autoSave: false }, ]; function loadExtFields() { const s = localStorage.getItem('extF'); G.extFields = s ? JSON.parse(s) : [...DEFAULT_EXT]; } function loadPTpl() { G.promptTpl = localStorage.getItem('ptpl') || 'Kamu adalah B2B marketer...\n\nNama: [nama]\nJabatan: [jabatan]\nPerusahaan: [perusahaan]'; const el = document.getElementById('prompt-tpl'); if (el) el.value = G.promptTpl; } function savePTpl() { G.promptTpl = document.getElementById('prompt-tpl').value; localStorage.setItem('ptpl', G.promptTpl); notify('Template prompt disimpan', 'ok'); } function buildExtFields() { const el = document.getElementById('ext-fields-list'); if (!el) return; el.innerHTML = G.extFields.map((f, i) => `
${f.key}${f.type}${f.mapDb ? `→ ${f.dbCol}` : ''}
${f.label}
`).join('') || '
Belum ada field kustom.
'; } function buildExtAutoSave() { const el = document.getElementById('ext-autosave-list'); if (!el) return; el.innerHTML = G.extFields.map((f, i) => `
${f.label}[${f.key}]${f.mapDb ? `${f.dbCol}` : ''}
Auto-save
`).join(''); } function rmEF(i) { G.extFields.splice(i, 1); localStorage.setItem('extF', JSON.stringify(G.extFields)); buildExtFields(); buildExtAutoSave(); } function togEfMap(cb) { document.getElementById('ef-map-wrap').classList.toggle('hidden', !cb.checked); if (cb.checked) { const sel = document.getElementById('ef-map-col'); sel.innerHTML = G.schema.filter(f => !f.system).map(f => ``).join(''); } } function addExtField() { const k = document.getElementById('ef-k').value.trim(); const l = document.getElementById('ef-l').value.trim(); if (!k || !l) { notify('Key dan label wajib diisi', 'wn'); return; } G.extFields.push({ key: k, label: l, type: document.getElementById('ef-t').value, placeholder: document.getElementById('ef-ph').value.trim(), mapDb: document.getElementById('ef-map').checked, dbCol: document.getElementById('ef-map-col').value || '', autoSave: document.getElementById('ef-map').checked }); localStorage.setItem('extF', JSON.stringify(G.extFields)); closeMo('mo-add-ef'); buildExtFields(); buildExtAutoSave(); notify('Field ditambahkan', 'ok'); ['ef-k', 'ef-l', 'ef-ph'].forEach(id => document.getElementById(id).value = ''); document.getElementById('ef-map').checked = false; document.getElementById('ef-map-wrap').classList.add('hidden'); } /* ══ TABLE ══ */ function buildTblHead() { const vis = G.schema.filter(f => f.visible); const isObs = G.user && G.user.role === 'observer'; document.getElementById('tbl-head').innerHTML = '' + vis.map(f => `${f.label} `).join('') + (!isObs ? 'Aksi' : '') + ''; } function sortBy(col) { if (G.sortCol === col) G.sortDir *= -1; else { G.sortCol = col; G.sortDir = 1; } document.querySelectorAll('.t-th[data-col]').forEach(t => { t.classList.remove('srt'); const a = t.querySelector('.sar'); if (a) { a.textContent = '▲'; a.style.opacity = '.3'; } }); const th = document.querySelector(`.t-th[data-col="${col}"]`); if (th) { th.classList.add('srt'); const a = th.querySelector('.sar'); if (a) { a.textContent = G.sortDir === 1 ? '▲' : '▼'; a.style.opacity = '1'; } } renderTbl(); } function filterTbl() { const q = document.getElementById('srch').value.toLowerCase(); G.filtered = G.contacts.filter(c => G.schema.some(f => String(c[f.key] || '').toLowerCase().includes(q))); if (G.activeFilter.negara) G.filtered = G.filtered.filter(c => c.negara === G.activeFilter.negara); if (G.activeFilter.by) G.filtered = G.filtered.filter(c => c.addedBy === G.activeFilter.by); G.pg = 1; renderTbl(); } function renderTbl() { if (!G.schema.length) loadSchema(); buildTblHead(); const sorted = [...G.filtered].sort((a, b) => { const av = a[G.sortCol] || '', bv = b[G.sortCol] || ''; return av < bv ? -G.sortDir : av > bv ? G.sortDir : 0; }); const total = sorted.length, st = (G.pg - 1) * G.perPg, pg = sorted.slice(st, st + G.perPg); const isObs = G.user && G.user.role === 'observer'; const vis = G.schema.filter(f => f.visible); const tb = document.getElementById('tbl-body'); if (!pg.length) { tb.innerHTML = `
Tidak ada data
Tambahkan kontak atau ubah pencarian
`; } else { tb.innerHTML = pg.map(c => { const cells = vis.map(f => { const v = sanitize(String(c[f.key] || '')); if (f.key === 'negara') return `${FLAGS[c[f.key]] || '🌐'} ${v}`; if (f.type === 'email') return `${v}`; if (f.type === 'textarea') return `
${v}
${v}
`; if (f.key === 'tanggal') return `${v}`; if (f.key === 'perusahaan') return `${v}`; return `${v}`; }).join(''); const act = !isObs ? `
` : ''; return `${cells}${act}`; }).join(''); } document.getElementById('pag-info').textContent = total ? `${st + 1}–${Math.min(st + G.perPg, total)} dari ${total} entri` : '0 entri'; const pages = Math.ceil(total / G.perPg); document.getElementById('pag-btns').innerHTML = [...Array(Math.min(pages, 8))].map((_, i) => `` ).join(''); } /* ══ CRUD ══ */ function buildForm(cid, vals = {}, ro = false) { const el = document.getElementById(cid); if (!el) return; const cols = G.schema.filter(f => !f.system || f.key === 'tanggal'); el.innerHTML = cols.map(f => { const v = vals[f.key] || ''; const span = f.type === 'textarea' ? 'style="grid-column:1/-1"' : ''; let inner = ''; if (f.type === 'select') { const opts = f.key === 'negara' ? ALL_COUNTRIES : (f.options || ALL_COUNTRIES); inner = ``; } else if (f.type === 'textarea') { inner = ``; } else { inner = ``; } return `
${inner}
`; }).join(''); } function getFormVals() { const v = {}; G.schema.filter(f => !f.system || f.key === 'tanggal').forEach(f => { const el = document.getElementById('f-' + f.key); if (el) v[f.key] = el.value; }); return v; } function openAdd() { if (G.user.role === 'observer') return; buildForm('add-fields', { tanggal: new Date().toISOString().slice(0, 10) }); openMo('mo-add'); } async function addContact() { const v = getFormVals(); for (const f of G.schema.filter(x => x.required)) { if (!v[f.key]) { notify(`"${f.label}" wajib diisi`, 'wn'); return; } } v.tanggal = v.tanggal || new Date().toISOString().slice(0, 10); v.addedBy = G.user.username; const r = await api('POST', '/contacts', v); v.id = (r && !r.error && !r._local && r.id) ? r.id : String(Date.now()); G.contacts.unshift(v); G.filtered = [...G.contacts]; G.pg = 1; closeMo('mo-add'); filterTbl(); updStats(); buildStorBars(); setTimeout(() => { if (G.negCh) { G.negCh.destroy(); G.trendCh.destroy(); G.usrCh.destroy(); G.negCh = null; initCharts(); } }, 100); notify(`Kontak "${v.perusahaan || v.nama}" berhasil ditambahkan`, 'ok'); pushLog('in', 'Data Ditambahkan', `${v.perusahaan || v.nama} - oleh ${G.user.username}`, G.user.username); pushTicker('in', 'Tambah', v.perusahaan || v.nama, G.user.username); } function openDet(id) { const c = G.contacts.find(x => x.id == id); if (!c) return; G.editId = id; const isObs = G.user.role === 'observer'; buildForm('det-fields', c, isObs); document.getElementById('det-sub').textContent = isObs ? 'Mode Observer - hanya baca' : 'Edit dan simpan perubahan'; document.getElementById('btn-dd').style.display = isObs ? 'none' : ''; document.getElementById('btn-ds').style.display = isObs ? 'none' : ''; openMo('mo-detail'); } function saveDet() { if (G.user.role === 'user') { G.totpCb = _saveDet; document.getElementById('totp-i').value = ''; openMo('mo-totp'); return; } _saveDet(); } async function _saveDet() { const v = getFormVals(); const c = G.contacts.find(x => x.id == G.editId); if (!c) return; Object.assign(c, v); const r = await api('PUT', '/contacts/' + G.editId, v); if (r && r.error && CFG.workerUrl) { notify('Gagal menyimpan: ' + r.error, 'er'); return; } closeMo('mo-detail'); filterTbl(); notify('Perubahan berhasil disimpan', 'ok'); pushLog('in', 'Data Diubah', `${c.perusahaan || c.nama} - oleh ${G.user.username}`, G.user.username); pushTicker('in', 'Edit', c.perusahaan || c.nama, G.user.username); } function delFromDet() { if (G.user.role === 'user') { closeMo('mo-detail'); G.totpCb = () => _del(G.editId); document.getElementById('totp-i').value = ''; openMo('mo-totp'); return; } if (confirm('Hapus kontak ini secara permanen?')) { _del(G.editId); closeMo('mo-detail'); } } function reqDel(id, e) { if (e) e.stopPropagation(); G.editId = id; // Admin: hapus langsung dengan konfirmasi if (G.user.role === 'admin') { if (confirm('Hapus kontak ini?')) _del(id); return; } // User: perlu TOTP if (G.user.role === 'user') { G.totpCb = () => _del(id); document.getElementById('totp-i').value = ''; openMo('mo-totp'); return; } notify('Anda tidak memiliki izin untuk menghapus data', 'er'); } async function _del(id) { const c = G.contacts.find(x => x.id == id); const r = await api('DELETE', '/contacts/' + id); if (r && r.error && CFG.workerUrl) { notify('Gagal menghapus: ' + r.error, 'er'); return; } G.contacts = G.contacts.filter(x => x.id != id); G.filtered = G.filtered.filter(x => x.id != id); filterTbl(); updStats(); buildStorBars(); closeMo('mo-detail'); notify('Kontak berhasil dihapus', 'ok'); if (c) { pushLog('wn', 'Data Dihapus', `${c.perusahaan || c.nama} - oleh ${G.user.username}`, G.user.username); pushTicker('wn', 'Hapus', c.perusahaan || c.nama, G.user.username); } } /* ══ FILTER ══ */ function openFilter() { const negs = [...new Set(G.contacts.map(c => c.negara).filter(Boolean))]; const bys = [...new Set(G.contacts.map(c => c.addedBy).filter(Boolean))]; document.getElementById('filter-fields').innerHTML = `
`; openMo('mo-filter'); } function applyFilter() { G.activeFilter.negara = document.getElementById('ff-neg').value; G.activeFilter.by = document.getElementById('ff-by').value; filterTbl(); closeMo('mo-filter'); const act = !!(G.activeFilter.negara || G.activeFilter.by); const b = document.getElementById('btn-flt'); b.style.background = act ? 'var(--acd)' : ''; b.style.borderColor = act ? 'var(--ac)' : ''; b.style.color = act ? 'var(--ac)' : ''; } function rstFilter() { G.activeFilter = {}; filterTbl(); closeMo('mo-filter'); const b = document.getElementById('btn-flt'); b.style.background = ''; b.style.borderColor = ''; b.style.color = ''; } /* ══ IMPORT (universal format detection) ══ */ function dzOver(e) { e.preventDefault(); document.getElementById('import-dz').classList.add('over'); } function dzLeave(e) { document.getElementById('import-dz').classList.remove('over'); } function dzDrop(e) { e.preventDefault(); document.getElementById('import-dz').classList.remove('over'); const f = e.dataTransfer.files[0]; if (f) processImportFile(f); } function handleImportFile(input) { const f = input.files[0]; if (f) processImportFile(f); } async function processImportFile(file) { notify('Membaca file: ' + file.name, 'in'); try { let rows = []; if (file.name.endsWith('.csv')) { const text = await file.text(); const parsed = Papa.parse(text, { header: true, skipEmptyLines: true, dynamicTyping: false }); rows = parsed.data; } else { const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); const ws = wb.Sheets[wb.SheetNames[0]]; rows = XLSX.utils.sheet_to_json(ws, { defval: '', raw: false }); } if (!rows.length) { notify('File kosong atau tidak terbaca', 'er'); return; } G.importData = rows; G.importMapping = detectColumnMapping(Object.keys(rows[0])); showImportPreview(rows, G.importMapping); } catch (e) { notify('Gagal membaca file: ' + e.message, 'er'); } } function detectColumnMapping(headers) { const schemaKeys = G.schema.filter(f => !f.system).map(f => f.key); const mapping = {}; const aliases = { nama: ['nama', 'name', 'contact', 'kontak', 'person', 'full name', 'fullname', 'recipient'], email: ['email', 'e-mail', 'mail', 'alamat email', 'email address'], jabatan: ['jabatan', 'position', 'title', 'job title', 'jobtitle', 'role', 'posisi'], perusahaan: ['perusahaan', 'company', 'organisation', 'organization', 'institusi', 'firm', 'corp'], negara: ['negara', 'country', 'nation', 'countries', 'nationality'], keterangan: ['keterangan', 'notes', 'note', 'catatan', 'remarks', 'comment', 'description', 'info'], tanggal: ['tanggal', 'date', 'created', 'created_at', 'added', 'tgl'], }; for (const schKey of schemaKeys) { const al = aliases[schKey] || [schKey]; for (const h of headers) { const hl = h.toLowerCase().trim(); if (al.some(a => hl.includes(a) || a.includes(hl))) { mapping[schKey] = h; break; } } } return mapping; } function showImportPreview(rows, mapping) { document.getElementById('import-step1').classList.add('hidden'); document.getElementById('import-step2').classList.remove('hidden'); document.getElementById('btn-do-import').classList.remove('hidden'); document.getElementById('import-count').textContent = rows.length; const schKeys = G.schema.filter(f => !f.system).map(f => ({ key: f.key, label: f.label })); const colMapHtml = schKeys.map(f => { const mapped = mapping[f.key] || ''; const headers = Object.keys(rows[0]); return `
${f.label}
`; }).join(''); document.getElementById('col-mapping').innerHTML = `
Pemetaan Kolom Otomatis (dapat diubah manual)
Kolom DatabaseKolom File
${colMapHtml}`; const previewCols = Object.keys(rows[0]).slice(0, 6); const previewRows = rows.slice(0, 5); document.getElementById('import-preview-table').innerHTML = `${previewCols.map(h => ``).join('')}${previewRows.map(r => `${previewCols.map(h => ``).join('')}`).join('')}
${sanitize(h)}
${sanitize(String(r[h] || ''))}
`; const unmapped = G.schema.filter(f => f.required && !f.system && !mapping[f.key]); if (unmapped.length) { const wn = document.getElementById('import-warn'); wn.textContent = `Kolom wajib tidak terdeteksi otomatis: ${unmapped.map(f => f.label).join(', ')}. Pilih secara manual di atas.`; wn.classList.remove('hidden'); } notify(`${rows.length} baris siap diimpor`, 'ok'); } async function doImport() { const schKeys = G.schema.filter(f => !f.system).map(f => f.key); const finalMapping = {}; for (const k of schKeys) { const sel = document.getElementById('map-' + k); if (sel && sel.value) finalMapping[k] = sel.value; } let imported = 0, skipped = 0; const now = new Date().toISOString().slice(0, 10); for (const row of G.importData) { const c = { tanggal: now, addedBy: G.user.username }; for (const [dbCol, fileCol] of Object.entries(finalMapping)) { c[dbCol] = String(row[fileCol] || '').trim(); } const missing = G.schema.filter(f => f.required && !f.system && !c[f.key]); if (missing.length) { skipped++; continue; } c.id = String(Date.now() + imported); const r = await api('POST', '/contacts', c); if (r && r.id) c.id = r.id; G.contacts.unshift(c); imported++; } G.filtered = [...G.contacts]; G.pg = 1; closeMo('mo-import'); resetImport(); filterTbl(); updStats(); buildStorBars(); setTimeout(() => { if (G.negCh) { G.negCh.destroy(); G.trendCh.destroy(); G.usrCh.destroy(); G.negCh = null; initCharts(); } }, 100); notify(`Import selesai: ${imported} berhasil, ${skipped} dilewati (data tidak lengkap)`, imported > 0 ? 'ok' : 'wn'); pushLog('in', 'Import Data', `${imported} kontak diimpor oleh ${G.user.username}`, G.user.username); pushTicker('in', 'Import', `${imported} kontak`, G.user.username); } function resetImport() { G.importData = []; G.importMapping = {}; document.getElementById('import-step1').classList.remove('hidden'); document.getElementById('import-step2').classList.add('hidden'); document.getElementById('btn-do-import').classList.add('hidden'); document.getElementById('import-file').value = ''; const wn = document.getElementById('import-warn'); if (wn) wn.classList.add('hidden'); } /* ══ EXPORT ══ */ function doExport() { const fmt = document.getElementById('exp-fmt').value; const bar = document.getElementById('ep-bar'); bar.style.width = '0'; setTimeout(() => bar.style.width = '100%', 30); setTimeout(() => { closeMo('mo-export'); notify(`Diekspor sebagai ${fmt}`, 'ok'); bar.style.width = '0'; }, 1700); } /* ══ TOTP ══ */ function verifyTotp() { const c = document.getElementById('totp-i').value; if (!/^\d{6}$/.test(c)) { notify('Masukkan 6 digit angka', 'wn'); return; } closeMo('mo-totp'); notify('TOTP terverifikasi', 'ok'); if (G.totpCb) { G.totpCb(); G.totpCb = null; } } /* ══ LOGS (mencatat semua role) ══ */ function pushLog(type, action, detail, actor) { const now = new Date(); const ts = now.toLocaleString('id-ID', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); const entry = { type, action, detail: detail || '', actor: actor || G.user?.username || 'system', ts }; G.logs.unshift(entry); if (G.logs.length > 500) G.logs.pop(); if (document.getElementById('pg-logs')?.classList.contains('on')) renderLogs(); } async function loadLogs() { const r = await api('GET', '/logs'); if (r && !r.error && !r._local && Array.isArray(r.data)) G.logs = [...r.data, ...G.logs].slice(0, 500); renderLogs(); } function renderLogs() { const q = (document.getElementById('log-srch') || {}).value?.toLowerCase() || ''; const tf = (document.getElementById('log-type-filter') || {}).value || ''; const col = { ok: 'var(--ok)', in: 'var(--inf)', wn: 'var(--wn)', er: 'var(--er)' }; let list = G.logs; if (q) list = list.filter(l => (l.action + l.detail + l.actor).toLowerCase().includes(q)); if (tf) list = list.filter(l => l.type === tf); document.getElementById('log-list').innerHTML = list.map(l => `
${sanitize(l.action)}${sanitize(l.actor)}
${sanitize(l.detail)}
${l.ts}
`).join('') || '
Belum ada aktivitas
'; } function filterLogs() { renderLogs(); } async function clearLogs() { if (G.user.role !== 'admin') { notify('Hanya Admin yang dapat menghapus log', 'er'); return; } if (!confirm('Hapus log lama (>90 hari)?')) return; await api('DELETE', '/logs'); G.logs = G.logs.slice(0, 50); renderLogs(); notify('Log lama dihapus', 'ok'); } /* ══ USERS ══ */ async function loadUsers() { const r = await api('GET', '/users'); G.users = (r && !r.error && !r._local && Array.isArray(r.data)) ? r.data : Object.entries(UL).map(([un, u]) => ({ username: un, ...u, added: G.contacts.filter(c => c.addedBy === un).length })); if (document.getElementById('pg-users')?.classList.contains('on')) buildUsersGrid(); } function buildUsersGrid() { const g = document.getElementById('u-grid'); if (!g) return; g.innerHTML = G.users.map(u => `
${(u.name || u.username)[0]}
${sanitize(u.name || u.username)}
@${sanitize(u.username)}
${u.online ? 'Online' : 'Offline'}
${u.role === 'admin' ? 'Admin' : u.role === 'user' ? 'User' : 'Observer'}${u.evf ? 'Email OK' : 'Belum verif.'}
Email: ${sanitize(u.email || '-')}
Data: ${u.added || 0} entri
${u.username !== G.user.username ? `` : ''} ${!u.evf ? `` : ''}
`).join(''); } async function rmUser(un) { if (!confirm(`Hapus user @${un}?`)) return; await api('DELETE', '/users/' + un); G.users = G.users.filter(u => u.username !== un); buildUsersGrid(); notify(`@${un} dihapus`, 'ok'); pushLog('wn', 'User Dihapus', `@${un} dihapus oleh ${G.user.username}`, G.user.username); pushTicker('wn', 'Hapus User', `@${un}`, G.user.username); } function resetAddUser() { ['au-nm', 'au-em', 'au-un'].forEach(id => document.getElementById(id).value = ''); } async function addUser() { const em = document.getElementById('au-em').value.trim(); const un = document.getElementById('au-un').value.trim().toLowerCase(); const nm = document.getElementById('au-nm').value.trim(); const role = document.getElementById('au-role').value; if (!em || !un) { notify('Email dan username wajib diisi', 'wn'); return; } if (!/^[a-zA-Z0-9_]{3,20}$/.test(un)) { notify('Username hanya boleh huruf, angka, underscore (3-20 karakter)', 'wn'); return; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(em)) { notify('Format email tidak valid', 'wn'); return; } const r = await api('POST', '/users', { username: un, name: nm, email: em, role }); if (r && r.error && CFG.workerUrl) { notify(r.error, 'er'); return; } G.users.push({ username: un, name: nm || un, email: em, role, online: false, evf: false, added: 0, color: 'linear-gradient(135deg,#818cf8,#a78bfa)' }); closeMo('mo-adduser'); buildUsersGrid(); notify(`Undangan dikirim ke ${em}`, 'ok'); pushLog('in', 'User Baru Dibuat', `@${un} (${role}) - email verifikasi dikirim`, G.user.username); pushTicker('in', 'User Baru', `@${un} (${role})`, G.user.username); } /* ══ SETTINGS - PROFILE ══ */ function buildSettingsPf() { if (!G.user) return; document.getElementById('set-av').textContent = G.user.name[0]; document.getElementById('set-av').style.background = G.user.color || 'var(--grad)'; document.getElementById('set-nm').textContent = G.user.name; document.getElementById('set-em').textContent = G.user.email; const eb = document.getElementById('set-eb'); eb.textContent = G.user.evf ? 'Terverifikasi' : 'Belum Diverifikasi'; eb.className = 'bdg ' + (G.user.evf ? 'bdg-ok' : 'bdg-er'); document.getElementById('pf-nama').value = G.user.name; document.getElementById('pf-user').value = G.user.username; document.getElementById('pf-email').value = G.user.email; document.getElementById('pf-role').value = G.user.role === 'admin' ? 'Administrator' : G.user.role === 'user' ? 'User' : 'Observer'; } function openEditPf() { document.getElementById('ep-nm').value = G.user.name; document.getElementById('ep-un').value = G.user.username; document.getElementById('ep-em').value = G.user.email; openMo('mo-editpf'); } async function savePf() { const nm = document.getElementById('ep-nm').value.trim(); const em = document.getElementById('ep-em').value.trim(); if (!nm || !em) { notify('Nama dan email wajib diisi', 'wn'); return; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(em)) { notify('Format email tidak valid', 'wn'); return; } const ec = em !== G.user.email; G.user.name = nm; G.user.email = em; if (UL[G.user.username]) { UL[G.user.username].name = nm; UL[G.user.username].email = em; } if (ec) { G.user.evf = false; document.getElementById('verif-banner').style.display = 'flex'; } const r = await api('PUT', '/users/' + G.user.username + '/profile', { name: nm, email: em }); closeMo('mo-editpf'); buildSettingsPf(); applyRole(); notify(ec ? 'Profil disimpan. Verifikasi email baru dikirim.' : 'Profil berhasil disimpan', 'ok'); pushLog('in', 'Profil Diubah', `${G.user.username} memperbarui profil`, G.user.username); } async function changePw() { const o = document.getElementById('pw-old').value; const n1 = document.getElementById('pw-n1').value; const n2 = document.getElementById('pw-n2').value; if (!o || !n1 || !n2) { notify('Lengkapi semua field password', 'wn'); return; } // Validate old password let oldOk = false; if (UL[G.user.username]) oldOk = UL[G.user.username].pw === o; else { const r = await api('POST', '/auth/verify-pw', { password: o }); oldOk = !r.error; } if (!oldOk) { notify('Password lama tidak sesuai', 'er'); return; } if (n1 !== n2) { notify('Konfirmasi password tidak cocok', 'er'); return; } const re = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*\-_]).{8,15}$/; if (!re.test(n1)) { notify('Password: 8-15 karakter dengan huruf besar, kecil, angka, dan simbol', 'wn'); return; } // Save password if (UL[G.user.username]) UL[G.user.username].pw = n1; const r = await api('PUT', '/users/' + G.user.username + '/password', { oldPw: o, newPw: n1 }); if (r && r.error && CFG.workerUrl) { notify('Gagal ubah password: ' + r.error, 'er'); return; } ['pw-old', 'pw-n1', 'pw-n2'].forEach(id => document.getElementById(id).value = ''); document.getElementById('pw-fb').classList.add('hidden'); notify('Password berhasil diubah', 'ok'); pushLog('ok', 'Password Diubah', `${G.user.username} mengubah password`, G.user.username); } function chkPw() { const n1 = document.getElementById('pw-n1').value; const n2 = document.getElementById('pw-n2').value; const fb = document.getElementById('pw-fb'); const re = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*\-_]).{8,15}$/; if (!n1 && !n2) { fb.classList.add('hidden'); return; } fb.classList.remove('hidden'); if (n1 && !re.test(n1)) { fb.className = 'ib ib-er text-xs mb-3'; fb.textContent = '8-15 karakter: huruf besar, kecil, angka, dan simbol (!@#$%^&*-_)'; return; } if (n1 && n2 && n1 !== n2) { fb.className = 'ib ib-er text-xs mb-3'; fb.textContent = 'Konfirmasi password tidak cocok'; return; } if (n1 && n2 && n1 === n2 && re.test(n1)) { fb.className = 'ib ib-ok text-xs mb-3'; fb.textContent = 'Password kuat dan cocok'; } else fb.classList.add('hidden'); } function saveSecSettings() { G.secSettings.maxFail = parseInt(document.getElementById('sec-maxfail').value) || 5; G.secSettings.lockDur = parseInt(document.getElementById('sec-lockdur').value) || 15; notify('Pengaturan keamanan disimpan', 'ok'); } function sendVerif() { notify('Email verifikasi dikirim', 'ok'); } /* ══ API KEYS ══ */ function detProv(v, dId) { const el = document.getElementById(dId); if (!el || !v) { if (el) el.innerHTML = ''; return; } let p = '', c = ''; if (v.startsWith('gsk_')) { p = 'Groq'; c = 'var(--ok)'; } else if (v.startsWith('sk-ant-')) { p = 'Anthropic Claude'; c = 'var(--inf)'; } else if (v.startsWith('sk-proj-') || v.startsWith('sk-')) { p = 'OpenAI'; c = 'var(--ok)'; } else if (v.startsWith('AIzaSy')) { p = 'Google Gemini'; c = '#ea4335'; } else if (v.startsWith('xkeysib-')) { p = 'Brevo'; c = 'var(--inf)'; } else { p = 'Provider tidak dikenali'; c = 'var(--t3)'; } el.innerHTML = `${p} terdeteksi`; } function saveKey(name, kId, wId, dId) { const v = document.getElementById(kId)?.value?.trim(); if (!v) { notify('Masukkan API key', 'wn'); return; } const sk = name === 'groq' ? 'groqKey' : name === 'sub' ? 'submitKey' : name + 'Key'; localStorage.setItem(sk, v); const masked = v.slice(0, 5) + '....' + v.slice(-4); document.getElementById(wId).innerHTML = `
${masked}Tersimpan
`; if (dId) document.getElementById(dId).innerHTML = ''; notify(`Key "${name}" berhasil disimpan`, 'ok'); } function saveWorkerUrl() { CFG.workerUrl = document.getElementById('wk-url').value.trim(); CFG.workerSecret = document.getElementById('wk-sec').value.trim(); localStorage.setItem('wkUrl', CFG.workerUrl); localStorage.setItem('wkSec', CFG.workerSecret); notify('Worker URL disimpan', 'ok'); } /* ══ NOTIF SETTINGS ══ */ function buildNotifSettings() { const items = [['Notifikasi saat data baru ditambahkan', true], ['Peringatan API key mendekati limit', true], ['Alert saat ada akses tidak sah', true], ['Email saat login dari perangkat baru', false], ['Ringkasan aktivitas mingguan', false], ['Notifikasi backup harian', true]]; const el = document.getElementById('notif-settings-list'); if (!el) return; el.innerHTML = items.map(([l, on]) => `
${l}
`).join(''); } function buildBkList() { const el = document.getElementById('bk-list'); if (!el) return; el.innerHTML = BACKUPS_DEMO.map(b => `
${b.f}${b.sz}${b.ok ? '✓ Berhasil' : '✗ Gagal'} ${b.ts}
`).join(''); } /* ══ ACCORDION ══ */ function togAcc(hd) { const bd = hd.nextElementSibling; const op = bd.classList.contains('op'); bd.classList.toggle('op', !op); hd.parentElement.classList.toggle('ac-open', !op); } /* ══ MODAL ══ */ function openMo(id) { const m = document.getElementById(id); if (m) m.classList.add('sh'); } function closeMo(id) { const m = document.getElementById(id); if (m) m.classList.remove('sh'); } document.querySelectorAll('.mo').forEach(m => { m.addEventListener('click', e => { if (e.target === m) m.classList.remove('sh'); }); }); /* ══ FLOATING NOTIFICATION SYSTEM ══ */ let notifQueue = []; function notify(title, type = 'in', msg = '', opts = {}) { const stack = document.getElementById('notif-stack'); if (!stack) return; const icons = { ok: '✓', er: '✗', wn: '⚠', in: 'ℹ' }; const el = document.createElement('div'); el.className = `notif notif-${type}`; el.innerHTML = `
${icons[type] || 'ℹ'}
${sanitize(title)}
${msg ? `
${sanitize(msg)}
` : ''}
`; stack.appendChild(el); const dur = opts.duration || 4500; setTimeout(() => dismissNotif(el), dur); if (stack.children.length > 5) dismissNotif(stack.children[0]); } function dismissNotif(el) { if (!el || !el.parentElement) return; el.classList.add('out'); setTimeout(() => { if (el.parentElement) el.parentElement.removeChild(el); }, 220); } /* ══ BRANDING ON LOAD ══ */ function loadBranding() { const br = localStorage.getItem('brand'); if (br) { try { G.branding = { ...G.branding, ...JSON.parse(br) }; applyBrand(); } catch {} } } /* ══ BOOT ══ */ window.addEventListener('load', () => { loadBranding(); loadSchema(); loadExtFields(); loadPTpl(); G.perPg = 10; G.filtered = []; // Prefill settings const wu = localStorage.getItem('wkUrl'); const ws = localStorage.getItem('wkSec'); if (wu && document.getElementById('wk-url')) document.getElementById('wk-url').value = wu; if (ws && document.getElementById('wk-sec')) document.getElementById('wk-sec').value = '••••••••'; // Load saved API keys display ['groqKey', 'submitKey'].forEach(k => { const v = localStorage.getItem(k); if (!v) return; const nm = k === 'groqKey' ? 'groq' : 'sub'; const wId = 'w-' + nm; const dId = 'd-' + nm; const wEl = document.getElementById(wId); const dEl = document.getElementById(dId); if (wEl) wEl.innerHTML = `
${v.slice(0, 5)}....${v.slice(-4)}Tersimpan
`; if (dEl) dEl.innerHTML = ''; }); });